128 Commits

Author SHA1 Message Date
bbda5e4cd7 Point the reader at the two scripts, so the page's claim about itself is testable
"Every program below was run" is the kind of assurance nobody can act on. Naming
check.sh and quotes.sh turns it into something a reader can re-run, and says
plainly that a disagreement makes one of them go red.
2026-09-12 03:55:06 +07:00
4d1a0c7807 Build sand for wasm32 and compare the hash, rather than repeat the number
It is the project's headline cross-target claim and the page was asserting it
second-hand. Both targets print 2256461126764447066 on this machine, so the
transcript is now what the page shows.
2026-09-12 03:53:38 +07:00
23601f382d Say that break and continue do not exist, since a loop section implies them
plan.org settled the loop story as "while/for with break/continue and return",
so a reader will reach for them; they are not implemented and, unlike the rest,
not refused by name either — they come back as unknown function.
2026-09-12 03:51:53 +07:00
6c34d4a66e Quote the compiler's own words for the index rule, and pin the Emacs keys
The paraphrase of why a wide index is refused was shorter and said less than
the message; and the keybinding table came from NEXT.md, which is two keys
behind flan-mode.el, so it now reads the keymap instead.
2026-09-12 03:50:48 +07:00
fc47489802 Show the bounds check failing, because "checked" without a message says little
The claim worth making is not that there is a check but that a failure names
the line, and the only way to show that is to trip one.
2026-09-12 03:49:41 +07:00
1d206518cf Colour the primitive type names too, since only the capitalised ones showed
The rule was "capitalised is a type", which leaves i32 and string looking like
ordinary names in the one position — a signature — where the reader is there
to see the types.
2026-09-12 03:48:30 +07:00
a5e0c01224 Check the quoted blocks too, since a paraphrase reads exactly like a quotation
The blocks that are not programs were the ones that had drifted: the usage text
had lost its indentation and the refusal table had trimmed "(see plan.org)" off
every message, so the page was showing wording the compiler does not print.
2026-09-12 03:47:09 +07:00
86ef557433 Run the break loop rather than quote it, since the restart order is a claim
NEXT.md prints the banner with the restarts in source order; the walk is
innermost-first, so it is the other way round. A --dev build under timeout is
enough to settle that, and settles the two place and global snippets with it.
2026-09-12 03:43:24 +07:00
dfd64d89ea The examples are files that run, not prose, so the page cannot drift from them
Copying a snippet into HTML is where a documented language stops being the
real one. Each block on the page is a program here with its recorded output
beside it, and check.sh is what says the page is still true after a change.
2026-09-12 03:40:03 +07:00
2ecfac7561 A page to point someone at, so the language is readable before it is installed
Everything here is checked against the compiler rather than against plan.org:
the design documents describe a language larger than the one that runs, and a
page that documented the plan would mislead the first person to try it.
2026-09-12 03:38:55 +07:00
5f0bde8149 A thunk that holds a string keeps its mapping
The transient marker said nothing outside the module points into it once the
call returns - true of its text, silent about its data. A string literal is
emitted into the evaluating module's own image and an expression may store one
anywhere: C-x C-e on (set msg "tuned") left a program global pointing into the
mapping the agent was about to drop. The next thunk can be mapped at the same
address, so what comes back is silent garbage rather than a fault, and nothing
in the compiler refused it.

The third condition is that the module emitted no string constants. Then there
is nothing in its image anyone could still be pointing at. One that did keeps
its mapping, which costs a page and is the bargain every redefinition already
makes.

Found by reading jank, which has met the neighbouring hazard from the other
side: its notes are explicit that nothing is ever unloaded, and the one place
Flan makes an exception is the one place the rule had a hole.
2026-09-11 20:50:23 +07:00
fe1237ccea Four ways the break loop lied about the program's state
Found by a concurrency audit that demonstrated three of them against a running
program rather than reasoning about them.

The break state was a flag, not a depth. A C-x C-e thunk may itself error, and
the break loop that catches it nests inside the first - so the inner loop's
resume stored broken = 0 while the outer one was still stopped. Every verb that
could rescue the program then answered "not stopped", status answered "running",
and the outer loop spun forever with no protocol path out. Only kill recovered
it, and Emacs' modeline read live throughout. The audit showed it with ticks
frozen at 0 beside :stopped nil. It is a depth now, capped, and past the cap the
program says so and exits rather than grinding. The condition name is saved and
restored per frame for the same reason.

An idle connection wedged the whole listener. The accept loop is single-threaded
and serves each connection inline on a blocking read, so a client that connected
and sent nothing - an editor killed mid-request - blocked every later request
including the abort that ends a stopped program. Worse, requests the client had
already given up on were served when its socket finally closed, so an abandoned
abort could kill the program minutes later against a state that had moved on.
Two seconds is generous for one line.

chosen_ready was cleared after the resume attempt, so a restart arriving in that
window was answered ok and then erased. It is claimed into a local and cleared
first now, which also keeps strlen off a buffer the listener may be writing.

And aborting was sticky: an abort that passed its check just as the program
resumed stayed armed and would have killed it at the next unhandled error,
minutes later, in unrelated code, giving nobody the chance to choose.
2026-09-11 20:40:57 +07:00
17ef50898d The FFI shim is generated, and goes where its package goes
vendor/raylib has no C in it any more: shim.c is deleted and its 84 wrappers
are emitted from declare-c, which names the library's function in the library's
own signature. The reason the shim exists is unchanged - a small struct's
calling convention is a per-target classification and clang reproduces it for
free - but writing it by hand has stopped.

declare-c is a second form rather than a change to declare, because the two make
opposite claims about the same shape: (declare start-raw [path string] ...) says
the symbol takes ptr+len, and (declare-c init-window [... title string] ...)
says it takes a NUL-terminated char*. No structural rule separates them, so the
author says which.

The merge needed two fixes that neither lane could have found alone.

Load's uses-walker matches decl_kind exhaustively and did not know DeclareC, so
the reachability work and the generator did not compile together.

And the generated C is now emitted in parts keyed by the wrapper's own C symbol,
not as one translation unit. Reach.link drops the bindings nothing reachable
calls; a single TU holding every wrapper referenced every raylib symbol, so
sand-headless - which deliberately links no libraylib, and is the reason Reach
exists - failed at the link with undefined references to GetTime and its
neighbours. The first attempt keyed the parts by Flan name and broke the other
way, dropping a wrapper that was called: the flattened declaration is named
foo-c when a Flan wrapper is generated over it and foo when none is needed, so
the Flan name is not one thing. The wrapper's C symbol is what the declaration
binds in both branches.

Worth recording how close that came to passing: the acceptance suite died with
an exception rather than printing FAIL, so a grep for failures counted zero and
the suite looked green. Only the count of reporting suites - ten where there had
been eleven - showed it.
2026-09-11 20:38:17 +07:00
e08b3914fb Padding is a closed case, and a made-up name can still collide
Two gaps in what was claimed. The first is prose: "the typedef follows the
defstruct" answers field order and field types but says nothing about
padding, which reads like the remaining hazard. It is not one. Every field
type the generator admits has the same layout under LLVM as under C, and
emit.ml writes no datalayout, so clang applies the target's own rules to
both halves; everything where they could diverge — an array, a slice, an
Option, a map, a union — is already refused at the field.

The second is real. The flattened declaration's name is invented by
appending -c, so a hand-written foo-c beside (declare-c foo ...) came out
as the checker complaining that a name not in the file was declared twice.
Refused now where it happens, naming both and saying to rename one.
2026-09-11 20:31:55 +07:00
fe4d2e1b15 Launch, restart and document, from inside Emacs
M-x flan-dev builds, launches and connects in one command, so a terminal is no
longer part of the loop. It waits for a connection rather than for the socket
file: the daemon unlinks a stale socket before binding, so waiting on the file
either succeeds against nothing or races the unlink. A daemon that dies before
binding - a program that does not compile, which is the failure people will
actually hit - pops its buffer and refuses by name, because the compiler's
reason lives only there.

flan-dev-restart-program is elisp rather than a daemon op, and that is a design
answer rather than a shortcut: a session's struct layouts describe a process
only if that session compiled it, so restart the program, keep the session is
not a coherent thing to offer. It waits the old daemon out before starting the
new one, or the old one's exit unlinks its successor's socket.

Quit says the program may have outlived the daemon when it has to kill rather
than close, instead of reporting success - a killed daemon never runs the
cleanup that signals its child.

The restart test proves itself by what it discards: a name installed into the
old program is absent from the new one. Both quit and restart were verified
able to fail by mutating the client.

Two places now show that Tast.global and Tast.extern carry no Loc - M-. refuses,
and the doc buffer says the daemon reports no location - which is the same gap
recorded twice rather than papered over.
2026-09-11 20:30:25 +07:00
61ca469a7c A restart that stopped at the quit has not restarted anything 2026-09-11 20:29:48 +07:00
f925a79475 Say at the top that the terminal is optional now 2026-09-11 20:28:13 +07:00
fc3cd2361a Point at the reason rather than naming the buffer it is in
A program that does not compile kills the daemon before it binds, which is
the failure anyone starting one from Emacs will actually hit. Showing that
buffer is the difference between a message and an answer.

The prompt also offers the program last started: a restart after a quit is
the common case, and it is rarely the buffer you happen to be reading when
you decide on it. C-c C-x does the restart without the prompt at all.
2026-09-11 20:27:50 +07:00
aee8a032b1 Some changes are not a reload, and saying so is the feature
A struct whose layout moved cannot be installed into a program built with the
old one, and the daemon says so. There is no smaller answer than a rebuild: a
session's layouts and global types describe a process only if that session
compiled it, so the program and everything in its memory go too. That is the
cost, and it is why this is its own command and not something C-c C-c falls
back to.

Emacs owns the daemon now, so this is stop-and-start rather than a new op.
The old one is waited out first: it unlinks the socket as it leaves and would
otherwise take its successor's with it.

Also: quitting a daemon that would not close now says its program may have
outlived it, because killing the daemon skips the cleanup that signals the
child — and C-c C-v rather than C-c C-h for the doc buffer, which was
shadowing the way anyone discovers what is under C-c.
2026-09-11 20:26:36 +07:00
dbf5748c56 Assert the reasons, and say what a permutation proves
Every refusal is by name with the reason, so the tests assert on the
reasons and weakening one to a bare "cannot" breaks them: a slice, an
Option, a union, a fixed array, a map, a returned string, a callback, an
unknown type, a struct field C cannot hold, and two Flan names for one C
symbol.

The rest is text about text, which is the honest scope: what a wrapper
does is settled by clang, and what is worth checking in OCaml is the
shape of what clang is handed. Two cases assert the typedef's field
order against a defstruct and against the same defstruct permuted,
because only the pair rules out a generator that sorts — and sorting is
exactly the mutation the raylib cases cannot see, since every raylib
struct is fields of one size and a rename changes no offset.

What the raylib cases do see is a permuted defstruct, and that was run:
Rectangle width/height, Vector2 x/y, Image width/height, Image with data
moved last, Texture2D id/format, Color r/a and Camera2D offset/target
all go red. Texture2D width/mipmaps stays green, which is what NEXT.md
already says headless cannot pin — the one green is the control, not a
gap.
2026-09-11 20:26:11 +07:00
a3e06ce3d4 raylib says what it takes, and shim.c stops existing
All 84 bindings migrated, so the package is raylib.flan and link and no
C at all. Two keep a wrapper and both wrappers are Flan, not C:
collision-point-poly? takes a slice and collision-lines answers with an
Option, and neither is raylib's signature. A slice in a declare-c is
refused by name — the length crosses as i64 and the type of the C count
parameter beside the pointer is not recoverable from [T] — so that one
declares (Ptr Vector2) with an explicit count and the Flan wrapper hands
over (addr (at points 0)) and (len points), answering an empty polygon
itself rather than reading out of bounds.

What this buys and what it costs, stated rather than assumed.
Guaranteed: the C typedef and the Flan struct are made from one
defstruct, so they cannot disagree — permute the defstruct and both
permute. Trusted: that the defstruct is raylib's real struct and that
the declare-c is raylib's real signature. No header is read, on purpose,
so the build needs libraylib linkable and not raylib-devel, and nothing
here can check either half. A _Static_assert on sizeof and offsetof
would have both sides coming from the same field list, so it was left
out rather than mistaken for evidence.

The sharper edge is the prototype: it is generated from the declaration
now, so f64 where raylib says float emits double and raylib reads
garbage, where before clang narrowed it at the hand-written call site.
Every one of the 84 was diffed against the prototypes in the shim.c
being deleted, which was the ground truth, and they agree.

Strings are sized here and not per call site, because a generator has no
call site to look at. 256 bytes on the stack, the heap past that, freed
after the call; the only truncation left is on malloc failure. The old
wrappers truncated at 256, PATH_MAX and 512 by hand.
2026-09-11 20:26:00 +07:00
d2bc2bd714 The wrapper per binding was always mechanical, so write it here
84 hand-written C wrappers is the shape of a job the compiler should be
doing. The reason the shim exists is unchanged and is not negotiable: a
small aggregate's calling convention is a per-target classification, not
part of its layout, and reproducing x86-64, arm64 and wasm32 inside
emit.ml is three classifiers to keep correct forever, where a mistake
reads as a field full of garbage rather than as a link error. clang does
it, per target, for free. So the C stays; the typing of it stops.

declare-c names the library's own function in the library's own
signature, and Shim emits the typedefs, the extern prototype, the
flattening wrapper and the flattened declaration the Flan side calls.

It is a second form rather than a change to declare because no
structural rule can separate them: (declare start-raw [path string] i32
"flan_agent_start") means the symbol takes ptr+len, and (declare-c
init-window [w i32 h i32 title string] "InitWindow") means it takes a
NUL-terminated char *. Same shape, opposite claims. declare is
untouched, so sqrtf and vendor/agent keep working unedited.

The generated C rides on Tast.program rather than beside it, so the CLI,
the REPL and the acceptance table all carry it without being told about
it. `flan shim` prints it, because a wrong binding is wrong in a wrapper
that is otherwise on no disk anywhere.
2026-09-11 20:26:00 +07:00
76f84071df A signature in the echo area is gone the moment you type
C-c C-h puts what the daemon knows about a name in a buffer instead: kind,
signature, and a button on the place it is written. No new protocol — defs
has carried all four facts since it existed.

Where there is no location it says so in M-.'s own words rather than leaving
the line out, because a missing line reads as "this name has no home" and
the truth is that Tast.global carries no Loc.

imenu and which-function come with it, and neither needs a program running:
they read the buffer, so they work on a file nobody has built yet and keep
working while it is stopped. Anchored at column 0, so a defn inside a let is
not offered as a definition of anything.
2026-09-11 20:23:00 +07:00
99e59dba9f The reader learns quasiquote, and defmacro says why it does nothing
Clojure's backtick, tilde and tilde-at rather than Common Lisp's comma forms:
is_delimiter already treats a comma as whitespace and every binding vector in
the corpus assumes it, so freeing the comma would rewrite more of the language
than macros are worth. They read as (quasiquote x), (unquote x) and
(unquote-splicing x), the way 'x already reads as (quote x) - the reader stays
dumb and the meaning is resolved later.

The backtick previously read as an ordinary symbol character, which is exactly
the failure the reader's own header warns about for the apostrophe. Both sigils
are delimiters now, so a~b is two things and can never be one name.

defmacro validates its shape before refusing, because a malformed one and a
well-formed one are different mistakes and deserve different sentences. The
three new reader names are refused by name too, or they would fall through to
Call and come back as unknown name quasiquote. unquote outside a quasiquote is
refused as a mistake rather than as a milestone, since the reader cannot know
where it is.

Nothing is stored: no Ast.Defmacro and no macro table. A new decl variant would
have forced edits to four files other agents hold this session, and a
process-global registry spanning the prelude parse, the package parses and
hundreds of test snippets would make results order-dependent. The storage shape
is the expander author's first decision anyway.

The design note records what the expander needs, and the blocker worth knowing:
a macro is [Form] -> Form, so Form has to be a Flan union whose layout the
compiler and the loaded macro agree on exactly, and union values are milestone
6.
2026-09-11 20:22:03 +07:00
4f0b1012e8 Three ways to pass the checker and die afterwards
Found by a read-only audit of emit.ml's failwith sites, each of which is a claim
that the checker guarantees something. Three of those claims were false, and
every one failed in the shape NEXT.md calls the worst available: type checks,
then dies with no source location.

An enum comparison is lowered now rather than refused. Types.is_comparable
already admits an enum, so the checker was stating an intent the backend never
honoured - (= k :a) is the first thing anyone writes with an enum, and it raised
Failure("comparison on K"). An enum is an i32 at run time, so all six
operators are an icmp. Signed, because (defenum K [a -1]) is accepted and an
unsigned compare would call -1 the largest member.

A union in a type position is refused instead. Constructing a union value and
reading a field of one were already refused, so nothing could ever be done with
such a value - only the declaration got through, and it reached clang as a
reference to an undefined %"U", which is a link error naming an emitted symbol
with the source location long gone.

A function type annotation is refused too. The function *value* was refused
where it is written; the annotation was refused nowhere, so (defn f [g (Fn []
i32)]) died with "no layout for". It now sits beside the Map line directly
above it, which is the same shape of not-yet.

The audit also found the sentence that covered the last two: NEXT.md and
check.ml's header both claim unions and function values are rejected by name.
That is true of values and false of types, which is exactly the gap the two
findings lived in.
2026-09-11 20:21:33 +07:00
a311664a08 The delimiter half of the backtick fix was observed by nothing
Every sigil in the corpus test sat in leading position, where read_form
handles it before is_delimiter is ever consulted — so reverting the
is_delimiter line alone left every case green. a`b now has the case a~b
already had, and the corpus carries both, which is what makes the class
guard cover the delimiter change rather than only the read branches.

The handoff note hedged on the one thing it exists to decide: expansion
runs over Form before Parse, not over Ast. There is no Ast.Defmacro, so
an Ast pass would have nothing to read. Says milestone 6 for union
values because that is the number check.ml itself gives.
2026-09-11 20:20:24 +07:00
f590436ed3 Write down the expander so the next lane inherits a decision, not a table
The front half is here and the back half is not, and the reason is that
running a macro means compiling it and dlopening it into the compiler —
which is Emit.redefinition plus Build.shared, already measured at ~19ms,
pointed at our own process instead of the program's.

The part worth recording is what blocks it: a macro is [Form] -> Form,
so Form has to be a Flan union with a layout the compiler and the loaded
macro agree on exactly. That is milestone 6 work landing before
milestone 5's, and it is bigger than the expander.

Nothing is stored on purpose. No macro table and no Ast.Defmacro: a
table nothing reads is where a design rots, and the storage shape is the
expander author's first decision rather than one to inherit from a lane
that could not test it.
2026-09-11 20:17:12 +07:00
ab2a31d002 A dev loop should not need a terminal window
The daemon owns the program's lifetime, so the terminal it was started in
was also the only place that program could be stopped from. M-x flan-dev
builds, launches and connects; M-x flan-dev-quit ends it.

It waits for a connection rather than for the socket file to appear: the
daemon unlinks a stale socket before binding, so waiting on the file either
succeeds instantly against nothing or races the unlink. And when the daemon
dies before binding — which for a program that does not compile is the
ordinary failure — the refusal names its buffer, because that is where the
compiler's reason is and nothing this end sees says it.
2026-09-11 20:17:08 +07:00
1b2533b41e Merge branch 'pkg-visibility' into dev-loop 2026-09-11 20:16:18 +07:00
d4cef99718 The house rule had a hole at the top level, and the reader just widened it
(defmacro m [x] x) answered "unknown top-level form (defmacro ...)" —
refused, but not by name and with no reason, because the refusal list
only covered expressions. Now it checks the shape and then refuses,
which are two different mistakes and get two different reasons: a
defmacro with no body is a typo, a defmacro with a body is a feature
that is not here.

The reader's new sigils made this urgent rather than tidy. quasiquote,
unquote and unquote-splicing are now real heads arriving at the parser,
and without a case each they would fall through to Call and come back
from the checker as "unknown name quasiquote" — which tells you nothing
about what is missing. unquote and unquote-splicing are refused as
mistakes rather than as milestones: they mean nothing outside a
quasiquote and the reader cannot notice, because it does not track
where it is.

gensym is neither a reader token nor a special form — it is a function a
macro body calls while the macro runs, and there is nowhere for it to
run. Refused by name so it does not arrive as an unknown one.
2026-09-11 20:16:13 +07:00
fcebe03576 A file that is a package is still a package to the editor
package_of matched the file being edited against a package's directory, which
is right for every package that is one — and answers "not a package" for one
that is a single file, because the file's directory is not the file. A form
typed into sand.flan with the headless driver running would have spliced as a
bare step, the evaluation would have said ok, and the program would have gone
on calling the step it already had. That is the exact silent failure the
function exists to prevent, so it now matches the file too.
2026-09-11 20:15:31 +07:00
9eb87e486a A backtick was a name character, which is how the apostrophe used to be
`(a b) came back as the unknown name "`" — precisely the failure the
reader's own header warns about for the apostrophe, one sigil over and
still open. Same fix: the sigil reads as a wrapper and the reader stays
dumb about what it means.

Clojure's ` ~ ~@ rather than Common Lisp's ` , ,@ because a comma is
already whitespace here, and every binding vector in the corpus is
written assuming that. Changing is_delim to free up the comma would
rewrite more of the language than macros are worth.

Backtick and tilde join is_delimiter so that a~b is two things and can
never be one name. No symbol in the corpus contains either character, so
closing the class costs nothing now and would cost a migration later.
2026-09-11 20:13:32 +07:00
f78c935b95 Say what a package is now, since the answer changed three times
NEXT.md described a packaging system with no visibility, no nesting and a link
that ignored the program, and explained sand's two files by it. All four are
now wrong. The Packages section says what the rules are; a new section says how
the link is decided and why the pruning has to take the functions as well as
the flags; and the sand section keeps the part that still stands — the headless
test needs no window on any target, which is a reason for two entry points and
never was a reason for two files.

The comments in load.ml and session.ml that used sim.flan to explain package
qualification now use vendor/agent, which is the package left with a defn in
it.
2026-09-11 20:12:34 +07:00
259cf3b3e2 sand is one program again
The simulation was in a package of its own for one reason: importing raylib
linked libraylib on every target, so the headless run could not name the
package the interactive one needs. That reason is gone, and the split was
never anything else — the physics is the same code either way.

So sim.flan is back inside sand.flan, and test/programs/sand-headless.flan
imports sand.flan itself: window, raylib bindings, dev agent and all. It builds
for wasm32 anyway. Nothing it calls reaches raylib, so no shim is compiled, no
-lraylib is passed, and the front-end's functions are never emitted; sand.flan's
main is not exported, so the only main is the headless one. The hash is
unchanged on both targets at both optimisation levels, which is the point —
a refactor that moved the number would have moved the simulation.

The new cases cover what made it possible rather than only the result: a
package nothing calls into, native and wasm32; raylib reached both directly and
through sand.flan and read once; and the three refusals — sand/main, one
directory under two aliases, and two mains.

test_session's package-qualification case moves to vendor/agent, which is now
the package in the tree with a defn in it.
2026-09-11 20:11:01 +07:00
d59c72af60 A package may import a package, and may be one file
Three limitations, and the same program wanted all three gone.

An imported package's own imports were refused by name. They are resolved now,
and the qualification flattens to the inner alias: raylib imported by a package
that is itself imported is still rl/..., never sand/rl/.... That is forced, not
chosen — a directory reached along two routes has to arrive under one set of
names or the checker sees every declaration twice — and it is what lets the
dedupe work. A directory is keyed by its real path and read once, which also
ends a cycle: a package that imports itself meets its own entry and contributes
nothing the second time, and since the namespace is flat, mutually dependent
packages simply work. The same directory under two different aliases is
refused, because both cannot be true at once.

main is not exported. A package carrying one would collide with the importer's
the moment anything imported it, so a program could never be a package; and
main is a root, so an imported one keeps everything it calls reachable — for a
raylib front-end, the whole library, on the target that cannot link it. Writing
sand/main is refused at the line that wrote it rather than left to the checker,
which would only say the name is unknown. That is true and useless: the name is
missing on purpose and the message should say which purpose. A package that
calls its own main is refused too — it would silently get the importer's.

And a package may be a single .flan file named outright. sand.flan shares the
repository root with three other loose programs, so naming its directory would
import all four; moving it into a directory of its own would be arranging the
tree around a limitation. A file carries no .c and no link file — those belong
to a directory, and a package that needs them has one.
2026-09-11 20:11:01 +07:00
da38a3db5f An EDN tokenizer, as a package
vendor/edn rather than the prelude: the prelude is prepended to every program
and everything in it is emitted, so a reader nobody imports would be a cost
every build pays.

The tokenizer only. A type-directed reader - the compiler emitting a parser
from a walk over a struct's fields, the dual of the printer C-x C-e already has
- lands in check.ml and emit.ml and is not this. What a caller writes today is
a struct reader by hand against the cursor, and the acceptance program carries
one, because that is what proves the API is usable rather than present.

Every token is a slice into the source, so nothing allocates and the buffer has
to outlive the tokens. That contract is stated at the top of the package,
because it is the kind of thing found the hard way.

Escaped strings are refused rather than half-supported: unescaping needs a copy
and there is nowhere to put one, and handing back the raw bytes would return a
three-byte string as four with a backslash in it. Each other refusal carries its
own sentence - #inst and #uuid separately from tagged literals, because a file
is most likely to contain those two and being told tagged literals are refused
would not say that the timestamp is the thing to delete.

Errors live on the cursor, a code and a byte offset, not in the return type: an
Option loses the position, which is the whole point for an editor. A failed
cursor is poisoned so a caller's loop terminates on a malformed file rather than
spinning.

# Conflicts:
#	test/test_acceptance.ml
2026-09-11 20:06:47 +07:00
ad092d7d02 The fixed stack needs a case, and .5 needs a decision
err-too-deep was the one error code nothing observed. The message is the least
of it: the plausible wrong version is `>` where the guard wants `>=`, which
writes one element past a [32 i32] and traps at exit 134 rather than answering
anything. 33 opening brackets is the input that separates them, and it is the
whole justification for a fixed array instead of a growable stack — the place
this lane pushes hardest against having no allocator.

`.5` reads as a float here and does not in EDN, where a number must start with
a digit and `.` is a legal symbol-start byte. That makes it a reinterpretation
of a token that is already legal as something else, which is exactly what the
house rule says to name rather than leave to be discovered, so it is written
beside the refusals.

Also: every symbol in the table was lowercase, so the A-Z half of alpha? was
unexercised and a version missing it passed. Enemy/Goblin in an existing dump
rather than a new case. And a line under "Internal helpers" saying the heading
is intent and not enforcement — a package has no visibility, so edn/scan-atom
is as callable as edn/next, the same way rl/get-color-raw is.

Both new cases verified by mutation: the depth guard traps, and alpha? without
its uppercase range fails Enemy/Goblin.
2026-09-11 20:04:29 +07:00
0e88954664 The link follows the program, not the import list
A package handed over its .c files and its `link` arguments the moment it was
imported, whatever the importing program did with it. That is what made sand's
two halves two files: anything naming vendor:raylib linked libraylib on every
target, and on wasm32 that link cannot succeed, so the headless run could not
so much as mention the package the interactive one needs.

Reach.link answers it from the checked program instead. Start at main and at
the globals that run before it, follow every call — including the Handled
frames, where a lifted handler clause is reached by address and by nothing
else — and keep what is reached. A package none of whose externs survive
contributes no C and no linker argument.

Dropping the flags alone would only move the failure: the bodies that called
into raylib would still be emitted, and wasm-ld would fail on the symbols
rather than on the argument. So the same walk prunes the functions and externs
too. Only those — globals, structs and unions stay, because an unreferenced
global is bytes in BSS and a dropped one is a silently different program.

Dev builds keep everything. What a REPL may redefine next is not a function of
what has been called so far.
2026-09-11 20:02:21 +07:00
7e7f77f2da The struct reader is what proves the cursor is usable
read-enemy in test/programs/edn.flan is the worked example the API is for: the
map opened, the keys looped over, each known one dispatched onto its field and
the rest skipped, written by hand because the compiler cannot emit it yet. It
is there rather than in a doc comment because an API only a compiler could
call would be present without being usable, and writing one out is the only
way to find out which it is. Two things came back from writing it — that
float-of has to accept an integer token, since a config file writing `:speed 2`
for an f32 field is not making a mistake, and that a caller needs `fail` on the
cursor, because a reader's own "expected an integer here" has nowhere else to
get a position from.

The expected output is a raw literal. The dump is brackets and quotes end to
end, and escaping it into an ordinary OCaml string would put a second reader
between the test and what the program printed.

Every case was checked by breaking the tokenizer and watching it go red;
sixteen of them, each restored afterwards. The ones worth naming, because they
are the ones that could have been quietly unobservable: dropping the escape
refusal, accepting `#{`, and collapsing every refusal onto one message — that
last is the shape where a table asserting only "it failed" stays green while
observing nothing. Also: a semicolon no longer ending an atom, a comment scan
that does not test for end of input (which traps rather than differing, on the
comment with no trailing newline), the ratio rule widened to any atom
containing a slash (which takes foo/bar with it), text slices left including
the quote and the colon, a closer counted but not matched, any byte accepted as
a symbol start, a comma not counted as whitespace, and skip-value consuming one
token instead of a whole collection.
2026-09-11 20:01:21 +07:00
d07d6fb4db vendor:edn has to be a build dependency of the tests
An import reads the directory at build time, so a package that dune has not
copied under the test's build dir does not resolve — and the failure is a
missing collection, not a missing file, which reads like a bug in Load.
2026-09-11 19:58:16 +07:00
19be614f22 A tokenizer is what fits without an allocator
The type-directed half — (read-edn Enemy bytes), a parser emitted from a
compile-time walk over a struct — is the compiler's work and is not here.
What a running program can have today is the half underneath it, and the
shape of that half is decided entirely by there being no heap: a token is a
slice of the input, so reading a file costs one buffer and nothing else, and
the cost is a lifetime contract the types cannot state. It is stated in the
header instead, because a dangling [u8] is otherwise found from a corrupted
string several frames later.

A package and not the prelude. The prelude is prepended to every program and
everything in it is emitted, so a reader nobody imports would be a tax on
every build.

Token kinds are i32 constants rather than a defenum, which reads like a
downgrade and is not one: an Enum value cannot be compared with `=` (emit
fails) and a keyword is not a pattern (`match` refuses one), so a defenum here
is FFI-only and a caller could not branch on a kind at all. Both fixes live in
check.ml and emit.ml, which this lane does not touch.

Errors land on the cursor — a code and a byte offset — rather than in an
(Option Token). None says something went wrong; an editor needs to know where,
and a second out-parameter for the position is the same two fields with a
worse shape. A failed cursor is poisoned so a caller's while loop stops
instead of spinning. error-message turns a code into the sentence, and every
refusal gets its own: escapes, sets, tagged literals, #inst and #uuid
separately, metadata, ratios and characters each name themselves and say why,
so a file using one fails with what to remove rather than with a number.

Escapes are the refusal that had to be a refusal. Unescaping needs somewhere
to put the copy and there is nowhere; returning the raw bytes would hand back
a three-byte string as four, with a backslash in it, and nothing would say so.

Balance is checked in `next` against a fixed [32 i32] stack in the cursor,
because `[1 2}` is malformed in a way only the tokenizer has the position for,
and a growable stack is another thing there is no allocator for. Past 32 the
answer is err-too-deep rather than a closer that quietly went unchecked.

Symbol starts are a list and not "anything that is not a delimiter". Without
that, `@` and a backtick read as one-character symbols instead of being
reported; the ratio test is likewise digit-started only, so foo/bar stays a
namespaced symbol.
2026-09-11 19:58:04 +07:00
66cd83d2a1 wasm32 runs the table, and the hash matches
sand-headless prints 2256461126764447066 on native and on wasm32, at -O2 and at
-O0, in one dune test run. That is the whole point of the exercise and the
reason rand-f32 is written in Flan rather than bound to libc.

The old note said the builtins archive has to come from a wasi-sdk release. It
does not: emscripten builds the same compiler-rt and it links correctly under
the other name. It is a different triple built by a different clang, so it is a
substitution rather than the real article, and both the code and the note say so
- nobody should read "wasm32 works" without knowing which joint is glued.

Two findings the note did not have. The entry point is __main_argc_argv, not
main, and the link succeeds before trapping on a signature-mismatched weak stub.
And the target has to reach the C compiles as well as the link, since flan_rt.c
includes stdio.h.

Also corrects why sand is two programs. Two claims had been run together: raylib
does work on wasm through emscripten, and a game loop is expressible there with
emscripten_set_main_loop - a different main, not a different program. What
justifies the split is only that a headless test needs no window on any target.
What makes it mandatory is Load collecting a package's C and link flags whether
or not anything references the package, and that is the thing to fix.
2026-09-11 19:51:33 +07:00
32e20f03da Merge branch 'wasm32' into dev-loop 2026-09-11 19:48:51 +07:00
8a175ebec5 Read wasi-sdk's version instead of guessing it, and pin the one ABI path left
The wasi-sdk candidate had an LLVM version in it, which moves release to
release — so the path advertised as the proper article would have matched only
by coincidence, while the emscripten one beside it was derived. Both are
derived now.

calc-me on wasm32 covers what the other three cases cannot: flan_argv hands
Flan an array of flan_slice built in C, so what it pins is the element stride
of a ptr+len pair — 16 bytes native, 12 on wasm32 — rather than a field
offset. It is also the claim in this file's own header, that the table runs on
the second target, honoured for the first time.

flan emit refuses --target rather than stripping it. The IR really is
target-free, so ignoring it is correct and silence about it is not.
2026-09-11 19:48:04 +07:00
e4586b55c7 The Image family, and 22 shapes
Image first and deliberately: it is CPU-side, so it is the only large piece of
raylib that can be asserted headlessly rather than looked at. gen-image-color,
the pixel reads, both flips, a PNG round trip through export and load, and the
resize and crop dimensions and contents are all in the table at -O2 and -O0.
The shapes, text and timing calls are observed only, by running sand under Xvfb
and looking, and the program and NEXT.md both say which is which.

Five permutations were run red and restored: Image's width against height and
mipmaps against format, GetImageColor's two indices, the two flip wrappers
bound to each other, and the crop rectangle's width against height. The third
of those also broke the export and load lines, which is what makes the PNG
round trip verified rather than merely plausible.

Two corrections to the brief it was given. MeasureText is not headless material
- it measures with the default font, which only InitWindow loads, and a C probe
returns 0 - and the same is true of the frame-time and screen-size calls. And
the raylib.h on this machine is 5.1-dev while the linked library is 5.5, so
every signature was checked against nm -D instead: IsImageValid rather than
IsImageReady, and DrawRectangleRoundedLines takes no thickness.

Font loading is refused by name. A Font carries a Texture2D, a Rectangle* and a
GlyphInfo*, and a GlyphInfo carries an Image - two more aggregates and two owned
arrays, for something with no headless test.
2026-09-11 19:48:03 +07:00
cb11fdf272 Write down what the Image family taught, where the next lane will look
The section on what a headless FFI test can and cannot pin was written before
anything CPU-side was bound, so it had no example of the one shape that beats
store-and-return: scalars in and struct fields out, with nothing for a
permuted layout to cancel against.

It also did not say that MeasureText answers 0 without a window, which is the
assumption this lane started with and had to measure its way out of. Two
lanes have now guessed the same thing.
2026-09-11 19:47:08 +07:00
05676f3181 The two bindings nothing was calling, found by listing rather than by reading
An audit over every public name in raylib.flan against every file that calls
one turned up draw-circle-v and load-texture-from-image with no call site at
all — bound, linked, and never once executed, which is the state the parent
commit already made a rule about. Reading the diff had not caught either.

load-texture-from-image now has the only call site it can have: sand.flan
loads brush.png a second time as an Image, mirrors it in RAM, and uploads
that. The two badges sit side by side, so a flip that did nothing or an
upload that took the unedited buffer shows as two identical sprites rather
than as nothing.

draw-circle-v fills the dot at the world cursor's centre, beside the pixel
that was already there — both Vector2 forms, so both land where the ring's
centre is rather than where an integer cast would have put them.

Still uncalled and not this lane's to invent a use for: key-released? and
mouse-button-pressed?.
2026-09-11 19:46:44 +07:00
8c99c12005 Rounding, sqrt and the rest of the bytes family
f32 only, and each refusal by name: clamp and abs stay compositions of the
min/max builtins, split-at wants a pair type there is no way to spell, and the
f64 and other-element-type copies wait for a program that wants them.

-lm goes on every link, after the objects. The default --as-needed drops a
library named before the object that wants it, and at -O2 LLVM folds most sqrtf
calls into the hardware instruction so nothing has to resolve - which makes the
flag look unnecessary until the -O0 build emits the call and fails to link. That
is how it was found, on the -O0 acceptance run.

sqrt is libm's rather than Newton's, because there is no bit cast between f32
and u32 to seed a guess from, and IEEE-754 makes sqrt correctly rounded so
libm is bit-identical across targets anyway. llvm.sqrt.f32 as a builtin would be
better still - one instruction, no symbol, no link flag - and belongs to
whoever next touches check.ml.

The finding worth keeping is a test that came back green when it should have
been red: nothing in the table could observe floor's zero guard, because
(ceil-f32 0.0) is +0.0 either way. (floor-f32 -0.0) is the only case where it
shows, and the prelude comment had claimed the wrong justification for it.
2026-09-11 19:44:34 +07:00
a90badbcd5 A camera nothing looked through was a camera nothing tested
begin-mode-2d and end-mode-2d have been bound since Camera2D went in and
called by nothing, which is the same as not having bound them. The grid now
draws through a camera the arrow keys pan and comma and period zoom, and
paint has to undo that transform with get-screen-to-world-2d — so a camera
plumbed in wrongly is visible at once as grains landing somewhere other than
the cursor, rather than as nothing at all.

The shapes, the text and the timing come with it, and none of them can be
asserted: every one needs a GL context, and measure-text needs init-window
too — the default font is loaded there and nowhere else, so headless it
answers 0 for every string. Measured against libraylib.so.550, not assumed,
which is why it is absent from the acceptance table despite looking exactly
like a call that belongs in it.

So the HUD is built to be looked at instead: each shape binding appears once
and each is asymmetric enough that crossed arguments show. The ellipse is
wider than it is tall, the ring's sweep comes from get-time, the triangle has
its counter-clockwise winding with an outline over it as a control, and the
panel is sized by measure-text rather than by a guess.

draw-rectangle-rounded-lines takes no thickness in raylib 5.5 — it moved to
the -ex form, and both are here. The 5.1 header on this machine still shows
the five-argument version; nm -D on the library is what settled it.

Font loading stays unbound and says so: a Font carries a Texture2D, a
Rectangle* and a GlyphInfo*, and a GlyphInfo carries an Image.
2026-09-11 19:44:25 +07:00
046593acf1 Say which joint is glued, so the next session does not trust it
Item 6 said the builtins archive has to come from wasi-sdk. It does not have
to, and what is standing in its place is emscripten's compiler-rt for a
different triple — which works, and is worth writing down as a substitution
rather than leaving as "wasm32 works".
2026-09-11 19:43:42 +07:00