flan/spec-syntax.md

25 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. key-pressed?, dyn->f64, bytes=?, 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; returns of different types give dyn; 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 always whitespace, so no block opens inside a call's parentheses (§3.1's blocks all open after the ); a lambda with a block body is a statement or a value, let f = fn(x) plus a block).
  • 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.
  • 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).
  • 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 (== != < <= > >=) < << >> < + - < * / % < unary - < postfix (call, index, field). Built. Mixing comparison operators in one chain, a < b <= c, is refused. An operator glued to ( is always a call.
  • == 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).
  • and, or, not are words, since they are Flan's own names. Built.
  • 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 is always flat: a line indented deeper under let x = v is refused. To end a let's scope early, put it in a do: block. The printer writes every let flat. 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/loop 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.

  • 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).

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 ordered?($t) after the return type. Built, with -> R required until step 6; several predicates are where p, q.
  • def x = v, def x: T = v, once x: T, once x = v, const n = 3, def scratch: [4 u8] = uninit. Built. def x = v and once x = v read with dyn; const n = 3 reads (defconst n 3), its type inferred as today.
  • 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).
  • import rl "vendor:raylib". Built.
  • Every other form uses the fallback (next item) until someone asks for sugar: defclass, defgeneric, defmulti, defmethod, declare, declare-c, defalias, defmacro, loop/recur, array-fill. Built. The class forms keep the fallback for good (2026-09-26): defmethod(area, point, [p]): reads well enough.

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. Built (the arrow is read only in a type position; inside a value, vec-new(Fn([i32], i32)) is the call spelling).

Macro templates

defmacro(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 is refused.
  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. The printer writes that form back as the typed lambda.
  3. Dir.north is the enum member :north, in a value and in a match pattern, in both syntaxes. :north stays.
  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, …). Until step 6 lands, a .fln function must write -> T; omitting it is refused with a message saying inference is coming. 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; no :syntax means paren, except a load-file of a .fln file; 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 fn(…) 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.

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.