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.
compile.el puts note in the same capture group as info — group 7, level 0 —
while warning is group 6, level 1, and compilation-skip-threshold defaults to
1. So next-error walks the errors with no configuration, which is the claim
M-x compile rests on and it holds; it steps over the notes until the
threshold is 0. They are still parsed, coloured and clickable.
Labelling notes warning: would make them navigable at the default and is
refused. A note is not a warning, and a compile whose only complaint is an
error would start reporting warnings that are not warnings.
The macro expansion field gets the test it was missing, through a real
expansion rather than a unit test on either half: the tag is put on by Macro
and defaulted into the diagnostic by Loc, and either half alone would pass
with the other broken. clamp misused expands into a call to a name that does
not exist, so the checker refuses something the author never wrote, which is
the case the field is for.
The refusals: the arities, a pool of an owning element, ordering handles,
free of a handle, and clone of a pool. Each names what it would cost — a
cloned pool duplicates the generation counters with the slots, so one handle
would resolve in both copies and name two different things.
pool-stale-region.flan is the other failure, and the point of it is that it
is not the first. A stale handle is an answer and resolve says None; a pool
whose region was released has no slot array left to ask, so it traps. Same
rule that keeps a Vec's generation word and its epoch word apart.
Loc.Error now carries a diagnostic: a stable kind, a span, notes that each
have their own span and severity, and the macro expansion it came from. The
notes are the part that was actually missing — "this is wrong here" plus
"because of that, over there" is two places and two explanations, and a
single string can state only one of them.
The compatibility story for the daemon, which was the open question: the
single-diagnostic exception stays the single-diagnostic exception. Session
and dev evaluate one form and have one failure to report, so they take a
location and a message out of it with Loc.summary and are otherwise
unchanged. A second exception carries a list, and only a driver that
compiles a whole file raises it, so nothing interactive has to know it is
there.
No message text changed.
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.
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.
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.
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.
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.
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.
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.
[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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
render.ml's output and emacs/flan-inspect.el's parser are the two ends of one
wire format, which is why the printer was left on the colon when the rest of
the corpus moved: shifting it alone would have broken inspection in the dev
loop without breaking a test that said so. They move together here.
The field list in the inspector is labelled with the dot too, which is the
spelling flan-inspect-step-expr already used to build `(.x b)' — the label and
the expression it stands for now read the same.
One case needed a guard the colon never did: `...' also begins with a dot and
is the renderer saying it stopped, not a field called `..'. A field name never
starts with a second dot, so one character of lookahead separates them.
The colon is not gone from the rendered grammar. An enum member is `:green' and
is a *value*, so the two are now told apart by the character alone, which is
the only thing that distinguishes them.
Also font lock, handed over with the same change: `:name' was the rule that
drew field labels, and with the colon belonging to keywords every label in the
corpus was left unfontified. `.name' is drawn as a constant, in both the places
it appears — the label in `{.x 1.0}' and the accessor in `(.x v)', which are
the same name.
The same boundary the raylib FFI case covers, reached from declarations
generated out of the header instead of transcribed into raylib.flan. The
package binds none of the four functions by hand, so the program running at all
is the claim.
What it prints pins more than that, by the argument the GetColor case already
makes: handing a struct over and reading it back proves nothing, since storing
and returning is symmetric and a permuted layout comes back permuted the same
way. ColorToInt of {17,34,51,68} is 0x11223344, so exchanging any two fields
changes the number, and ColorTint by white hands the four bytes back
separately. TextLength of "hello" is 5 only if the wrapper NUL-terminated the
copy.
At -O0 as well, for the reason the rest of the table is: every struct here
crosses as (addr v) on a local, which is the alloca mem2reg would launder
before anyone noticed it was wrong.
Skipped without FLAN_RAYLIB_H, since the import is opt-in. The importer's own
table does not skip — it runs against test/headers/sample.h, which is
committed.
maps.flan and map-exhausted.flan as fixed-output cases, the six refusals
by name, and map-stale-region.flan beside stale-region.flan.
The last one is not a line in the Vec's program because the two reach
the check by different routes. A Vec's operations check on the way in
and stop there. A map's get goes on to call a hash and an equality
function through pointers into the block, so a missing check there is
not a wrong number — it is a probe loop walking released memory. It
traps naming the site and exits 134, as the Vec's does.
{K V} resolves now, so the test that asserted it was milestone 6 is
replaced by the one that still holds: the arity, refused for the reason
Vec's arity is refused, because a near-miss would otherwise resolve to a
type variable and come back as generics.
render.ml's output is parsed by emacs/flan-inspect.el, which hard-codes
the colon when it reads a field out of a rendered struct. Moving the
printer on its own would break inspection in the dev loop without
breaking a test that says so, so the printer waits and moves with its
reader, in the Emacs lane.
The sweep could not tell a rendered *expectation* from a Flan *source*
snippet -- both are strings in a test -- so it converted both. The suite
named every one it got wrong, and those are back.
emacs/test-flan-dev.el:415 is the one edit inside emacs/: Flan source sent
to the daemon for eval, which the parser now refuses in the old spelling.
One label, in a fixture.
defer is a compile-time construct: the cleanup is copied into every exit
path of the function. That is why a loop body and a branch are refused —
a loop body's would fire once at function exit rather than once per
iteration, and a branch would have to express "maybe registered", which
a form copied into every exit path or into none cannot say.
A let is neither. It is not a frame here: its bindings are function slots
like any other and nothing is released at scope exit, so a let at the top
level of a function body has exactly the function's extent and a defer
written in it always registers. It was refused for a reason that does not
apply to it. A let nested inside such a let has the same extent and the
same permission; a let inside a while or an if has the loop's or the
arm's, and inherits the refusal.
The permission is granted again before every form of a body, never once
around the body: check withdraws it as it starts, so granting it once
would let the first defer through and refuse the second — and two
resources acquired in one let is the case this exists for. defer-let.flan
covers that one specifically, along with nesting, interleaved
registration order across the let boundary, and an early return.
The two refusals that stay now name what blocks them.
The script is in tools/ rather than thrown away, because two lanes are
writing Flan in the old spelling right now and their files need the same
pass at merge.
It works on forms, not on text: a keyword becomes a dot only where it sits
in a field-label position inside a brace, so an enum member in value
position, a map key inside an EDN string and a type-position {K V} are all
left alone. :keys keeps its colon -- it names no field.
On Linux open_in_bin on a directory succeeds and in_channel_length
answers a number; the read is where EISDIR arrives. Guarding only the
open turned (embed "assets") — someone who meant embed-dir — into an
uncaught OCaml exception out of the checker, which is the one way a user
could make the compiler crash rather than refuse. It now says it is a
directory and names the form that embeds one.
Same class, same function family: read_embed_dir tested is_directory
before file_exists, and Sys.is_directory raises on a path that does not
resolve, so a dangling symlink inside an embedded directory crashed
before the existence test ran. The conjuncts are swapped.
slurp.flan gets its dev build, and the compiler-emitted use-value gets
the same unarmed-restart assertion the hand-written one has. It is the
first clause the compiler emits with a parameter — alloc_guard's retry
takes none — so it is worth saying it rides emit.ml's existing path
rather than sitting beside it.
flan_file_read loses its declare: nothing Flan emits calls it, only
flan_slurp_into does, from C. That takes the edit to emit.ml down to
four declare lines and a comment.
A computed path, a file that is not there, and a second argument that is
not `string`. The type argument is now settled before the file is
opened: a program asking for a type embed cannot read a file as was
otherwise told the file was missing, and got the real complaint only
after fixing the wrong thing.
A missing asset is a compile error naming it rather than an empty embed,
because an asset silently absent is the class of quiet wrongness the
whole feature exists to remove. An empty *directory* is not that: it
embeds cleanly as [0 EmbedFile] and len answers 0.
programs/web-files.flan is built for both targets from the same text and
neither build reads the target anywhere in parse.ml or check.ml. On the
desktop it writes the file and says so; in the browser barf signals a
FileError the program handles, naming the file and reason 4,
file-unsupported. The whole of the difference is one #ifdef in
flan_rt.c, which is where the host ABI is already implemented twice.
The web case is run under node rather than inspected. An artifact-shape
assertion would say nothing about what decision 2 actually bought —
that a program on the web is told its write did not happen instead of
quietly losing it — so the test asserts the refusal is printed and that
the desktop's success line is absent. A silent no-op would have taken
that branch, which is the outcome the decision rules out by name.
The same program embeds a file and prints it, because that is the half
needing no filesystem and no host ABI: the line is identical on both
targets and is the answer for assets a web build has to carry.
Decisions 2 and 5. slurp allocates, which is why it waited for Vec, and
it follows spec-memory.md's rule exactly: no allocating operation
returns an error, so there is no Result here and no out-parameter. A
failure to allocate is StorageExhausted under retry; a failure to read
is FileError under retry and use-value. The two guards nest rather than
merge, because they are two different failures with two different
answerable questions — the handler that grows an arena is not the
handler that supplies another path.
The restarts are the pair Common Lisp establishes for a file-error.
use-value is a typed restart, the other thing that landed this session,
and this is the first one the compiler itself emits with a parameter.
Its parameter *is* the path slot the attempt reads, so the clause body
is empty: emit.ml's bind_params stores the invoker's argument into the
slot, the clause falls through, and the loop re-attempts against the new
path. Everything is inside that loop, so a use-value naming a different
file re-measures it and re-allocates for its size; the Vec is freed at
the top of each turn, which is why a retry does not leak.
The host ABI grows by three calls and one reason reader: flan_file_size,
flan_file_read, flan_file_write, flan_file_fail_reason. They are
POSIX-shaped and Vec-ignorant — no handle crosses the boundary and
nothing is held between calls — so a second target implements three
functions. flan_slurp_into is runtime glue on this side of the ABI
rather than a fourth call. These do touch paths, which is the widening
plan.org names as the #1 portability risk and which decision 2 took
knowingly; embed is the answer that does not touch them at all.
The debug-info arm and the structural printer are each a separate path from
everything the suite was exercising: `outputs ~dev:true` goes through the cells,
not through DWARF, and no program printed a Vec or an allocator. That is
NEXT.md's landed item 2 exactly — field_addr took only Types.Named, so the
printer's Option arm had never run and would have died on the first (Option T)
pointed at it. Both arms work; both are now reached, and the DWARF row asserts
the composite's size as well as its name, because an element count that
disagreed with `lay` would print plausible values for the wrong fields.
Printing a Vec did not work: `println` checked its argument as an ordinary read,
so it moved, and every printing of a Vec would have been its last. Printing is a
borrow — the walk goes over the value and keeps nothing.
And `vec-new` with an explicitly named null allocator no longer substitutes the
heap for it. Adopting the context for a *zeroed* Vec is the documented rule;
quietly substituting for an allocator the program named is the same "released
the region / never made one" collapse free-all already traps for, except silent
and found later as a leak. The no-allocator-named case never arrives as null —
the checker passes flan_context_allocator(), which always answers one.
spec-memory.md says ownership is structural: a struct containing a Vec is
itself move-only, free recurses into owning fields, and a field cannot be
freed on its own. None of that machinery exists — it is the recursive teardown
drop brings — and the move rule as written covered only the types Vec appears
in directly. Three ways past it, each of which hands out a second owner of one
buffer:
A struct field of Vec type. The struct copies its header on assignment and
nothing records a move.
A global of Vec type. The dead set is per function, so two functions each
freeing it is a double free nothing could see, and a global read does not go
through the move path at all — even the one-function case was accepted. Half a
rule is worse than none, so the type is refused where it is declared. A global
Allocator is not this and stays legal: an allocator is a copyable handle, and
it is what makes a handler that owns the arena expressible.
A Vec of a Vec. The runtime is type-erased and copies elements bytewise, so
clone would duplicate inner headers rather than copying what they own and free
would drop their buffers. Shipping the shallow answer under the deep name was
the alternative.
All three name drop as what they wait on.
Also: match arms shared one dead set, so `(match o (Some k) (free v) None
(free v))` reported the second arm as a use after the first arm's move — a
legal program refused, the same case that was already fixed for `if`. Arms are
alternatives, so each starts from the state before the match and the union
survives the join.
And a Vec reaching declare-c now says what to pass instead. It was already
refused, by the shim generator's catch-all for a type it does not know; the
reason it is refused is that handing a header that owns storage to C hands out
an owner, and that is worth saying at the declaration.
Two element types, one runtime, and the element type appears nowhere below
the call site: size_of and align_of are produced where the concrete type is
known, which without generics is simply the concrete call site. That is
Odin's arrangement and it is what spec-memory.md specifies. `at` and `len`
were already the names for a fixed array and a slice, so a Vec extends them
rather than adding a parallel pair — the asymmetry `nth` was removed for —
and the value form and the place form go through one helper so they cannot
drift apart.
StorageExhausted lands with step 2 rather than after it, because the
signatures depend on it: `push` and `reserve` are Unit, `clone` is the
container, and nothing grows a Result. It is built out of nodes that already
existed — a while, a restart-case and an error — so the backend learned
nothing about allocation. The restart is established at the failing
allocation, which spec-memory.md names as the exception to "restarts go at
the resync point, once", and the element a push was given is bound to a slot
before the loop so a retry re-attempts the allocation and not the expression.
Move-only is a dead set on the checker context, and it is flow-sensitive at
an `if`: both arms start from the same set and the union survives the join,
so `(if c (free v) (free v))` is legal and a one-armed free still kills the
binding. The case a dead set cannot answer is a move inside a loop — merged
once at the end of the body it counts one move, not two — so that is a rule,
refused with its reason.
Four decisions the spec did not settle:
The Vec header is six words in every build, not four in release. A layout
that changes with a build flag can disagree across the reload boundary
silently: a redefinition module is built by llc and ld against a host built
separately, and nothing makes the two agree on a struct size. The 32-byte
release layout is deferred on that.
A zeroed Vec has a null allocator, and the first operation needing storage
adopts the context allocator. Odin's behaviour. The alternative was refusing a
Vec-typed struct field until drop lands; shipping the null was a null deref on
the first push.
A Vec's length and index are i32, like every other length here. Widening
indices is one change across all the containers, not a Vec question.
`let` has no type annotation, so a local Vec has nowhere to say what it holds
and the element type is written at the call: `(vec-new i32)`. This is not the
explicit instantiation syntax the generics section rules out — nothing here is
generic and the name resolves as an ordinary type. Where the context says, it
may be left out.
The allocator grew a budget: a ceiling on live bytes, 0 for none. The retry
restart is only answerable by a handler that can make the *same* request
succeed, and for a fixed backing store the handler that works is the one that
raises the ceiling — releasing the region a container lives in invalidates
the container, which is what the epoch check catches. The spec's "grows the
arena and then invokes retry" needed something to grow.
The generation word is bumped on every reallocation and read by nothing. The
stale-slice trap it is for needs a slice that can carry the Vec's identity,
and a slice is ptr+len. Said plainly rather than implied by the word's
presence.