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.
415 lines
21 KiB
OCaml
415 lines
21 KiB
OCaml
(** 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
|