18 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
dynbody givesdyn;returns of different types givedyn; 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,
Types.to_string, the usage strings in parse.ml and check.ml, and
Render all print parens today (inventory in section 5). Fixing that waits on
the author deciding to switch.
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. - Comments stay
;. Nothing else wants the character. - Spaces only. A tab in indentation is an error. The corpus has no tabs.
- Indentation is measured in columns, any width. A dedent must land on a
column already on the stack (GDScript
gdscript_tokenizer.cpp:1291-1296). - Blank and comment-only lines never open or close a block (GDScript 1170-1239).
- 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. - 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. - 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". ->needs spaces as the return arrow.dyn->f64stays a name.- Character literals stay
\c, lexed before brackets and operators:\(,\,,\space. 277 uses, many of them delimiters of the new syntax.
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. - 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. - 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.
Expressions
- Precedence, low to high:
or<and<not< comparisons (== != < <= > >=) <<< >><+ -<* / %< unary-< postfix (call, index, field). ==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)); like++today, the place is evaluated twice.- 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). - 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. and,or,notare words, since they are Flan's own names.- Casts and type-taking builtins are calls:
i32(x),vec-new(u8),max-value(u8),the([3 f32], [1 2 3.5]).
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.let x = vfollowed by a deeper-indented block scopes to that block only, which is how the printer writes aletthat has siblings after it. 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.) -
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. -
while c,until c, optional label first:while :outer c. -
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. -
return v,break,break :outer,continue,defer expr(ordeferplus a block). -
match:match shape Circle(r) -> 3.14 * r * r Rect(w, h) -> w * h :north -> 0 _ -> 0An arm's body can be an indented block, which reads as
(do …). -
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. -
Unit:
()as a statement reads(do); in a type it is(). -
Lambda:
fn(i, j) = i * 10 + j, orfn(i, j)plus a block.
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 ordered?($t)after the return type.def x = v,def x: T = v,once x: T,once x = v,const n = 3,def scratch: [4 u8] = uninit.struct Cellwith aname: Typeline per field.data Shapewith a line per case:Circle(r: f32),Empty.enum Kwithlo = -1,mid.union Ulikestruct.import rl "vendor:raylib".- 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.
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.
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.
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.)
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_.
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, …). Until step 6 lands, a.flnfunction must write-> T; omitting it is refused with a message saying inference is coming. 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: aletwhose whole body is anotherletcounts as equal to the mergedlet. 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. - 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).
- 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.
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.