99 Commits

Author SHA1 Message Date
a2004ae7a6 One macro call, several declarations, and a refusal that carries a sentence
A type provider produces a struct and a reader over it, and a struct per
nesting level in the data. Expansion is form-for-form, so one call could only
ever become one declaration — which was enough while every macro expanded to an
expression. A top-level (do ...) is now its items, spliced in place, after
expansion and before the declaration walk. Nobody writes one in a file, and the
single-declaration entry point says so by name for anyone who tries.

And (compile-error "...") is what a macro expands to when it has to refuse. The
prelude's `unless` records the gap: a macro has no error facility, so a
malformed call answers a name nothing defines and the report is the right place
with the wrong sentence. A name carries a name. A type provider's refusals are
all sentence — the third element of this vector is a string where the first two
were integers, at line 3 column 9 of a file the compiler is not reading — and
no symbol an expansion could invent holds that. Loc.from_macro already stamps
the call site onto the expansion, so the location is the form the author wrote.

A builtin because it has to fail while checking: a declared function would
compile, link and run, and the compile it was meant to stop would have
succeeded.
2026-09-19 05:43:51 +07:00
a0f37e72a2 The ! suffix retires: a mutator is named for what it does, not marked
The !-means-mutates convention distinguished nothing — there is no
immutable counterpart to contrast with — so every mutating name drops
the mark: sort, sort-by, sort-bytes, swap, reverse, append, append-i64,
append-f64, encode-rune, split-next, map-remove, map-next, and the test
helpers beside them. Two could not simply shed it: map! is map-in-place,
because map is the into transform's word and means the non-mutating
thing; put! is put-at, because put is the Map builtin. The ?-means-asks
convention stays. Dated records keep the old spellings; watch.clj's
reset-spies! and the other Clojure names are not ours to rename.
2026-09-19 05:21:02 +07:00
617440aee6 Merge: a global's initialiser may compute, and main runs it first 2026-09-19 04:25:54 +07:00
495629f5f3 A global's initialiser may be computed, and both backends run it the same way
The x86 backend ran initialisers from .init_array and the LLVM one refused
them by name, so (defvar frame Allocator (arena-new 262144)) — which the
author kept writing — was a program on one backend and an error on the other.
A rule that holds on one backend and not the other is not a rule.

The checker lifts a computed initialiser into a function of its own and the
global's initialiser becomes the call. That is what gives it a frame, which is
the bug underneath the feature: a `let` or a `match` in an initialiser indexed
a slot array of length zero and took the x86 emitter down with an uncaught
Invalid_argument.

Both backends call the lifted initialisers from main, after flan_rt_init and
before a line of the program's own code — Odin's __$startup_runtime shape, not
a constructor, so the runtime is up and the order is the compiler's to choose.
x86 keeps .init_array for one thing only, and it is named: writing the
constant image this backend has no folder for, which is standing in for the
other backend's object image rather than for a program.

The computed globals are sorted by what they read, transitively through the
functions they call, so a global written above the one it reads works and a
ring is refused with every name in it. A reload still re-runs nothing: a new
global with a computed initialiser starts as ZII on both backends.

The refusal that lived in x86.ml is now the checker's and is narrower. Nothing
can escape an initialiser — the handler and restart stacks are empty and every
frame it pushes it also pops — so what is refused is a signal or an
invoke-restart with no handler-bind or restart-case around it, which is inert
by construction. A restart-case inside one is ordinary code, which is what
makes (defvar data (Vec u8) (slurp "level.edn")) an ordinary program.

Three refusals go with the premise they rested on: a container global with a
computed initialiser, a union member in a defvar, and a data type case in one.
A defconst is untouched and keeps all three.

One change here is not about any of that. sand.flan carried an unfinished
line — (defvar game-data (embed (with-allocator frame ))), which parses as a
declaration whose type is (embed ...) — so the checker refused the file and
`dune test` was red at the tip of dev-loop before a line of this landed,
verified by stashing this work and rebuilding. It is commented out rather than
guessed at: the arena above it is the half that works, and what the global
should read is the author's to decide.
2026-09-19 04:24:52 +07:00
38a04f7a47 The compiler's own names answer C-c C-v, and M-. says where they are
`arena-new` is a builtin, so it is in no program's symbol table and `defs`
never mentioned it — which made the editor answer "the running program
defines no arena-new" about a name that works. The fix is not a better
refusal: it is that the seventy-eight names the checker answers without
being told are now in the reply, under a kind of their own.

check.ml carries the table, beside the arms it describes, because a table
in another file drifts from them with nothing said. test_flan reads both
the arms and the table and fails on either having a name the other does
not, in both directions — there is no reflecting over a match, so it reads
the source.

Each entry is a signature in `signature_of_fn`'s shape and one line. The
arms that do not have one shape say what is true instead of pretending:
`?` for an argument that may be left out, `|` for the types an arm really
takes, and the checker's own predicate names for the type-directed ones.
The three user-allocator names carry a bare name and no bracket list,
because they are refused wherever they are written.

`defs` grows a fifth string for the prose, and builtins are appended last
so a completion table does not bury the names being worked on. A defn has
no docstring to put there and does not get one here: the Tast keeps none,
and that is a different piece of work.

The editor reads the kind, not a special case. `flan-doc--where` and the
xref backend both answer before their empty-location branch, because a
global's missing location and a builtin's absent one are different facts
and only one of them is about the daemon.
2026-09-19 03:12:38 +07:00
bb2471f7a1 The gen word leaves both headers: a check nothing runs is weight, and Odin carries none of it 2026-09-18 12:23:39 +07:00
4a563d0e67 The move-only concept follows the flow analysis out: everything copies, and the frees are yours 2026-09-18 12:16:05 +07:00
c46fd56447 Pool and Handle leave the language: two containers are enough, and a slab is a Vec you free less often 2026-09-18 11:56:22 +07:00
2edd441ecf The flow analysis is repealed: ownership lives in the types, the allocator, and the dev runtime 2026-09-18 07:45:00 +07:00
c31feb85eb A while condition runs every trip, so it may not move what it stands on
The condition was checked before in_loop, outside the diff that catches a
loop giving away what the next iteration needs. Emit puts the condition in
the loop header, so it runs again every trip: a condition that frees a
local freed it once per trip. It is now diffed against the same dead set,
with its own reason. A dotimes count and a loop's initial values stay
outside: those are evaluated exactly once.
2026-09-18 07:26:28 +07:00
c0ccbe2df2 The runtime stops trusting multiplication, sharing one buffer, and keeping an unread word ambiguous 2026-09-17 23:14:28 +07:00
81b807f544 A rendered number goes in the caller's frame, not in one buffer for the process
Every number-to-text conversion wrote into one file-static in the runtime and
answered a slice over it, and nothing copied. Two of them in one expression
printed the second number twice — no crash, no diagnostic, and nothing a
sanitizer could find, because every byte read was inside an object that was
alive. The wrong object.

The buffer is now the caller's, one frame slot per call site. The slot is
allocated in the checker rather than in either backend: a slot is a
function-lifetime location in both of them, where an x86 backend temporary is
bump-allocated and reclaimed at the end of the expression that made it — which
is the one lifetime a returned slice must outlive. Each backend gains one
pointer argument and no reasoning of its own, which is what keeps them
symmetric.

The static is gone rather than left unused, since a buffer with nothing but a
comment beside it is a loaded gun. What remains is the ordinary lifetime a
pointer into a frame has: storing one of these slices in a container that
outlives the frame, or returning it, is still a copy the caller has to make.
NEXT.md's sharp edge now says that instead of what it used to say.
2026-09-17 22:55:30 +07:00
594a42b54e A map you can take a key out of, and the run closes behind it
(map-remove! m k) answers the value that was there, or None, which is the
answer get already gives and for the same reason: a key that is not in the map
is an answer, not a failure. Handing the value back rather than dropping it
makes "take this out and use it" one call instead of two that hash the key
twice.

The removal shifts the probe run back over the hole. A Robin Hood lookup stops
at the first empty slot, so a hole left in the middle of a run hides every
entry after it — and the hidden ones are precisely what a test that only asks
after what it removed never looks at, which is why the program removes a
thousand of two thousand keys and then asks for the other thousand.

Odin was read rather than recalled here, and it does the opposite: its erase
marks a tombstone and its insert carries the repair loop. Staying tombstone-
free keeps the shape the rest of the file already assumed, and the lookups —
which outnumber the removals — pay nothing for it. The note in the runtime and
the two in BUILT.md that said Odin deletes by backward shift were describing
Odin's insert, and now say which is which.

It allocates nothing and releases nothing, so there is no guard around it and
it means the same thing on a map in an arena as on one in the heap: a key and a
value live inside the one block the map allocated, and there was never anything
per entry to hand back.
2026-09-17 22:31:58 +07:00
0fd83dc95e The driver refuses in sentences, and CI exists 2026-09-17 22:23:19 +07:00
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
c5b8af23a1 Files beyond slurp and barf, split by whether a handler could act
Five more: file-exists?, file-size, delete-file, rename-file and
make-directory. The interesting thing is not the list, it is the line drawn
through it.

file-exists? and file-size answer a value -- a bool and an (Option i64) -- and
are prelude functions over one declare that the compiler knows nothing about.
Absence is the reply to those two questions and not a fault, so a condition
would make the ordinary case pay for a handler search, and there is no restart
a handler could take that would turn "it is not there" into a different
answer.

delete-file, rename-file and make-directory answer () and signal FileError,
and they are check.ml builtins for the one thing a declare cannot do: they go
through file_guard, so each failure arrives under retry and use-value. Those
are restarts a handler really can take -- make the parent directory and retry,
or supply another path -- which is exactly the case a bool return throws away.
op continues the prelude's numbering as 2, 3 and 4.

One C function behind the two questions rather than two, because they are one
question: stat answers whether the path resolves and how big it is in the same
breath. It is stat and not flan_file_size's fopen-plus-ftell, which is shaped
by slurp being about to read the file and is wrong as a general size -- fopen
on a directory succeeds on Linux and ftell then answers a number that is not a
file size. The two coexist and answer different questions.

rename holds the source in the guard's path slot, so a use-value renames a
different file to the same destination. Both readings are plausible until
somebody says which, so check.ml says which.

The errno mapping is not extended. Its three buckets are what a handler can
act on; EEXIST and ENOTEMPTY land in io with everything else, and that is
honest until conditions have a hierarchy to hang a fourth reason off.

All three carry barf's decision 2 unchanged: they change the filesystem, so on
the web they signal rather than succeeding quietly into a filesystem the page
throws away.

Not here, and not half-parsed either: a directory listing, which needs an
allocating builtin and a Vec of owned strings, and streaming IO. Neither has
a name to trip over.

programs/files.flan makes and removes its own tree and takes both restarts on
operations that write. The runtime additions continue the block at the end of
flan_rt.c.
2026-09-17 22:04:48 +07:00
ac7ee912e7 A wrong main signature points at the main that is wrong
check_main raised against Loc.unknown, so both of its refusals opened with
<unknown>:0:0. env.locs is the table of where each type was declared and a
function is not in it, so the location comes from the declaration list the
caller already holds. A main that arrived without a defn keeps the unknown
span rather than being given an invented one.
2026-09-17 21:44:40 +07:00
d57eeb3265 The refusal was about teardown, and a region has none
A (Vec Value) where a Value may itself hold a (Vec Value) — the recursive
dynamic value an EDN reader has to answer with when nobody hands it a target
struct type — was refused five different ways, and every one of the five gave
the same reason: the container runtime is type-erased, so it copies and
releases slots bytewise and cannot reach inside a slot. A free would release
the slots and leave every block they point at stranded.

That reason is about teardown, and it does not hold for a region. free-all
never releases an individual slot; it takes the whole arena, and every block
the elements own is in it, because they came out of it. The refusals were
over-broad, and what they were guarding was never ownership — ownership
tracking is untouched here, moves are still moves, and Types.is_move_only is
the same function it was.

So the question moved rather than disappeared. It could not stay at the type,
because can-free is a capability on an allocator value and with-allocator
rebinds a dynamic variable: which tier a (vec-new) will meet is not a property
of the place its type is written. What is decided at compile time is only
whether to ask, which is a property of the element type; the answer is a
run-time branch on the allocator, one per container and never per element,
because the alternative is a walk at release and a walk at release is the
registry of destructors the frame tier's reset exists to not have. It is
emitted at every growth and not only at the construction, because ZII means a
container can exist without ever passing through (vec-new) — a case field left
out of a literal, a global that starts zeroed — and those adopt the context on
their first push.

free on such a container is refused rather than made quietly shallow. It cannot
recurse, which is the whole premise, and releasing the outer block alone would
be "I freed it" written over a program that stranded everything inside; this
runtime refuses that collapse everywhere else. The message names free-all,
which is reachable by construction. clone stays refused for a reason the region
does not dissolve, and the old message had bundled the two failures under one
sentence: what disqualifies clone is not that it copies a header — so do at and
get, and they are fine, because they promise nothing — it is that clone
allocates a new block and promises independence, and a bytewise copy hands back
elements still pointing into the original's region.

A struct or union field is admitted only where the field's container holds
owning elements, because that container can only have been built against a
region. A field holding a plain (Vec u8) stays refused: nothing would force
that one into a region, and two copies of the aggregate would be two headers
over one heap block. vec-in-struct.flan still pins that.

The epoch already covered use after free-all, including the case this makes
reachable — an inner header copied out of an arena-held element into a local
still traps, because an Allocator is a pointer and a copied-by-value one would
carry its own epoch.

arena-value.flan builds the value by hand; arena-edn.flan reads a real document
through the tokenizer, and its reader takes no allocator and names none,
because spec-memory.md already puts the allocator in the calling convention.
arena-region.flan is the branch itself: run 0 is the (Vec (Vec i32)) control
that must not trap, and runs 1 and 2 are the two ways this dies.
2026-09-17 20:37:08 +07:00
aa9a2e03bb A union member is refused in a constant as well as in a global
The refusal was on the defvar path alone, so (defconst c U (U {.i 1}))
still came back from the emitter as "a global's value must be a
compile-time constant — this one is computed", which is true and says
nothing about unions. Both kinds of global reach the same encoder, so
both get the same message.

It is decided on the checked value rather than on the declared type now,
which is what lets the one initialiser that *is* a constant through: (U
{}) is all-bytes-zero, the same value a declaration with no value gets,
and refusing it would have been telling someone to write the thing they
had written.
2026-09-17 20:08:04 +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
69f5d8a05a A global Vec is borrowed, never moved, and outlives every entry to main
A program that wants to load its data once and keep it could not say so. Every
move-only global was refused where it was declared, on an argument about the
dead set being per function: two functions each freeing the same global would
be a double free nothing could see. The argument was sound and the conclusion
was too strong. It assumed a global has an owner. It does not.

Reading a move-only global is now always a borrow. Nothing may take ownership
of one, so nothing may free one, and with no owner to hand over there is no
double free left to catch. This is not a general ownership model for globals
and is not meant to grow into one: it is sound precisely because the lifetime
question that model would exist to answer has a constant answer here, the
process's. The refusal lands at the read, which is where a move would have been
recorded for a local -- passing the global to something that owns its
parameter, binding it to a local, returning it and freeing it all reach the
same place, and each is told to borrow instead, or to clone if it really wants
something of its own.

Such a global is mutable where it stands. push, put, reserve and set already
take their target through the borrow path, so a global (Vec u8) is filled and
grown in place, and the aliasing that raises is the one every Vec has:
spec-memory.md's explicit Zig/Odin contract, where a push that reallocates
invalidates a slice taken before it and the dev build's generation word traps
on the stale one. Globals get no borrow rule locals do not have, because the
hazard is not new and the trap lives on the Vec rather than on the binding.

What a move-only global may not do is carry a computed initialiser. A global's
initialiser is a link-time constant -- there is no init-at-startup path in the
LLVM backend by design, and the x86 backend that has one deliberately leaves it
out of a reload module, because re-running an initialiser wipes the live state
reloading exists to preserve. So the global starts zeroed, which for a Vec is
an empty Vec and therefore a value rather than a placeholder, and the load is
an ordinary assignment in whichever function loads it. That is also what makes
the data survive: nothing runs between one entry to main and the next, so a
re-entered main finds the global as it left it. A defconst cannot be one at
all, since a constant is not an assignable place and nothing could ever load
it; both refusals name the (defvar g (Vec u8)) that works.

The reload fixture gains a global Vec in the host and another that arrives at
run time, because that is where declaring instead of defining has teeth: a
module that defined the host's Vec would take a zeroed header of its own and
strand the block the process is still using, which a re-zeroed i64 cannot
demonstrate.
2026-09-17 18:42:01 +07:00
9d5689ffa2 Every citation of a moved document now resolves from where it is written 2026-09-14 07:12:27 +07:00
185d162124 A pointer from C can state its length, and then it is a slice
`indexed` took an Array or a Slice, so a `(Ptr T)` that came back
from C was readable at element 0 through `deref` and nowhere else.
The length is not missing from the world — for `font.recs` it is in
the struct, one field over — it was missing from the language.

`(slice-from-ptr p n)` is the form that says it. No marker on the
name: `!` here means mutates and `?` means asks, and `zeroed`, the
nearest neighbour, carries neither; `ptr` is the marker, because a
`(Ptr T)` only ever arrives from a `declare-c`.

Nothing new in the representation. A slice is already {ptr, i64} in
both backends, so this is two insertvalues; `x86.ml` takes the new
constructor on its existing `unsupported` arm.

It refuses a first argument that is not a pointer, a negative literal
length at check time, and a negative computed one at run time — that
last through `signal_block` and `@flan_slice_error`, reused rather
than growing the runtime a function, and *signed*, because
`check_slice` compares unsigned and a negative i32 sign-extends to a
huge u64 that walks through it. Behind `f.md.checks` like the other
two: on at -O0 and -O2, off only when checks were asked off.

It owns nothing and needed no analysis to say so — a slice is not
move-only and carries no allocator, so `free` refuses it by the rule
that already refuses `(as-slice v)`.

`rl/font-recs` and `rl/font-glyphs` are where the promise is written,
beside raylib's own invariant rather than at every call site, and
they are the shape a count-naming binding directive could never have
covered. `examples/text-rectangle-bounds.flan` is the port that
motivated this and it runs; `test/programs/slice-from-ptr.flan`
covers the form with no raylib and no window.
2026-09-13 17:40:59 +07:00
762bc988fa The map operations are deferred, and the clause is what pays for it
hashable? gated the type and not the operations: a generic could take and
return a (Map $t V) and could not get or put into one. The hash and the
equality are emitted as concrete symbols chosen from the key type, and
while $t is a variable there is no symbol to name.

The five arms that reach the pair - put, get, has-key?, reserve, clone -
now check their arguments and return a placeholder of the operation's own
type when the key is a type variable: Unit for put and reserve, None for
get so the (Option V) around it still checks, false for has-key?, a zeroed
map for clone. The node is thrown away with the rest of the abstract pass
and the real one is built in the copy, exactly as println's is.

What makes that different from print's free ride is the clause. A map
operation can fail at a concrete type; it is deferred anyway because
{:where (hashable? $t)} is in the signature, so the refusal lands at the
call that asked for the type, against a requirement the author wrote down.
A generic that declares nothing gets no deferral - deferred_key checks
first, and map_type has usually refused the signature already. So the rule
for the allow-list is not a headcount: either the operation cannot fail
after substituting, or a declared predicate gives its failure somewhere to
land. The comment at the print arm says that now instead of "stays two
long".

The instantiation-time refusal names the call site, the type it asked for,
the predicate and the clause, rather than repeating the generic's name
twice.
2026-09-13 15:23:37 +07:00
b3cb657992 hashable? gates the type and not the operations, and say so where it bites
A map keyed by a type variable cannot be put into inside a generic body:
the hash and the equality are concrete symbols chosen from the concrete
key type, and there is none until the copy exists. The refusal now says
that, and says what hashable? does buy - taking and returning a
(Map $t V) - rather than leaving the reader to infer it.

Closing the hole means adding the map operations to the list of forms
the abstract pass defers to instantiation. That list is print and
println and nothing else, and every member is a place where a refusal
moves from the definition to a call site, which is what the abstract
pass exists to prevent. Two is short enough to hold in your head.

Also written down: four of the prelude's copyable? declarations are
convention rather than checker-enforced. The move analysis tracks
locals, not reads out of a slice, so swap! and friends check without it
- and would still duplicate a header at [(Vec i32)].
2026-09-13 15:01:49 +07:00
de93ffc89e A cast to a type variable, and the container builtins over one
(t x) is not a name is_cast knows - t is not a machine type - so it is
its own arm, admitted by numeric? because a cast produces a number.
vec-new, pool-new and map-new all reach the one list of what names a
type, so the spike's line for vec-new had already covered the other two;
zeroed takes its type from the position it is written in. All four are
pinned in programs/generics.flan.
2026-09-13 14:52:17 +07:00
dad725afe4 The prelude's per-type families collapse: 22 functions become 10, 27 become 16
swap!, reverse!, sort!, sort-by!, index-of, min-of, max-of, map!,
reduce and filter, each written once over $t. Every call site in the
corpus moves with them.

min-of and max-of are not min and max because min and max are builtins
over two or more numbers and nothing shadows a builtin. These reduce a
slice, which is a different operation at a different arity.

sort-bytes! did not collapse into sort!, and the reason is the point of
the predicates: a [u8] is not ordered? and cannot be, because < is an
instruction and comparing two slices lexicographically is a loop. It is
sort-by! with bytes<? written in, one line, keeping its name and its
stability note. sum-i32/sum-f32 and append-i64!/append-f64! stay for the
reasons the spike gave.

Not what the notes predicted: none of the ten collapses on a signature
change alone. filter and reduce need copyable? because the checker
demands it - reduce's accumulator at (Vec i32) is a double move - and
the rest declare it because a slice of owning elements would have them
duplicating headers.
2026-09-13 14:49:11 +07:00
b438a71031 C-c C-c on a generic installs its copies, and a refusal about one says where it came from
A generic defn produces no Tast.fn, so the editor was told nothing had
been installed and nothing had gone wrong. eval now expands a redefined
generic name to its copies, and picks up any copy the running process
was never built with - which is how a redefined caller reaching a
generic at a new element type gets that copy built and loaded.

C-x C-e is the path that could really go stale, and did: it checks
against the live environment, so an expression naming a generic at an
unused type generated a copy that existed in no program and the thunk
called a symbol nothing defined. Marked and spliced.

There was no cache to invalidate. program_with_env builds a fresh env
every evaluation, so the instantiation cache cannot survive one; the
test pins that rather than inventing machinery for it.

A signature change reaches the session as a refusal about put!-i32, a
name the source does not contain. It now says which generic it is a
copy of, at which types, and that every copy changed together.
2026-09-13 14:37:55 +07:00
7f86f32699 where predicates admit operators, and a type variable is move-only until it says otherwise
The spike proved the shape; this makes it the feature. A generic body is
still checked abstractly once, but now it may be told what to assume:
{:where (ordered? $t)} at the head of the body, Clojure's {:pre [...]}
spelling, with five predicates - ordered?, equal?, hashable?, numeric?
and copyable?.

The syntax catch settled structurally: {K V} is still a legal return
type, and a constraint map is told from one by its leading keyword. A
keyword is not a type anywhere in the language, so the slot after the
return type is unambiguous and {K V} did not have to go.

A type variable is move-only by default, with copyable? the opt-out.
Move is the stricter rule, so assuming it can only refuse a valid
program, never admit a bad one. That is Rust's T: Copy and not Odin's
anything - Odin has no move semantics at all.

The runaway refusal no longer names a depth. It names the chain: a
generic already on the instantiation stack, asked for again at a type
built around the one it had before, is growing and will not stop.
2026-09-13 14:33:45 +07:00
eec0dfd1c3 A runaway instantiation refuses instead of hanging the editor 2026-09-13 13:32:33 +07:00
0749913420 A generic filter allocates its Vec, and the sweep says what a rebuild costs 2026-09-13 13:24:45 +07:00
50798aac89 A generic sort takes its comparison as a value, and an operator over a variable is refused 2026-09-13 13:20:12 +07:00
cb56fc14b1 Generic functions instantiated at their call sites, spiked 2026-09-13 13:13:10 +07:00
d07edef8e5 loop and recur, and into that fuses a chain 2026-09-13 09:35:49 +07:00
0405666b1f The two cases the tests did not cover, and one leak of a permission
A type-changing (map f) is the case into's single shadowed element name
would break if the shadowing were a trick rather than the language's
rule; it is not, because each stage is a fresh slot at its own type, and
into.flan now runs an i32 source into a (Vec f32) to say so.

A move-only accumulator carried round by recur is the shape BUILT.md
pitches the form on and was untested. It works, and recur.flan now
carries a Vec three times round and answers with it.

block's empty-body arm returned before the loop that distributes the
tail, so (do) in a tail position left ctx.tail set for whatever was
checked next. Latent rather than live — every consumer sets it
immediately before use, and the leaking form is always Unit-typed — but
it is one line to close and the invariant is easier to state closed.

Also the PORTING.md line listing loop/recur among the things with no
customer: it was built, and the half of that finding that still stands is
tail calls, which were not.
2026-09-13 09:32:58 +07:00
fedaec3e18 recur is checked, which is the reason to prefer it over tail calls
There is no TCO here and recur is not a cheaper substitute for one: the
compiler verifies the call is in the loop body's tail position, so the
mistake is a compile error where it was written rather than a stack
overflow somewhere else. A loop is a let, a While whose condition is
true, and two jumps — emit.ml is untouched, and the barrier question
recur asks is the one labelled break already answered.

Tail position is a permission that is withdrawn at the top of check, the
same read-and-withdraw defer_ok does, handed back only by a block's last
form, both arms of an if and a match arm. So nothing enumerates the forms
that are not tails, which a pre-pass over the Ast would have had to, and
would have had to keep doing.

loop is also a barrier for break and continue, which is added rather than
inherited: a loop answers with the value of its body and a jump out has
no value to give. That is also why it takes no label. A while inside a
loop keeps its own break.

Two things the shape forced. A loop binding is a plain name, because
destructuring would make recur's argument count unreadable off the
binding vector. And in_loop's "moves a value bound outside the loop"
rule had to be told about the loop's own names, or (loop [v (vec-new
i32)] ...) would have been refused for doing the ordinary thing.
2026-09-13 09:17:56 +07:00
c897526e47 Following a pointer was never a type question; it was a permission question
(Ptr Enemy) already says Enemy, at compile time, in the walk. What the renderer
lacked was any way to know whether the storage at the far end is still there —
and an allocation registry is exactly a record of which addresses it is still
true to read. So the inspector follows a live one and renders the pointee by the
same walk as anything else, and names what died at a dead one.

println does not, and the split is not squeamishness: spec-memory.md fixes what
a printed Ptr prints, a printed line belongs to the program and has to read the
same in a release build, and a release build has no registry to ask. The two
callers already differ in an emitter record; they differ in one more.

No address appears in the text. An address is not stable across two runs, so
printing one would make a rendering depend on where the heap landed — the rule
Render already follows for an allocator. What a reader wants from a dangling
pointer is what died.

registry.flan is one program read twice: a dev build answers for an address at
the heap, arena and pool tiers, and a release build answers 0 to all of it. The
arena row is the free-all Valgrind cannot see — this does not make memcheck
report it, it makes the same read answerable.
2026-09-13 09:17:47 +07:00
662b25ef5b The note is emitted where the type is known, and dropped where it is not
The checker builds one note after every operation that may have allocated,
because the checker is the only place the concrete element type exists — and it
builds them in every build, because a tree that differed by build flag would
make every pass between here and the backend ask which one it was looking at.
The backend drops them when [dev] is off, before walking the arguments: a note
takes the container's address, and emitting that only to discard the call would
leave an escaped alloca that mem2reg will not promote.

Armed by a global constructor rather than a line in main. A defvar initialiser
can allocate before main runs, and a note that arrived before the flag was set
would be a block the table never heard of.

A dev build reports the live block, answers 1 for a pointer into it, and 0 for
the same pointer after the free. A release build answers 0 to all of it.
2026-09-13 09:13:36 +07:00
6891592d79 An error is a span with notes, and a run reports more than one 2026-09-13 08:14:20 +07:00
41b60d2e4a The daemon cannot be handed a list by accident
Loc.Errors is a second exception, and the handlers in the session and the
daemon name only Loc.Error — so a list reaching them is an unhandled
exception and a dead session, which is the one thing the dev loop exists to
prevent. A flag on the function the session already calls left that one
label away from happening. Parse.program_all and Check.program_all are
separate names, so the session's call site has to be edited by a person for
its behaviour to change, and the guarantee stops being a default argument.

Placeless diagnostics now sort last rather than first. A wrong main signature
is raised against unknown, which is line 0, and sorting on the number alone
put it above every error that can actually be clicked. It is a real error and
it is not anywhere, so it goes after the ones that are.
2026-09-13 08:04:45 +07:00
17892852f8 Kinds, and the notes that point at the other place
A kind is a stable id per error, so a test can assert which error this is
without matching on prose and a message can be reworded without breaking
anything. The reader's fourteen refusals all have one; in the checker they
go on the errors a test names and the handful that are common enough to be
worth classifying. Not a hundred of them, because jank has a hundred from
being mature and the number is not the feature.

The notes are the part that could not be said before. A duplicate definition
now points at the second and notes the first; a duplicate parameter and a
duplicate field do the same; an unknown field, an unknown struct and a
non-exhaustive match all note the declaration and list what is actually
there, so the reader's next move arrives with the question instead of after
it. The reader's unclosed bracket is the clearest case — the error sits on
the bracket, because that is where the fix goes, and the note sits where the
file ran out, because that is the surprise.

No message text changed, so every existing needle still means what it meant.
The new assertions are on kinds and on note positions, which is the house
rule about asserting the reason, made stable.
2026-09-13 08:02:33 +07:00
2efed1630f The compiler finishes the file before it reports
A sink collects what a pass found so the pass can go on to the next thing.
It is switched on by the caller, not by the code that raises, which is what
leaves the interactive path untouched: the daemon checks one form, asks for
a sink that is off, and still gets one exception.

Two resync points, and both are places the work already had a boundary. In
the parser it is a top-level form — the reader found where each declaration
ends, so skipping a bad one cannot lose its place, while inside a
declaration there is no such landmark and one bad defn stays one error. In
the checker it is the two passes: pass one, which builds every name and
signature, still stops at the first refusal, because a signature it could
not make sense of leaves a hole that pass two would report once per mention.
Thirty unknown-name lines under one wrong signature are not thirty errors.

Pass two is where the volume is and where collecting pays, and by then every
signature is sound, so a body that fails cannot make the next body fail.
That is what makes a declaration a resync point needing no resynchronising.
2026-09-13 07:56:17 +07:00
e933f5a84c The projectile asks, and is told the thing it was chasing is gone
The surface: (pool-new T), (insert p x) answering a handle, (resolve p h)
answering (Option (Ptr T)), (release p h) answering whether this call was
the one that released it, (len p) and (live p), and (pool-handle p i) for
enumeration. free extends to the pool and refuses a handle by name, because
a handle owns nothing and consuming one copy would say nothing about the
others.

resolve answers a pointer rather than a value because spec-memory.md's own
worked example does, and says why a line above it: a pattern binding binds
a value, and a copy cannot be written back.

test/programs/handles.flan prints <handle 1:1> and <handle 1:3> for the same
slot before and after a death, and the projectile still holding the first
gets -1 rather than the newcomer's 99.
2026-09-13 07:55:24 +07:00
8f429bcd5d A pool slot that remembers how many times it has been reused
(Handle T) and (Pool T) land as types and as a runtime. A handle is one
int64_t — slot index low, generation high — so it copies, zeroes and
compares like the integer it is and owns nothing. A live slot's generation
is odd, which makes a zeroed handle resolve to nothing rather than to slot
zero, and makes iteration free. Wrapping retires the slot rather than
reissuing it: 2^31 reuses is rare, and rare is not an answer when the
failure is the silent wrong one the type exists to prevent.

No surface yet — the checker still has no names for any of it.
2026-09-13 07:50:06 +07:00
86296dd99a An error stops being a location and a string
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.
2026-09-13 07:49:44 +07:00
26c53e0a19 Every defn in the tree states its return type, and Unit is written ()
The mechanical half, ahead of the parser change that needs it. tools/unit-return.py
fills the empty slot with () and rewrites Unit as () wherever a type is spelled --
(Fn [i32] Unit), (Map i32 Unit), a return type written out.

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

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

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

Two things ride along because the sweep needs them: parse.ml reads a lone () as
the return type of a function with no body, which was not a shape the old
optional slot could produce; and the map refusals name () rather than Unit, since
that is now the spelling a caller wrote.
2026-09-12 23:06:40 +07:00
a9f903a63d A bare name is the function, and capture is the part that is not built 2026-09-12 22:44:33 +07:00
772d1d5b18 A cursor over the block, because nothing walked it 2026-09-12 22:24:05 +07:00
008165335d break crosses only sometimes, so the refusal is relative now
return is refused inside handler-bind and restart-case blanketly, and rightly:
a return always crosses the frames they pushed. A break does not. A loop
written wholly inside a restart-case body has a perfectly good local break, so
the rule is a barrier on the loop stack rather than a flag — a jump is refused
exactly when a barrier stands between it and the loop it names, and the message
says which construct. handler-bind and restart-case bodies are barriers, so is
a restart clause, so are a defer's forms; a handler clause is lifted into its
own function and needs no rule at all. in_frames is untouched: a return is the
special case where the target is always outside every barrier.

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

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

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

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

reach.ml and render.ml take the While arity change and nothing else.
2026-09-12 21:58:53 +07:00