A .fln file's common mistakes are answered in .fln terms, a bare () in a body slot does nothing, and the syntax gaps found writing by hand are listed in TODO.org

This commit is contained in:
Joseph Ferano 2026-09-25 23:44:04 +07:00
parent 7e48f1eaf5
commit dca29a5605
6 changed files with 215 additions and 12 deletions

View File

@ -628,6 +628,52 @@ awaiting confirmation, and the build order. Rules out Parinfer, wisp and
sweet-expressions, and a simplified in-paren syntax — all thin the parens
without removing them.
** TODO A one-line if cannot take its else on the next line
=if c then a= then =else b= under it is refused. Proposal: an =else=/=elif= at
the if's column continues a one-line if, as F#'s does.
** TODO A lambda with a block cannot be a call's argument
=sort-by(xs, fn(a, b)= plus a block is refused; the lambda has to be bound first,
with its type written. Proposal: a call ending in =fn(...)= and a trailing =:=
hands the block to that lambda: =sort-by(xs, fn(a, b)):=.
** TODO A lambda's parameters cannot be typed
=fn(a, b)= takes bare names, so a lambda bound by =let= needs
=let f: Fn(C, C) -> bool = fn(a, b)=. Proposal: =fn(a: C, b: C)= as in a
=fn= definition.
** TODO A condition struct with a parent has no sugar
=defstruct(DiskFull, :parent, IoError, [free i64])= is the fallback, with a
paren field vector. Proposal: =struct DiskFull :parent IoError= plus field lines.
** TODO defmacro has no sugar
=defmacro(repeat, [i n & body]):= with a space-separated parameter vector.
Proposal: =macro repeat(i, n, & body)= plus a block.
** TODO loop/recur has no sugar
=loop([x a y b]):=. Proposal: =loop x = a, y = b= plus a block; =recur(...)=
stays a call.
** TODO A restart's report string is a body line
=:report= and its string sit as two statements under =restart name()=.
Proposal: =restart name() "report text"= on the header line.
** TODO An enum member has only a keyword spelling
=:north= works, =Dir.north= is an unknown name, while a data case is
=Shape.Rect=. Proposal: accept =Dir.north= as the member.
** TODO Checker and runtime messages print types and fixes in parens
In a .fln file, =(Fn [A] R)=, =(Option i32)=, =(get 0 :body)=, =(/ x 0)= and most
usage hints still print paren syntax; a handful now print .fln spellings.
Proposal: =Types.to_string= and the hints take the syntax of the location's file.
** TODO flan convert separates adjacent one-line globals with blank lines
=once a: i32= on consecutive lines come back one blank line apart.
** TODO A bad map key type is followed by unknown-name errors for its parts
=map-new([const u8], i32)= reports the key, then "unknown name const" and
"unknown name i32" — in both syntaxes.
** TODO The shims in sand.flan can go
=sand.flan= defines =dyn->f64= and =dyn->u32=, one-line functions whose only job
is that their parameter slot unboxes. Every call site can write =(f64 d)= and

View File

@ -9036,6 +9036,19 @@ and unknown_name : 'a. ?setting:bool -> ctx -> Loc.t -> string -> 'a =
name head field head
end
else
(* [x++]: a name may end in +, so the increment of another language
reads as one unknown name. *)
let n = String.length name in
let stem = if n > 2 then String.sub name 0 (n - 2) else "" in
let suffix = if n > 2 then String.sub name (n - 2) 2 else "" in
if (suffix = "++" || suffix = "--") && lookup ctx stem <> None then
Loc.failk "check/unknown-name" loc
"unknown name %s — to %s %s, write %s"
name (if suffix = "++" then "add one to" else "take one from") stem
(if Source.indented_at loc then
Printf.sprintf "%s(%s) or %s %s= 1" suffix stem stem (String.make 1 suffix.[0])
else Printf.sprintf "(%s %s)" suffix stem)
else
match nearest (value_candidates ctx) name with
| Some m ->
Loc.failk "check/unknown-name" loc "unknown name %s — did you mean %s?" name m

View File

@ -380,7 +380,16 @@ and list _f h args =
| _ -> false
in
let ft = if fl < lvl || (fl = lvl && same) then paren ft else ft in
(String.concat (" " ^ op ^ " ") (ft :: List.map (at (lvl + 1)) rest), lvl)
(* [(a and b) or c]: the parentheses precedence makes optional are
written, as most readers expect them. *)
let and_in_or (x : Form.t) t =
match x.v with
| Form.List ({ v = Form.Sym "and"; _ } :: _ :: _ :: _) when s = "or" && t.[0] <> '(' -> paren t
| _ -> t
in
let ft = and_in_or first ft in
(String.concat (" " ^ op ^ " ")
(ft :: List.map (fun x -> and_in_or x (at (lvl + 1) x)) rest), lvl)
| Form.Sym "-", [ x ] ->
let t, l = expr x in
if l >= 9 && t <> "" && R.is_neg_char t.[0] then ("-" ^ t, 8)
@ -410,7 +419,7 @@ and list _f h args =
| Form.Sym s, [ ({ v = Form.Map _; _ } as m) ] when name_ok s && R.capitalised s ->
(s ^ fst (expr m), 9)
| Form.Sym "fn", [ { v = Form.Vec ps; _ }; body ] when List.for_all sym_param ps ->
("fn(" ^ commas ps ^ ") = " ^ at 0 body, 0)
("fn(" ^ commas ps ^ ") = " ^ unit_text body, 0)
| Form.Sym "if", [ c; a; b ] ->
("if " ^ at 1 c ^ " then " ^ inline_text ~lvl:1 a ^ " else " ^ inline_text b, 0)
| _ -> call ()
@ -420,6 +429,10 @@ and list _f h args =
everything else as a value. [lvl] is what a value in the slot needs. *)
and inline_text ?(lvl = 0) (f : Form.t) =
match f.v with
(* In a one-line slot a bare [()] reads as [(do)]; the value [()] is
written [(())]. *)
| Form.List [] -> "(())"
| Form.List [ { v = Form.Sym "do"; _ } ] -> "()"
| Form.List [ { v = Form.Sym (("break" | "continue" | "return") as w); _ } ] -> w
| Form.List [ { v = Form.Sym (("break" | "continue") as w); _ }; { v = Form.Kw k; _ } ]
when kw_ok k ->
@ -431,6 +444,13 @@ and inline_text ?(lvl = 0) (f : Form.t) =
at 9 t ^ " " ^ op ^ "= " ^ at (max lvl 1) w
| _ -> at lvl f
(* A body after [=]: [()] there reads as [(do)]. *)
and unit_text (f : Form.t) =
match f.v with
| Form.List [] -> "(())"
| Form.List [ { v = Form.Sym "do"; _ } ] -> "()"
| _ -> at 0 f
(* [t = v], or [t += w] when [v] is [(+ t w)]. *)
and assign_text ?(lvl = 0) t v =
let tt = at 9 t in
@ -653,6 +673,9 @@ and plain n (f : Form.t) : string list =
(* No arguments before the block: [comment:] rather than
[comment():], the author's decision 85. *)
| Form.Sym s, [] when name_ok s && not (List.mem s reserved) -> s ^ ":"
(* A header word glued to its parenthesis is the fallback call,
[if(c):], so it needs no parentheses of its own. *)
| Form.Sym s, _ :: _ when List.mem s reserved -> s ^ "(" ^ commas fixed ^ "):"
| _ -> head_text h ^ "(" ^ commas fixed ^ "):"
in
[ ind n ^ guard opener ] @ block ~seq (n + 2) rest
@ -916,7 +939,7 @@ and sugar n (f : Form.t) : string list option =
| _ -> true)
&& String.length head + 3 + String.length (at 0 x) <= width
&& not (!inside f) ->
Some [ head ^ " = " ^ at 0 x ]
Some [ head ^ " = " ^ unit_text x ]
| _ -> Some (head :: block (n + 2) body)))
| Form.List ({ v = Form.Sym (("def" | "defonce" | "defconst") as d); _ }
:: { v = Form.Sym name; _ } :: rest)

View File

@ -208,9 +208,22 @@ let lex ?(line = 1) ?(col = 1) ~file src : token list =
Reader.advance st;
emit COLON (piece l0.Loc.line (l0.Loc.col + n) 1)
end
else
else begin
(* [0..10]: a range from another language. *)
let text = String.sub src st.Reader.pos (stop - st.Reader.pos) in
(match String.index_opt text '.' with
| Some i when i + 1 < String.length text && text.[i + 1] = '.' ->
failk "dot-range" (piece l0.Loc.line l0.Loc.col (String.length text))
"%s is not a number. A range of numbers is written range(%s, %s), \
as in for i in range(%s, %s)"
text (String.sub text 0 i)
(String.sub text (i + 2) (String.length text - i - 2))
(String.sub text 0 i)
(String.sub text (i + 2) (String.length text - i - 2))
| _ -> ());
let f = Reader.read_number st in
emit (ATOM f.v) f.loc
end
| _ -> name_run ()
in
let rec go () =
@ -477,9 +490,13 @@ let expect_eol p ~after =
if (peek p).tok = INDENT then
failk "stray-indent" (peek_at p 1).loc
"this line is indented under %s, which takes no block. A call takes \
an indented block only with a trailing colon, as in \
rl/with-drawing():"
an indented block only with a trailing colon, as in %s:"
after
(* The call itself when [after] is one, [f(a, b)]; else an example. *)
(match String.index_opt after '(', String.index_opt after ' ' with
| Some i, Some j when i < j -> after
| Some _, None when after.[String.length after - 1] = ')' -> after
| _ -> "rl/with-drawing()")
| EOF -> ()
| _ -> stray p ~after
@ -725,6 +742,14 @@ and if_expr p =
(* What a one-line slot takes — a match arm's value, a then or an else, the
thing after defer: a value, or one of the statements that fit on a line,
break, continue, return and an assignment. *)
(* A bare [()] written where a body goes — a one-line slot, a function's
[= ()] — is the empty statement, [(do)], as it is on a line of its own:
"do nothing" is what it says there. [(())] stays a value. *)
and unit_slot p i0 (t0 : token) (e : Form.t) =
if p.i - i0 = 2 && t0.tok = LP && e.v = Form.List [] then
Form.make (Form.List [ sym t0.loc "do" ]) e.loc
else e
and inline_stmt p : Form.t =
let t = peek p in
let glued = let n = peek_at p 1 in n.tok = LP && not n.sp in
@ -744,6 +769,7 @@ and inline_stmt p : Form.t =
mk p t.loc (Form.List [ sym t.loc "return"; v ])
else mk p t.loc (Form.List [ sym t.loc "return" ])
| _ ->
let i0 = p.i in
let e, _ = expr p in
match (peek p).tok with
| NAME "=" ->
@ -754,7 +780,7 @@ and inline_stmt p : Form.t =
let eq = advance p in
let v, _ = expr p in
mk p t.loc (compound eq.loc (List.assoc op assign_ops) e v (span p e.loc))
| _ -> e
| _ -> unit_slot p i0 t e
(* [fn(a, b) = body] is a lambda; [fn(...)] followed by anything else is the
fallback call spelling of [(fn ...)]. *)
@ -766,7 +792,9 @@ and fn_expr p =
| NAME "=" ->
ignore (advance p);
let ps = lambda_params args in
let i0 = p.i and t0 = peek p in
let body, _ = expr p in
let body = unit_slot p i0 t0 body in
(mk p t.loc
(Form.List
[ sym t.loc "fn"; Form.make (Form.Vec ps) (span_of_list lp.loc args); body ]),
@ -826,6 +854,15 @@ and items p closer open_loc ~what =
\ let f = %s\n ...\n\n\
and pass f, or write it on one line: %s = value"
(text_of e) (text_of e)
else if starts_value n.tok && n.sp && not (negative_literal n.tok)
&& n.loc.Loc.line > e.loc.Loc.eline then
(* Most often the bracket was never closed: the next statement
has been read as one more argument. *)
failk "missing-comma" n.loc
"%s on a new line follows %s with no comma between them. If the \
%c on line %d was meant to close before this line, close it; \
otherwise separate %s with commas"
(show n.tok) (text_of e) opener open_loc.Loc.line what
else if starts_value n.tok && n.sp && not (negative_literal n.tok) then
failk "missing-comma" n.loc
"%s follows %s with no comma between them. Separate %s with \
@ -883,6 +920,14 @@ and map_items p open_loc =
| COMMA -> ignore (advance p); go (e :: acc)
| RC -> ignore (advance p); List.rev (e :: acc)
| EOF -> unclosed p '{' open_loc
(* [P{x = 1}] or [P{x: 1}]: another language's field syntax. *)
| (NAME "=" | COLON) as tk
when (match e.v with Form.Sym n -> n <> "" && n.[0] <> '.' | _ -> false) ->
let n = text_of e in
failk "brace-field" (peek p).loc
"a field in braces is written {.%s value}: a dot before the name, \
and no %s between it and the value"
n (if tk = COLON then "colon" else "= sign")
| tk when starts_value tk && (peek p).sp ->
if lvl < 8 then refuse_ws ~brace:true t.loc e;
go (e :: acc)
@ -1294,7 +1339,15 @@ and header (s : st) w : Form.t =
ignore (advance p);
block s ~after:"fn"
end
else [ value_line s ~after:"=" ]
else begin
let i0 = p.i and t0 = peek p in
let v = value_line s ~after:"=" in
let bare =
t0.tok = LP && p.toks.(i0 + 1).tok = RP && v.v = Form.List []
&& (match p.toks.(i0 + 2).tok with NEWLINE | EOF | DEDENT -> true | _ -> false)
in
[ (if bare then Form.make (Form.List [ sym t0.loc "do" ]) v.loc else v) ]
end
| NEWLINE ->
ignore (advance p);
if (peek p).tok = INDENT then block s ~after:"fn" else []

View File

@ -206,7 +206,8 @@ Each item: the proposal, then the reason in one line.
- **`for i in range(n)`**, `range(a, b)`, `range(a, b, step)` read as
`dotimes`. `range` here is syntax, not a function. `..` is avoided because
`a..b` would lex as one name. **Built** (a label goes first here too:
`for :outer i in range(n)`).
`for :outer i in range(n)`; in a macro template the variable may be an
unquote, `for ~i in range(~n)`).
- **`return v`, `break`, `break :outer`, `continue`, `defer expr`** (or `defer`
plus a block). **Built**; `defer` plus a block reads `(defer a b …)`.
`break`, `continue`, `return v` and `x = v`/`x += v` also fit the one-line
@ -255,7 +256,8 @@ Each item: the proposal, then the reason in one line.
the body, where the form wants them. **Built.**
- **Unit:** `()` as a statement reads `(do)`; in a type it is `()`. **Built**;
inside an expression `()` stays `()`, and the printer writes a lone `()`
statement as `(())`.
statement as `(())`. A bare `()` in a one-line body slot (`fn f() -> () = ()`,
`_ -> ()`, `fn() = ()`, `then ()`) is a statement too, and reads `(do)`.
- **Lambda:** `fn(i, j) = i * 10 + j`, or `fn(i, j)` plus a block. **Built**;
its parameters are bare names, as `(fn [i j] …)` wants, with no `dyn`.
`fn(…)` followed by anything else is the fallback call.

View File

@ -582,6 +582,25 @@ let () =
reads "typed let" "let x: i32 = 5\nx" "(let [x (the i32 5)] x)";
refuses "a let takes no block" "fn f() -> ()\n let x = 1\n g(x)\n h(x)"
"indent/let-block" "go at the let's column";
(* Mistakes carried over from other languages, answered in this one. *)
refuses "a block without the colon names the call" "with-allocator(a, b)\n g()"
"indent/stray-indent" "as in with-allocator(a, b):";
refuses "field with =" "p = P{x = 1}" "indent/brace-field" "{.x value}";
refuses "field with a colon" "p = P{x: 1}" "indent/brace-field" "no colon";
refuses "a dotted range" "for i in 0..10\n g(i)" "indent/dot-range" "range(0, 10)";
refuses "a block lambda inside a call" "sort-by(xs, fn(a, b)\n a < b)"
"indent/lambda-block-in-brackets" "let f = fn(a, b)";
refuses "an unclosed call swallows the next line" "fn f() -> ()\n push(v, 1\n g()"
"indent/missing-comma" "If the ( on line 2 was meant to close";
refuses "else under a one-line if" "if a then b\nelse c"
"indent/orphan-else" "one-line if";
reads "a bare () in a body slot does nothing"
"fn f() -> () = ()\nfn g(x) -> ()\n match x\n 1 -> h()\n _ -> ()\n k = fn() = ()"
"(defn f [] () (do))\n(defn g [x dyn] () (match x 1 (h) _ (do)) (set k (fn [] (do))))";
reads "a parenthesised () stays a value" "x = (())" "(set x ())";
reads "a template's for takes an unquoted variable"
"quote\n for ~i in range(~n)\n g(~i)"
"(quasiquote (dotimes [(unquote i) (unquote n)] (g (unquote i))))";
(* And back: the printer writes the idioms. *)
let prints name src want =
match Reader.read_all ~file:"<p>" src with
@ -718,7 +737,40 @@ let () =
"(defn f [] i32\n (let [a 1 ; first\n b 2] ; second\n (+ a b)))"
" let a = 1 ; first\n let b = 2 ; second";
back "comments to parens" "; head\n\nfn main() -> i32\n ; why\n g() ; note\n 0"
"; head\n\n(defn main [] i32\n ; why\n (g) ; note\n 0)"
"; head\n\n(defn main [] i32\n ; why\n (g) ; note\n 0)";
(* Written the way the corpus writes them. *)
back "quasiquote as its reader sugar"
"defmacro(m, [x & body]):\n quote\n g(~x)\n ~@body"
"`(do (g ~x) ~@body)";
back "arms a pair to a line"
("fn f(s) -> dyn\n match s\n 1 -> \"one, a long string to break the line\"\n"
^ " _ -> \"another long string to push it over\"")
" (match s\n 1 \"one, a long string to break the line\"\n _ \"another";
back "a let's bindings hang after their names"
("fn f() -> dyn\n let a = compute-something-long(1, 2, 3, 4, 5)\n"
^ " let b = compute-something-long(5, 6, 7, 8, 9)\n a")
"(let [a (compute-something-long 1 2 3 4 5)\n b (compute-something-long 5 6 7 8 9)]";
back "a label stays with its test"
"fn f() -> ()\n while :outer some-long-condition?(1, 2, 3) and another-long-one?(4, 5, 6)\n g()"
"(while :outer";
back "a call's arguments fill the line"
"fn f() -> ()\n println(\"alpha\", \"beta\", \"gamma\", \"delta\", \"epsilon\", \"zeta\", \"eta\", \"theta\", \"iota\", g(1))"
"\"eta\" \"theta\" \"iota\"\n (g 1))";
back "a comment between a cond's test and its branch stays there"
"fn f(x) -> dyn\n if x\n ; why\n 1\n elif y\n 2\n else\n 3"
"; why";
prints "an if with no else keeps its test in the parentheses"
"(defn f [] () (if (> a 1) (let [k 2] (g k))))" " if(a > 1):\n let k = 2";
prints "and inside or keeps its parentheses"
"(defn f [a bool b bool c bool] bool (or (and a b) c))" "= (a and b) or c";
prints "a field of a field chains" "(defn f [] () (g (.count (.x w))))" "g(w.x.count)";
prints "an else-if chain on one line"
"(defn f [r] dyn (if (> r 7) :rich (if (> r 4) :fair :poor)))"
"if r > 7 then :rich else if r > 4 then :fair else :poor";
prints "a long vector wraps" ("(defn f [] () (let [v [" ^ String.concat " " (List.init 30 string_of_int) ^ "]] (g v)))")
" let v = [0 1 2 3";
prints "a template's for keeps its unquotes"
"(defmacro m [i n & body] `(dotimes [~i ~n] ~@body))" "for ~i in range(~n)"
(* ── Spans, for pause marks and error overlays ──────────────────────── *)
@ -914,7 +966,21 @@ let () =
(* A unit argument deeper in the last form is about that argument. *)
refused "unit-arg.flan"
"(defn g [x dyn] dyn x)\n(defn f [coll] dyn (g (println 1)))\n(defn main [] () (f 1))\n"
[ "() does not box into dyn" ]
[ "() does not box into dyn" ];
(* Checker messages about a .fln file are in its spelling. *)
refused "enum-none.fln" "fn main() -> i32\n let n = None\n 0\n" [ "the(Option(i32), None)" ];
checks "enum-annotated.fln"
"enum Dir\n north\n south\n\nfn main() -> i32\n let d: Dir = :north\n i32(d)\n";
refused "annotation-dyn.fln" "fn main() -> i32\n let d = the(dyn, 3)\n let x: i32 = d\n x\n"
[ "a type annotation checks a value as i32"; "write i32(d)" ];
refused "narrowing.fln" "fn f() -> i64 = 1\n\nfn main() -> i32\n let x: i32 = 0\n x = f()\n x\n"
[ "it has to be written: i32(x)" ];
refused "unknown-type.fln" "fn f(p: Keyword) -> i32 = 0\n\nfn main() -> i32 = 0\n"
[ "unknown type Keyword" ];
refused "untyped-lambda.fln" "fn main() -> i32\n let f = fn(a)\n a\n 0\n"
[ "let f: Fn(T, ...) -> R = fn(...)" ];
refused "plusplus.fln" "fn main() -> i32\n let x = 1\n x++\n x\n"
[ "write ++(x) or x += 1" ]
(* ── Both directions of an import, on both backends ────────────────── *)