219 Commits

Author SHA1 Message Date
7332bed14a The acceptance case stops asking for a header it no longer needs
The imported-bindings program was skipped without FLAN_RAYLIB_H, which was
right when the bindings only existed if a header was read and is now the one
gate hiding the change. It runs on the same terms as every other raylib case:
libraylib linkable, no raylib-devel. A generated.flan regenerated empty or
stale is now caught on an ordinary machine rather than only on one with a
header exported.

Reach.link already answers the shim worry BUILT.md's cold-build attribution
would otherwise raise: sand.flan links 110 wrappers, not 425, because the
bindings nothing reachable calls are dropped. What is left is 65ms of frontend
on a cold build, against a header read that was 60-90ms of a fresh session and
15.5ms of every redefinition. Both numbers are in BUILT.md now.

Also a swallowed line continuation in the exclusion message.
2026-09-13 08:22:10 +07:00
ecfe180d2e The config is tested by what it is for, not by its parser alone
Exclusion says why rather than going quiet, a pattern matches by prefix, an
override changes the Flan face and leaves the C symbol verbatim, and a rename
dissolves a kebab collision instead of leaving both halves refused — that last
one is why the kebab rule is consulted in exactly one place.

The file itself too: a line that is neither directive is an error, because a
typo in a name override would otherwise land a binding under the wrong name.
2026-09-13 08:09:59 +07:00
85ef56f657 The bindings are committed, and regeneration is what checks them
generated.flan carries the 253 declarations the importer reads out of raylib's
header, so a build needs libraylib linkable and no header at all. The opt-in
no longer decides how many bindings a package has — every build now gets all
425, they are greppable, and they diff when raylib moves.

What that gives up is the build-time check, so `flan generate-c` is the only
thing that writes the file and it compares first: every defstruct against the
header's record, every hand-written declare-c against the header's signature,
and it writes nothing when they disagree. Against the 5.1-dev header on this
machine that is ten real differences and no write.

The 172 hand-written lines stay, and not out of caution. Everything the
generator emits agrees with the header by construction, so diffing generated
output against its own source is a tautology; the hand-written lines were
transcribed by a person, so they are the only thing here a header can
contradict. All ten of those differences came from them.

`bindings` beside `headers` is what survives regeneration, because a hand-edit
to a committed generated file does not. Two directives: `exclude` drops
raylib's three allocator entry points, and `name` gives the 19 generated
predicates the `?` spelling the hand-written ones already use.
2026-09-13 08:07:40 +07:00
422b698be7 Every defn states its return type, and unit is () 2026-09-13 07:26:28 +07:00
df73f87b2f The return type stops being a guess: the slot is mandatory, unit is ()
The slot after a defn's parameters is unconditionally a type. Parse.decl no
longer takes a set of type names, and is_type_form, qualified_type, types_in,
declared_types and prelude_types are gone with the pre-pass that fed them.

What they were for: (Option f64) and (Some 1) are the same s-expression, so the
parser decided which it had by looking the head up in a set of the file's own
type names. Sound -- one top-level namespace means a name cannot be both a type
and a value -- and brittle, because the set had to be complete. It was wrong
twice in one day, the second time parsing (defn f [] (Rune {.code 65}) (bar))
as a function returning a Rune with a one-form body, silently, in every file in
the language.

Two things fall out. A type the parser could not have known -- a struct
declared further down the file, rl/Vector2 behind an unresolved alias, a
prelude type -- never needed recognising, only placing. And a mistyped type is
a mistyped type: (defn f [] f65 0.0) reaches the resolver's near-miss check and
says did you mean f64, where it used to be read as the first form of the body
and reported as an unknown name.

Unit is written (). The old spelling is refused with a message naming the new
one, the rule the colon-to-dot change followed. Internally it is still
Tname "Unit" and Types.Unit, so the resolver, the shim and the emitter did not
change; Cimport still builds Tname "Unit" for C's void without going through
the parser. Types.to_string prints () though -- that printer prints what a
person would write for every other type it knows, [i32], {K V}, (Ptr T), and
Unit was the odd one out once the source spelling moved.

Dropping prelude_types removes one of the two reasons Macro.reduce may only
drop defns: the memoised set a bootstrap build could have poisoned is gone, so
the remaining reason is the plain one.
2026-09-12 23:18:28 +07:00
bdbb6e094c The watch window pushes, because a poll cannot answer a stopped program
Ported from the author's Clojure watch.el. Three of its decisions survive
unchanged — the program decides what is shown, the request is async, and the
paint is replace-buffer-contents so point survives every tick. The transport
is the part that had to turn round.

An eval here compiles a module and dlopens it, so the planned fix of
compiling the render thunk once and re-invoking it per tick was still a poll,
and a poll has a defect caching does not touch: a thunk runs at a frame
boundary and a stopped program has no more of those. So the program writes
into a table in flan_dev.c from its own loop and Emacs reads the table, which
is memory. The values are then as fresh as the last frame whatever the
repaint interval is, and they are still there while the program is stopped.

The frame thread's constraints decide the storage: no allocation, so names
are fixed arrays rather than strdup'd; no lock, because the reader is the
listener thread; and not the result buffer, which is written once per C-x C-e
and would be overwritten sixty times a second by watch traffic. One seqlock
per slot rather than one for the table, so a reader retries one slot instead
of having to catch the gap between two frames' writes; a snapshot from
adjacent frames is what a HUD looks like anyway.

Sixty-four slots, and past that a name is dropped rather than fatal — killing
the program because somebody watched a 65th value is the diagnostic shooting
the patient. Reported as a flag and not a count: the only number the write
path could keep is of write attempts, which at frame rate says "3847 names"
about one name.

Nothing writes the table until a watch buffer is open, so a watch call in a
program nobody is debugging is a load and a branch that is not taken — the
same number in a release build, since flan_dev.c is linked into both.

Scalars work today through declare-c against four runtime entry points, which
is why this needed no compiler change. A struct or a slice needs a walk over
its type, which is one arm in check.ml beside print; BUILT.md writes it out
rather than reaching into a file another lane holds. Ghost text turns out to
be gated on the same arm, for a different reason: nothing in the table
carries a source location, and a hand-written declare-c call cannot supply
one that does not drift when the line moves.
2026-09-12 23:16:44 +07:00
26c53e0a19 Every defn in the tree states its return type, and Unit is written ()
The mechanical half, ahead of the parser change that needs it. tools/unit-return.py
fills the empty slot with () and rewrites Unit as () wherever a type is spelled --
(Fn [i32] Unit), (Map i32 Unit), a return type written out.

Deciding whether a defn already had a return type is the whole difficulty, and
the script does it the way parse.ml did: is_type_form is transcribed rather than
improved, because being identical to the parser it replaces is what makes the
sweep meaning-preserving. It is re-runnable, so the lanes that branched before
this can have the same pass at merge:

    python3 tools/unit-return.py .
    python3 tools/unit-return.py --in-strings test/test_flan.ml test/test_acceptance.ml \
        test/test_session.ml emacs/test-flan-dev.el emacs/test-flan-mode.el
    python3 tools/unit-return.py --raw-ml lib/prelude.ml
    python3 tools/unit-return.py --in-html web/index.html

-v logs every defn it saw and what it decided, which is how a sweep of 440 sites
gets reviewed at all. Embedded modes pool a file's type declarations across all
its fragments, because a snippet split across concatenation -- decls ^ "(defn f
[s [u8]] Cursor ...)" -- cannot see the names the other half declared; pooled
names count only in bare-symbol position, for the same reason the prelude's do.
A fragment that cuts off mid-form is skipped rather than guessed at. Five sites
in test_flan.ml still needed a hand, and they are in this commit.

Two things ride along because the sweep needs them: parse.ml reads a lone () as
the return type of a function with no body, which was not a shape the old
optional slot could produce; and the map refusals name () rather than Unit, since
that is now the spelling a caller wrote.
2026-09-12 23:06:40 +07:00
8d8346e58a Function values with no capture, map iteration, and the prelude's macro cycle 2026-09-12 22:45:44 +07:00
a9f903a63d A bare name is the function, and capture is the part that is not built 2026-09-12 22:44:33 +07:00
772d1d5b18 A cursor over the block, because nothing walked it 2026-09-12 22:24:05 +07:00
65a62bbb97 A socket that looped back into its own address space, deleted
flan dev is one process, so the unix socket between the compiler and the
program was a connect, a write and a read that never left the machine's own
memory. A delivery is now a call into the agent's verb table.

flan_agent.c's handlers write into a sink that is either an fd or a growing
buffer, so serve() and the new flan_agent_request() share one verb table rather
than two that would drift -- every answer in there is assembled from several
pieces, so the seam had to be the writing. Dev.deliver, Dev.result and Dev.ask
were three copies of the same socket dance and are now three lines over one
Dev.request.

Which path is taken is the linker's answer, not a flag: flan_agent_request is
weak in dynload_stubs.c, null in every binary that links no flan_agent.o, and
Agent.request is None there. So --two-process, flan reload and every test go on
using the socket with nothing to configure.

Three things the direct path must not quietly change. The ring's room check is
separate from its store, which was safe only while the accept loop was the sole
producer, so handle_line runs under a mutex. A delivery is still only published
-- the install is one store per function on the game thread at a frame
boundary, and doing it on the spot would be a frame running half in the old
code and half in the new. And the OCaml runtime system is released across the
call, because a delivery is a dlopen.

Measured, and the number is the point: the transport was ~50us of a 23ms
redefinition, so end to end did not move. Code generation is 19 of the 22
milliseconds. The merge's prize was never latency.

The test is a deletion, because a reply cannot say which way it came:
test_dev.ml unlinks the agent's socket file once the merged program has bound
it, and every evaluation after that still installs.
2026-09-12 22:22:46 +07:00
e24aee5120 A prelude function may call a prelude macro, and the fix was not ordering 2026-09-12 22:19:27 +07:00
a26469894e The prelude's second tier, which returns things instead of filling buffers 2026-09-12 22:05:22 +07:00
242f8c2047 Three section headers still said there was no allocator
The refusal list was rewritten and the prologues that pointed at it were not,
so prelude.ml claimed in three places that what it now contains is impossible:
the splitting header said `split` is refused at the foot of the file, forty
lines above `split`; the ASCII-case header said Odin's allocating to_lower is
not available here, next to the one that was written; and the UTF-8 header
said the rest of core/strings is refused rather than ported.

Each keeps its point rather than losing it. The iterator is still the shape
that owns nothing and still the right call when there is no result to own;
lower-ascii and bytes-ci=? are still the right calls when a copy is not
wanted, since folding a comparison over two inputs beats lowering both. What
changed is the reason, which used to be the absence of an allocator and is now
a choice between two shapes that both exist.

And strings.flan told the reader the opposite of what it did -- "not freed",
on the line above the free. vec.flan already had the right framing: the free
is written, it keeps the block because an arena cannot release one, and that
is the difference the capability set exists to state.
2026-09-12 22:03:34 +07:00
eda460b052 break and continue, with labels, and an array constructor 2026-09-12 22:00:39 +07:00
3f097de522 Pin the latch to the continue path, and the clause barrier to its reason
A dotimes whose every iteration continues still counts to its trip count. The
existing case fails by hanging if the latch is wrong; this one fails by
counting wrong, which is the off-by-one the four-block layout could have.

A restart-case clause was made a barrier on reasoning alone and nothing
observed it. Now something does.

And say what a labelled continue means, which is the half that is not obvious:
it advances the named loop's counter and skips the rest of its body, not just
the rest of the innermost one.
2026-09-12 22:00:13 +07:00
008165335d break crosses only sometimes, so the refusal is relative now
return is refused inside handler-bind and restart-case blanketly, and rightly:
a return always crosses the frames they pushed. A break does not. A loop
written wholly inside a restart-case body has a perfectly good local break, so
the rule is a barrier on the loop stack rather than a flag — a jump is refused
exactly when a barrier stands between it and the loop it names, and the message
says which construct. handler-bind and restart-case bodies are barriers, so is
a restart clause, so are a defer's forms; a handler clause is lifted into its
own function and needs no rule at all. in_frames is untouched: a return is the
special case where the target is always outside every barrier.

continue wanted the other blocker. check_dotimes folded its step onto the end
of the body, which a continue would jump past, so the counter would never
advance and the loop would hang. Tast.While carries a latch now — condition,
body, latch — the step goes there, and emit_while emits four blocks. A while's
latch is empty and folds away.

Labels are Odin's, in the head position: (while :outer c ...) and (break
:outer). A keyword there is unambiguous because a loop condition is never one,
so one label function serves while, until, dotimes, break and continue. It is
not a goto — the checker resolves a label against the loops the form is
lexically inside, so control can only leave a loop it is already in.

Break and Continue carry a relative depth rather than a name, because that is
what a backend already has: emit keeps one entry per While the way it keeps
one pad per frame, and indexes it.

Nothing in the prelude wants either. Every early exit there is a return from
the function, which break cannot replace; the sentinel-flag loop break exists
to remove does not appear in it. The two the compiler emits are that shape and
are the one place it cannot help — their sentinel is set inside a restart-case.

reach.ml and render.ml take the While arity change and nothing else.
2026-09-12 21:58:53 +07:00
7ce6043c47 A second and third sort, and why there is not a generic one
sort-i32! was the only sort in the language. sort-f32! and sort-bytes! are
the other two, and they are copies rather than an abstraction for a reason
worth naming precisely: map, filter, reduce and a sort taking a comparator
are not blocked on generics, they are blocked on *function values*. Types.Fn
exists and check.ml refuses it with "a function type is not implemented yet --
milestone 5", and there is nothing else in the language to pass. Generics on
top of that is what would make them one copy instead of one per element type.

The f32 family carries one caveat the i32 family cannot have: a NaN makes the
order undefined, because every comparison against one is false, so the
insertion loop never moves it and never moves anything past it. sum-f32
accumulates in f64 for a stronger version of sum-i32's argument -- an f32
total does not wrap, it absorbs, and the answer comes out silently short.
The test prints the difference rather than the total, because %g hides it.

sort-bytes! is the one a caller of split actually wants, and its ordering is
memcmp's: bytewise, unsigned, prefix first. Not alphabetical -- "Zebra" sorts
before "apple" -- and the note says so, for the same reason the ASCII-case
note refuses a locale. The slices move and the bytes never do, so it sorts
fields borrowed out of a string literal, which an in-place byte sort could
not.
2026-09-12 21:58:12 +07:00
b5e7351c7e A number with a precision, which %g cannot be asked for
f64->bytes is snprintf "%g": six significant digits, exponent notation of its
own accord, and no precision to pass it. A frame time of 1/60 comes back as
0.0166667 and a score past a million as 1.23457e+06. format-f64 returns a Vec
instead, so it inherits neither that nor the shared static scratch buffer --
and it is the reason append-i64! exists, because it renders the integer part
and the fraction through that one buffer in strict sequence.

Half away from zero at the last digit kept, which is round-f32's rule and not
printf's. 0.125 at two places is 0.13 here and 0.12 there; matching printf
would mean pinning a particular libc's nearest-even on the binary value, and
that answer is not the same on every target anyway.

The three cases that ship broken are each one line and each tested: the
carry, where the rounded fraction equals the scale and is the next integer
(0.999995 at five places prints "0.100000" without it); the zero padding,
without which 1.005 at three places prints "1.5"; and the sign, which belongs
to the number rather than to its integer part, since -0.5 has an integer part
of 0 and 0 carries no sign.

The clamp on the precision is spelled (min 9 (max 0 prec)) and not with the
clamp macro, and the reason is a finding: the prelude is never
macro-expanded. macro.ml's pass runs over the file being compiled, and the
prelude arrives at the checker through Check.program's own prepend, so a
prelude function calling a prelude macro resolves the macro's underlying
defn -- the one that takes a [Form] -- and reports an arity error.
2026-09-12 21:55:17 +07:00
a915140f17 flan dev is one process, and the socket does not know it 2026-09-12 21:52:35 +07:00
02850d7282 The prelude returns new bytes now: a builder, join, split and the case pair
Eight functions that the file used to refuse by name, and the refusal was
always one sentence -- there is no allocator -- which stopped being true when
Vec landed. Three rules hold across all of them and are written at the head
of the section: the result is owned and the caller frees it, the allocator is
the context's, and no signature carries a Result because no allocating
operation returns an error.

The builder is not a type. Odin's strings.Builder wraps a [dynamic]u8; here
the (Vec u8) already is that and already has push, so a wrapper would be a
move-only struct whose only method is the one it wraps. What was missing is
appending a run of bytes, and append! is that -- taking a (Ptr (Vec u8)),
because a Vec parameter moves and a by-value builder would be consumed by its
first append.

append-i64! and append-f64! are the argument for the whole shape. The
runtime renders numbers into one shared static buffer, so two of its results
cannot be held at once; these copy out before returning, so a builder holds as
many numbers as it likes. strings.flan puts two integers and a float on one
line to show it.

split returns a (Vec [u8]) and not a (Vec (Vec u8)): the fields borrow the
input, and the owning shape is refused outright because a Vec copies and
releases its elements bytewise. Constructing it needed a one-line slices-new,
because (vec-new) takes its element type as a bare symbol and [u8] is not
one -- a compiler gap, noted rather than worked around in silence.

replace-bytes guards its empty needle with an if and not an early return: a
returned Vec is a move, the dead set spans the function, and a return on one
branch would kill the binding on the other.
2026-09-12 21:51:42 +07:00
b71b7a7981 Valgrind over the corpus, in 88 seconds, with no suppressions to write 2026-09-12 21:49:50 +07:00
b0bc40ca05 atan2 and pow go out to libm, and clamp is a macro rather than four functions
The two declares inherit the sin/cos caveat in full and not the sqrt one:
IEEE-754 requires nothing of atan2f or powf either, so they are the third and
fourth places in the prelude where native and wasm32 may differ in the last
bit. Every case in math2.flan is therefore a value that is exact in binary --
a quadrant boundary, a power of two, a perfect square -- rather than one that
would pin a particular libm and then fail on wasi.

clamp is the interesting one. The prelude already argued against wrapping
(min hi (max lo x)) in a function, and that argument gets stronger rather
than weaker: min and max are builtins at every numeric type and there are no
generics, so a clamp *function* is one copy per type. A macro is
type-agnostic for free and emits nothing at all. The test calls the same
three words at i32, i64, u8 and f32 to show it, and counts evaluations to
show that each argument appears once -- the shape that names x twice reads
identically and calls it twice.

lo above hi answers hi and is not checked. A macro has no error facility, so
the only diagnostic available would be a run-time one, in the construct whose
whole point is that it costs nothing at run time.
2026-09-12 21:48:37 +07:00
9f4a59c56f A let binding has no type slot, so (array 4 T) is how you say it
[4 T] is the type syntax and is unchanged; it already works in a defvar, a
parameter, a field and a return. A let binding is the one position with no
type slot, and there the brackets are an array literal of two elements whose
second is a type name — which came back as "unknown name rl/Vector2" and cost
32 hand-written Vector2s in one raylib example.

(array COUNT TYPE) is a parser form rather than a builtin call, because the
second argument is a type and the parser's callers have none. Parse assembles
the Tarray itself, so the count takes a constant's name for free and a value
in the type position is refused by the type reader's own message. The checker
resolves it to Tast.Zero — no new backend node and no new type.

(zeroed [4 T]) was proposed first and rejected: the parser can tell, a person
cannot. zeroed keeps its job of being inferred; array is the one that is told.
2026-09-12 21:48:08 +07:00
884d6a4d47 One process is a claim about the process table, so the test reads it
A reply over the socket looks the same either way, which is the point of the
merge and also why nothing in the suite noticed it. So the daemon's pid is
checked directly: /proc/<pid>/exe is the merged program, at a path named for
the pid that built it, and there is no child at all.

The same check runs the other way round over --two-process, which gets one
round trip of its own — describe, an eval, and the output coming back — because
it is the escape hatch for a machine that cannot build the compiler object and
an escape hatch nobody exercises is not one.

Linux only, by /proc, and skipped rather than faked elsewhere: what is being
asked about is the process table.
2026-09-12 21:47:33 +07:00
3a91c7ef06 A corpus program that is not idempotent looks exactly like a tool finding 2026-09-12 21:46:10 +07:00
c2df38e139 The corpus under memcheck, because ASan cannot see an unwritten byte 2026-09-12 21:40:35 +07:00
49bb9b9c42 Macros expand, and unless is a prelude defmacro 2026-09-12 21:11:40 +07:00
545ef6e0ea A package may not declare a macro, and says so
The expander collects defmacros from the prelude and from the file being
compiled. Not from an imported package, and the reason is an ordering one:
Load learns a package's imports by parsing it, so reaching a package's macros
would mean resolving that package's own imports over Forms, before Load runs.
That is a second import resolver, and it is a bigger thing than this lane.

Refused by name, which is the rule that caught the two misparse bugs. Left
alone the call arrives at the checker as an unknown name -- true, and no help.
Refused where the defmacro is written rather than where it is called, because
that is where the fix goes.

The check has to sit in Load's read, because that is the only place that can
see one: by the time Parse is finished a defmacro is an ordinary Ast.Defn and
the word is gone.

Measured while here, since a prelude that grows a defmacro is a cost every
program pays or does not:

  - A build of a program that names no macro: 50ms, the same as before. The
    pass scans the top level, finds nothing, and no compiler runs.
  - A program that calls one: 310ms the first time, 70ms after. The 240ms is
    the clang driver building the macro module; it is cached under the object
    cache, keyed by the prelude's source and the file's defmacros, so it is
    paid once per change rather than once per build.
  - A hello-world's binary carries exactly one symbol out of all of this:
    flan.gensym-n, eight bytes. Reach.link drops unless, form-cons, form-nil,
    form-append, form-rest and gensym, because nothing reachable calls them.
2026-09-12 21:02:33 +07:00
f3a0e435fd unless is not a special form any more
plan.org milestone 5 says when, unless, until, cond and dotimes are special
forms only until macros land. This is the first one to stop being one, and
running test/programs/macro-unless.flan means the compiler built a shared
object, dlopened it into itself and called a Flan function to find out what
(unless c a b) means.

unless is the one that moved because it is the one nothing else needs: zero
uses in the prelude, so moving it cannot make the prelude depend on the
expander that compiles it. Its coverage is sand.flan, seven calls, compiled
through Session in test_session -- which is the in-process path and the reason
lib/dune now passes -linkall. Say plainly what that coverage is not: nothing
in test/programs used unless before today, so macro-unless.flan is a test
written after the feature. The corpus that was written before it is sand.flan
and web/examples/control.flan, and both compile unchanged.

lib/macro.ml is the half of expansion that has to compile something. Expand is
the image format and the quasiquote desugaring and depends on nothing above
Form; this needs Check, Build and Emit, so it sits above the parser it feeds
and arrives through Parse.expander.

What it does, in order:

- Collects every defmacro from the prelude and from the file. Not from an
  imported package: Load learns a package's imports by parsing it, so
  collecting from one means a second import resolver over Forms, and that is a
  bigger thing than this.

- Builds them in rounds, because a macro's body may call a macro and a body
  with an unexpanded call in it will not compile at all -- the call is a name
  nothing defines. Round 0 takes every macro that names no macro still
  waiting; round 1 expands the rest against round 0's module. A round that
  takes nothing while macros remain is a ring and is named. macros.flan has
  the round-1 case and macro-cycle.flan has the ring, and the distinction
  between them is the one thing here that is easy to get wrong: a call inside
  a quasiquote is *not* a compile-order dependency. It is part of what the
  macro answers, and the answer is expanded again after it returns. The first
  macro-cycle.flan written for this commit quasiquoted, and it was not a cycle
  at all -- it hit the fuel instead, correctly.

- Walks bottom up, so a macro never sees a call to another macro in what it is
  handed, and re-expands what comes back, so a macro that expands into a call
  to itself keeps going. That loop is bounded at 200 and says which macro ran
  out: macro-spin.flan.

- Skips all of it when the file names no macro, which is nearly every file.
  Otherwise every build in the suite would pay a clang driver to answer a
  question nobody asked. When it does build, the module is cached under the
  object cache and keyed by the prelude's source plus the file's defmacros, so
  a second process pays a dlopen.

lib/dune passes -linkall, which is the one line in another lane's file. The
module installs itself into Parse.expander at initialisation and nothing
references it, so without -linkall the linker drops it from every executable
that does not name the module -- bin/main.exe among them -- and a program
calling a macro fails with an unknown name. The alternative was an install
call at every entry point, including ones in files this lane must not touch.

The one thing a macro cannot do that parse.ml could is give a reason. A macro
runs inside the compiler and anything it signals aborts the compile with no
location, so a malformed (unless) answers a name nothing defines and the
report is "unknown name unless-takes-a-test-and-a-body" at the call site --
right place, wrong sentence. NEXT.md says so.

test_flan.ml's "unless -> if(not)" assertion is gone, because it asserted a
desugaring in a file that no longer does one. Nothing else in the suite
changed.
2026-09-12 20:59:12 +07:00
0e7f53a510 Quasiquote is a desugaring, and a defmacro is a defn
Two things that look like plumbing and are the frontend half of expansion.

A quasiquote becomes calls to the prelude's three form-building functions and
nothing else: form-nil, form-cons for an item, form-append for a splice. It is
pure, it needs nothing loaded, and it runs over every form on the way into
Parse.program and Parse.decl, which is what lets the prelude's own macros parse
in a process that has not built a macro module yet.

Running it *before* the expander's walk is not an ordering preference. A cond
macro's body contains a quasiquoted (cond ...) for its own tail; with the
quasiquote still standing, the walk would see that head and expand it then and
there, against the wrong arguments. Desugared first, that subform is a
(Form.Sym {.s "cond"}) and there is no head left to mistake. So the walk needs
no idea that quoting exists, which is the whole reason this runs first.

Nesting levels are not counted -- not by the reader, which was written that way
deliberately, and not here. A quasiquote inside a quasiquote is refused by
name. Only a macro that writes a macro wants one, nothing in the corpus does,
and CL's level arithmetic costs more than the use case is worth so far.

A defmacro is now an Ast.Defn: (defmacro m [args] body) is
(defn m [args [Form]] Form body). There is no Ast.Defmacro and there is not
going to be one -- a macro is [Form] -> Form, compiled by the same backend as
everything else, and the only thing that makes it a macro is that the expander
calls it at compile time. One parameter, the slice of forms at the call site,
so variadics come free in a language with no &rest; two parameters is a
misunderstanding rather than an arity error and says so.

Parse.expander is the hook the walk arrives through, because expanding a macro
means compiling and dlopening it, so the expander sits above Check and Build
and Parse sits below them. Nothing fills it in yet.

The quasiquote refusal stays as a backstop: it now means a form reached the
parser without coming through program or decl. gensym's refusal is gone -- it
is an ordinary prelude function returning a Form, and a macro body calls it
like any other.
2026-09-12 20:51:06 +07:00
a34b63af5d A prelude type is a name, not a list head
The last commit put the prelude's types into the set the parser uses to tell a
return type from the first form of a body, and put them in plainly. That set is
read by two arms: a bare symbol, and a list head. The list-head arm is why
(defn f [] (Some 1) (bar)) does not lose its body, and the comment above it has
warned about this since it was written -- so adding Rune plainly made
(defn f [] (Rune {.code 65}) (bar)) a function returning a Rune with a
one-form body, silently, in every file in the language. Confirmed before
fixing: it failed with "a map type is {K V}", which is the misparse arriving a
step later wearing someone else's error.

The enums already solve this one comment up, under their own key, for the same
reason. The prelude's types go in the same way. No prelude type takes
arguments, so a bare symbol is the only type position any of them can occupy.

Both halves are pinned in test_flan.ml's return-type section: Form is a return
type, and a prelude struct literal opening a body is not.
2026-09-12 20:41:32 +07:00
f585df494c A macro runs inside the compiler, and hands a Form back
The boundary was verified by compilation and had never executed. Now it does:
three Flan functions compiled into a .so, dlopened into the test process, and
called with Forms this side laid out in raw memory.

lib/expand.ml is the image format and nothing else yet. A Form is 24 bytes,
align 8, payload at offset 8, and every case holds one member at the payload's
start -- a string and a slice are both { ptr, i64 }, so there is no third
offset anywhere in it. The tag is the case's position in the prelude's
defunion, which is why that list says it is a layout contract; a tag this file
and the prelude disagree about is named rather than read as some other case.

The case sends one Form of every one of the nine shapes through an identity
macro, so a tag nobody thought about is a failure and not a gap. Then two
arguments through a macro that reads the second, because a slice whose length
did not cross reads past its arguments and an identity macro would not notice.
Then a Form the *macro* allocated, through the prelude's form-cons, on the
loaded module's own heap: that is the direction nothing had ever tested, and
it is the one the expander spends all its time in.

Checked by breaking the last expectation before restoring it.

A parser bug this turned up, and it is the reason the previous lane's Form
work could not have been finished as written: is_type_form decides whether a
leading form is a return type or the first form of the body by asking whether
its name is a declared type, and the set it asks was collected from the file's
own declarations only. Check.program prepends the prelude to every program, so
the prelude's types are every file's types -- but nothing told the parser that.
It never mattered while the prelude's structs were only taken as parameters.
A macro is (defn m [args [Form]] Form ...), and bare Form in return position
was parsed as a body expression and reported as an unknown name, while [[Form]]
worked, because a Vec in that position is a type whatever is inside it. The
prelude's types are now part of the base set, read once.
2026-09-12 20:35:56 +07:00
404c810958 Which frame the inspector answered from, asserted rather than reasoned about
The `inspect' verb had no coverage. The discriminating case is not a path
step, it is the frame: dev-inspect.flan gives `mark' to a global holding 99
and to a local of the OUTER frame holding a Point, so evaluating the name and
rooting at the frame answer differently and not even with the same type. One
`eval-expr' and one `inspect' of that name is the bug and the fix in a pair.

The slot index comes off the locals listing's fourth element rather than being
written as a literal, which exercises the field the editor depends on and
keeps the test from passing for the wrong reason if slot allocation shifts.

The rest is what a path can and cannot do: a struct field, an array element,
an option's payload and a union case's field — the last two having offsets but
no accessor form in the language — and four refusals, each checked for naming
the step and saying why. A `:path' of `nil' is read as the slot itself,
because Emacs has no other spelling for an empty list.

Two claims about the frame, since `stopped_frame' being shared is an assertion
about code rather than about behaviour until something proves it: the frame
whose body was redefined under it is refused, and so is the whole stack once
the program resumes.
2026-09-12 20:34:32 +07:00
3ae2c089ec Form's image format, measured instead of assumed
24 bytes, align 8, payload at offset 8. Those three numbers are the whole
agreement between the compiler and a dlopened macro -- the compiler writes a
Form into raw memory a field at a time and reads one back the same way -- and
they were written down in a handoff note and asserted nowhere. Nothing at run
time would notice a disagreement of one byte; the macro would simply return a
different form than it built.

So they go through the oracle the DWARF cases already use: ptrtoint of a
getelementptr through null, constant-folded by llc and read back out of the
.quad. The offsets and the size that oracle already answered. Alignment it did
not, and reading [2 x i64] out of the emitted type and concluding 8 would be
asserting the layout against itself -- the circularity BUILT.md rejected when
it turned down a _Static_assert. It is asked instead: the offset of field 1 in
{ i8, Form } is alignof(Form), because a member sits at the first offset its
own alignment allows.

Checked by breaking it both ways before restoring: 25 for the size and 16 for
the alignment each fail, and name which number moved.

The llc plumbing is now one run_oracle over a module of folded constants, with
llvm_members and llvm_align as the two questions asked through it.
2026-09-12 20:30:25 +07:00
1ee54d6b56 A union is a type name, and (vec-new) did not think so
The prelude's own (vec-new Form) was refused with "nothing here says what
(vec-new) is a Vec of" -- a message about a missing annotation, to a program
that had written one. The build went red the moment the Form declaration was
checked against anything, which is why the front half landed unmeasured.

The test a leading bare symbol has to pass was spelled out twice, once in
vec_new_elem and once in map_new_types, and both lists were written before
unions existed: primitives, structs, enums, aliases. resolve_name has known
about unions since they landed, so the two halves disagreed about what a type
name is. Now there is one list, read by both, so the next kind of type cannot
be added to one of them.

The case is in unions.flan rather than in a file of its own, because what it
asserts is that a union is an element type like any other -- (vec-new Shape),
(map-new string Shape) -- and that is a sentence about unions.

Also drops forms.so, a build artefact the last lane committed.
2026-09-12 20:27:19 +07:00
66c29dfa5f A union is a tag and room for the largest case 2026-09-12 17:04:18 +07:00
cd34c3fea9 A union recurses through a pointer, and not by value
check_finite already walked a union's cases, so a union containing itself by
value was refused before the emitter could try to lay it out -- which it would
have done forever, since payload_lay calls lay calls payload_lay. Asserted
both ways round: directly, and two unions through each other.

Through a pointer it works, and that is the shape a Form has, so it is in the
program rather than only in the prose: a Tree with a (Ptr Tree) field, matched
through a deref, summed recursively.

BUILT.md also records why match's fall-through is still unreachable rather
than a trap. It is only sound because no reachable program can hold a tag no
case names: Zero is tag 0, every construction writes a tag the checker
resolved, and uninit -- the one way to get bytes nobody wrote -- is refused on
a union for exactly this reason. The refusal is what pays for the unreachable.
2026-09-12 17:02:59 +07:00
f3f973d775 A second fingerprint for the globals a body names, and the header caches at both levels 2026-09-12 17:00:20 +07:00
36c3e5a56d The globals a frame names, checked the way its slots already were
The globals section attributed a frame by its slot fingerprint, which is the
wrong cut for it: a redefined body can name entirely different globals while
binding identical locals, so the check saw no change and the new body's
reference set went into the union under the old body's frame, with the frame
numbers beside an entry saying so.

So a second fingerprint. Reach.ref_fingerprint hashes the set of globals a body
names — sorted and deduplicated, because a reference set is not ordered, where
slot indices make the slot fingerprint order-sensitive on purpose — and it
travels the path the first one already cut: %fninfo, flan_dev_frame_refsig, the
agent's snapshot, the backtrace line, Dev.globals_op. Different means the frame
is skipped by name with its reason, and the rest of the stack still contributes.

Two numbers rather than one, because they are two facts. A frame whose slots
match and whose globals do not has locals that are perfectly readable and
attribution that is not, and a combined hash would make locals refuse a frame
with nothing wrong with it. locals still checks the slot fingerprint alone.

It lives in reach.ml because expr_refs is already the walk that answers what a
body refers to, and is the walk the union itself is built from. One consequence:
emit now reaches reach, which closes a cycle through Load if cimport calls
Build.cachedir, so the header cache spells the object cache directory itself.

test_dev.ml drives the exact case — a body that binds identical locals and names
untouched where the stopped frame names pressure. With the check disabled it
fails twice: the missing refusal, and untouched appearing under frame 0.
2026-09-12 16:59:44 +07:00
6c026cb32e uninit on a union, and .field on one, are both refused
Everywhere else uninit is an opt-out from ZII and the bytes are whatever they
were: a garbage f64 is a garbage number. A union is the one type where that
is qualitatively worse. Its tag steers control flow, a tag no case names falls
past every comparison in a match, and the block after those comparisons is
unreachable -- which LLVM is entitled to assume cannot happen. So the one
place where garbage becomes "the optimiser may do anything" is refused by
name, with the zeroed form, which is a real case, named beside it.

(.x u) on a union said "Shape is not a struct, so it has no fields", which
is true and unhelpful. A union's fields belong to a case and which case is
being held is what the tag says, so they are reached by match, whose arms bind
the fields of the case they matched. The message says that.
2026-09-12 16:57:56 +07:00
ee15745718 What a union has to do, and what it must refuse
The acceptance table runs unions.flan at -O2, at -O0 and as a dev build. -O0
because a union value is built in an alloca and mem2reg is exactly what would
hide a store to the wrong half of it; dev because every body goes behind an
indirection cell there and a union crosses one both as a parameter and as a
return value.

The layout goes through the oracle the DWARF section already had: LLVM's own
answer for the emitted type, read back as a folded ptrtoint. Two unions, one
whose widest case is a pair of f64 and one whose cases are all i32, so the
payload size and alignment are not constants the test could have agreed with
by accident.

Eleven refusals, each by name. The first is the diagnostics bug NEXT.md
listed: a case name written as if it were a struct said "unknown struct A",
because nothing in the environment could tell a case from a misspelling.

Non-exhaustive matches are refused rather than defaulted. A match that fell
through would have to produce a value of the match's type out of nothing, and
the case a union grows tomorrow is the one a reader wants to be told about
today; _ is how to say "the rest", written where it can be seen.

A case pattern binds all of a case's fields or none, positionally: binding
some of them reads the wrong field the moment one is inserted above it.

test_flan's 'match works on an Option at milestone 2' assertion moved with the
message, which no longer blames a milestone that has arrived.
2026-09-12 16:56:31 +07:00
03f7609201 The structural printer reads only the case in hand
print, the REPL inspector and the break buffer's locals all walk a concrete
type through render.ml, and a union fell through its Named arm to <Shape>.
It now recovers the case from the tag by a chain of comparisons -- the same
shape the enum arm already had, and for the same reason: the name is erased
before any backend sees it -- and reads the fields of that case only. Reading
the others would be reading a payload that is not there.

It prints (Shape.Dot {.x 1.5 .y -2.5}), which is what the source would write.

The union table has to reach the walk, so Render.ctx grew a field and its
three construction sites in session.ml and one in check.ml pass it. That is
the whole of the session.ml change.

test/programs/unions.flan is the program: a case with no fields, a case wider
than another, a case holding a string, a union in a struct, a union through a
call in both directions, ZII, reassignment, and printing. Its layout was
checked against clang's for the same declaration -- 32 bytes aligned 8 with
the payload at offset 8, and 40/8 for the struct holding it.
2026-09-12 16:53:26 +07:00
77ee428d4b The alias-clash test was claiming which line the message names
It does not get to. Which of the two imports is refused is whichever arrived
second, which follows the entry file's textual order — reverse the two lines and
the message moves from the package's import to the program's. Both refusals are
correct and the needle matches either, so the test was green while its comment
was wrong.

The comment now says what the case actually tests: that a clash is caught when
its two halves are a directory apart, rather than side by side as in
pkg-two-aliases.
2026-09-12 16:48:51 +07:00
c604911ecb A ring of imports is refused by name, not swallowed
Loading a package kept one table, keyed by real path, and used it for two
different questions. Already loaded meant "skip", which is right for the second
route of a diamond and wrong for a ring: a package that imported itself round a
chain met its own entry, contributed nothing, and appeared to work. The comment
said so and called it a feature.

It is not one. A ring has no package order, and a definite package order is what
the macro expander needs — every defmacro has to be compiled before anything
that calls it. So the chain currently being read is now carried separately from
the set already finished. A directory found in the first is a cycle and is
refused; a directory found only in the second is still the diamond's second
route and still a no-op.

The refusal names the ring — a -> b -> c -> a — and only the ring, not the route
that led to it. "There is a cycle" leaves the reader to find which three imports
it was.

pkgs now comes back dependencies-first, which is the topological order the
acyclic rule buys. The declaration list is left alone: check.ml collects every
top-level name before it checks any body, so declarations are order-independent
by construction and sorting them would be churn in the field every test reads.

The tests are a real tree rather than a second copy of pkg-shared. pkg-diamond
builds a shape/Box inside area/ and hands it to a function declared inside
draw/, which only type-checks if the bottom package was read once — two copies
of one struct are two types. What proves it is the numbers, not the compile.
2026-09-12 16:46:30 +07:00
03e8a1fd1b A Map, open-addressed and Robin Hood, over the type-erased runtime 2026-09-12 16:35:01 +07:00
ea24461107 Cover a dev build and a map that leaves its let
Two gaps nothing in the suite reached.

A dev build, because the hash and equality pair emitted for a struct key
is a function nobody wrote, and the only other inhabitant of the lifted
list — a handler-bind clause — carries a parent this one cannot: the
pair is shared by every function that maps that key type, so it has no
single parent. A dev build puts every body behind an indirection cell
and is the build that would notice. It does not; maps.flan answers the
same nineteen ways at --dev as it does at -O2 and -O0.

And a map crossing a function boundary in both directions. Everything
else in the file lives and dies inside one let, so nothing would have
noticed if the 48-byte header travelled wrongly by value while every
runtime operation takes its address. Returning one and passing one are
both moves, which is the rule a Vec already follows — verified against a
Vec rather than assumed, since a refusal that fired for the wrong reason
would look the same.

has-key? is flagged in BUILT.md as what it is: an addition, not
something spec-memory.md names.
2026-09-12 16:34:25 +07:00
3325c41fb7 The globals a stopped stack touches, in a section of their own 2026-09-12 16:23:31 +07:00
635d12782d The globals a stopped stack reaches, in one section and not under a frame
A global is program state a frame happened to touch, not part of it, so
nesting it under one implies an ownership that is not there and repeats the
name once per frame that reads it. One section instead, holding the union of
the globals every frame on the stack references — the compiler does the
choosing, since Reach.expr_refs already answers a body's reference set, and
listing every global a program has would bury the one that matters under the
prelude's PRNG state.

Each entry says which frames touch it, by the index the stack section already
numbers them with, which recovers what per-frame nesting would have told you
at no cost in duplication. Ordered by the innermost frame that touches it:
a deep stack makes the union large and proximity to the error is what puts
the likely culprit on top.

Simpler than locals, because a global is reached by name rather than by
address. Emit.redefinition writes a global the host has as external, so the
thunk binds to the program's own storage and nothing is asked of the stopped
thread — no dev-slot round trip and no not-yet-bound case to refuse.

A frame that cannot be attributed contributes nothing and is named in
:skipped; the union being incomplete and the union being complete are
different answers. The hole in that is stated rather than papered over:
slot_fingerprint hashes a body's slots, which is the right cut for locals and
not for this, so a body that names different globals while binding the same
locals is not caught. The test drives the case that is.

MANUAL.md also loses a stale paragraph claiming the fingerprint check never
fires with a failing test pinned to it. It fires, and test_dev covers it.
2026-09-12 16:22:58 +07:00
b239d2ae59 Read raylib's header instead of trusting the transcription 2026-09-12 16:20:54 +07:00