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.
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.
Two things that look like plumbing and are the frontend half of expansion.
A quasiquote becomes calls to the prelude's three form-building functions and
nothing else: form-nil, form-cons for an item, form-append for a splice. It is
pure, it needs nothing loaded, and it runs over every form on the way into
Parse.program and Parse.decl, which is what lets the prelude's own macros parse
in a process that has not built a macro module yet.
Running it *before* the expander's walk is not an ordering preference. A cond
macro's body contains a quasiquoted (cond ...) for its own tail; with the
quasiquote still standing, the walk would see that head and expand it then and
there, against the wrong arguments. Desugared first, that subform is a
(Form.Sym {.s "cond"}) and there is no head left to mistake. So the walk needs
no idea that quoting exists, which is the whole reason this runs first.
Nesting levels are not counted -- not by the reader, which was written that way
deliberately, and not here. A quasiquote inside a quasiquote is refused by
name. Only a macro that writes a macro wants one, nothing in the corpus does,
and CL's level arithmetic costs more than the use case is worth so far.
A defmacro is now an Ast.Defn: (defmacro m [args] body) is
(defn m [args [Form]] Form body). There is no Ast.Defmacro and there is not
going to be one -- a macro is [Form] -> Form, compiled by the same backend as
everything else, and the only thing that makes it a macro is that the expander
calls it at compile time. One parameter, the slice of forms at the call site,
so variadics come free in a language with no &rest; two parameters is a
misunderstanding rather than an arity error and says so.
Parse.expander is the hook the walk arrives through, because expanding a macro
means compiling and dlopening it, so the expander sits above Check and Build
and Parse sits below them. Nothing fills it in yet.
The quasiquote refusal stays as a backstop: it now means a form reached the
parser without coming through program or decl. gensym's refusal is gone -- it
is an ordinary prelude function returning a Form, and a macro body calls it
like any other.
The last commit put the prelude's types into the set the parser uses to tell a
return type from the first form of a body, and put them in plainly. That set is
read by two arms: a bare symbol, and a list head. The list-head arm is why
(defn f [] (Some 1) (bar)) does not lose its body, and the comment above it has
warned about this since it was written -- so adding Rune plainly made
(defn f [] (Rune {.code 65}) (bar)) a function returning a Rune with a
one-form body, silently, in every file in the language. Confirmed before
fixing: it failed with "a map type is {K V}", which is the misparse arriving a
step later wearing someone else's error.
The enums already solve this one comment up, under their own key, for the same
reason. The prelude's types go in the same way. No prelude type takes
arguments, so a bare symbol is the only type position any of them can occupy.
Both halves are pinned in test_flan.ml's return-type section: Form is a return
type, and a prelude struct literal opening a body is not.
The capability lists were written before the code held the line they claim.
Under an expression root, RET on a field of a union built `(.at s)' and sent
it, and the checker refused it — "a union's fields belong to a case ... they
are reached by (match ...)". A refusal from the far end of a socket is exactly
what this buffer's own comment says not to do: every refusal is by name, here,
with the reason, because RET working on some lines and erroring on others
teaches nothing about the language.
It is a refusal of the *parent* and not of the value at point, which is why it
is not in `flan-inspect-refusal': a struct field that merely holds a union is
an ordinary accessor and has to stay enterable. It is a field of the union
itself that cannot be written. The two cases are one test each.
The slot root steps into it by offset and is unaffected, which is the
difference the manual now claims and the tests now show.
`lib/dev.ml' cited DISCUSS.md item 1 as a hole; item 1 is the answer now, so
it cites BUILT.md instead. And the item 1 stub is two sentences and a pointer
— everything else in it is in BUILT.md verbatim, and DISCUSS.md's own header
says nothing in it is a decision.
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.
`i' on a local sent the local's *name* to be evaluated, and an expression is
evaluated where the evaluator stands. That is the right frame only when the
frame is the innermost one; on any other it may resolve to a global, to
another binding of the same name, or to nothing, with the locals listing right
above it showing the frame's own storage and nothing saying the two disagree.
The daemon verb for the fix landed already. What was missing was the state
layer under it: `flan-inspect--expr' held a bare expression, so there was
nowhere to put a frame. It is `flan-inspect--root' and `flan-inspect--path'
now — `(:expr E)' or `(:slot FRAME SLOT NAME)', plus the steps walked from it
— and a stack entry is `(ROOT PATH . POINT)'. RET appends a step, `l' restores
a pair it pushed. Every step is still a fresh request, so the view is never
stale.
`l' cannot cross between the two roots, and that is structural rather than a
rule someone has to keep: RET only ever extends the path under the root the
buffer already has, and `flan-inspect' and `flan-inspect-slot' both start with
an empty stack, so a mixed stack cannot be built at all. It stays true if a
third rooting mode is added.
The break buffer hands over the frame and the slot *index*, which is the
fourth element `locals' now puts on each line. A name does not identify a
slot: two slots of one frame can share one, and a refused slot is not in the
listing, so its position is not an identifier either. A global still goes in
by name, because a global's name really is an expression that means the same
thing wherever it is evaluated — the loaded thunk binds to the program's own
storage through the dynamic linker.
Two smaller things the wire needed. A field step carries the type it was read
out of, because a union's payload is at an offset that depends on the case and
only the renderer knows which case the value is in — so `Union.case.field',
which is the head the renderer wrote with the field appended. And an empty
path is sent by omission: Emacs prints an empty list as `nil', which is a
symbol on the wire, so the daemon now reads that as no path rather than
refusing it as a step.
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.
`i' in the break buffer sent a local's *name* to be evaluated, and an
expression is evaluated where the evaluator stands. On the innermost frame
that is the right frame; on any other it may resolve to a global, to another
binding of the same name, or to nothing, with the listing above it showing the
frame's own storage and nothing saying the two disagree.
The shadow stack is what makes the second rooting mode cheap: a frame's
address and every slot's type are both here, so a step into a field is an
address plus an offset with that field's type — the arithmetic
`Render.render' already does for the listing. `Session.render_slot' is
`render_locals' with a path applied to the root and one line out.
The slot travels by *index*, because a name is not unique: two `v's is two
slots and both are in the listing, and a refused slot is not, so the position
in the list is not an identifier either. So `locals' now puts the index on
each line.
The frame checks are `locals'' by construction — `stopped_frame' is one
function now, and an inspector with its own copy would be free to read a frame
whose body was redefined since it was entered. The build-and-read tail is one
function too, for the reason this file already records about the fingerprint.
And `layout' said union values were milestone 6, which they have not been
since today.
Running a macro means compiling it and loading it into the compiler, and the
step that reads as small in NEXT.md is not: OCaml has no dlopen for ELF, and
lib/dune had no foreign_stubs. So the boundary is built first and the expander
not at all. lib/dynload_stubs.c is the whole of it — dlopen, dlsym, a
four-argument call into a macro thunk, and a peek/poke family, because OCaml
cannot address the raw memory a Form image has to be laid out in.
Nothing aggregate crosses to C. The unions lane verified a union's memory
layout against clang, which is a different claim from LLVM's convention for an
aggregate passed or returned by value in hand-written IR, so Emit.macro_thunk
wraps every macro in void(ptr,i64,ptr,ptr): the slice is built and the result
stored on the LLVM side, and the compiler's side is four pointers.
Build.macro_module links the runtime in rather than declaring it external, so
the module has no undefined symbols and the compiler's own link needs no
-rdynamic. That is the difference from Build.shared, whose host is a running
Flan program.
defunion Form and the list-building surface quasiquote will desugar into are in
the prelude. Form mirrors Form.value and not Form.t: no loc field, so the
compiler stamps the call site's location onto everything a macro returns.
The compiler builds. dune test was not run, and Form's layout is asserted
nowhere — NEXT.md's new handoff section says what the three numbers are, what
the next two commits should be, and the four decisions this made that the
design did not settle.
The globals section attributed a frame by its slot fingerprint, which is the
wrong cut for it: a redefined body can name entirely different globals while
binding identical locals, so the check saw no change and the new body's
reference set went into the union under the old body's frame, with the frame
numbers beside an entry saying so.
So a second fingerprint. Reach.ref_fingerprint hashes the set of globals a body
names — sorted and deduplicated, because a reference set is not ordered, where
slot indices make the slot fingerprint order-sensitive on purpose — and it
travels the path the first one already cut: %fninfo, flan_dev_frame_refsig, the
agent's snapshot, the backtrace line, Dev.globals_op. Different means the frame
is skipped by name with its reason, and the rest of the stack still contributes.
Two numbers rather than one, because they are two facts. A frame whose slots
match and whose globals do not has locals that are perfectly readable and
attribution that is not, and a combined hash would make locals refuse a frame
with nothing wrong with it. locals still checks the slot fingerprint alone.
It lives in reach.ml because expr_refs is already the walk that answers what a
body refers to, and is the walk the union itself is built from. One consequence:
emit now reaches reach, which closes a cycle through Load if cimport calls
Build.cachedir, so the header cache spells the object cache directory itself.
test_dev.ml drives the exact case — a body that binds identical locals and names
untouched where the stopped frame names pressure. With the check disabled it
fails twice: the missing refusal, and untouched appearing under frame 0.
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.
print, the REPL inspector and the break buffer's locals all walk a concrete
type through render.ml, and a union fell through its Named arm to <Shape>.
It now recovers the case from the tag by a chain of comparisons -- the same
shape the enum arm already had, and for the same reason: the name is erased
before any backend sees it -- and reads the fields of that case only. Reading
the others would be reading a payload that is not there.
It prints (Shape.Dot {.x 1.5 .y -2.5}), which is what the source would write.
The union table has to reach the walk, so Render.ctx grew a field and its
three construction sites in session.ml and one in check.ml pass it. That is
the whole of the session.ml change.
test/programs/unions.flan is the program: a case with no fields, a case wider
than another, a case holding a string, a union in a struct, a union through a
call in both directions, ZII, reassignment, and printing. Its layout was
checked against clang's for the same declaration -- 32 bytes aligned 8 with
the payload at offset 8, and 40/8 for the struct holding it.
The 15.5ms attributed to re-reading the header on every reload is not that.
A timer around each stage says the cached dump reads in 0.33ms, the extraction
takes 3.3ms and the checks 0.55ms — about 4ms, once, in Session.create. The
rest of flan reload's delta is Load and Check over 256 more declarations, and
the +3.6ms a redefinition really pays is Check and Emit.redefinition against a
bigger program. A C-c C-c reads no header at all: eval's forms carry no import,
so no package is read.
Both cache levels anyway, because a long-lived process should pay nothing
twice. In the session, two tables: the dump by header, the declarations by
header and by what the package already declares. On disk, the existing cache
moved into the object cache directory beside the .o files. The in-memory key
is the path and the flags with no mtime, so a header edited mid-session is not
picked up until the session restarts — the rule a changed .c file follows, and
the rule that keeps new signatures from being checked against a process still
running the old layouts.
Measured: repeat import 3.65ms to nothing; flan reload unchanged, as it must
be, since it imports once per process.
defunion parsed and its shape checked; naming the type and constructing a
value were both refused as milestone 6. They are not any more.
A union is Types.Named, exactly as a struct is, so every path that carries a
type -- a field, a parameter, a slot, a copy -- learns nothing about unions.
Which table the name is in is the only thing that tells the two apart.
The layout is a tag then room for the largest case, with the alignment the
widest member of any case needs: %"U" = type { i32, [k x iA] }, and one
named %"U.C" per case laid over the blob. That is C's
struct { int tag; union { ... } u; } byte for byte, which is the requirement
the macro expander's Form will arrive with.
A value is (U.C {.field value ...}), or U.C on its own when the case has no
fields. Construction goes through the struct-literal syntax already there, so
parse.ml is untouched: the dot is a symbol constituent and U.C reads as one
name.
Tags are declaration order from zero, so an all-bytes-zero union is the first
declared case with a zeroed payload -- the same rule that makes an Option's
zero a None, and it makes case order part of a union's contract.
A move-only field in a case is refused in the same words a struct's is, and a
union is refused as a map key: the payload past the case in hand is
indeterminate, so hashing the blob would make two equal values hash
differently.
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.
A global is program state a frame happened to touch, not part of it, so
nesting it under one implies an ownership that is not there and repeats the
name once per frame that reads it. One section instead, holding the union of
the globals every frame on the stack references — the compiler does the
choosing, since Reach.expr_refs already answers a body's reference set, and
listing every global a program has would bury the one that matters under the
prelude's PRNG state.
Each entry says which frames touch it, by the index the stack section already
numbers them with, which recovers what per-frame nesting would have told you
at no cost in duplication. Ordered by the innermost frame that touches it:
a deep stack makes the union large and proximity to the error is what puts
the likely culprit on top.
Simpler than locals, because a global is reached by name rather than by
address. Emit.redefinition writes a global the host has as external, so the
thunk binds to the program's own storage and nothing is asked of the stopped
thread — no dev-slot round trip and no not-yet-bound case to refuse.
A frame that cannot be attributed contributes nothing and is named in
:skipped; the union being incomplete and the union being complete are
different answers. The hole in that is stated rather than papered over:
slot_fingerprint hashes a body's slots, which is the right cut for locals and
not for this, so a body that names different globals while binding the same
locals is not caught. The test drives the case that is.
MANUAL.md also loses a stale paragraph claiming the fingerprint check never
fires with a failing test pinned to it. It fires, and test_dev covers it.
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.
BUILT.md gains "The header is read now", directly under the section whose last
paragraph promised that reading a header was what would convert the trusted
half into a checked one and that it was not built. That sentence is replaced by
a pointer to the one below it, in BUILT.md and in shim.ml's docstring both.
It records the things worth not re-deriving: why the dump and not libclang (and
that Zig left libclang too, which strengthens the argument rather than weakening
it), why the import is bounded by the package's own defstructs, why generating
defstructs would make the check circular in exactly the way a _Static_assert
was rejected for, refusal-by-demotion from Zig's failDecl, the naming rule and
what it must actually guarantee, and both const-vs-non-const char * and the
target-varying widths.
The diff and the costs are stated as measurements, with the table: 16 of 16
defstructs and 172 of 172 declare-c agree against 5.5, ten real differences
against 5.1-dev, release +4ms warm, redefinition 31.0 -> 46.5ms.
DISCUSS.md item 6 is rewritten rather than removed. The mechanism question is
settled and is now in BUILT.md; what is left is narrower and is two decisions
that are the author's — whether the header stays a build-time read or becomes a
committed generator, and whether the 172 hand-written lines migrate. Both have
the argument on each side written out, including what migration would lose:
key-pressed? is a better name than is-key-pressed, and an enum parameter
imports as i32 because nothing tells the importer the package calls KeyboardKey
"Key".
Clojure's spelling and Clojure's semantics. Repeated — #_#_ a b c — discards
that many following forms, and that falls out of the recursion rather than
being counted: the discard reads *a form*, and the form it reads may itself
begin with a discard, so the outer one throws away what the inner one already
stepped past.
It belongs to read_form rather than to the sequence readers, which is what makes
it work in every position a form can appear — top level, inside a list or a
vector or a map, before or after a quote. The two loops that look for a closer
or for end of input skip it as well, because a discard is not an element and a
file ending in one has read everything there is to read.
A trailing #_ with nothing after it is an error, and it is the same error an
unterminated form already gives.
Two C functions whose names kebab to one Flan name used to resolve by order:
the first won the name, the second was refused. Which one that is depends on
the order the header happens to declare them in, so moving two lines in
somebody else's header would silently rebind a name a Flan program is already
calling — and the winner was left in the hidden list too, so using the name it
did get reported that it could not be had.
Neither takes it now. There is no reading of spin-2d that is obviously right
when the header offers both Spin2D and spin2d, so both are refused and both say
why; the author binds the one they want with a hand-written declare-c, which is
what that form is for. Found by test/headers/sample.h, which is why it is a
fixture rather than a raylib case.
raylib is unaffected: its 581 names are injective under the rule.
Reading the header produced declarations and nothing else, so the gap the whole
thing exists to close — that nothing verifies a declaration against the library
— was closed by a command somebody could run rather than by a property the
build had. Now `import` runs both comparisons whenever a header resolves.
Build-stopping, not a note. The package named the header, so the header is the
package's own claim about what it binds; a defstruct that disagrees lays fields
out in the wrong order and reads as five plausible numbers rather than as a
link error. Continuing past a known-wrong layout to produce a program that will
read garbage is the shape the house rule against swallowing things exists to
prevent. Both messages point at the line in raylib.flan, not at the header.
Verified by breaking it on purpose: a permuted Texture2D stops the build naming
the field that moved, and `f64` where raylib says `float` stops it naming the
parameter — which is the hazard BUILT.md calls out by name and says only a test
can catch.
A set-but-wrong FLAN_RAYLIB_H used to be indistinguishable from not opting in:
the line was skipped and nothing was said. Unset still means off and silent; a
path that is not there is now an error naming it. That is the difference
between an opt-in and a trap.
test/headers/sample.h is one function per decision the importer makes. The
raylib case needs raylib installed, at the right version, with a variable set,
so it would skip everywhere and cover nothing; this one does not move. It also
found a bug, fixed next.
Reach still prunes with 256 extra declarations in play: a wasm32-wasi build of
a program that imports raylib and calls none of it links without libraylib,
which is the case Reach.link exists for.
`headers` beside `link`, read the same way: a path, any clang flags that header
needs, ${NAME} expanded from the environment. What comes back is ordinary
declare-c declarations, generated before the package's names are qualified, so
they arrive as rl/… exactly like the hand-written ones and nothing downstream
can tell which is which. No new form, no new decl_kind, no reader or parser
change.
A leading `?` makes a line optional. vendor/raylib uses it, because "a build
needs libraylib linkable and not raylib-devel installed" is a property worth
keeping — requiring a header would take it from everyone to give the check to
whoever has one. Unset FLAN_RAYLIB_H and the build is exactly what it was; set
it and every signature is checked against raylib's own header.
A C symbol the package already binds by hand is left alone, so declare-c
remains the escape hatch and stays the thing that wins. A refused function
becomes a hidden name through Load.refuse_hidden, so writing rl/get-gamepad-name
says "GetGamepadName returns char *, and a string only crosses as a parameter"
rather than "unknown name".
Measured, because the cost is the whole argument for how much to import:
release build +14ms cold, +4ms warm — Reach prunes the wrappers
redefinition 31ms -> 46.5ms
dev build +333ms cold — dev does not prune, 428 wrappers
Reach.link already drops a generated wrapper whose declaration nothing
reachable calls, and that is what makes a wholesale import cost nothing in a
release build. It does not prune dev builds, on purpose, so a dev build
compiles every wrapper once at session start; Build.shared compiles no C, so
redefinition does not pay that again.
Reading the header is cached — 64ms of a 72ms check, against 8ms for the whole
program without it. Keyed like the object cache, on everything that could
change the answer: the header's path, size and mtime, the full flag list, and a
format version, since the cached value is a marshalled dump. The extracted
signatures are cached rather than clang's JSON, because the parse is half the
cost. That takes the delta to 17ms.
Verified end to end and headless, using only imported declarations:
ColorToInt of {17,34,51,68} is 0x11223344 and ColorTint hands the four bytes
back separately, so field order is pinned by arithmetic rather than by a
round trip. TextLength of "hello" is 5, so the string crossing works.
declare-c generates the wrapper, the typedefs and the prototype from one
declaration, so they cannot disagree with each other. What nothing checked was
whether the declaration matched the library — BUILT.md records that as trusted
rather than guaranteed, because no header was ever read.
This reads one. clang is asked for a JSON AST dump of the header and shelled
out to, not linked: -Xclang -ast-dump=json is the same binary on PATH that
every build already runs, which is plan.org's "Why LLVM IR as text" applied a
second time. Zig's old @cImport linked clang as a library and that is precisely
the dependency plan.org rejected.
cjson.ml is enough JSON to read the dump and no more, so this adds no opam
package to parse it.
What comes out of the header is signatures and nothing else — not structs, not
enums, not macros. The bound on how much is imported is the package's own
defstructs: a function whose signature mentions a struct the package has not
described is refused with that reason, so vendor/raylib describing thirteen
structs is what makes the import thirteen structs wide. Keeping the layouts
hand-written is also what makes checking them against the header's records
worth doing — a _Static_assert was rejected in BUILT.md as circular, and this
is not, because the two sides have different authors.
Refusals are demotions, taken from Zig's translator: it never drops a
declaration it cannot handle, it binds the name to a @compileError carrying the
reason so the failure lands at the use site. Load.refuse_hidden is already that
mechanism. So a returned char * does not kill the header — it makes one name
unavailable, with the reason attached.
flan import-c prints what it would produce, what it refused, how the package's
defstructs compare with the header's records, and how the hand-written
declare-c lines compare with the header's signatures.
Against raylib 5.5, the version whose .so vendor/raylib/link names: all 16
defstructs and all 172 hand-written declare-c agree exactly. Against the 5.1-dev
header installed in /usr/local it reports ten differences, nine functions that
version does not have and one that gained a parameter — so the check has teeth
and the clean run is not a vacuous one.
test/programs/maps.flan is seven claims over the Map, each one a
plausible wrong version gets wrong, with the numbers differing per
failure so a single wrong answer names its own cause: an integer key
past eight grows, a struct key whose padding must never be hashed, a
struct key holding a string, an enum key, clone's independence, upsert
not growing the length, and a map living in an arena.
The move refusal said "a Vec is move-only" whatever had been moved, so
moving a Map was reported as a fact about Vecs. It names the type now.
The checker half. {K V} and (Map K V) resolve, and map-new, put, get,
has-key?, len, reserve, clone and free are named calls over the
type-erased runtime, with the two sizes and the key's hash and equality
pair produced at the site because the site is where the concrete types
are known. len, reserve, clone and free were extended rather than given
map-shaped names of their own, which is what at and len already did for
Vec: one question, one word.
The key's pair is resolved per key type and mostly is not emitted at
all. Every integer, enum, bool and fixed array of those is compared
bytewise and served by one runtime pair over (pointer, size). A string
is not, because its bytes are elsewhere and two equal strings at
different addresses must hash alike. A struct is not, because its
padding bytes are indeterminate — two structs equal field by field can
differ bytewise — and because it may hold a string. So a struct gets a
pair emitted for it, walking its fields in declaration order and
addressing nothing but fields, and that is the only case that does. Two
maps with the same key type share one pair, and a struct reached twice
through two fields emits one.
get returns (Option V) and builds it here rather than in the runtime,
which has no idea what an Option's layout is — keeping it that way is
what lets one entry point serve every value type. put is upsert
returning Unit. Both bind their arguments to slots before the guard, so
a retry re-attempts the allocation and not the expressions that produced
the key and the value.
Refusals, each by name: a float key has no usable equality at all, which
is not a milestone question; a Ptr, slice, Vec or Map key would hash an
address rather than what it points at; a move-only value would have its
header duplicated by clone, which is the refusal (Vec (Vec T)) already
carries; Unit as a value has no bytes to store, and it is the natural
spelling of a set, so it is refused by name rather than by dividing a
cache line by zero.
Work in progress: it builds and the runtime is exercised and green, but
no Flan program can reach it yet — the checker half is not written, so
(Map K V) is still refused where it is resolved.
runtime/flan_rt.c is Odin's map, followed deliberately: open-addressed
Robin Hood hashing at a 75% load factor, cache-line cell packing so no
key or value straddles a line, and the probe loop kept to pointer-width
integers. One type-erased runtime over (key size, value size) plus a
hash and equality pair, the same arrangement the Vec runtime has over
(size, align).
Two departures from Odin, both deliberate and both commented where they
are made. There are no tombstones, because removal is deferred by
spec-memory.md, and that deletes the backward-shift loop entirely — it is
the single largest reason this is shorter than the original. And the
header does not stuff log2cap into the low bits of the data pointer:
Odin does that because Raw_Map must be three words, whereas this header
already carries an allocator, a generation and an epoch, so the tagging
would buy nothing, cost a mask on every access, and make correctness
depend on the block being 64-byte aligned rather than merely faster
when it is.
The scaffolding around it: a Map is 48 bytes and six words like a Vec,
it crosses to the runtime by address because it is move-only and must be
mutated in place, and it has a DWARF type showing all six fields.
Tast.FnAddr is new — the address of a function, either one this compiler
emitted or a runtime C symbol. It is not a function value: nothing in
the surface language can produce one, name its type or call through it.
Odin's Map_Info reaches its hash and equality pair exactly this way.
reach.ml learns that edge, because a function reached only by address is
invisible to the reachability walk otherwise, which is the same hazard
handler-bind clauses already had.
The hash and equality pair carries the transfer channel as its last
parameter, because a pair emitted for a struct key is an ordinary Flan
function and every Flan function's signature ends with one.
check.ml's prose carried struct literals in the old spelling in two
comments the form-level scan does not see, OCaml comments not being forms.
The Emacs handoff said MANUAL.md and flan-mode.el's font-lock still show
the colon. MANUAL.md does not mention a struct literal at all. font-lock
does have something, but it is the opposite of what was written: it colours
:name as a constant and has no rule for .name, so a field label is now
unfontified rather than wrongly coloured. Said accurately, with the line.
runtime/flan_rt.c:256 also shows {:name ...} and is left alone on purpose --
it describes the *printed* form, which still uses colons and is correct.
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 delimiter is what disambiguates: (.x v) is a call and therefore an
access, {.x 1.0} is a brace form and therefore a construction. The colon
kept two jobs -- field label and enum member -- and this leaves it with
one, keys, which is what a map literal will want.
The old spelling is refused rather than quietly accepted, and the refusal
names the new one. Two accepted spellings is how two spellings become
permanent, and this repo rejects what it does not support and says why.
:keys keeps its colon. It names no field -- it is an instruction to the
compiler that happens to sit in the same brace -- so leaving it alone is
what lets the dot mean exactly one thing.
render.ml prints the dot too, or a struct the daemon shows would not be
Flan anyone could paste back.
"every slot in it is one the compiler made up" is a claim about the body this
session holds, not about the frame, and it was answered before either body
check ran — so a zero-slot frame whose body had since been replaced by one with
slots got that note instead of the refusal. No values were misattributed, which
is why it is not the defect just fixed, but the reason given was untrue. The
count and fingerprint checks now run first and the note is the last arm.
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.
The refusal for a frame whose body has been redefined underneath it did not
fire because four of its five hand-offs were never written. `Emit.fninfo` has
been storing `slot_fingerprint` in the last `i32` of every `%fninfo` all along;
`flan_dev.c` called that field `spare`, there was no accessor for it, the agent
never snapshotted it, the backtrace line never carried it, and `Dev.locals`
compared slot counts and nothing else. The handoff note's "every piece is
written and the refusal does not happen" was a guess, and the first step it
suggested — printing both sides of the comparison — could not have found it,
because there was no comparison.
So: `spare` becomes `slotsig` and gets `flan_dev_frame_slotsig`; the agent
snapshots it beside the slot count and puts it on the backtrace line *before*
the location, since the name is the one field that can contain a space and has
to stay last; `Dev.backtrace` parses it; `Dev.locals` compares it against
`Emit.slot_fingerprint` of the body this session holds and refuses by name when
they differ. No change to `emit.ml` — the value was already there.
The mechanism itself is right and stays. `slot_fingerprint` hashes every slot's
name together with the spelling of its type, so a rename that keeps the count
and the types — exactly the case this exists for — changes it. The count check
stays in front of it because its message is the more specific one.
The fingerprint stays off the wire. A hash is not something an editor can act
on, and the refusal says the fact in words: this frame's body was redefined
since it was entered, so its names no longer describe its values.
`test_dev.ml` gains the inverse and the control. A body that drops a `let` is
refused on the count, and `main` — untouched by the redefinition of `look` —
must still answer, which is the assertion that would catch a fingerprint that
never matched anything and made the verb useless while turning the suite green.
locals compares the frame on the stack against the body the session holds:
installing while stopped is allowed, so the two can be different bodies of
one function, and a rename that keeps the slot count pairs every name with
the wrong value. Emit.slot_fingerprint hashes each slot's name and type,
emit_fn puts it in the frame's static description, the agent reports it on
the backtrace line and Dev.locals compares it.
It does not fire. The test that drives it -- a redefinition that renames
every local of a function that is on the stack -- fails, and is committed
failing rather than deleted, because it is the only record of what is
wrong. Everything else in the suite is green; this one check is red.
It builds. See NEXT.md's handoff for where to look first.
Three things stood between the flagship program and the web target, and each
is answered here rather than worked around.
The brush was a path. (rl/load-texture "brush.png") hands raylib a filename to
open, and a bare relative path means nothing on a target with no filesystem.
It is (embed "brush.png") now, decoded through a new binding —
LoadImageFromMemory, declared (Ptr u8) plus an explicit count because the shim
generator refuses a slice parameter and says so, with a Flan wrapper taking
the slice apart exactly as collision-point-poly? and load-font-ex already do.
One decode now serves both textures: the unflipped upload first, then
ImageFlipHorizontal in place, then the mirrored one. load-texture and
load-image lose their only call site in this repository; that is deliberate,
because a path-based load is the thing that cannot work here.
A package's C may now be addressed to one target, the way a link line already
could. A .c file may carry a tag before its extension — flan_agent.web.c — and
on that target it is compiled and *replaces* the untagged file of the same
base name. Replacement rather than plain tagging, so that teaching a package
about a new target is additive: the file that was right on three targets is
not renamed to say so. Selection is in Build and not in Load, for the reason
select_lflags gives.
The dev agent on the web is a no-op, and the reasoning is written at length in
vendor/agent/flan_agent.web.c. Short version: the agent is a socket server and
a browser has no sockets, so the missing <sys/time.h> was the surface and not
the cause. Refusing vendor:agent on a web target was the other candidate and
is ruled out by arithmetic — Flan has no conditional compilation, sand.flan
calls agent/start unconditionally, Reach cannot prune a package something
reachable calls into, so a refusal means the program does not build for the
browser at all. This does not contradict the `barf` decision made earlier
today. `barf` is asked to make something durable, and a no-op returns success
to a program that now believes bytes are on disk. The agent is asked to accept
redefinitions, and on the web there is no editor, no socket and no session —
--dev is refused by name on every wasm target — so there is nothing to lose.
sand.flan already says the same of a native release build at the call site.
test/test_web.ml builds sand.flan for the browser and reads the module for
brush.png's own bytes, whole. Not "IHDR": stb_image carries that string itself,
linked in from raylib, so it would pass on a build where the embed emitted
nothing. It is not run — node has no DOM, so main reaches InitWindow and dies
inside glfwInit on `window is not defined`, which says the module is live and
nothing about whether the canvas paints.
test/dune gains brush.png, because an embed is read by the checker and the
headless case reaches sand.flan through ../../ from a sandboxed _build.
test_session's C-c C-k case now passes ~origin, which is what both editor
paths already send; omitting it was testing a request nobody makes.
dune test is green. Docs follow in the next commit.
The half the shadow stack was built for. A slot's entry in the frame is its
address, null until the binding that fills it has run, so "not bound yet at
this point" is a null and needs no liveness analysis. The daemon compiles a
thunk that renders the types it already knows -- Tast.fn.slots, with snames
beside them -- at the addresses the stopped program supplies, and reads the
text back the way C-x C-e does. Nothing is copied out, because a value with
no header is bytes with no meaning anywhere but in the program that holds
it.
That is render.ml's walk with its root changed, which is the pointer-rooted
thunk NEXT.md said this needed, and one new arm in the backend: a cast from
one pointer type to another, which emits nothing.
Only named slots are recorded. A recorded slot escapes and stops being
promotable, and the slots that would cost most are the ones with nothing to
show -- dotimes' bound, the temporaries min and max use, the walk's own
scratch. They are refused by name rather than shown under an invented one.
Recording every slot was built and timed and is inside the noise, so the
rule stands on what it shows.
Four refusals, each by name and with its reason: a slot nobody named, a
slot the program has not reached, a type the printer has no arm for, and
two whole frames -- an evaluation's thunk, and a frame running a body that
has been redefined since, where every slot index would be a guess.
Measured, minimum of nine runs: +61% on call-heavy code over globals
against +33% for the frames alone, 0.06% of a frame at 60fps.