739 lines
41 KiB
Markdown
739 lines
41 KiB
Markdown
# 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.
|
|
- A `T` where a `T?` is wanted is `Some` of it (decision 138): an
|
|
assignment, a `let` with a type, an argument, a return, a struct field, an
|
|
array or `Vec` element, and an `if` or `match` arm beside an Option arm.
|
|
A literal is built at `T` first, so `s = -1` over an `i64?` is `Some(-1)`
|
|
at i64. One level at a time: a `T` into a `T??` is `Some(Some(t))`, a `T?`
|
|
into a `T??` is `Some` of it. A `$t` meeting `$u?` binds `$u` to `T`.
|
|
Never inside a container (`Vec(i32)` is not a `Vec(i32?)`), and never the
|
|
other way: a `T?` where a `T` is wanted still needs `!`, `??`, `x?` or
|
|
`as`. A kept chain whose arms are a `T` and a `T?` is a `T?` (decision
|
|
140, under `when c`). **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 `let`s 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. When `a`
|
|
is already an Option it is not wrapped again (decision 140): `when c then
|
|
o` over an `i32?` is an `i32?`, `None` when `c` fails or `o` is `None`, and
|
|
a chain mixing `T` and `T?` arms is a `T?`. One level only: an arm that is
|
|
a `T??` gives a `T??`. Where an Option of the arm's type is wanted, as a
|
|
`T??` over a `T?` arm, the arm is `Some` of its value and a failed test is
|
|
the outer `None`. `if let` with no `else` follows the same rule. **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`** tests that `e` holds a value and names it `g` (decisions 133,
|
|
136): an Option that is `Some` binds its payload, a dyn that is not `nil`
|
|
binds itself. `e? as g` is the same test. Over any other type it is
|
|
refused; `as` is never a conversion, which is written `i32(x)`. It stands
|
|
after `if`, `elif`, `while` and a one-line `if … then` or kept `when`, as
|
|
the whole condition or as any test of an `and` chain, and `g` is bound for
|
|
the rest of that chain and for the block: Swift's `if let g = e, c`.
|
|
|
|
```
|
|
if get(grid, r + 1, c - 1) as g and is-empty-cell(g)
|
|
move(g)
|
|
if a as x and b as y and x < y
|
|
println(x, y)
|
|
let left = when get(grid, r + 1, c - 1) as g and is-empty-cell(g) then g
|
|
```
|
|
|
|
The tests run left to right and stop at the first that fails, so each
|
|
value is found once. `g` is not bound in the `else`, in an `elif` or after
|
|
the block. It is refused under `or` and `not`, and after `until`, where the
|
|
block could run with nothing found. `x?` on a plain local narrows beside it
|
|
in the same chain. Reads `(as g e)` in the test's place, `(when (and a (as
|
|
g e) (f g)) …)`; `while c` with one reads `(while true (if c (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 fn with several arities is one definition: `fn area` alone on its line,
|
|
and under it one line per arity, its parameters in parentheses, with its
|
|
block under that or `= expr` on the line:
|
|
|
|
```
|
|
fn area
|
|
(w: i32) -> i32 = w * w
|
|
(w: i32, h: i32) -> i32
|
|
w * h
|
|
```
|
|
|
|
It reads `(defn area ([w i32] i32 (* w w)) ([w i32 h i32] i32 (* w h)))`,
|
|
Clojure's multi-arity `defn`. A call picks the arity by how many arguments it
|
|
passes; types play no part, so two arities of one count are refused. An arity
|
|
may be generic, carry its own `where`, and call the others. The arities are
|
|
closed: a second `fn area` anywhere is `area` defined twice, whatever its
|
|
arity, and extension is `generic`/`method`'s. `main` has one arity. The name
|
|
alone is a value only where the wanted `Fn` type says how many parameters,
|
|
as in `apply(area, 6)` against `f: Fn(i32) -> i32`; anywhere else write a
|
|
lambda, `fn(w) => area(w, 2)`. In the dev loop the form replaces every arity
|
|
at once: one it no longer has is gone, and its compiled callers are listed
|
|
as stale. **Built** (decision 139).
|
|
- 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/`):
|
|
|
|
6. **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.
|
|
7. **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.
|
|
8. **`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.
|
|
9. **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.
|
|
10. **`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 `set`s, 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`.
|