flan/spec-syntax.md

38 KiB

Spec 3 — The indented surface

Status: draft. Sections 1 and 3 are decided by the author. Section 2 is proposed and needs the author's confirmation before the matching piece is built. Nothing here changes what a program means: the indented syntax is a second reader that produces the same Form.t tree as lib/reader.ml.

Evidence for every file:line below was read on 2026-09-25. Re-read before building on it.

1. Decided

One tree, two readers. A new reader turns indented text into Form.t, exactly the nine node kinds in lib/form.ml:11-27, with locations filled the way lib/reader.ml:47 fills them. Everything after the reader (Expand, Parse, Check, both backends) is untouched. This is the plan the old WAIT entry in TODO.org described, and it rules out Parinfer, wisp and sweet-expressions the same way.

Both syntaxes live side by side. File extension picks the reader. A program may mix files of both kinds, and imports cross freely. The paren syntax is not deprecated; this is a test drive.

Blocks are indentation. A header line (fn …, if …, while …, else, …) followed by deeper-indented lines opens a block. Headers need no :, then, do or end. (An ordinary call takes a block only with a trailing :, section 3.)

Calls and indexing are adjacency. f(a, b) with no space before ( is a call. x[i] with no space before [ is indexing; x[i, j] is (at x i j). Arguments are separated by commas.

Lisp names stay. is-key-pressed, dyn->f64, is-bytes-equal, swap!, *earmuffs* are one token each. The rule that makes this work: binary operators need spaces around them. a-b is a name; a - b is subtraction. No snake_case mapping.

Package qualifier stays /. rl/draw-fps(20, 20). Division is always spaced, a / b.

Keywords stay. :key-r, :else. A colon glued to the front of a word is a keyword; x: T (colon glued to the end, space after) is an annotation.

Collection literals. [a b] or [a, b] is a vector, {k v} a map or struct literal, '(a b c) a list. 'name stays the quoted symbol that invoke-restart takes (60 uses, all restart names).

Types are marked, and omitted means dyn. x: i32 gives a type. A parameter, field or global with no : T is dyn. The reader writes dyn explicitly into the parameter vector: it emits [x dyn y dyn], never [x y], because Check.pair_params (check.ml:1589) reads [x y] as one parameter of type y when some type is named y. That silent misparse is what parse.ml:1413-1418 warns about; the new syntax must not inherit it.

The return type is inferred when omitted. Body-local only, as docs/SPIKE-INFERENCE.md ("The cheap first step" and "Verdict") scopes it:

  • the return type is the body's type; a dyn body gives dyn; the exits (the last form and each return) meet exactly as an if's or match's arms do, in any order: each is asked the others' type first, so a literal, nil or arithmetic takes it; only arms that are refused that way meet at the join (lossless widening, the read-only side of a const difference, dyn beside a genuinely dyn value); what if refuses is refused; no value gives ().
  • it reads only the function's own body, never a call site.
  • a self-recursive or mutually recursive function must write its return type. Refuse by name, naming the whole cycle. The corpus has 17 self-recursive functions and 5 mutual groups (all in calc-me.flan, vendor/edn, vendor/json).
  • nothing is ever defaulted in signature position. Parameters are never inferred; an omitted parameter type is dyn, which is a rule, not inference.

The paren syntax spells "infer the return" as _ in the return slot (section 3), which is what the indented reader emits when -> is omitted.

A changed signature installs, and the warning names the cause. The dev loop already accepts a signature change and stops stale callers with StaleCall (lib/session.ml:353-363, TODO.org "Signature generations and stale-caller warnings"). When the change came from inference, the stale-caller warning says so and points at the line: "speed now returns f64, not i32, because of line 12".

Macros stay. They run on Form.t, so they work in both syntaxes. A call can take an indented block as its last arguments, so rl/with-drawing and comment take a body (with a trailing :, section 3):

rl/with-drawing():
  rl/clear-background(rl/black)
  game-draw()

reads as (rl/with-drawing (rl/clear-background rl/black) (game-draw)). Replacing macros with built-in constructs is not part of this work.

Diagnostics may print paren syntax during the test drive. Form.to_string, the usage strings in parse.ml and check.ml, and Render print parens today (inventory in section 5). Types in check.ml's messages do not: they are spelled by Types.spell, in the syntax of the code the message is about (section 3, item 9).

2. Proposed (confirm before building the piece it governs)

Each item: the proposal, then the reason in one line.

Lexical

  • Extension .fln. Short; .flan keeps meaning parens, so generated.flan and every existing path stay valid. Built.
  • Comments stay ;. Nothing else wants the character. Built.
  • Spaces only. A tab in indentation is an error. The corpus has no tabs. Built.
  • Indentation is measured in columns, any width. A dedent must land on a column already on the stack (GDScript gdscript_tokenizer.cpp:1291-1296). Built.
  • Blank and comment-only lines never open or close a block (GDScript 1170-1239). Built.
  • Inside ( [ {, newlines and indentation are ignored except where a trailing block is allowed. Make it parser-driven, the way GDScript's push_multiline is (gdscript_parser.cpp 658-672, 3695-3770), not a paren counter in the lexer, or a block inside a call can't work. Built as a depth counter instead: inside brackets a line break is whitespace, with one exception. A => that ends a line inside brackets opens a lambda's block there, laid out as at the top level against the column its line starts at, and the block ends where the enclosing bracket closes.
  • Continuation outside brackets: a line that starts with a spaced infix operator (+, and, ==, |>, …) continues the previous line; so does a line after one that ends in a spaced infix operator. (F# LexFilter.fs 360-380, 1850-1870, 2345-2360.) No \ continuation. Built (= does not continue: let x = plus a block is a block value). A continuation line must sit deeper than the line it continues; one that does not is refused.
  • Minus. - glued to a digit is a negative literal (-1; 269 in the corpus). - glued to a name is negation (-x becomes (- x); no name starts with - except two prelude sentinels, lib/prelude.ml:2280,2285, which rename). a - b is subtraction. a -1 is an error: "separate with a comma or space the minus". Built.
  • -> needs spaces as the return arrow. dyn->f64 stays a name. Built.
  • A name carries no ? or ! (decision 130). A name that asks a question starts with is- or has-: is-empty, has-key, rl/is-key-pressed. A ? or ! inside a name, or ending one where no type or chain can follow, is refused with the is- form as the fix. ? and ! are the marks of the optionals below. Built.
  • Character literals stay \c, lexed before brackets and operators: \(, \,, \space. 277 uses, many of them delimiters of the new syntax. Built.

Collections and separators

  • Commas separate elements. With no commas, whitespace does, but only between single terms. [1 2 3], [i n], {.x 1 .y 2} and [4 f32] read as today. [a - 1 b] is refused: "separate elements with commas". This keeps the Lisp look for data and is refusable by shape. Built (in braces a value may have an operator in it, {.x a + 1, .y 2}; the comma after it is what is required).
  • Indices separate the same way. grid[r c] and grid[(r + 1) (c - 1)] are two indices each; grid[r + 1, c] needs its comma, and grid[r + 1 c] is refused with the commas put in as the fix. grid[i -1] is refused as a glued minus. Built; the printer writes indices with commas.
  • Struct literal: Vector2{.x 1, .y 2} (brace glued to the name) reads (Vector2 {.x 1 .y 2}). A bare {.x 1} is today's bare literal. {:a 1} is a dyn map. Built.
  • No set literal. Flan has none today: #{1 2} reads as the symbol # and a map. Adding sets is a language change, not a syntax one. In a .fln file #{1 2} reads (# {1 2}), a brace glued to a name.

Expressions

  • Precedence, low to high: |> < or < and < not < comparisons (== != < <= > >=) < ?? < || < ^^ < && < << >> < + - < * / % < prefix - and ~~ < postfix (call, index, field, !, ?, ?.). Built. An operator glued to ( is always a call. The bit operators sit where Python and Rust put them, so x && mask == 0 is (x && mask) == 0.

  • The pipe (decision 137): x |> f(a, b) reads (f x a b); x |> f() and x |> f read (f x). The value on the left is the first argument. A chain runs left to right: get(grid, r, c) |> or-else(empty) |> is-empty-cell() reads (is-empty-cell (or-else (get grid r c) empty)). The name may be qualified, x |> m/f(a), and the call may take a block, xs |> each():. It is the loosest operator, so each side is a whole expression: a + 1 |> f() is f(a + 1), a ?? b |> f() is f(a ?? b), a == b |> f() is f(a == b), not x |> f() is f(not x) and a or b |> f() is f(a or b). When the right side names a function, the left side is evaluated once, before the call's other arguments. A macro receives the left side unevaluated as its first argument, as it would in the written call: x |> set(5) is set(x, 5), which assigns to x. The right side is a name or a call to one; anything else — x |> 3, x |> a + b, x |> a.b, x |> f(a).x, x |> f(a)(b), a word such as if, let or fn — is refused. Since the pipe binds loosest, a |> f ?? d is a |> (f ?? d) and is refused with the bracketed form (a |> f) ?? d as the fix; likewise a test, (a |> f)? as v. A pipe line under a statement is indented past it:

    let cell = get(grid, r, c)
               |> or-else(empty)
               |> is-empty-cell()
    

    The reader writes the plain call, so nothing after it knows a pipe was written. |>(a, b), glued, is a call to a function named |>. Built.

  • The bit operators are a && b, a || b, a ^^ b and ~~a, reading (bit-and a b), (bit-or a b), (bit-xor a b) and (bit-not a). They take integers; and, or and not are the logical ones. ~~ is one token, so a nested unquote is written ~(~x). Built.

  • A comparison chain may mix < with <=, or > with >= (decision 124). 0 <= r < rows reads (and (<= 0 r) (< r rows)). It is evaluated as a < b < c is: every operand once, left to right, before any test, with no short-circuit. When an operand is more than a name or a literal, every operand but a literal is bound first, in order, to a fresh name, so a name is read before a call to its right runs: a < f(x) <= b reads (let [~cmp1 a ~cmp2 (f x) ~cmp3 b] (and (< ~cmp1 ~cmp2) (<= ~cmp2 ~cmp3))). flan convert to parens names them mid, mid2, ..., or, inside a template, ~(Form.Sym {.s "~cmp1"}), which no caller can capture. A chain that changes direction, a < b > c, or mixes in == or !=, is refused with the whole chain rewritten as and, a middle call named by a let first. The printer writes such an and back as the chain. Built.

  • == is =; = is assignment. x = v reads (set x v), a[i] = v reads (set (at a i) v), p.x = v reads (set (.x p) v). x += v reads (set x (+ x v)) where every part of the place is a name or a literal, and (update x + v) otherwise, so the place is evaluated once either way, as with ++. Built (also -=, *=, /=).

  • A run of the same operator flattens (variadics, section 3): a + b + c reads (+ a b c), a < b < c reads (< a b c) (Flan's chain semantics, test/programs/chain.flan). This keeps the converter round trip exact (section 4). Built.

  • Field access is postfix: camera.target.x reads (.x (.target camera)). A capitalised left side is a qualified case, not a field: Shape.Rect stays one symbol. test/programs/dev-rerun.flan:65 names a global .init-once.counter; rename it. Built, without the rename: it prints and reads back through the fallback, defonce(.init-once.counter, i64, 7).

  • On a dyn value, x.name is (get x :name) and x.name = v is (put x :name v), for a class slot and a plain map's key alike; m[:k] is (get m :k) and m[:k] = v puts. The paren spellings (.name x) and (at m :k) mean the same. Built.

  • and, or, not are words, since they are Flan's own names. Built. not is a prefix word: not a == b is not (a == b), not a and b is (not a) and b, and not(x), glued, is the call.

  • Optionals, after Swift (decision 130). Built.

    • x ?? d reads (?? x d): what x holds, or d when x is None (nil over a dyn). d is evaluated only then. a ?? b ?? c reads (?? a b c) and groups from the right; a default that is itself an Option keeps the whole an Option. a ?? b == c is (a ?? b) == c.
    • x! reads (!! x): what x holds, and a trap at that site naming x when it holds nothing.
    • a?.b reads (?. [~o1 a] (.b ~o1)): None (or nil) when a holds nothing, otherwise Some of the rest of the postfix chain over what it holds — a?.f(x), a?[i], a?.b.c. A result that is already an Option is not wrapped again, so a?.b?.c is one Option. A rest with no value makes the whole a statement. ~o1 is a fresh name no reader produces.
  • Casts and type-taking builtins are calls: i32(x), vec-new(u8), max-value(u8), the([3 f32], [1 2 3.5]). A pointer cast is the type called: Ptr(Color)(p) reads ((Ptr Color) p). Built.

Statements and blocks

  • let x = v scopes to the end of its block and reads as (let [x v] rest…). Consecutive lets merge into one binding vector. A let takes more bindings on the lines indented under it, lined up with its first name, each seeing the ones above:

    let row = r + 1
        col: i32 = c - 1
        {x .x} = p
    

    is (let [row (+ r 1) col (the i32 (- c 1)) {x .x} p] rest…). A name that is an operator word is written in parentheses, (not) = 3. A binding at another column than the first name, or any other line indented there, is refused, and so is a tab between let and its first name. A binding whose value is a block (= match x, a lambda header, = and the lines under it) is its let's last; the printer starts a new let after one. A block lambda whose brackets close after its block, g = map(xs, fn(x) => over x + 1), is not: its block is shut when the value ends, and more bindings may follow. At the top level each such line is one more global, (def col i32 …), and may be name: T alone as a global's own line may; a pattern there is refused, since a global binds one name. A let is otherwise flat: its scope is the rest of its block. To end it early, put it in a do: block. The printer writes every let flat, and a run of bindings whose values are short (one line, 40 characters or fewer) as one let with the rest under the first name; top-level globals stay one let each. A let with statements after it takes them into its body; when one of them means an outer name the let rebinds, the let's is renamed (x to x-2, a name the top-level form does not use; a struct pattern is written as {x-2 .x} pairs). A macro's body counts as statements run in order when its definition splices its rest parameter only into a do, a let/fn/when/while body or another such macro's body; comment counts too. Where a rename cannot be trusted (the name quoted, qualified as x/y, or called as x(...)), and at the top level, among a call's other arguments and in a quasiquote, the let goes in a do: block instead, and so does one whose longer scope would reach a call of a macro whose template names the let's name. A macro's body counts only if nothing but its templates depends on how the body splits into arguments (a count against the body's start, a predicate on its first form). One case this cannot see: a macro defined nowhere the printer reads (not the prelude, the file or an imported package) whose expansion names a variable its call does not spell. Destructuring: let {.x .y} = p, let [head & tail] = xs. (defer is function-scoped, not let-scoped, TODO.org "defer may be written in a let", so merging never moves a cleanup.) Built; let x = with the value as an indented block also reads, and so does def/once/const.

  • if/elif/else. else and elif sit at the if's column. No elif reads as if (with else) or when (without); with elif it reads as cond. One-line form: if c then a else b, for use in a let. Built (a block of one line is that line; of more, (do …)). An else or elif on the line after a one-line if c then a, at its column, continues it (section 3, item 6); each such clause is one-line (elif c then x, else y) or takes a block.

  • when c plus a block, or when c then a, reads as when, which is what an if without else reads as too. No else or elif follows it. A when whose value is kept (a let's value, an argument, a return) gives Some(a) when c holds and None when it does not; where a dyn is wanted, a or nil. As a statement it gives nothing. An if/elif chain with no else is the same when kept: None when no test holds. Built.

  • if let P = v plus a block reads as (if-let [P v] then); elif and else follow as for if, the rest of the chain being the if-let's else. elif let P = v is a further if-let nested in that else. Kept with no else at the end of its chain, it gives an Option as when does. P is any match pattern, and its names are bound in the block only. One line: if let Some(g) = o then g else 0. A plain name, if let g = o, is refused toward if o? and if o? as g below; _ is refused toward let. Built.

  • x? tests that a value is present (decision 133): a bool, true when an Option is Some and when a dyn is not nil. It reads (? x). In if x?, elif x? and while x?, and in the rest of an and after the test, a local x that is an Option is its payload in the block (a dyn stays a dyn). It is the same storage, so x.count += 1 there changes the Option's payload. Not in the else, not after the block, and not through or or not. In the block x may be given a value of the payload's type, which keeps it present; giving it an Option is refused, and a parameter or a captured copy is no more assignable than outside it. A local whose address is taken, or that a fn assigns, anywhere in the function is not narrowed (something else could clear it); if x? as g copies what it holds instead. A ? after a chain tests the whole chain: o?.i?. A capitalised name before ? is read as a type, so a local tested this way needs a lowercase name. Built.

  • e? as g names what a test found, for an e that is not a plain name: if get(grid, r, c)? as cell reads (if-let [cell (get grid r c)] …), an if-let over a plain name, which binds what an Option holds or a dyn that is not nil. It works after if, elif and while; while e? as g plus a block reads (while true (if-let [g e] (do …) (break))). Built.

  • while c, until c, optional label first: while :outer c. Built.

  • 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); 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 slots: a match arm's value, then/else, and after defer.

  • match:

    match shape
      Circle(r) -> 3.14 * r * r
      Rect(w, h) -> w * h
      :north -> 0
      _ -> 0
    
    match code
      404 -> "missing"
      -1 -> "none"
      "ok" -> "fine"
      \a -> "a"
      _ -> "other"
    
    match ready
      true -> go()
      false -> wait()
    

    An arm's body can be an indented block, which reads as (do …). Built (a one-line block reads as that line). A number, char or string pattern is the literal as written, compared as (= t lit); over a dyn a keyword or true/false is too. A bool's arms are true and false, and naming both needs no _.

  • Conditions, clauses at the header's column:

    handler-case
      edn/read-file("game-data.edn")
    on FileError(c)
      nil
    
    restart-case
      agent/poll()
    restart continue()
      ()
    restart use-value(v: i32)
      v * 2
    

    handler-bind takes the same on clauses; the reader moves them in front of the body, where the form wants them. Built. A restart's report text goes on its header, restart retry() "Try the load again", and reads (retry [] :report "Try the load again" …).

  • Unit: () as a statement reads (do); in a type it is (). Built; inside an expression () stays (), and the printer writes a lone () 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. A lambda may state its types, fn(a: C, b) -> bool => … or plus a block (section 3, item 7). => is a lambda's only spelling: fn(a) = x and a lambda header with a block under it and no => are refused, with the => form as the fix. Named functions keep =. A block lambda may sit inside brackets:

    sort-by(slice(xs), fn(a, b) =>
      let d = a.n - b.n
      d < 0)
    

    The block ends where the brackets close, with the ) at the end of its last line or on a line of its own at the call's column. It is the last thing in them: a comma after the block is refused (so a call takes one block lambda, as its last argument; name any other with let), as is a line inside the brackets at or left of the column the => line starts at. Block lambdas nest, each block ending at its own brackets. flan convert writes a call whose last argument is a lambda with a block this way.

Definitions

  • fn name(a: i32, b) -> R plus a block; fn name(a) = expr for one expression. Reads (defn name [a i32 b dyn] R …). A {:where …} constraint becomes where is-ordered($t) after the return type. Built; with no -> R the return is _, read off the body. Several predicates are where p, q.
  • A let at the top level is a global: let x = v, let x: T = v, let scratch: [4 u8] = uninit read (def x dyn v), (def x T v); once x: T, once x = v, const n = 3. Built. let x = v and once x = v read with dyn; const n = 3 reads (defconst n 3), its type inferred as today. def is refused with the let to write. A let in a block, comment:'s included, is local, and so is one in code the editor evaluates as an expression.
  • struct Cell with a name: Type line per field. data Shape with a line per case: Circle(r: f32), Empty. enum K with lo = -1, mid. union U like struct. Built (an untyped field is dyn; Empty() is (Empty [])). A member is :mid or K.mid, in a value and in a match arm, in both syntaxes (section 3, item 8). A condition names its parent after the name, struct DiskFull :parent IoError with its field lines, reading (defstruct DiskFull :parent IoError [free i64]); with no field lines it reads (defstruct IoError :parent Error). A struct or union fits on one line with its fields in parentheses, struct Pt(x: i32, y: i32) or struct DiskFull(free: i64) :parent IoError; flan convert writes that when it fits the line and no comment sits among the fields. Built.
  • type Row = Vec(i32) reads (defalias Row (Vec i32)). Built.
  • macro repeat(i, n, & body) plus a block reads (defmacro repeat [i n & body] …). A parameter is a bare name, a destructuring vector [a b], or & rest, last. Built.
  • There is no loop or recur. A loop is while, until, dotimes or for, over let variables it changes, with break and continue. The reader refuses loop and recur in any spelling, inside quote too (indent/no-loop), and flan convert refuses a .flan file that uses them, naming each line (convert/no-loop). A macro defined in a .flan file may still expand to them. Built.
  • class lambda(param, body, env), or class lambda with a slot per line, reads (defclass lambda [param body env]); a typed slot is pause: bool and its type follows its name in the vector. Built.
  • generic describe(v) -> dyn reads (defgeneric describe [v] dyn); multi kind(v) -> dyn = type-of(v), or plus a block, reads (defmulti kind [v] dyn (type-of v)). Their parameters are bare names. Built.
  • method describe(f: lambda) plus a block reads (defmethod describe lambda [f] …): a class is the first parameter's type. Any other dispatch value follows when: method kind(v) when :int, when :else for the default. = value for a one-line body. Built.
  • import rl "vendor:raylib". Built.
  • Every other form uses the fallback (next item) until someone asks for sugar: declare, declare-c, array-fill. Built.

The fallback

Any form can be written as a call: head(arg, …), or head(arg, …): plus an indented block, reads as (head arg … block…). Commas vanish into the form. defmethod(describe, :square, [s]): plus a block is (defmethod describe :square [s] …). So every form is reachable on day one, the printer has something to fall back on, and the sugar above can land one piece at a time. Built; a header word glued to ( is always this call, if(c, a), let([x 1], x). A bare name with a trailing colon takes a block too, comment: (author's decision 85).

Types

After : and ->, a small type grammar that reads to today's type forms: i32, $t, (), [T], [const T], [n T], Vec(T), Map(K, V), Option(T), Ptr(T), Ptr(const T), Fn(A, B) -> R, CFn(A) -> R, rl/Vector2. T? is Option(T) anywhere a type is written: [i32?], Vec(Shape?), Option(i32)?, and a lowercase type's grain?. Where a value is written, ? after a capitalised or primitive type's name is still the type, vec-new(i32?); after anything else it is the test x?. i32?? and x!! are refused toward Option(i32?) and (x!)!. Built (the arrow is read only in a type position; inside a value, vec-new(Fn([i32], i32)) is the call spelling).

Macro templates

macro with-mode-2d(camera, & body)
  quote
    begin-mode-2d(~camera)
    ~@body
    end-mode-2d()

quote plus a block is a quasiquote; ~x and ~@xs are unquote and splice, the Clojure spellings the reader already has. (An earlier sketch used $x; that collides with type variables such as $t.) Built: one line reads (quasiquote line), more read (quasiquote (do …)); ~ takes the atom right after it, so ~name(x) is ((unquote name) x), and ~(f(x)) unquotes a call.

3. Settled after review (2026-09-25)

  1. A call takes a block only with a trailing :. rl/with-drawing(): then the indented body. A deeper-indented line after an ordinary call is an error, never an extra argument, so a stray indent can't be silently absorbed into the call above it. This is F#'s rule too: a block opens only after a specific token (=, ->, then, do, …; LexFilter.fs 2236-2526), and F#'s own with-drawing would need fun () ->. Headers (fn, if, while, …) are the openers here and need no :. The fallback form takes its block the same way: defmethod(describe, :square, [s]):.
  2. Variadics stay. Three spellings, all reading to the same variadic form:
    • a run of one operator flattens, a + b + c → (+ a b c), a < b < c → (< a b c), x and y and z → (and x y z);
    • an operator glued to ( is a call: +(a, b, c), !=(a, b, c), and(p, q, r);
    • ordinary variadic functions are just calls: println(a, b, c). The one exception: a != b != c is refused, with a message pointing at !=(a, b, c), because Flan's != means "all distinct", not what the chain suggests.
  3. '(a b) is quoted: a list of symbols, as in Lisp, the same ' as 'name. list(a, b) builds a list of values. Revisit if lists get common.
  4. Private functions are fn- name(…).
  5. "Infer the return type" is _ in the return slot in the paren syntax: (defn f [x i32] _ (+ x 1)). The indented reader emits _ when -> is omitted. _ in type position means "fill this in" in Rust and OCaml too, and no type can be named _.

Settled 2026-09-26, after writing programs by hand (test/syntax/handwritten/):

  1. A one-line if continues on the next line. if c then a followed by else b (or elif c2 then d, or either with a block) at the if's column is one if. An else left of that column, or indented deeper, is refused. After an if with a block, else x on one line is accepted too. Binding: an else or elif on a line of its own belongs to the if that starts at its column. An if inside a one-line slot (after then, after else, in an arm) ends with its line and takes no later clause, so

    if a then x
    else if b then y
    else z
    

    is refused at its last line: the first else took if b then y as its value, and the chain is if a then x else if b then y else z on one line, or elif b then y on the second.

  2. Typed lambdas. fn(a: C, b) -> R => body, or plus a block, reads (the (Fn [C dyn] R) (fn [a b] body)): the paren fn has no typed parameters, and the is how a value states its type, as in let x: T = v. An untyped parameter is dyn; the return type is required. Where a CFn of the same signature is wanted, the literal is that CFn; at a generic's CFn($t) -> $t parameter the literal is a CFn at its own types, which bind $t as any argument's would. The printer writes that form back as the typed lambda. A typed block lambda sits inside brackets as an untyped one does.

  3. Dir.north is the enum member :north, in a value and in a match pattern, in both syntaxes. :north stays. A local named Dir shadows the enum as a local shadows any global: Dir.north is then its field.

  4. Types in messages follow the code's syntax. Types.spell ~indented is the one printer, Fn(A) -> R, Option(i32), Small(4, i32) for a .fln location and (Fn [A] R) for a .flan one; Types.to_string stays the spelling for keys, symbols and runtime strings. Hard-coded code in a hint is written per message.

  5. flan convert keeps adjacent one-line globals adjacent, in both directions.

4. Build order

Each step lands on its own, with dune test --root . green.

  1. The reader (lib/indent_reader.ml or similar): tokenizer with the indent stack, then a parser to Form.t. Start with what sand.flan and algorithms.flan need, then the fallback, then the sugar in section 2 in order of corpus frequency (set, let, +, at, =, if, dotimes, …). Test: hand-convert algorithms.flan and sand.flan to .fln; the forms read from each pair must be equal, ignoring locations.

  2. Switch readers by extension at every program-source entry point: Front.load (lib/front.ml:16), Load.import (lib/load.ml:1211, plus entries at 137-142 and is_package_file at 103-105), Session.create (lib/session.ml:260), and bin/main.ml 327, 334, 393-399, 557-560. The prelude (lib/prelude.ml:2441), the wire protocol (lib/wire.ml:103) and the registry spelling (lib/dev.ml:2725) stay paren syntax.

  3. The printer, Form.t → indented text, and a flan convert command. Test: for every corpus file, read with parens, print indented, read indented; the forms must be equal to the first read, after one normalisation: every name a let binds is renamed through its scope to one numbered by binding order; then, in a body run in order, a let counts as equal to itself taking in the later statements of the body (a macro's body by the same rule as the printer's); (do x) with x a let counts as x; a let whose whole body is another let counts as equal to the merged let; (and x) and (or x) count as x. Taking in and the one-argument and stop at a quote or quasiquote. That covers 394 files and runs on readers alone, so it's fast.

  4. The dev loop. Code-carrying wire ops (eval, eval-expr, macroexpand, set) get an explicit :syntax field instead of guessing from :file. The :file guess breaks for <repl>/<inspect> origins and for flan-macroexpand-again (emacs/flan.el:3279-3309), which sends paren-syntax expansion text under the original file's name. Replace the space-padding in flan--text-at (emacs/flan.el:2602-2622), which breaks significant indentation, with :line/:col fields; the reader seeds its indent stack with that column. Built (also load-file and restart arguments; with no :syntax a request is read in the syntax of the source :file it names, as paren under a pseudo-name such as <repl>, and with no :file at all in the program's — so evaluating in a stopped frame of a .fln program reads indented; several indented statements sent as one expression read as (do …)).

  5. Emacs mode for .fln:

    • A top-level form runs from a column-0 line that isn't else, elif, on or restart to just before the next one, minus trailing blank and comment lines (python.el python-nav-end-of-defun, ~/Repositories/emacs/lisp/progmodes/python.el:2175-2195).
    • An inner block is its header line plus every deeper-indented line after it. Send the text unchanged, with its start line and column.
    • Replace the flan-mode-only checks: flan-watch--ghost-buffers uses (eq major-mode 'flan-mode) (emacs/flan-watch.el:247-255), and there are derived-mode-p 'flan-mode checks at flan.el:1140, 2095, 2408 and in flan-dape.el:96-129.
    • The breakpoint position Emacs sends (:pause (LINE COL), flan--pause-bounds at flan.el:2637-2660) must equal the start location the reader gave that form. Ast.mark_pause matches exactly (ast.ml:491-492).

    Built (emacs/flan-fln-mode.el; keys and objects in emacs/MANUAL.md, "Indented files"). A line ending in = or => also opens a block for TAB, and a body is its statement's own block, up to its first clause.

  6. Return-type inference in Check, with the recursion refusal and the stale-caller cause. This is independent of steps 1-5 once the marker exists. Built (Check.infer_returns). _ is refused outside a defn's return slot, in a generic's, and in defgeneric/defmulti's (defmethod has no return slot). An exit with no value beside one with a value is refused. A self- or mutually recursive group whose every exit gives () is (). A stale body with _ keeps the signature it was compiled with.

Out of scope: dropping macros, built-in replacements for with-*/defedn, printing diagnostics in the new syntax, converting the prelude or vendor packages, website and docs.

5. Reference

Where the grammar gets hard, from a survey of the whole corpus (394 files):

  • 513 (Type {…}) constructions, all with .field keys.
  • 1304 (.f x) accesses; chains of up to 5 (.a (.b x)) deep.
  • 65 cond and 185 match, written as flat pairs.
  • 1229 sets, all with 2 arguments. The target is a symbol 809 times, at 217, a field 180, get 10 and deref 9.
  • 22 chained comparisons and 16 variadic !=.
  • and/or with up to 15 arguments (vendor/edn/edn.flan:251).
  • 332 ().
  • 1048 lines of code-generating macros in vendor/edn/provide.flan and vendor/json/provide.flan, with 139 quasiquotes and 188 unquotes.

What prints paren syntax to a user:

  • Form.to_string in parse.ml and expand.ml diagnostics.
  • About 70 hard-coded usage strings and "write (…)" hints in parse.ml and check.ml.
  • Types.to_string, which is in every type error and in the eldoc, defs, layout and locals replies.
  • The macroexpand :text, :flat and :source fields (dev.ml:1416-1430).
  • Macro signatures in defs (dev.ml:1616-1629).
  • Render's value notation (println, the REPL, the inspector), which emacs/flan-inspect.el:122-272 parses and flan-inspect--literal (991) writes back as code.

Offside-rule references:

  • F#: ~/Repositories/Fable/src/fcs-fable/src/Compiler/SyntaxTree/LexFilter.fs. Copy the adjacency rule (2663-2670), continuation operators (360-380, 1850-1870), and else/elif alignment (2028-2032, 202-218). Skip the context stack; it exists for keyword-opened blocks, which this syntax doesn't have.
  • GDScript: ~/Repositories/godot/modules/gdscript/gdscript_tokenizer.cpp (check_indent, 1143-1304) and gdscript_parser.cpp (multiline flags and push_expression_indented_block).
  • tree-sitter-python: ~/Repositories/tree-sitter/languages/tree-sitter-python/src/scanner.c.