An indented surface beside s-expressions is specified as a second reader chosen by file extension

This commit is contained in:
Joseph Ferano 2026-09-25 14:21:51 +07:00
parent f04cd18c0a
commit 22d2a4cfb2
2 changed files with 373 additions and 6 deletions

View File

@ -608,12 +608,12 @@ commitment restarts the process. SBCL's push-through-and-invalidate is cheap onl
because its instances carry headers. =docs/SBCL-REDEFINITION-NOTES.md= is the
reading behind it.
** WAIT An F#-ish indentation surface beside s-expressions
Deferred 2026-09-19 with no spike queued, blocked on evidence: the author will write
imperative Flan as it stands and see whether the parens still grate. If it is ever
built, it is one AST with the existing forms unchanged and a second reader in
front. Rules out Parinfer, wisp and sweet-expressions, and a simplified in-paren
syntax — all thin the parens without removing them.
** NEXT An indented surface beside s-expressions
Decided 2026-09-25 as a test drive: a second reader, chosen by file extension,
producing the same forms. =spec-syntax.md= holds the decisions, the proposals
awaiting confirmation, and the build order. Rules out Parinfer, wisp and
sweet-expressions, and a simplified in-paren syntax — all thin the parens
without removing them.
** TODO The shims in sand.flan can go
=sand.flan= defines =dyn->f64= and =dyn->u32=, one-line functions whose only job

367
spec-syntax.md Normal file
View File

@ -0,0 +1,367 @@
# Spec 3 — The indented surface
Status: **draft**. Sections 1 and 3 are decided by the author. Section 2 is
proposed and needs the author's confirmation before the matching piece is
built. Nothing here changes what a program means: the indented syntax is a
second reader that produces the same `Form.t` tree as `lib/reader.ml`.
Evidence for every file:line below was read on 2026-09-25. Re-read before
building on it.
## 1. Decided
**One tree, two readers.** A new reader turns indented text into `Form.t`,
exactly the nine node kinds in `lib/form.ml:11-27`, with locations filled the
way `lib/reader.ml:47` fills them. Everything after the reader (`Expand`,
`Parse`, `Check`, both backends) is untouched. This is the plan the old WAIT
entry in `TODO.org` described, and it rules out Parinfer, wisp and
sweet-expressions the same way.
**Both syntaxes live side by side.** File extension picks the reader. A program
may mix files of both kinds, and imports cross freely. The paren syntax is not
deprecated; this is a test drive.
**Blocks are indentation.** A header line (`fn …`, `if …`, `while …`, `else`,
…) followed by deeper-indented lines opens a block. Headers need no `:`,
`then`, `do` or `end`. (An ordinary call takes a block only with a trailing
`:`, section 3.)
**Calls and indexing are adjacency.** `f(a, b)` with no space before `(` is a
call. `x[i]` with no space before `[` is indexing; `x[i, j]` is `(at x i j)`.
Arguments are separated by commas.
**Lisp names stay.** `key-pressed?`, `dyn->f64`, `bytes=?`, `swap!`, `*earmuffs*`
are one token each. The rule that makes this work: **binary operators need
spaces around them.** `a-b` is a name; `a - b` is subtraction. No
snake_case mapping.
**Package qualifier stays `/`.** `rl/draw-fps(20, 20)`. Division is always
spaced, `a / b`.
**Keywords stay.** `:key-r`, `:else`. A colon glued to the front of a word is a
keyword; `x: T` (colon glued to the end, space after) is an annotation.
**Collection literals.** `[a b]` or `[a, b]` is a vector, `{k v}` a map or struct
literal, `'(a b c)` a list. `'name` stays the quoted symbol that
`invoke-restart` takes (60 uses, all restart names).
**Types are marked, and omitted means dyn.** `x: i32` gives a type. A parameter,
field or global with no `: T` is `dyn`. The reader **writes `dyn` explicitly**
into the parameter vector: it emits `[x dyn y dyn]`, never `[x y]`, because
`Check.pair_params` (check.ml:1589) reads `[x y]` as one parameter of type `y`
when some type is named `y`. That silent misparse is what `parse.ml:1413-1418`
warns about; the new syntax must not inherit it.
**The return type is inferred when omitted.** Body-local only, as
`docs/SPIKE-INFERENCE.md` ("The cheap first step" and "Verdict") scopes it:
- the return type is the body's type; a `dyn` body gives `dyn`; `return`s of
different types give `dyn`; no value gives `()`.
- it reads only the function's own body, never a call site.
- a self-recursive or mutually recursive function must write its return type.
Refuse by name, naming the whole cycle. The corpus has 17 self-recursive
functions and 5 mutual groups (all in `calc-me.flan`, `vendor/edn`,
`vendor/json`).
- nothing is ever defaulted in signature position. Parameters are never
inferred; an omitted parameter type is `dyn`, which is a rule, not inference.
The paren syntax spells "infer the return" as `_` in the return slot
(section 3), which is what the indented reader emits when `->` is omitted.
**A changed signature installs, and the warning names the cause.** The dev loop
already accepts a signature change and stops stale callers with `StaleCall`
(`lib/session.ml:353-363`, `TODO.org` "Signature generations and stale-caller
warnings"). When the change came from inference, the stale-caller warning says
so and points at the line: *"speed now returns f64, not i32, because of line
12"*.
**Macros stay.** They run on `Form.t`, so they work in both syntaxes. A call
can take an indented block as its last arguments, so `rl/with-drawing` and
`comment` take a body (with a trailing `:`, section 3):
```
rl/with-drawing():
rl/clear-background(rl/black)
game-draw()
```
reads as `(rl/with-drawing (rl/clear-background rl/black) (game-draw))`.
Replacing macros with built-in constructs is **not** part of this work.
**Diagnostics may print paren syntax** during the test drive. `Form.to_string`,
`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; `.flan` keeps meaning parens, so
`generated.flan` and 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'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.
- **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.
- **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".
- **`->` needs spaces as the return arrow.** `dyn->f64` stays 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 = 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))`; like `++` today, the place is evaluated twice.
- **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).
- **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.
- **`and`, `or`, `not` are 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 = v`** scopes to the end of its block and reads as
`(let [x v] rest…)`. Consecutive `let`s merge into one binding vector.
`let x = v` followed by a deeper-indented block scopes to that block only,
which is how the printer writes a `let` that has siblings after it.
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.)
- **`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`.
- **`while c`, `until c`**, optional label first: `while :outer c`.
- **`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.
- **`return v`, `break`, `break :outer`, `continue`, `defer expr`** (or `defer`
plus a block).
- **`match`:**
```
match shape
Circle(r) -> 3.14 * r * r
Rect(w, h) -> w * h
:north -> 0
_ -> 0
```
An 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 * 2
```
`handler-bind` takes the same `on` clauses; 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`, or `fn(i, j)` plus a block.
### Definitions
- `fn name(a: i32, b) -> R` plus a block; `fn name(a) = expr` for one
expression. Reads `(defn name [a i32 b dyn] R …)`. A `{:where …}` constraint
becomes `where ordered?($t)` after the return type.
- `def x = v`, `def x: T = v`, `once x: T`, `once x = v`, `const n = 3`,
`def scratch: [4 u8] = uninit`.
- `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`.
- `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)
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 `_`.
## 4. Build order
Each step lands on its own, with `dune test --root .` green.
1. **The reader** (`lib/indent_reader.ml` or similar): tokenizer with the
indent stack, then a parser to `Form.t`. Start with what
`sand.flan` and `algorithms.flan` need, then the fallback, then the sugar
in section 2 in order of corpus frequency (`set`, `let`, `+`, `at`, `=`,
`if`, `dotimes`, …). Until step 6 lands, a `.fln` function must write
`-> T`; omitting it is refused with a message saying inference is coming.
**Test:** hand-convert `algorithms.flan` and `sand.flan` to `.fln`; the
forms read from each pair must be equal, ignoring locations.
2. **Switch readers by extension** at every program-source entry point:
`Front.load` (`lib/front.ml:16`), `Load.import` (`lib/load.ml:1211`, plus
`entries` at 137-142 and `is_package_file` at 103-105), `Session.create`
(`lib/session.ml:260`), and `bin/main.ml` 327, 334, 393-399, 557-560. The
prelude (`lib/prelude.ml:2441`), the wire protocol (`lib/wire.ml:103`) and the
registry spelling (`lib/dev.ml:2725`) stay paren syntax.
3. **The printer**, `Form.t` → indented text, and a `flan convert` command.
**Test:** for every corpus file, read with parens, print indented, read
indented; the forms must be equal to the first read, after one normalisation:
a `let` whose whole body is another `let` counts as equal to the merged
`let`. 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.
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`).
6. **Return-type inference** in `Check`, with the recursion refusal and the
stale-caller cause. This is independent of steps 1-5 once the marker exists.
Out of scope: dropping macros, built-in replacements for `with-*`/`defedn`,
printing diagnostics in the new syntax, converting the prelude or vendor
packages, website and docs.
## 5. Reference
**Where the grammar gets hard**, from a survey of the whole corpus (394 files):
- 513 `(Type {…})` constructions, all with `.field` keys.
- 1304 `(.f x)` accesses; chains of up to 5 `(.a (.b x))` deep.
- 65 `cond` and 185 `match`, written as flat pairs.
- 1229 `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`.