The colon belongs to keys; the prose, the page and the sweep agree now

web/index.html's Flan blocks convert and its output blocks do not, which
is the same split render.ml makes: the printed form keeps the colon until
the Emacs inspector that reads it moves too. Same in BUILT.md.

plan.org, spec-conditions.md and spec-memory.md carried struct literals in
the old spelling and now do not.

NEXT.md decision 6 is struck, and batch item 2 with it, naming what to run
at merge. BUILT.md says why the colon belongs to keys -- mostly that a map
literal wants {:key value}, and two literals sharing one syntax would have
left the reader asking the checker which it was looking at.

The sweep was not idempotent and is now: {.k :hi} -- a field already
converted, holding an enum member -- read as a destructuring pair on a
second run and ate the member. A re-run over a lane's files would have
corrupted them silently, which is exactly what the tool exists to do
safely.
This commit is contained in:
Joseph Ferano 2026-09-12 15:00:34 +07:00
parent cb757868b4
commit e992491799
8 changed files with 86 additions and 37 deletions

View File

@ -1570,7 +1570,7 @@ And a `Vec` does not cross to C: handing a header that owns storage to C hands o
### `StorageExhausted` went in *with* `Vec`, not after it
No allocating operation returns an error and none can fail silently. When the allocator cannot satisfy a request the
operation signals `(StorageExhausted {:bytes n :align a :allocator id})` with `error` — whose type is `Never` — inside a
operation signals `(StorageExhausted {.bytes n .align a .allocator id})` with `error` — whose type is `Never` — inside a
`restart-case` offering `retry`. One rule over every allocating operation, which is what keeps `push` and `reserve` at
`Unit`, `clone` at the container, and no signature anywhere growing a `Result`.
@ -1688,7 +1688,7 @@ rule without an exception: *no allocating operation returns an error*. There is
no error code — `slurp`'s type is `(Vec u8)` and `barf`'s is `Unit`.
**Two failures, two conditions, and the guards nest rather than merge.** Allocation failure is `StorageExhausted` under
`retry`, unchanged and reused. File failure is `FileError {:path :op :reason}` under `retry` and `use-value`. They stay
`retry`, unchanged and reused. File failure is `FileError {.path .op .reason}` under `retry` and `use-value`. They stay
apart because they ask two different answerable questions: the handler that grows an arena is not the handler that
supplies another path, and collapsing them would make one handler guess which it was looking at. `file_guard` in
check.ml is `alloc_guard`'s shape built from the same nodes — a `while`, a `restart-case` and an `error` — so the
@ -2042,3 +2042,44 @@ on `window is not defined` — which says the module is live and says nothing ab
correct for a page, which is torn down by the tab closing, and it is worth knowing before reading anything into it.
- **Canvas size against `screen-width`/`screen-height`.** The shell is a string in `Build` and its canvas is not sized
from the program, so 900x600 may be letterboxed or cropped.
## The colon belongs to keys, so a field label is a dot
`{.x 1.0 .y 2.0}` is how a struct is constructed, and `{inner .field}` is how a pattern names one. The colon is gone
from both, and what is left of it is one job: keys — map keys and enum members.
**What was wrong with the colon.** Nothing, taken alone. The problem was that it had two jobs and the dot had one.
`(.x v)` already read a field; `{:x 1.0}` also named a field, while `:space` named an enum member. So the dot meant
"field" and the colon meant "field, or member, depending". Moving the label to the dot leaves each mark with one
meaning, and it costs nothing to read because **the delimiter already disambiguates**: `(.x v)` is a list and
therefore a call and therefore an access; `{.x 1.0}` is a brace form and therefore a construction. There is no
position where the two could be confused, which is why the same spelling can serve both.
**Why it had to land before `Map`, and this is the real reason.** A map literal wants to be `{:key value}`. While
struct construction owned that exact spelling, a map literal and a struct literal were *the same syntax*, and the
only thing that could tell them apart was what the checker expected at that position. That is a context-sensitive
grammar for no gain. Reserving the colon for keys keeps the two visibly distinct at the reader, before any type is
known. Doing it after `Map` landed would have meant changing both; doing it first meant changing one.
**`:keys` kept its colon, and that is the point rather than an inconsistency.** `{:keys [x y]}` is the one thing in a
brace that is not a field name — it is an instruction to the compiler that happens to sit there, and it takes a
vector rather than a value. Giving it a dot would have made the dot mean "a field, or the word keys". Leaving it a
colon lets the dot mean exactly one thing, *this names a field*, which is the whole reason the colon was given up.
Everything else Clojure puts in that position — `:as`, `:or`, `:strs`, `:syms` — is still refused by its own name.
**The old spelling is refused, not accepted quietly**, and the refusal names the new one: `a field label is written
.x, not :x — the colon is for keys`. Two accepted spellings is how two spellings become permanent, and the standing
rule here is that what is not supported is rejected explicitly with the reason. Both refusals are tested by their
reason, on the construction side and on the destructuring side, which is what stops the colon drifting back.
**The sweep is a tool, not a one-off.** `tools/colon-to-dot.py` converted 681 labels across 45 `.flan` files,
`vendor/`, and the Flan embedded in `lib/prelude.ml` and the tests. It works on *forms*, not on text: a keyword
becomes a dot only where it sits in a field-label position inside a brace, so an enum member in value position
(`{.k :hi}`), a genuine EDN map inside a string (`test/programs/edn.flan`), and a type-position `{K V}` are all left
alone. It was kept in the tree because several lanes branched before it and their Flan needs the same pass at merge.
**What it deliberately did not change: the printed form.** `render.ml` still prints `(V {:x 1.5 :y 0})`. That string
is a wire format — `emacs/flan-inspect.el` parses it back and hard-codes the colon when it reads a field out — so
moving the printer alone would break struct inspection in the dev loop without breaking any test that says so. The
printer moves when its reader does, in the Emacs lane. It is the one place the old spelling is still correct, and
the reason is worth keeping: **a format with two ends only changes at both.**

34
NEXT.md
View File

@ -277,23 +277,16 @@ length is not known until the file is read and therefore cannot exist before an
## Decided later the same day, and queued
**6. A field label is written with a dot, not a colon, and the colon is reserved for keys.** `{.x 1.0 .y 2.0}`
replaces `{:x 1.0 :y 2.0}` in struct construction, and the same change applies where destructuring names a field.
The delimiter is what makes it unambiguous, which is the author's argument and it holds: `(.x v)` is a call and
therefore an access, `{.x 1.0}` is a brace form and therefore a construction. Today the dot already means "read a
field" and the colon means both "field label" and "enum member", which is the ambiguity the change removes.
~~**6. A field label is written with a dot, not a colon, and the colon is reserved for keys.**~~ **Done.**
`{.x 1.0 .y 2.0}` is struct construction and `{inner .field}` is destructuring; the old spelling is refused, and the
refusal names the new one. `:keys` kept its colon — it names no field, so leaving it alone is what lets the dot mean
exactly one thing. 681 labels across 45 `.flan` files, `vendor/` and the Flan embedded in `lib/prelude.ml` and the
tests. `Map` is now free to take `{:key value}` without colliding with struct literals. See BUILT.md, "The colon
belongs to keys".
**The reason to do it before `Map`, not after.** A map literal will want to be `{:key value}`. If struct
construction owns that exact spelling, map literals and struct literals are the same syntax and the checker has to
tell them apart from context. Reserving the colon for keys — map keys and enum members — keeps the two visibly
distinct. Doing this after `Map` lands means changing both; doing it now means changing one.
Cost: ~284 sites across 45 `.flan` files, mechanical. **Queued rather than started** only because it touches nearly
every Flan file in the repo, including ones a running lane held. Run it when the tree is quiet, before step 4.
One thing to decide with it: destructuring uses the colon two ways — `{inner :field}` names a field, which should
become a dot like any other field, and `{:keys [x y]}` where `:keys` is an instruction to the compiler rather than a
field name, which arguably stays a colon. Settle both in the same pass rather than leaving the rule half-applied.
**Two things it left behind.** `render.ml` still *prints* a struct with colons, deliberately:
`emacs/flan-inspect.el:165` parses that output and hard-codes the colon, so the printer has to move with its reader
and that belongs to the Emacs lane. `emacs/MANUAL.md` and `flan-mode.el`'s font-lock also still show the colon.
**7. `Map` follows Odin's implementation.** Read `base/runtime/dynamic_map_internal.odin` before writing any of it;
the checkout is at `~/Repositories/Odin`. Three properties are the ones worth copying, and they are stated in its own
@ -416,9 +409,10 @@ run one lane at a time; item 4 is disjoint and runs alongside any of them.
from this, and a red suite stops being a signal quickly. The handoff says to start by printing both sides of the
comparison in `Dev.locals`.
2. **The colon-to-dot change.** Cheap, mechanical, ~284 sites across 45 files — and it must land **before** `Map`, or
map literals and struct literals collide and both have to change instead of one. It gets more expensive every day
more Flan is written. See the decision above for the destructuring wrinkle to settle in the same pass.
2. ~~**The colon-to-dot change.**~~ **Done**, and `Map` is unblocked: `{:key value}` is free. The sweep is
`tools/colon-to-dot.py`, kept rather than thrown away, because the lanes that branched before it wrote Flan in the
old spelling and their files want the same pass at merge — `python3 tools/colon-to-dot.py .` over the tree, and
`--in-strings` for a `test/*.ml` that embeds Flan.
3. **`Map`, and the `defer` relaxation.** `Map` is step 4 of the container build order and finishes what `Vec` started.
The `defer` change — permitting it in a `let` whose extent is the function body — is small, independent, and is the
@ -854,7 +848,7 @@ expander last, on 6's unions.
with it — `rt_die` is the non-dev path too, where there is no listener and nothing to deadlock against, so whether it
should be `_exit` unconditionally or only under `--dev` is a decision rather than a typo.
- **`(A {:x 1})` on a union variant says "unknown struct A"** rather than the union refusal `check_struct` plainly
- **`(A {.x 1})` on a union variant says "unknown struct A"** rather than the union refusal `check_struct` plainly
intends — `env` has no table of variant names. A diagnostics bug, not a backend death.
### Test blind spots, from a mutation pass

View File

@ -117,9 +117,9 @@ world.
~(clone x)~. Value structs are the snapshot / undo / replay story; they need no
separate type.
- Literals live in read-only memory.
- Struct literals name fields: ~(Cursor {:src src :pos 0})~. *Omitted fields are
- Struct literals name fields: ~(Cursor {.src src .pos 0})~. *Omitted fields are
zeroed*, as in Odin — the same rule as a declaration with no initialiser, so
~(Cursor {:src src})~ is complete and means ~pos~ is 0.
~(Cursor {.src src})~ is complete and means ~pos~ is 0.
- *Zero is initialisation (ZII), with an opt-out.* No initialiser means
all-bytes-zero. ~(defvar buf [65536 u8] uninit)~ skips it, exactly as Odin's
~---~ does, for a large buffer that is about to be overwritten. ~uninit~ is

View File

@ -25,7 +25,7 @@ must produce its type on the *fall-through* path too:
(if (file-exists? path)
(rl/load-texture path)
(restart-case
(do (signal (AssetMissing {:path path}))
(do (signal (AssetMissing {.path path}))
(abort "unhandled AssetMissing")) ; fall-through must not return
(use-placeholder [] placeholder-texture)
(retry [] (load-texture path)))))

View File

@ -385,7 +385,7 @@ fixed here; nothing is built that needs it yet.
the allocator cannot satisfy a request, the operation signals
```
(StorageExhausted {:bytes n :align a :allocator id})
(StorageExhausted {.bytes n .align a .allocator id})
```
with `error`, whose type is `Never` (spec-conditions.md §2), inside a

Binary file not shown.

View File

@ -42,6 +42,11 @@ class Atom:
t = self.text
return t[1:] if len(t) > 1 and t[0] == ':' else None
def converted(self):
"""True if this atom is already a `.field` label."""
t = self.text
return len(t) > 1 and t[0] == '.' and not t[1].isdigit()
class Seq:
def __init__(self, open_char, start):
@ -52,6 +57,9 @@ class Seq:
def label(self):
return None
def converted(self):
return False
def lex_forms(src, i, end, stop=None):
"""Read forms from src[i:end] until `stop` (a closing char) or exhaustion.
@ -129,6 +137,12 @@ def collect(node, out):
pass # a directive, not a field
elif a_label is not None:
out.append(a.tok) # {:field value}
elif a.converted():
pass
# Already `{.field value}`. Without this the pair would fall
# to the rule below and an enum member in value position --
# `{.k :hi}` -- would be read as a destructuring label and
# converted on a second run. Re-running must be a no-op.
elif b_label is not None and b_label != 'keys':
out.append(b.tok) # {pattern :field}
k += 2

View File

@ -381,13 +381,13 @@ heap is involved.</p>
;; `set` takes a fixed list of forms, not an extensible setf.
(defn main []
(let [e (Enemy {:hp 10 :name "slime"})
(let [e (Enemy {.hp 10 .name "slime"})
p (addr e)]
(set spawned (+ spawned 1)) ; a local or a defvar
(set (.hp e) 7) ; a struct field
(set (.hp p) 8) ; through a (Ptr Enemy) — derefs one level
(set (at room 2) 5) ; a fixed array or slice element
(set (deref p) (Enemy {:hp 3 :name "wisp"})) ; a whole-object store
(set (deref p) (Enemy {.hp 3 .name "wisp"})) ; a whole-object store
(print (.hp e)) (println "")
(println (.name e))
@ -506,7 +506,7 @@ its fields, and omitted fields are zeroed.</p>
(set (.pos c) (+ (.pos c) 1))) ; field access derefs one level
(defn main []
(let [c (Cursor {:src (bytes "hi")})] ; pos omitted, so pos is 0
(let [c (Cursor {.src (bytes "hi")})] ; pos omitted, so pos is 0
(print (peek (addr c))) (println "")
(advance (addr c))
(print (peek (addr c))) (println "")))</code></pre>
@ -753,7 +753,7 @@ user-supplied printer to choose between.</p>
(defn main []
(println 42) ; an i32, uncast
(println 1.5)
(println (Enemy {:hp 3 :name "wisp" :key :left}))
(println (Enemy {.hp 3 .name "wisp" .key :left}))
(println (look-up :space))
(println (look-up :left))
(print "no newline: ") (println true))</code></pre>
@ -848,7 +848,7 @@ and no ceremony.</p>
(defstruct V2 [x f32 y f32])
(defn add [a V2 b V2] V2
(V2 {:x (+ (.x a) (.x b)) :y (+ (.y a) (.y b))}))</code></pre>
(V2 {.x (+ (.x a) (.x b)) .y (+ (.y a) (.y b))}))</code></pre>
<pre><code>;; geom/len.flan — a second file in the same directory shares one top-level
;; scope: it does not import vec.flan, and the order of the two does not matter.
@ -860,8 +860,8 @@ and no ceremony.</p>
(import g "geom")
(defn main []
(let [v (g/add (g/V2 {:x 3.0 :y 0.0})
(g/V2 {:x 0.0 :y 4.0}))]
(let [v (g/add (g/V2 {.x 3.0 .y 0.0})
(g/V2 {.x 0.0 .y 4.0}))]
(print (g/length v))
(println "")))</code></pre>
@ -934,8 +934,8 @@ normally leaves the signaller to carry on — the accumulation case:</p>
(defvar seen i64)
(defn load-all []
(signal (AssetMissing {:id 1})) ; Unit — the caller carries on
(signal (AssetMissing {:id 2})))
(signal (AssetMissing {.id 1})) ; Unit — the caller carries on
(signal (AssetMissing {.id 2})))
(defn main []
(load-all) ; no handler: a no-op
@ -959,7 +959,7 @@ first, before the clause body starts.</p>
(defvar cleanups i64)
(defn load [n i32] i32
(signal (AssetMissing {:id n}))
(signal (AssetMissing {.id n}))
100)
(defn middle [n i32] i32
@ -1055,7 +1055,7 @@ and an exit status of 134:</p>
(defn load [n i32] i32
(restart-case
(do (error (Missing {:id n})) ; Never — only a transfer gets past
(do (error (Missing {.id n})) ; Never — only a transfer gets past
0)
(use-placeholder [] -1)
(retry [] 7)))