flan/lib/tast.ml
Joseph Ferano 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

415 lines
21 KiB
OCaml
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(** The typed IR: what the checker produces and what every backend consumes.
Every backend shares this — the LLVM emitter and the hand-written x86-64
one, each of them under dev redefinition and under the release AOT build
(plan.org, Compilation) — so everything a backend would otherwise have to
re-derive is resolved here and nowhere else:
- names are gone. A local is a slot index into the frame, a global is a
name, and a call names its callee directly. No environment lookup.
- field access is an index, not a string, and any auto-deref the source
relied on is an explicit [Deref] node.
- literals have a machine type. There is no untyped 1 past this point.
- a struct literal lists every field in declaration order, with the omitted
ones filled in as [Zero] — ZII is settled here rather than at runtime.
- sugar is already gone from the AST; what is left is the small set below. *)
type prim =
(* arithmetic and comparison, per machine type — the operands carry their own
kind at runtime, so one constructor covers every width *)
| Add | Sub | Mul | Div | Rem
| Eq | Ne | Lt | Le | Gt | Ge
| Not
(* bitwise, integers only. [Shr] is arithmetic on a signed type and logical
on an unsigned one, which is what the operand's own kind already says. *)
| BitAnd | BitOr | BitXor | Shl | Shr
(* containers: fixed arrays and slices only at milestone 2 *)
| Len | At | Slice
(* (slice-from-ptr p n): a [T] made out of a (Ptr T) and a length the caller
supplies. It builds the same two words [Slice] builds and allocates
nothing — the storage stays whoever's it was, which in practice is C's.
The one thing the compiler cannot check is whether n is the truth; see
check.ml's "slice-from-ptr" case for what it can. *)
| SliceFromPtr
(* the milestone-2 host primitives, plan.org. The four conversions are
*text*: bytes->f64 parses "12.5", f64->bytes renders it — that is what
calc-me's tokenizer and the prelude's printers each need. *)
| Bytes | BytesToF64 | BytesToI64 | F64ToBytes | I64ToBytes
(* (string b): the other direction of [Bytes], and the same non-instruction.
See check.ml's "string" case for why it is unchecked. *)
| StrOfBytes
(* No surface name: the structural printer is the only thing that builds
these. U64ToBytes because u64 is not i64 with a flag, EscapeBytes for a
string nested inside a printed structure. *)
| U64ToBytes | EscapeBytes
| WriteStdout | Exit | Argv
(* A call into the runtime's C, named by symbol. The argument and result
LLVM types come off the expression nodes themselves, so one constructor
covers every entry point the allocator and container runtime has and the
backend grows one arm rather than one per operation — which matters
because spec-memory.md's runtime is type-erased and therefore *is* a list
of C entry points. A string or slice argument crosses as ptr+len, the
same rule as every other shim here. No transfer guard follows one: a
transfer cannot cross a C frame. *)
| Rt of string
(* spec-memory.md, "Alignment": a property of the type, computed at the call
site, passed as a parameter to the type-erased allocator — all three, and
they are not alternatives. The checker builds these at the site where the
concrete element type is known and the backend fills in the number from
the same layout calculator DWARF uses. *)
| SizeOf of Types.t
| AlignOf of Types.t
(* The address of any expression, not only of a place: the element a [push]
copies may be a computed value, and the runtime takes it by pointer
because it is type-erased. The backend already spills a non-place to a
temporary for exactly this. *)
| AddrOf
| Cast of Types.t
type expr = { e : expr_kind; ty : Types.t; loc : Loc.t }
and expr_kind =
| Int of int64 * Types.ikind
| Float of float * Types.fkind
| Bool of bool
| Str of string
| Unit
| Zero of Types.t (* ZII: all-bytes-zero of this type *)
| Uninit of Types.t (* the explicit opt-out *)
| Local of int (* slot index into the frame *)
| Global of string
| Prim of prim * expr list
| Call of string * expr list (* a call naming its callee *)
(* The address of a function the compiler emitted, by symbol. Which symbol
table, and whether the surface language can see it, is [fnref]'s job.
Two unrelated consumers, and the difference between them is the whole
reason [fnref] has three cases rather than two. The compiler's own uses —
the Map's hash and equality pair (Odin's [Map_Info] is two contextless
[proc] fields reached exactly this way) and a handler-bind clause's
symbol — want the *symbol*, always, and carry the Flan type [Alloc]. A
function *value* someone wrote wants the body that is current, which in a
dev build is not the symbol but whatever the indirection cell holds, and
carries the Flan type [Fn]. *)
| FnAddr of fnref
(* A call through a function value: the callee is an expression of type
[Fn], not a name. Its own node rather than a [Call] with an expression in
the name slot, because everything that walks this IR treats [Call]'s
string as a *link-time* edge — [Reach] roots the callee, [Dev] finds the
cell to redefine, [Emit] may load that cell — and none of those are
questions an indirect call can answer. Keeping them apart means each of
those readers keeps working on the direct case unchanged and says
explicitly what it does with the indirect one. *)
| CallPtr of expr * expr list
| Do of expr list
| Let of (int * expr) list * expr list
| If of expr * expr * expr
(* condition, body, and the *latch*: forms that run after the body and before
the condition is tested again. [dotimes] folds its increment in there
rather than onto the end of the body, because a [continue] branches to the
latch and a step written in the body would be skipped — the loop would
never advance and would hang. A [while] has an empty latch. *)
| While of expr * expr list * expr list
| Return of expr option
(* Leaving a loop, and jumping to its latch. The int is how many loops out
the target is, innermost first: 0 is the loop this is directly inside.
A *relative* depth rather than a name or an id because it is exactly what
each backend already has — [emit] keeps one entry per [While] it is inside
and indexes it. The invariant that makes it sound: the checker mints these
only from its own loop stack, and both stacks are pushed once per [While].
A [While] the checker *invents* (alloc_guard, the file-failure retry) is
built directly and never contains one of these, so the entry it pushes in
[emit] matches nothing and is harmless — keep it that way.
[check_loop]'s [While] is the one exception and the exception proves the
rule: it *is* pushed on [ctx.loops], so the [Break 0] that leaves it and
every [Continue] a [recur] mints are counted against the same stack [emit]
indexes. Invented is not the property that matters; being on the stack
is. *)
| Break of int
| Continue of int
| Set of place * expr
| Field of expr * int (* target is already a struct value *)
| Addr of place
| Deref of expr
| Make of string * expr list (* struct literal, every field, in order *)
(* A data type value: the data type's name, the case's name, and every
field of that case in declaration order with the omitted ones filled in
as [Zero] — the
same ZII rule [Make] carries, and settled here for the same reason. It is
its own node rather than a [Make] over a synthesised struct because the
value's *type* is the data type and its payload is a byte blob the case is
reinterpreted into; a backend that saw only [Make] would have to rederive
which of the two it was looking at. *)
| MakeCase of string * string * expr list
(* One field of one case of a data type value, by index. The case name is on the
node because the payload is untyped bytes: [Field]'s index alone cannot
say which case struct the blob is being read as. [match] is the only thing
that proves the case, so this is only ever built under an arm that
checked the tag — and by [Render], which reads a field only after the same
comparison. One node, so the payload layout is known in exactly one place
in each backend rather than once per reader. *)
| CaseField of expr * string * int
| Arr of expr list (* fixed-array literal *)
| Some_ of expr
| None_
| Match of expr * arm list
(* (some x): unwrap Some, else early-return None from the enclosing function.
An early return, not an expression that can fail — hence its own node. *)
| UnwrapSome of expr
(* Conditions, spec-conditions.md. [Signal] walks the handler stack and
returns Unit whatever it finds — with nothing matching it is a no-op, so
nothing here alters control flow. [HandlerBind] pushes one frame per
clause, runs its body, and pops them; each clause was lifted into its own
function by the checker, so what is left is the frame and the call. *)
| Signal of sigkind * int * expr (* how, the type id, the condition *)
| Handled of hframe list * expr list
(* The transfer, spec-conditions.md §3§6. [RestartCase] pushes one frame per
clause, runs its body, and pops them; if a transfer arrives naming one of
*its* frames it runs that clause instead, and the whole form yields either
way. [InvokeRestart] looks the name up on the restart stack, writes the
frame it found into the transfer channel and leaves — it has type Never,
so nothing follows it.
[InvokeRestart]'s arguments are already evaluated: the checker binds each
to a slot and wraps the node in a [Let], so what is left here is a list of
locals to copy into the frame. Two reasons, and both matter. An argument
that transfers on its own must be guarded before this one aims the
channel; and a call written in an argument has to be on the walk [Reach]
and [Load] already do, which a list hanging off a node they treat as a
leaf would not be. [rsig] is the argument types as written, and [rsig_id]
their hash — §3's run-time check, since the name is resolved on a stack
nothing static can see. *)
| RestartCase of rclause list * expr
(* (with-allocator A BODY...) — spec-memory.md. It rebinds the current
allocator for its dynamic extent and releases nothing. Its own node
because the restore has to happen on the *transfer* path too: a body that
errors, or a restart taken from inside it, must not leave the context
allocator pointing at a region the handler knows nothing about. *)
| WithAlloc of expr * expr list
(* name id, name, arguments, their spelling, its hash, where *)
| InvokeRestart of int * string * expr list * string * int * Loc.t
(* [Serror] is §2's diverging variant: the same lookup, type Never, and with
nothing transferring the program stops rather than carrying on. *)
(* Which symbol table the address comes out of. [Flanfn] is a function this
compiler emitted and is therefore name-mangled and reachability-tracked;
[Rtfn] is a C entry point in flan_rt.c, spelled as written. The two are
interchangeable at the call site because a Flan function's emitted signature
is its parameters followed by the transfer channel, and the runtime's
matching typedef spells that last pointer out. *)
(* [Flanfn] is a function this compiler emitted, named by its mangled symbol,
and always the symbol itself. [Rtfn] is a C entry point in flan_rt.c, spelled
as written. [Fnval] is also a Flan function this compiler emitted, but as a
*value* someone asked for by writing its name — and it is a separate case
because a dev build must answer it with the current body rather than with the
original symbol, which means a load from the indirection cell. The first two
must never take that path: a lifted handler clause and a hash pair have no
cell to load from. The three are interchangeable at a call site, because a
Flan function's emitted signature is its parameters followed by the transfer
channel and the runtime's matching typedef spells that last pointer out. *)
and fnref = Flanfn of string | Rtfn of string | Fnval of string
and sigkind = Ssignal | Serror
and place =
| Plocal of int
| Pglobal of string
| Pfield of expr * int
| Pindex of expr * expr list
| Pderef of expr
(* A pushed handler: which condition type it matches, and the lifted function
that runs when one is signalled. *)
and hframe = { htype : int; hfn : string }
(* A restart clause. [rname_id] is what [invoke-restart] matches by name; the
body is a branch in the function that wrote it, because unlike a handler a
clause runs at the restart-case, which is where it was written.
[rparams] are the slots §3's parameters are bound to, in order, with their
types; the invoker stores into a buffer this frame owns and the clause loads
them from it. [rsig] is how those types are spelled and [rsig_id] its hash:
what the two ends compare, since neither can see the other. *)
and rclause =
{ rname_id : int; rname : string; rparams : (int * Types.t) list;
rsig : string; rsig_id : int; rbody : expr list }
(* [binds] are the slots the pattern's fields are bound to, in field order. *)
and arm = { acase : string option; binds : int list; abody : expr list }
type field = { fname : string; fty : Types.t }
type structure = { sname : string; fields : field list }
type variant = { vname : string; vfields : field list }
type data = { dname : string; cases : variant list }
type fn = {
name : string;
params : Types.t list; (* bound to slots 0 .. n-1, in order *)
slots : Types.t array; (* the frame: one entry per slot *)
(* What the source called each slot, parallel to [slots]. [None] is a slot
the compiler made up and no one wrote a name for -- [dotimes]'s hidden
bound, the pair (min) and (max) evaluate their operands into, the slot a
tail expression goes through. Names are otherwise gone from this IR (see
the header); this is the one exception, and it exists so a debug build can
emit a [!DILocalVariable] that says [lo] where the source said [lo]. A
backend is free to ignore it entirely -- nothing is *resolved* through it,
and a slot is still only ever referred to by index. *)
snames : string option array;
ret : Types.t;
body : expr list;
(* The defers again, innermost first. [body] already has them spliced onto
the normal exit path; this is the same list for the *transfer* exit path,
which leaves through a landing block the backend builds and no form in
[body] can reach. spec-conditions.md §5: they run, and errdefer does not. *)
fdefers : expr list;
(* Set on a function the checker made up rather than one anyone wrote: a
handler-bind clause, lifted out of the function named here. It is reached
by address from that function's body and from nowhere else, so it needs no
cell and no registry slot, and a redefinition of the parent carries its
own copy. *)
fparent : string option;
floc : Loc.t;
}
(* [gfolded] is the difference between a constant whose value the *checker*
consumed — an array length, decided before any type resolves — and one that
is only ever read at run time. The first is in the program's shape and can
never be reloaded; the second is just bytes in memory and can. Nothing else
can tell them apart afterwards, so it is recorded here. *)
type global = {
gname : string;
gty : Types.t;
ginit : expr;
gconst : bool;
gfolded : bool;
}
(* A foreign function: no body, and [esym] is the symbol the linker sees. The
aggregate calling convention is not modelled here — a C shim flattens every
struct that crosses the boundary, so clang classifies it per target and
nothing in the backend has to know x86-64 from arm64 from wasm32. *)
type extern = {
ename : string; (* the Flan name, e.g. rl/init-window *)
esym : string; (* the C symbol *)
eparams : Types.t list;
eret : Types.t;
}
type program = {
structs : structure list;
datas : data list;
(* The untagged unions, carried as [structure] values: a union's members are
a field list whose every offset is zero, so the record a struct uses says
all of it. Which list a name came out of is what a backend reads to know
whether to accumulate the offsets or not. *)
unions : structure list;
globals : global list; (* in declaration order *)
externs : extern list;
fns : fn list;
(* The C the program's own (declare-c ...) forms generated, if any: one
translation unit, compiled into the build like a package's hand-written
.c file. It is on the program rather than beside it so that every driver
— the CLI, the REPL, the acceptance table — carries it without knowing
it exists. See [Shim]. *)
(* The generated FFI shim, in parts keyed by the declaration each serves,
with "" for the shared preamble. Parts rather than one string so that
[Reach.link] can drop a wrapper whose binding nothing reachable calls. *)
cshim : (string * string) list;
}
(* ── Walking an expression ─────────────────────────────────────────── *)
(* Every node of an expression, outermost first, the ones hanging off a [place]
included. One traversal in the IR's own file rather than one per reader:
three passes ask structural questions of a body — what names it refers to
([Reach]), whether a global's initialiser can transfer, and which globals it
reads ([Check]) — and each of them that spelled the traversal out again was
a place a new constructor could be forgotten in. What differs between those
readers is the question, which is [f]. The shape of the IR is not theirs to
restate.
A lifted clause's body is not in here: it is a function of its own, and this
walks one expression. A reader that wants it follows [hfn], the way [Reach]
does. *)
let rec walk (f : expr -> unit) (e : expr) =
f e;
let go = walk f in
let gos = List.iter go in
match e.e with
| Int _ | Float _ | Bool _ | Str _ | Unit | Zero _ | Uninit _ | Local _
| Global _ | None_ | FnAddr _ | Break _ | Continue _ -> ()
| Prim (_, es) | Call (_, es) | Do es | Make (_, es) | MakeCase (_, _, es)
| Arr es | InvokeRestart (_, _, es, _, _, _) -> gos es
| CallPtr (c, es) -> go c; gos es
| Let (bs, body) -> List.iter (fun (_, v) -> go v) bs; gos body
| If (a, b, c) -> go a; go b; go c
| While (c, body, latch) -> go c; gos body; gos latch
| Return v -> Option.iter go v
| Set (p, v) -> walk_place f p; go v
| Addr p -> walk_place f p
| Field (t, _) | Deref t | CaseField (t, _, _) | Some_ t | UnwrapSome t
| Signal (_, _, t) -> go t
| Match (sc, arms) -> go sc; List.iter (fun a -> gos a.abody) arms
| Handled (_, body) -> gos body
| RestartCase (cs, body) -> List.iter (fun c -> gos c.rbody) cs; go body
| WithAlloc (a, body) -> go a; gos body
and walk_place f (p : place) =
match p with
| Plocal _ | Pglobal _ -> ()
| Pfield (t, _) | Pderef t -> walk f t
| Pindex (t, idx) -> walk f t; List.iter (walk f) idx
(* ── What the object image can hold ─────────────────────────────────── *)
(* Whether an initialiser is a value a linker can write into the program's
image: a literal, a zero, an aggregate of those. It is [Emit.const]'s
accepted set asked as a question rather than answered as a string, and the
two have to stay the same set — [const] spells the value, this decides who
is allowed to ask it to.
Everything else is *computed*, which used to be the end of the road and is
now a fork: [Check] lifts a computed initialiser into a function of its own
and the program calls it at startup. So this is no longer "what a global may
be", only "what needs no code" — which is why a [MakeCase] is false here
rather than an error. A data type case written into the image would need a
byte-level encoder that could not encode a string field at all; written as a
store at startup it needs nothing. *)
let rec const_init (e : expr) =
match e.e with
| Int _ | Float _ | Bool _ | Str _ | Unit | Zero _ | Uninit _ | None_ -> true
| Make (_, es) | Arr es -> List.for_all const_init es
| Some_ v -> const_init v
| _ -> false
(* The declared position of a case, which is its tag, and the case itself. Tags
are declaration order from zero, so an all-bytes-zero data type is the first
case with a zeroed payload — the same rule that makes an [Option]'s zero a
[None], and the reason case order is part of a data type's contract. *)
let case_index (u : data) name =
let rec go i = function
| [] -> None
| (c : variant) :: rest ->
if String.equal c.vname name then Some (i, c) else go (i + 1) rest
in
go 0 u.cases
let vfield_index (c : variant) name =
let rec go i = function
| [] -> None
| (f : field) :: rest ->
if String.equal f.fname name then Some i else go (i + 1) rest
in
go 0 c.vfields
let field_index (s : structure) name =
let rec go i = function
| [] -> None
| f :: rest -> if String.equal f.fname name then Some i else go (i + 1) rest
in
go 0 s.fields