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
dynbody givesdyn; the exits (the last form and eachreturn) meet exactly as anif's ormatch's arms do, in any order: each is asked the others' type first, so a literal,nilor arithmetic takes it; only arms that are refused that way meet at the join (lossless widening, the read-only side of a const difference,dynbeside a genuinely dyn value); whatifrefuses 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;.flankeeps meaning parens, sogenerated.flanand 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'spush_multilineis (gdscript_parser.cpp658-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.fs360-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 (-xbecomes(- x); no name starts with-except two prelude sentinels,lib/prelude.ml:2280,2285, which rename).a - bis subtraction.a -1is an error: "separate with a comma or space the minus". Built. ->needs spaces as the return arrow.dyn->f64stays a name. Built.- A name carries no
?or!(decision 130). A name that asks a question starts withis-orhas-: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 theis-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]andgrid[(r + 1) (c - 1)]are two indices each;grid[r + 1, c]needs its comma, andgrid[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.flnfile#{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, sox && mask == 0is(x && mask) == 0. -
The pipe (decision 137):
x |> f(a, b)reads(f x a b);x |> f()andx |> fread(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()isf(a + 1),a ?? b |> f()isf(a ?? b),a == b |> f()isf(a == b),not x |> f()isf(not x)anda or b |> f()isf(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)isset(x, 5), which assigns tox. 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 asif,letorfn— is refused. Since the pipe binds loosest,a |> f ?? disa |> (f ?? d)and is refused with the bracketed form(a |> f) ?? das 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 ^^ band~~a, reading(bit-and a b),(bit-or a b),(bit-xor a b)and(bit-not a). They take integers;and,orandnotare 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 < rowsreads(and (<= 0 r) (< r rows)). It is evaluated asa < b < cis: 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) <= breads(let [~cmp1 a ~cmp2 (f x) ~cmp3 b] (and (< ~cmp1 ~cmp2) (<= ~cmp2 ~cmp3))).flan convertto parens names themmid,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 asand, a middle call named by aletfirst. The printer writes such anandback as the chain. Built. -
==is=;=is assignment.x = vreads(set x v),a[i] = vreads(set (at a i) v),p.x = vreads(set (.x p) v).x += vreads(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 + creads(+ a b c),a < b < creads(< 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.xreads(.x (.target camera)). A capitalised left side is a qualified case, not a field:Shape.Rectstays one symbol.test/programs/dev-rerun.flan:65names 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.nameis(get x :name)andx.name = vis(put x :name v), for a class slot and a plain map's key alike;m[:k]is(get m :k)andm[:k] = vputs. The paren spellings(.name x)and(at m :k)mean the same. Built. -
and,or,notare words, since they are Flan's own names. Built.notis a prefix word:not a == bisnot (a == b),not a and bis(not a) and b, andnot(x), glued, is the call. -
Optionals, after Swift (decision 130). Built.
x ?? dreads(?? x d): whatxholds, ordwhenxisNone(nilover a dyn).dis evaluated only then.a ?? b ?? creads(?? a b c)and groups from the right; a default that is itself an Option keeps the whole an Option.a ?? b == cis(a ?? b) == c.x!reads(!! x): whatxholds, and a trap at that site namingxwhen it holds nothing.a?.breads(?. [~o1 a] (.b ~o1)):None(ornil) whenaholds nothing, otherwiseSomeof 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, soa?.b?.cis one Option. A rest with no value makes the whole a statement.~o1is 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 = vscopes to the end of its block and reads as(let [x v] rest…). Consecutivelets merge into one binding vector. Alettakes 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} = pis
(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 betweenletand 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 newletafter one. A block lambda whose brackets close after its block,g = map(xs, fn(x) =>overx + 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 bename: Talone as a global's own line may; a pattern there is refused, since a global binds one name. Aletis otherwise flat: its scope is the rest of its block. To end it early, put it in ado:block. The printer writes everyletflat, and a run of bindings whose values are short (one line, 40 characters or fewer) as oneletwith the rest under the first name; top-level globals stay oneleteach. Aletwith statements after it takes them into its body; when one of them means an outer name theletrebinds, thelet's is renamed (xtox-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 ado, alet/fn/when/whilebody or another such macro's body;commentcounts too. Where a rename cannot be trusted (the name quoted, qualified asx/y, or called asx(...)), and at the top level, among a call's other arguments and in a quasiquote, theletgoes in ado:block instead, and so does one whose longer scope would reach a call of a macro whose template names thelet'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. (deferis 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 doesdef/once/const. -
if/elif/else.elseandelifsit at theif's column. Noelifreads asif(with else) orwhen(without); withelifit reads ascond. One-line form:if c then a else b, for use in alet. Built (a block of one line is that line; of more,(do …)). Anelseorelifon the line after a one-lineif 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 cplus a block, orwhen c then a, reads aswhen, which is what anifwithoutelsereads as too. Noelseoreliffollows it. Awhenwhose value is kept (alet's value, an argument, a return) givesSome(a)whencholds andNonewhen it does not; where adynis wanted,aornil. As a statement it gives nothing. Anif/elifchain with noelseis the same when kept:Nonewhen no test holds. Built. -
if let P = vplus a block reads as(if-let [P v] then);elifandelsefollow as forif, the rest of the chain being theif-let's else.elif let P = vis a furtherif-letnested in that else. Kept with noelseat the end of its chain, it gives an Option aswhendoes.Pis anymatchpattern, 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 towardif o?andif o? as gbelow;_is refused towardlet. Built. -
x?tests that a value is present (decision 133): a bool, true when an Option isSomeand when a dyn is notnil. It reads(? x). Inif x?,elif x?andwhile x?, and in the rest of anandafter the test, a localxthat is an Option is its payload in the block (a dyn stays a dyn). It is the same storage, sox.count += 1there changes the Option's payload. Not in theelse, not after the block, and not throughorornot. In the blockxmay 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 afnassigns, anywhere in the function is not narrowed (something else could clear it);if x? as gcopies 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 gnames what a test found, for anethat is not a plain name:if get(grid, r, c)? as cellreads(if-let [cell (get grid r c)] …), anif-letover a plain name, which binds what an Option holds or a dyn that is notnil. It works afterif,elifandwhile;while e? as gplus 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 asdotimes.rangehere is syntax, not a function...is avoided becausea..bwould 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(ordeferplus a block). Built;deferplus a block reads(defer a b …).break,continue,return vandx = v/x += valso fit the one-line slots: a match arm's value,then/else, and afterdefer. -
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 ortrue/falseis too. A bool's arms aretrueandfalse, 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 * 2handler-bindtakes the sameonclauses; 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, orfn(i, j) =>plus a block. Built; its parameters are bare names, as(fn [i j] …)wants, with nodyn.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) = xand 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 withlet), 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 convertwrites a call whose last argument is a lambda with a block this way.
Definitions
fn name(a: i32, b) -> Rplus a block;fn name(a) = exprfor one expression. Reads(defn name [a i32 b dyn] R …). A{:where …}constraint becomeswhere is-ordered($t)after the return type. Built; with no-> Rthe return is_, read off the body. Several predicates arewhere p, q.- A
letat the top level is a global:let x = v,let x: T = v,let scratch: [4 u8] = uninitread(def x dyn v),(def x T v);once x: T,once x = v,const n = 3. Built.let x = vandonce x = vread withdyn;const n = 3reads(defconst n 3), its type inferred as today.defis refused with theletto write. Aletin a block,comment:'s included, is local, and so is one in code the editor evaluates as an expression. struct Cellwith aname: Typeline per field.data Shapewith a line per case:Circle(r: f32),Empty.enum Kwithlo = -1,mid.union Ulikestruct. Built (an untyped field isdyn;Empty()is(Empty [])). A member is:midorK.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 IoErrorwith 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)orstruct DiskFull(free: i64) :parent IoError;flan convertwrites 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
looporrecur. A loop iswhile,until,dotimesorfor, overletvariables it changes, withbreakandcontinue. The reader refusesloopandrecurin any spelling, insidequotetoo (indent/no-loop), andflan convertrefuses 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), orclass lambdawith a slot per line, reads(defclass lambda [param body env]); a typed slot ispause: booland its type follows its name in the vector. Built.generic describe(v) -> dynreads(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 followswhen:method kind(v) when :int,when :elsefor the default.= valuefor 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)
- 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.fs2236-2526), and F#'s ownwith-drawingwould needfun () ->. Headers (fn,if,while, …) are the openers here and need no:. The fallback form takes its block the same way:defmethod(describe, :square, [s]):. - 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 != cis refused, with a message pointing at!=(a, b, c), because Flan's!=means "all distinct", not what the chain suggests.
- a run of one operator flattens,
'(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.- Private functions are
fn- name(…). - "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/):
-
A one-line if continues on the next line.
if c then afollowed byelse b(orelif c2 then d, or either with a block) at the if's column is one if. Anelseleft of that column, or indented deeper, is refused. After an if with a block,else xon one line is accepted too. Binding: anelseorelifon a line of its own belongs to the if that starts at its column. An if inside a one-line slot (afterthen, afterelse, in an arm) ends with its line and takes no later clause, soif a then x else if b then y else zis refused at its last line: the first
elsetookif b then yas its value, and the chain isif a then x else if b then y else zon one line, orelif b then yon the second. -
Typed lambdas.
fn(a: C, b) -> R => body, or plus a block, reads(the (Fn [C dyn] R) (fn [a b] body)): the parenfnhas no typed parameters, andtheis how a value states its type, as inlet x: T = v. An untyped parameter isdyn; the return type is required. Where aCFnof the same signature is wanted, the literal is thatCFn; at a generic'sCFn($t) -> $tparameter the literal is aCFnat its own types, which bind$tas 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. -
Dir.northis the enum member:north, in a value and in a match pattern, in both syntaxes.:northstays. A local namedDirshadows the enum as a local shadows any global:Dir.northis then its field. -
Types in messages follow the code's syntax.
Types.spell ~indentedis 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_stringstays the spelling for keys, symbols and runtime strings. Hard-coded code in a hint is written per message. -
flan convertkeeps adjacent one-line globals adjacent, in both directions.
4. Build order
Each step lands on its own, with dune test --root . green.
-
The reader (
lib/indent_reader.mlor similar): tokenizer with the indent stack, then a parser toForm.t. Start with whatsand.flanandalgorithms.flanneed, then the fallback, then the sugar in section 2 in order of corpus frequency (set,let,+,at,=,if,dotimes, …). Test: hand-convertalgorithms.flanandsand.flanto.fln; the forms read from each pair must be equal, ignoring locations. -
Switch readers by extension at every program-source entry point:
Front.load(lib/front.ml:16),Load.import(lib/load.ml:1211, plusentriesat 137-142 andis_package_fileat 103-105),Session.create(lib/session.ml:260), andbin/main.ml327, 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. -
The printer,
Form.t→ indented text, and aflan convertcommand. 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 aletbinds is renamed through its scope to one numbered by binding order; then, in a body run in order, aletcounts 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)withxaletcounts asx; aletwhose whole body is anotherletcounts as equal to the mergedlet;(and x)and(or x)count asx. Taking in and the one-argumentandstop at a quote or quasiquote. That covers 394 files and runs on readers alone, so it's fast. -
The dev loop. Code-carrying wire ops (
eval,eval-expr,macroexpand,set) get an explicit:syntaxfield instead of guessing from:file. The:fileguess breaks for<repl>/<inspect>origins and forflan-macroexpand-again(emacs/flan.el:3279-3309), which sends paren-syntax expansion text under the original file's name. Replace the space-padding inflan--text-at(emacs/flan.el:2602-2622), which breaks significant indentation, with:line/:colfields; the reader seeds its indent stack with that column. Built (alsoload-fileand restart arguments; with no:syntaxa request is read in the syntax of the source:fileit names, as paren under a pseudo-name such as<repl>, and with no:fileat all in the program's — so evaluating in a stopped frame of a.flnprogram reads indented; several indented statements sent as one expression read as(do …)). -
Emacs mode for
.fln:- A top-level form runs from a column-0 line that isn't
else,elif,onorrestartto just before the next one, minus trailing blank and comment lines (python.elpython-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-buffersuses(eq major-mode 'flan-mode)(emacs/flan-watch.el:247-255), and there arederived-mode-p 'flan-modechecks atflan.el:1140, 2095, 2408and inflan-dape.el:96-129. - The breakpoint position Emacs sends (
:pause (LINE COL),flan--pause-boundsatflan.el:2637-2660) must equal the start location the reader gave that form.Ast.mark_pausematches exactly (ast.ml:491-492).
Built (
emacs/flan-fln-mode.el; keys and objects inemacs/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. - A top-level form runs from a column-0 line that isn't
-
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 adefn's return slot, in a generic's, and indefgeneric/defmulti's (defmethodhas 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.fieldkeys. - 1304
(.f x)accesses; chains of up to 5(.a (.b x))deep. - 65
condand 185match, written as flat pairs. - 1229
sets, all with 2 arguments. The target is a symbol 809 times,at217, a field 180,get10 andderef9. - 22 chained comparisons and 16 variadic
!=. and/orwith up to 15 arguments (vendor/edn/edn.flan:251).- 332
(). - 1048 lines of code-generating macros in
vendor/edn/provide.flanandvendor/json/provide.flan, with 139 quasiquotes and 188 unquotes.
What prints paren syntax to a user:
Form.to_stringinparse.mlandexpand.mldiagnostics.- About 70 hard-coded usage strings and "write (…)" hints in
parse.mlandcheck.ml. Types.to_string, which is in every type error and in the eldoc,defs,layoutand locals replies.- The macroexpand
:text,:flatand:sourcefields (dev.ml:1416-1430). - Macro signatures in
defs(dev.ml:1616-1629). Render's value notation (println, the REPL, the inspector), whichemacs/flan-inspect.el:122-272parses andflan-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), andelse/elifalignment (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) andgdscript_parser.cpp(multiline flags andpush_expression_indented_block). - tree-sitter-python:
~/Repositories/tree-sitter/languages/tree-sitter-python/src/scanner.c.