9 Commits

Author SHA1 Message Date
263b9bb627 Four places the runtime answered with something other than the truth
The argument vector's malloc was unchecked, and a failure there would have
published a null pointer with a length beside it. It now dies naming what it
was building, because argv has no allocation site for a condition to hang on.

flan_slurp_into read a capacity of elements as a capacity of bytes and skipped
the epoch check every other container operation runs. The element size is now
a parameter and the length it publishes counts whole elements, so the day slurp
answers something other than (Vec u8) it does not answer with bytes nobody
wrote.

A string with a NUL in it is refused at the C boundary, which is the policy
flan_path_cstr has always had for a path: C reads to the first NUL, so what
crosses is a prefix of what was passed, and a window title is no different from
a filename in that respect. The refusal names the declare-c, which is the name
the program's author wrote.

The runtime's two translation units are compiled with -Wall -Wextra. They were
already clean under both; the flag is there so the next one is caught rather
than read.

The generation word keeps its place and loses its "yet": a reader for it is a
third word on every slice in the language, which is a spec amendment rather
than a runtime patch, and the comment now says so where someone deciding to
trust the word would read it.
2026-09-17 22:17:19 +07:00
6cc94e00d6 defunion is C's union, and reading the member you did not write is defined
The name freed up by the rename now means what C means by it: the members
overlay one storage, the size is the largest of them, the alignment the
strictest, and nothing anywhere records which one was written. It serves
two things that wanted it. Binding a C header means holding the union the
library holds and reading whichever member the library's own tag says is
live -- a tag Flan cannot see, because the rule relating them is prose in
a manual. Overlaying an f32 on a u32 to look at its bits is the other,
and it is the same read.

So that read is defined rather than refused. This is the one place in the
checker where bytes win over safety on purpose, and the alternative was
not a safer language, it was no feature: type punning *is* reading the
member that was not written. The promise is the one C's implementations
make and C's standard does not -- the layout is the target's, the bytes
are the bytes, a read is a reinterpretation of them -- and what is not
promised is anything about bytes nobody wrote, where a member wider than
the one last stored reads a tail that is indeterminate exactly as a
struct's padding is. ZII narrows that to almost nothing: a union starts
all-bytes-zero unless uninit says otherwise.

uninit on one is allowed, unlike on a defdata. The refusal there was
never about garbage; it is that a tag steers, and a tag no case names
falls past every comparison in a match into a block LLVM may treat as
unreachable. An untagged union steers nothing.

Which is also why three things are refused, each for a reason that does
not expire with a milestone. No move-only member: nothing knows which
member is live, so nothing can tear one down, and unlike the struct and
defdata refusals this is not waiting on recursive teardown -- there is no
fact for teardown to read. No bool at any depth: an i1 loaded from a byte
that is neither 0 nor 1 is a value the optimiser may assume cannot exist,
and a union is the only type that can produce one. No defdata at any
depth, for the reason uninit gives, arriving the other way round. An
Option member is fine and the walk says why: its match is a tag test and
a branch, not a chain with an unreachable tail.

Two members in one literal, a match on a union, a union map key and a
member written into a global initialiser are each refused by name.

A union is a field list whose every offset is zero, so it travels as a
Tast.structure and the checker, the emitter and the x86 backend each grow
one table rather than one shape. A value is a zeroed temporary and a
store -- Set over Pfield, which every backend already has -- so there is
no new IR node and no layout rule spelled out a second time per backend.
The LLVM type is the blob clang gives a union, the DWARF is
DW_TAG_union_type with every member at zero, and the printer names the
type and does not walk it: it cannot know which member is live, and one
of them may be a pointer.

cimport can now check what it could not. A C record holding a union
member was not recorded at all, so the defstruct beside it went unchecked
rather than checked wrongly; a named union member resolves to a defunion
now and the whole record is compared field by field. The defunion itself
is compared against the header's union as a set and not in order --
every member is at offset zero, so a permuted one is the same type and
reporting it would be a finding that is not one -- while a member the
header has and Flan lacks is reported, because that is what changes the
size. A defunion against a C struct, or a defstruct against a C union,
is reported in both directions. An anonymous union member is still
skipped, and the comment now says that the gap is on the Flan side:
there is nothing to declare.
2026-09-17 19:54:32 +07:00
ff2c949361 The tagged sum is defdata, and the old spelling is an error by name
Flan's tagged sum has been spelled defunion since it landed, which was
accurate right up until the language wanted C's untagged union as well.
Both cannot be called the same thing, and the tagged one is the one with
an alternative name that says what it is: a case, its fields, and a tag
that steers which case is live is a data type, not a union.

So the form is defdata everywhere -- the parser, the AST, the checker,
both backends, the prelude's Form, the editor's font-locking and imenu,
the docs and every .flan file in the tree. The internal vocabulary moves
with it: Tast.union is Tast.data, uname is dname, the tables the checker
and the emitter keep are datas. Leaving them would have inverted the
words permanently, with surface defunion meaning one thing and
env.unions meaning the other, which is exactly the kind of drift the
comments in those files exist to prevent. What did not move is case,
variant and vfields: a tagged sum still has cases, and it still has one
live at a time.

defunion is not kept as an alias. An alias would compile the day the
untagged form lands and mean the opposite of what it used to -- the same
silent misparse that made defn's return type mandatory, and worse,
because the reader would have no reason to look. The old spelling is a
named refusal instead, parse/defunion-renamed, which says what it is now
called and that the name is reserved for something else. It fires on the
head alone, so (defunion U [A B]) -- which would otherwise have parsed
cleanly as one field A of type B -- is refused with the rest.
2026-09-17 19:03:27 +07:00
70af1966a2 Braces are no longer a type: (Map K V) is the only spelling
The author's decision, and it removes the one syntax question generics
had. A return type can no longer be written in braces, so a {...} after
the signature is unambiguously the constraint map and there is no
structural rule to explain.

The reasons for the record: the brace's value meaning and its type
meaning do not correspond the way the bracket's do - [1 2 3] is a value
whose type is [3 i32], but {.x 1} is a value whose type is a name, and a
map value is built by map-new with no braces anywhere - and dropping it
reserves {} in type position for anonymous struct types.

Braces in a type are refused with the surviving spelling named rather
than falling through to "expected a type". Types.to_string and
Cimport's source printer both print (Map K V) now, and Shim refuses the
application spelling where it used to refuse only Ast.Tmap.
2026-09-13 14:58:27 +07:00
9d6784f2cd Write down what was read, what was refused, and what it cost
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".
2026-09-12 16:17:30 +07:00
5aa6c16209 Ownership is not transitive yet, so refuse the three shapes that assume it is
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.
2026-09-12 11:12:49 +07:00
17ef50898d The FFI shim is generated, and goes where its package goes
vendor/raylib has no C in it any more: shim.c is deleted and its 84 wrappers
are emitted from declare-c, which names the library's function in the library's
own signature. The reason the shim exists is unchanged - a small struct's
calling convention is a per-target classification and clang reproduces it for
free - but writing it by hand has stopped.

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

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

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

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

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

The second is real. The flattened declaration's name is invented by
appending -c, so a hand-written foo-c beside (declare-c foo ...) came out
as the checker complaining that a name not in the file was declared twice.
Refused now where it happens, naming both and saying to rename one.
2026-09-11 20:31:55 +07:00
d2bc2bd714 The wrapper per binding was always mechanical, so write it here
84 hand-written C wrappers is the shape of a job the compiler should be
doing. The reason the shim exists is unchanged and is not negotiable: a
small aggregate's calling convention is a per-target classification, not
part of its layout, and reproducing x86-64, arm64 and wasm32 inside
emit.ml is three classifiers to keep correct forever, where a mistake
reads as a field full of garbage rather than as a link error. clang does
it, per target, for free. So the C stays; the typing of it stops.

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

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

The generated C rides on Tast.program rather than beside it, so the CLI,
the REPL and the acceptance table all carry it without being told about
it. `flan shim` prints it, because a wrong binding is wrong in a wrapper
that is otherwise on no disk anywhere.
2026-09-11 20:26:00 +07:00