"Is there a way to do dotimes or a loop in reverse?" — the answer was a hand-written let plus set. Now it is (dotimes [i 9 -1 -1]). Three arities: [i n], [i start stop], [i start stop step]. The stop is exclusive in all of them, so [i 0 n] is [i n] — one rule, not two — and a negative step counts down, testing with > instead of <. A literal step of 0 is refused where it is written. One that is only a value cannot be, so the condition asks the sign first and 0 falls out of it as a loop that runs no times: terminating and deterministic, and free, because a literal step still emits the single comparison it always did. Each bound is evaluated once, left to right, before the counter exists: the start into the counter, the stop into the hidden slot it always had, the step into one of its own unless it is a literal. Still a special form, still a Let and a While with the step in the latch, so neither backend learned anything — the new program prints the same thing under --x86 and at -O0. load.ml's Form-level walk had to learn more than one bound for the same reason parse.ml did; it is part of this feature and not a bug that was sitting there, because before this a three-bound dotimes was a parse error long before that walk could reach it.
11273 lines
566 KiB
OCaml
11273 lines
566 KiB
OCaml
(** The checker: AST → typed IR.
|
||
|
||
Two passes, because top-level names in a package are order-independent
|
||
(plan.org, Modules): the first collects every type, signature and global,
|
||
the second checks bodies against them. Mutually recursive functions need no
|
||
forward declaration, and a struct may be used above where it is declared.
|
||
|
||
Checking is *bidirectional*. An expression is checked against an expected
|
||
type when there is one and inferred when there is not, which is what makes
|
||
[None], a bare [0] and a struct literal work without any inference engine:
|
||
the expected type flows in from the function's return type, the parameter
|
||
it is being passed to, or the field it is being stored in.
|
||
|
||
The rule from the two misparse bugs applies here too: *anything not yet
|
||
implemented is rejected by name*, never approximated. Milestone 2 is
|
||
calc-me.flan and nothing more (plan.org, Build sequence), so [Vec], [Map],
|
||
[Result]/[try], user data types, closures, [dotimes], [defer], generics and
|
||
cross-package imports are all errors with a message that says which
|
||
milestone they belong to. *)
|
||
|
||
let fail = Loc.fail
|
||
|
||
(* "A literal could not be built at the type this site asked for": 300 at a u8,
|
||
1.5 at an i32, 3000000000 at the i32 an unconstrained integer defaults to.
|
||
|
||
It is kinded rather than left generic because one caller has to tell this
|
||
refusal apart from every other one. [binary] retries a refused operand
|
||
against the other operand's type (FIX.org 2026-09-20, implicit widening),
|
||
and it must not retry *this* one: a literal takes its width from the other
|
||
side and always could, so a literal that does not fit is the program's
|
||
mistake and not a pair of types that failed to meet. Without the kind the
|
||
retry turns (+ u8-thing 300) into i32 arithmetic, which is a different
|
||
language from the one the author decided on. *)
|
||
let literal_at_want = "check/literal-at-want"
|
||
|
||
(* [List.map]'s evaluation order is unspecified, and checking allocates frame
|
||
slots as a side effect. Left-to-right is required, not a preference: a later
|
||
let binding sees an earlier one, and slot numbering must be reproducible. *)
|
||
let rec map_lr f = function
|
||
| [] -> []
|
||
| x :: rest -> let y = f x in y :: map_lr f rest
|
||
|
||
let rec map2_lr f xs ys =
|
||
match xs, ys with
|
||
| [], [] -> []
|
||
| x :: xs, y :: ys -> let z = f x y in z :: map2_lr f xs ys
|
||
| _ -> invalid_arg "map2_lr"
|
||
|
||
(* ── Environments ──────────────────────────────────────────────────── *)
|
||
|
||
type binding = {
|
||
slot : int;
|
||
bty : Types.t;
|
||
assignable : bool; (* locals are places; parameters are not — spec-memory *)
|
||
(* Where the name came from, in words, when that is worth saying in a
|
||
refusal about it. Set by the match-arm path and nowhere else: a case
|
||
pattern binds the case's fields positionally, so [(Circle c)] over a
|
||
one-field case binds [c] to an [f64] and the reach for [(.r c)] gets a
|
||
type fact about [f64] instead of the one sentence that helps, which is
|
||
that the field is already in hand. [None] everywhere else, and a refusal
|
||
with [None] says exactly what it said before. *)
|
||
bwhat : string option;
|
||
}
|
||
|
||
type env = {
|
||
structs : (string, Tast.structure) Hashtbl.t;
|
||
datas : (string, Tast.data) Hashtbl.t;
|
||
(* The untagged unions, by name, and they are [Tast.structure] values on
|
||
purpose: a union's members *are* a field list, and every one of them is at
|
||
offset zero. Giving them a record of their own would have meant a second
|
||
shape for [field_index] and for every walk over a member list, to say
|
||
nothing new — which table the name is in is already what says whether the
|
||
offsets are cumulative or all zero, exactly as it is for a data type. *)
|
||
unions : (string, Tast.structure) Hashtbl.t;
|
||
(* Every data type case, twice over: once under its full spelling ["U.C"], which
|
||
is how a value of it is written, and once under the bare ["C"], which is
|
||
how a [match] arm names it and how a mistake spells a constructor. The
|
||
full spelling is a key rather than something split out of a dotted name at
|
||
the use site, because a data type's own name can contain a slash (an imported
|
||
[rl/U]) and may one day contain a dot; string surgery would own an edge
|
||
this does not have to.
|
||
|
||
The bare entry is deliberately last-writer-wins and is *only* used to say
|
||
"C is a case of U, write (U.C ...)". Two data types may share a case name —
|
||
construction is qualified and a pattern resolves against the scrutinee, so
|
||
both are unambiguous — and refusing that would be a restriction with no
|
||
mechanism behind it. *)
|
||
cases : (string, string * Tast.variant) Hashtbl.t;
|
||
aliases : (string, Ast.texpr) Hashtbl.t;
|
||
consts : (string, int64) Hashtbl.t; (* compile-time array lengths *)
|
||
locs : (string, Loc.t) Hashtbl.t; (* where each type was declared *)
|
||
(* Enum name -> its members, in declaration order. A keyword at a call site
|
||
resolves against this and nothing else. *)
|
||
enums : (string, (string * int64) list) Hashtbl.t;
|
||
(* Flan name -> the C symbol it is really called by. A foreign function is an
|
||
ordinary entry in [fns] as well; this only records how to name it. *)
|
||
externs : (string, string) Hashtbl.t;
|
||
(* Where each [declare] was written, by Flan name. A second table rather
|
||
than a pair in [externs], because every other reader of that one wants
|
||
the symbol and nothing else. *)
|
||
extern_locs : (string, Loc.t) Hashtbl.t;
|
||
fns : (string, Types.t list * Types.t) Hashtbl.t;
|
||
(* The parameter vector as it was *written*, by function name: the names and
|
||
the locations [fns] threw away when it resolved the types. Nothing needs
|
||
it to compile; it exists so that a refusal at a call argument can point
|
||
at the parameter that wanted the other type, which is the second half of
|
||
every message in Elm and was the one thing the reader could not see from
|
||
the caret. Missing for a foreign [declare] and for a generic copy, and a
|
||
missing entry degrades to the message alone rather than to a wrong
|
||
pointer — [declared_note]'s rule. *)
|
||
fparams : (string, Ast.field list) Hashtbl.t;
|
||
(* And where the defn was written, for the same reason: a refusal about a
|
||
function can show it. Kept apart from [fparams] because a foreign
|
||
[declare] has a location and no parameter vector worth showing. *)
|
||
fn_locs : (string, Loc.t) Hashtbl.t;
|
||
globals : (string, Types.t * bool) Hashtbl.t; (* type, is a constant *)
|
||
(* Where each global was declared, so a refusal about one can show it. A
|
||
second table rather than a third field, because every other reader of
|
||
[globals] wants the type and the constness and nothing else. *)
|
||
global_locs : (string, Loc.t) Hashtbl.t;
|
||
(* Functions the checker made up: a handler-bind clause is lifted into one,
|
||
because a handler is called from wherever the signal was and cannot be a
|
||
branch in the function that established it. *)
|
||
mutable lifted : Tast.fn list;
|
||
|
||
(* ── Generics by monomorphisation (spike, milestone 5) ────────────────
|
||
A generic [defn] is *not* in [fns]: its signature mentions type variables
|
||
and nothing can be called at it. It lives here, as the AST it was written
|
||
as, and every call site turns it into an ordinary function with concrete
|
||
types. Odin's model exactly — [find_or_generate_polymorphic_procedure]
|
||
keeps the source [Entity] and hangs generated ones off it. *)
|
||
generics : (string, Ast.fn) Hashtbl.t;
|
||
(* Its signature as *written*: parameter and return types with [Types.Var]
|
||
in them. This is the pattern a call site matches its argument types
|
||
against to bind the variables. *)
|
||
gsigs : (string, string list * Types.t list * Types.t) Hashtbl.t;
|
||
(* The instantiation cache. Odin's [gen_procs] list, keyed the way Odin keys
|
||
it: a linear scan comparing whole concrete signatures with
|
||
[are_types_identical] — here [Types.equal] pairwise. Same types twice
|
||
means one copy. *)
|
||
insts : (string, (Types.t list * Types.t * string) list ref) Hashtbl.t;
|
||
(* The copies themselves, in the order they were generated. They are
|
||
ordinary [Tast.fn]s from here down; nothing in a backend knows they were
|
||
ever generic. *)
|
||
mutable instances : Tast.fn list;
|
||
(* The type variables in scope while a generic signature is being resolved.
|
||
Empty everywhere else, which is what keeps the lowercase rejection at
|
||
[resolve_name] the default. *)
|
||
mutable tyvars : string list;
|
||
(* What each of them is bound to while one instantiation's body is checked.
|
||
[resolve_name] consults it before anything else, so the body resolves
|
||
[t] to [i32] and every node under it is concrete. *)
|
||
mutable subst : (string * Types.t) list;
|
||
(* The [where] predicates in scope: what the abstract pass may assume about
|
||
the variables, and what each instantiation checks its concrete types
|
||
answer yes to. Empty everywhere a generic signature or body is not being
|
||
resolved, which is what keeps every refusal below the default. *)
|
||
mutable tvpreds : Ast.pred list;
|
||
(* The chain of instantiations currently being generated, innermost last:
|
||
the generic's name and the concrete parameter types each copy was asked
|
||
for. It is the refusal for a generic that instantiates itself without
|
||
end — [(defn grow [x $t] () (grow [x x]))] asks for a copy at [[2 t]],
|
||
which asks for one at [[2 [2 t]]], forever — and without it the checker
|
||
does not fail, it *hangs*, which through [Session.eval] is [C-c C-c]
|
||
hanging with the dev daemon wedged behind it.
|
||
|
||
The test is structural rather than a depth count. A depth count names a
|
||
number the programmer did not write and cannot act on; this names the
|
||
chain. Odin has no cap of its own to copy, so there was nothing to
|
||
borrow. *)
|
||
mutable chain : (string * Types.t list * Loc.t) list;
|
||
(* Set while a struct, data-case or union field's type is being resolved,
|
||
and only then. It exists for one message: an unknown lowercase name in a
|
||
type slot is told to introduce a type variable with [$name] in the
|
||
parameter vector, and a field has no parameter vector — only a defn
|
||
signature binds, and a field is built at one type for every value. The
|
||
flag is what lets [resolve_name] say the honest thing in each place
|
||
instead of a suggestion that cannot be followed. *)
|
||
mutable in_field : bool;
|
||
}
|
||
|
||
let new_env () = {
|
||
structs = Hashtbl.create 16;
|
||
datas = Hashtbl.create 16;
|
||
unions = Hashtbl.create 16;
|
||
cases = Hashtbl.create 32;
|
||
aliases = Hashtbl.create 16;
|
||
consts = Hashtbl.create 16;
|
||
locs = Hashtbl.create 16;
|
||
enums = Hashtbl.create 8;
|
||
externs = Hashtbl.create 32;
|
||
extern_locs = Hashtbl.create 32;
|
||
fns = Hashtbl.create 32;
|
||
fparams = Hashtbl.create 32;
|
||
fn_locs = Hashtbl.create 32;
|
||
globals = Hashtbl.create 16;
|
||
global_locs = Hashtbl.create 16;
|
||
lifted = [];
|
||
generics = Hashtbl.create 8;
|
||
gsigs = Hashtbl.create 8;
|
||
insts = Hashtbl.create 8;
|
||
instances = [];
|
||
tyvars = [];
|
||
subst = [];
|
||
tvpreds = [];
|
||
chain = [];
|
||
in_field = false;
|
||
}
|
||
|
||
(* Where a named type was declared, and what it has, as a note.
|
||
|
||
This is the second half of the two-place messages: a refusal that says
|
||
[Cursor has no field pos] is true, and the reader's next move is always to
|
||
go and look at Cursor. Attaching the declaration's location and its actual
|
||
field names means the answer arrives with the question, and [next-error]
|
||
will take you there because a note prints as an entry of its own. Empty when
|
||
the name is not one this environment placed, so it degrades to the message
|
||
alone rather than to a wrong pointer. *)
|
||
let declared_note env name =
|
||
match Hashtbl.find_opt env.locs name with
|
||
| None -> []
|
||
| Some at ->
|
||
let names =
|
||
match Hashtbl.find_opt env.structs name with
|
||
| Some s -> List.map (fun (f : Tast.field) -> f.Tast.fname) s.Tast.fields
|
||
| None ->
|
||
(match Hashtbl.find_opt env.datas name with
|
||
| Some u -> List.map (fun (c : Tast.variant) -> c.Tast.vname) u.Tast.cases
|
||
| None ->
|
||
match Hashtbl.find_opt env.unions name with
|
||
| Some u -> List.map (fun (f : Tast.field) -> f.Tast.fname) u.Tast.fields
|
||
| None -> [])
|
||
in
|
||
let what =
|
||
if names = [] then name ^ " is declared here"
|
||
else name ^ " is declared here, with " ^ String.concat ", " names
|
||
in
|
||
[ Loc.note at what ]
|
||
|
||
(* One edit apart: a substitution, an insertion, a deletion, or a transposition
|
||
of neighbours. Bounded at one, because two edits is no longer a typo, it is
|
||
a guess. Hoisted out of [near_miss] so that the did-you-mean over *values* —
|
||
function names, globals, locals — matches on exactly the same rule the one
|
||
over types has always matched on, rather than on a second one that would
|
||
drift. *)
|
||
let one_edit a b =
|
||
let la = String.length a and lb = String.length b in
|
||
if abs (la - lb) > 1 then false
|
||
else begin
|
||
(* Walk both until they diverge, then require the tails to match with the
|
||
single edit applied. *)
|
||
let i = ref 0 in
|
||
while !i < la && !i < lb && a.[!i] = b.[!i] do incr i done;
|
||
let ta s k = String.sub s k (String.length s - k) in
|
||
if la = lb then
|
||
!i < la
|
||
&& (ta a (!i + 1) = ta b (!i + 1)
|
||
(* stirng/string: two neighbours swapped. *)
|
||
|| (!i + 1 < la && a.[!i] = b.[!i + 1] && a.[!i + 1] = b.[!i]
|
||
&& ta a (!i + 2) = ta b (!i + 2)))
|
||
else if la < lb then ta a !i = ta b (!i + 1)
|
||
else ta a (!i + 1) = ta b !i
|
||
end
|
||
|
||
(* The same question asked of a candidate list the caller assembles, which for
|
||
a value position is the function table, the globals and whatever is in
|
||
scope — and nothing from the type tables, because a name written where a
|
||
value goes was not a mistyped struct. *)
|
||
let nearest cands n = List.find_opt (fun c -> c <> n && one_edit n c) cands
|
||
|
||
(* What the last language called it. [long] is two edits from [i64] and so is
|
||
outside [one_edit]'s net, which is right — two edits is a guess — but the
|
||
name is not a guess at all: it is what C, Java, Go and Python spell an
|
||
integer, and somebody writing it here has not mistyped anything, they have
|
||
not yet learned that this language sizes its integers in the name. Without
|
||
this list [long] falls through to the lowercase arm of [resolve_name] and
|
||
is reported as generic code over a type variable, which is a sentence about
|
||
a feature the reader was not reaching for.
|
||
|
||
Two names that belong on this list by that reasoning are *not* on it:
|
||
[int] and [float]. They are builtin aliases as of 2026-09-20 — [int] is
|
||
[i32] and [float] is [f32], spelled in [Types.ikind_of_name] and
|
||
[Types.fkind_of_name] — so they resolve rather than teach, and a row for
|
||
either here would be dead code the next reader would trust. The exception
|
||
stops at those two, on the author's decision: the default integer and the
|
||
default float are the two spellings a program reaches for constantly, and
|
||
[integer], [long], [double], [str] and the rest keep teaching. So [integer]
|
||
still answers [i32] here even though [int] no longer does — the sibling
|
||
spelling is still a name this language does not have.
|
||
|
||
Short on purpose, and only names with one honest answer. [char] is not
|
||
here: C's is a byte, Java's is a UTF-16 unit and Rust's is a scalar value,
|
||
and this language has [u8] and rune functions, so there is nothing to
|
||
translate it to in three words. Nor [void]: it is a return type and the
|
||
answer there is the shape [()], which is [parse]'s message to give and not
|
||
this one's. Nor [usize] and [size_t]: the honest answer is "as wide as a
|
||
pointer on this target", which is [u64] on x86-64 and [u32] on wasm32, and
|
||
a message that named one of them would be wrong half the time on a tree
|
||
that builds both. A name goes on this list when the answer does not depend
|
||
on anything. *)
|
||
let foreign_spelling = function
|
||
| "integer" -> Some "i32"
|
||
| "uint" | "unsigned" -> Some "u32"
|
||
| "long" -> Some "i64"
|
||
| "ulong" -> Some "u64"
|
||
| "short" -> Some "i16"
|
||
| "ushort" -> Some "u16"
|
||
| "byte" -> Some "u8"
|
||
| "double" -> Some "f64"
|
||
| "boolean" -> Some "bool"
|
||
| "str" -> Some "string"
|
||
| _ -> None
|
||
|
||
(* The builtin names, for the did-you-mean at a call — [prinltn] is a typo for
|
||
[println], and [println] is not in any table the checker keeps, it is an arm
|
||
of the call dispatch. The full [builtins] table is a long way below this
|
||
point and carries a signature and a sentence per entry for eldoc; a forward
|
||
reference to its names is cheaper than moving it or writing the list twice
|
||
and letting the two drift. Filled once, immediately after that table. *)
|
||
let builtin_names : string list ref = ref []
|
||
|
||
(* The same names as a set, and the two are not one because they are asked
|
||
two different questions. The list above is read once, at a refusal, and
|
||
its order is the order the did-you-mean walks. This is asked at *every*
|
||
named call — [shadows_builtin] is the first arm of the dispatch — and a
|
||
linear walk of eighty-odd strings per call is a cost a whole-program check
|
||
pays in full: measured at about a third of check time on a program of
|
||
twenty thousand calls. Filled beside the list. *)
|
||
let builtin_set : (string, unit) Hashtbl.t = Hashtbl.create 128
|
||
|
||
(* ── builtin/, the reserved qualifier ──────────────────────────────────
|
||
[builtin/len] is the builtin [len], whatever else the program has decided
|
||
[len] means. It is the way out of the dead end shadowing used to leave: a
|
||
[(defn len ...)] takes the bare name over for its whole file, and before
|
||
this there was no remaining spelling for the thing it was wrapping, so the
|
||
wrapper was unbounded recursion instead.
|
||
|
||
The spelling is the package qualifier's, deliberately. A reader who knows
|
||
that [rl/draw-fps] is [draw-fps] from the package imported as [rl] already
|
||
knows what [builtin/len] is, and needs no second syntax to learn. What
|
||
makes it work is that [builtin] is reserved rather than resolved: [Load]'s
|
||
qualifier comes from the alias in an [import] form and from nowhere else,
|
||
so refusing that one alias ([Load.reserved_alias]) is the whole of keeping
|
||
this prefix unambiguous.
|
||
|
||
It is legal when nothing is shadowed, too. A spelling that only compiles
|
||
while some other declaration exists is a spelling nobody can write down in
|
||
advance, and the point of an escape hatch is that it is always there. *)
|
||
let builtin_prefix = "builtin/"
|
||
|
||
let qualified_builtin n =
|
||
let p = String.length builtin_prefix in
|
||
if String.length n >= p && String.sub n 0 p = builtin_prefix then
|
||
Some (String.sub n p (String.length n - p))
|
||
else None
|
||
|
||
(* [builtin/] reached with something that is not a builtin's name. The
|
||
did-you-mean is over the builtins alone and not over the program's own
|
||
names: the reader wrote the qualifier, so they were reaching for a
|
||
compiler name, and offering them a defn called [lem] would be answering a
|
||
question they did not ask. Every other did-you-mean in this file keeps the
|
||
candidates it already had. *)
|
||
let not_a_builtin loc bare =
|
||
if bare = "" then
|
||
Loc.failk "check/unknown-builtin" loc
|
||
"%s needs a name after it — the qualifier reaches a builtin, as \
|
||
(%slen v)" builtin_prefix builtin_prefix
|
||
else
|
||
match nearest !builtin_names bare with
|
||
| Some m ->
|
||
Loc.failk "check/unknown-builtin" loc
|
||
"%s is not a builtin, so %s%s reaches nothing — did you mean %s%s?"
|
||
bare builtin_prefix bare builtin_prefix m
|
||
| None ->
|
||
Loc.failk "check/unknown-builtin" loc
|
||
"%s is not a builtin, so %s%s reaches nothing. The %s qualifier \
|
||
reaches the compiler's own names and nothing else; an ordinary \
|
||
function is called by the name it was defined under"
|
||
bare builtin_prefix bare builtin_prefix
|
||
|
||
(* What a [break] or a [continue] may be talking about, innermost first.
|
||
|
||
[Lloop] is a loop it is lexically inside, carrying its label if it was given
|
||
one. [Lbarrier] is something a jump may not cross, named so the refusal can
|
||
say which — and the barriers are the whole of the answer to the question
|
||
[return]'s [in_frames] rule could not answer.
|
||
|
||
[return] is refused inside a [handler-bind] or a [restart-case] blanketly,
|
||
because a return *always* crosses the frames established there and leaves
|
||
them on the stack pointing into a frame that has gone. A break crosses only
|
||
sometimes: a loop written wholly inside a [restart-case] body has a
|
||
perfectly good local break, and refusing it would be refusing the common
|
||
case for the uncommon one. So the rule here is relative rather than blanket
|
||
— a jump is refused exactly when a barrier stands between it and the loop it
|
||
names — and the two rules agree on the case they share, because a [return]
|
||
is a jump whose target is always outside every barrier.
|
||
|
||
A [defer]'s forms are a barrier for a different reason with the same shape:
|
||
they are copied into the function's exit paths, where the loop they were
|
||
written next to no longer exists. A loop *inside* the defer is fine, which
|
||
is again the relative rule and not a blanket one.
|
||
|
||
A handler clause is not on this list at all: it is lifted into a function of
|
||
its own and gets a fresh [ctx], so its loops start empty and nothing inside
|
||
it can name a loop outside it. *)
|
||
type lentry =
|
||
| Lloop of string option
|
||
(* A [(loop ...)], carrying the slot and type of each of its names so that a
|
||
[recur] can rebind them. It is *also* a barrier for [break] and
|
||
[continue]: a loop answers with the value of its body, so a jump that left
|
||
one would have no value to give. A [while] written inside a loop is
|
||
unaffected, which is the relative rule doing its job again. *)
|
||
| Lrecur of (int * Types.t) list
|
||
| Lbarrier of string
|
||
|
||
(* Per-function state. Slots are never reused, so [slots] is also the frame
|
||
size — the interpreter allocates one array of this length per call. *)
|
||
type ctx = {
|
||
env : env;
|
||
ret : Types.t;
|
||
mutable slots : int;
|
||
(* The type of each slot, newest first. A backend needs it to size the
|
||
frame — nothing else records it, since the IR refers to slots by index. *)
|
||
mutable slot_tys : Types.t list;
|
||
(* The source name of each slot, newest first, parallel to [slot_tys].
|
||
[None] for a slot the checker invented -- see [Tast.fn.snames]. Recorded
|
||
here rather than recovered later because this scope list is the only place
|
||
that ever knows it. *)
|
||
mutable slot_names : string option list;
|
||
mutable scope : (string * binding) list; (* innermost first *)
|
||
(* Deferred forms, most recently registered first — which is also the order
|
||
they run in. [defer] is function-scoped, so this list belongs to the
|
||
function and not to a block — see [defer_ok] for where one may be written
|
||
and [check_fn] for where the list is spliced onto the exit paths. *)
|
||
mutable defers : Tast.expr list;
|
||
(* The slot that counts how many of them have registered, minted on the
|
||
first [defer] this function writes and [None] until then.
|
||
|
||
The normal exit paths need no such thing: falling off the end is below
|
||
every defer in the text, and a [return] splices the ones registered
|
||
above it, both of which are decided while checking. The *transfer* exit
|
||
is the one path that is not, because a transfer can start anywhere,
|
||
including in the initialiser of the very [let] whose body the defer is
|
||
written in — and that defer has not registered yet. Running it there is
|
||
not a leak the other way round; it is cleanup over a binding nothing has
|
||
written, which is [(free v)] on whatever the stack held.
|
||
|
||
So the count is kept at run time and the transfer path's copy of each
|
||
defer is guarded on it. The cost is not all on the unwinding path, and
|
||
the half that is not is the half worth naming: every function with a
|
||
defer pays one i64 of frame, one store of zero at entry, and one store
|
||
of an ordinal where each defer is written — on the ordinary path,
|
||
whether anything ever transfers or not. What the unwind adds on top is
|
||
one compare per defer. A store of a constant into a frame slot nothing
|
||
else reads is about as little as a fact can cost, but it is not nothing
|
||
and it is not only on the cold path. *)
|
||
mutable defer_slot : int option;
|
||
(* Where a [defer] may be written, which is exactly: a form whose extent is
|
||
the whole function body. Two things have that extent and only two — a
|
||
top-level form of the body, and a form in the body of a [let] that itself
|
||
has it, to any depth. A [let] always registers and its bindings outlive
|
||
the block textually enclosing them, because a [let] is not a frame here:
|
||
its bindings are function slots like any other, and nothing is released at
|
||
scope exit (spec-memory.md, "When storage is released").
|
||
|
||
Everything else is refused, and the two that matter are refused for a
|
||
reason rather than by omission. [defer] is a *compile-time* construct —
|
||
the cleanup is copied into every exit path — so a branch would have to
|
||
express "maybe registered", which it cannot, and a loop body would fire
|
||
once at function exit rather than once per iteration.
|
||
|
||
The flag is set immediately before each form that may carry one, never
|
||
once around a body: [check] clears it on entry, so a body whose first form
|
||
set it would otherwise refuse the second. [defer_block] names the
|
||
innermost construct that cleared it, so the refusal says which. *)
|
||
mutable defer_ok : bool;
|
||
mutable defer_block : string;
|
||
(* Only for the two things a handler clause cannot do. [outer] is the
|
||
establishing function's scope, kept so that a reference to one of its
|
||
locals can be refused for the reason it is really refused for rather than
|
||
as an unknown name. *)
|
||
outer : (string * binding) list;
|
||
(* Set on the context of a body the checker lifted into a function of its
|
||
own — a handler clause, or an [fn] literal — and naming which, so the
|
||
refusal below says why the enclosing function's locals are not there. Both
|
||
are the same gap: capture does not exist. *)
|
||
mutable outer_what : string option;
|
||
(* True wherever handler or restart frames established by this function are
|
||
on the stack. A [return] from there would leave them pointing into a frame
|
||
that has gone, so it is refused — the same rule as [defer] inside a
|
||
block. *)
|
||
mutable in_frames : string option;
|
||
(* The loops and the barriers this form is inside, innermost first. See
|
||
[lentry]: it is what [break] and [continue] resolve against, and the whole
|
||
of why they are not a goto — a label that names no loop on this list is
|
||
refused, so control can only leave a loop it is already in. *)
|
||
mutable loops : lentry list;
|
||
(* True where this form's value is the value of the enclosing [loop]'s body,
|
||
which is the only place a [recur] may stand. Read and withdrawn at the top
|
||
of [check] exactly as [defer_ok] is, and granted again by the three forms
|
||
that pass a tail through: the last form of a block, both arms of an [if],
|
||
and a [match] arm. Everything else is therefore non-tail by construction,
|
||
and no walk has to enumerate the cases that are not. *)
|
||
mutable tail : bool;
|
||
(* True inside a [defer]'s forms. A defer is the cleanup a transfer runs on
|
||
its way out (§5), so a transfer *starting* there has no answer: this
|
||
function's defers are already half run and the first transfer's target is
|
||
already in hand. Refused where it is written. *)
|
||
mutable in_defer : bool;
|
||
(* The function being checked, so a clause lifted out of it can be named
|
||
after it. The name has to be stable and has to say whose it is: a
|
||
redefinition module emits the clauses belonging to the bodies it is
|
||
replacing, and nothing else in the program can tell it which those are. *)
|
||
owner : string;
|
||
}
|
||
|
||
(* [?name] is the source name, when there is one. It is optional so that the
|
||
several places that allocate a hidden slot say nothing and get [None] --
|
||
a synthesized slot cannot accidentally acquire a name it was never given. *)
|
||
let fresh_slot ?name ctx ty =
|
||
let s = ctx.slots in
|
||
ctx.slots <- s + 1;
|
||
ctx.slot_tys <- ty :: ctx.slot_tys;
|
||
ctx.slot_names <- name :: ctx.slot_names;
|
||
s
|
||
|
||
(* Shadowing is legal -- [(let [v 11] (let [v 22] ...))] is two slots, both
|
||
named [v] -- and the debug info has nowhere to put the distinction. Every
|
||
[!DILocalVariable] is scoped to the subprogram, because the typed IR has no
|
||
block structure for a [!DILexicalBlock] to be built from, so two variables
|
||
called [v] land in one flat scope and lldb answers [p v] with whichever it
|
||
finds first. Measured, not assumed: it answers with the *outer* one, so it
|
||
prints 11 while the body it is stopped in is computing with 22, and the
|
||
inner binding is not listed at all.
|
||
|
||
That is the one outcome worse than printing [s3]: a name the debugger is
|
||
confident about and wrong about. So a repeat of a name already bound in this
|
||
function gets a suffix, and both bindings are then visible and unambiguous.
|
||
[~] is the reader's delimiter and cannot occur in a source symbol (the same
|
||
reason [destructure~nth] is spelled that way), so [v~2] is visibly the
|
||
compiler's doing and can never collide with something the programmer wrote.
|
||
|
||
This is a way of not lying, not a way of being right: [v] is still the outer
|
||
binding everywhere, including inside the inner one's extent. Scoping the
|
||
variables properly means emitting a [!DILexicalBlock] per [Let] and moving
|
||
the [llvm.dbg.declare]s out of the entry block to the binding sites, which
|
||
needs block structure this IR does not carry. *)
|
||
let bind ctx ?what name bty ~assignable =
|
||
let taken n = List.exists (fun s -> s = Some n) ctx.slot_names in
|
||
let name' =
|
||
if not (taken name) then name
|
||
else
|
||
let rec go k =
|
||
let c = Printf.sprintf "%s~%d" name k in
|
||
if taken c then go (k + 1) else c
|
||
in
|
||
go 2
|
||
in
|
||
let slot = fresh_slot ~name:name' ctx bty in
|
||
(* [ctx.scope] keeps the *source* name: the suffix is a debug-info artifact
|
||
and resolving [v] must still find the innermost binding. *)
|
||
ctx.scope <- (name, { slot; bty; assignable; bwhat = what }) :: ctx.scope;
|
||
slot
|
||
|
||
let lookup ctx name = List.assoc_opt name ctx.scope
|
||
|
||
(* A handler clause is lifted into a function of its own, so the establishing
|
||
function's locals are simply not there. Capturing them is a closure with an
|
||
explicit environment — spec-memory.md's case 2, a non-escaping [fn] capturing
|
||
by value into a stack environment, since a handler frame does not outlive the
|
||
function that pushed it — and until that exists a reference to one is refused
|
||
for the reason it is really refused for, rather than as a name nobody has
|
||
heard of. *)
|
||
let captured ctx loc name =
|
||
match ctx.outer_what with
|
||
| Some what when List.mem_assoc name ctx.outer ->
|
||
let why =
|
||
if String.equal what "a handler" then
|
||
"a handler runs from wherever the signal was. Use a global, or pass \
|
||
it on the condition"
|
||
else
|
||
"an fn is lifted into a function of its own and is handed nothing but \
|
||
its parameters. Pass it in, or use a global"
|
||
in
|
||
Loc.failk "check/capture" loc
|
||
"%s cannot see %s: it is a local of the enclosing function, and %s."
|
||
what name why
|
||
| _ -> ()
|
||
|
||
let scoped ctx f =
|
||
let saved = ctx.scope in
|
||
let r = f () in
|
||
ctx.scope <- saved;
|
||
r
|
||
|
||
(* A scope that is also a named blocker for [defer]. An arm of an [if] or a
|
||
[match] runs only sometimes, and "maybe registered" is not something a
|
||
compile-time construct can express — the cleanup is copied into every exit
|
||
path or into none — so the refusal is about the branch and says so.
|
||
|
||
Outside the [check] recursion on purpose: inside it the inferred type would
|
||
be monomorphic, and the two callers pass functions returning different
|
||
things. *)
|
||
let branch ctx f =
|
||
let blocker = ctx.defer_block in
|
||
ctx.defer_block <- "a branch";
|
||
let r = scoped ctx f in
|
||
ctx.defer_block <- blocker;
|
||
r
|
||
|
||
(* ── Type resolution ───────────────────────────────────────────────── *)
|
||
|
||
let unimplemented loc what milestone =
|
||
(* No repo filename in a message. Somebody meeting this wants to know that
|
||
the thing is not there yet and roughly how far off it is; where the
|
||
schedule is written down is the compiler's business, not theirs. *)
|
||
fail loc "%s is not implemented yet — it is milestone %d work"
|
||
what milestone
|
||
|
||
(* ── where predicates ──────────────────────────────────────────────────
|
||
A predicate is a compile-time question about a type, and that is the whole
|
||
of it. It carries no implementation, selects no instance, and is not
|
||
extensible: it gates a builtin the compiler already has. So there are no
|
||
dictionaries, no coherence rules and no run-time cost — and the ceiling is
|
||
that nobody can supply a [<] of their own, which does not bind because
|
||
every operation the prelude and the containers need is a primitive.
|
||
|
||
Odin's [where] clause is the same shape ([core/slice/slice.odin:289] is
|
||
[where intrinsics.type_is_ordered(T)]) with forty-one predicates against
|
||
these five. There is no [copyable?] any more and no Odin counterpart
|
||
either: Odin has no move semantics, and since the repeal neither does this
|
||
language, so [$T] never has to answer the question.
|
||
|
||
[integer?] is the narrowest of the five and exists because [numeric?] was
|
||
one type too wide for a family of bodies: an integer body under [numeric?]
|
||
is instantiated at f32 and f64 too, and (if (< x 0) (- 0 x) x) at -0.0 is
|
||
the wrong abs while %, the bitwise operators and the shifts have no float
|
||
meaning at all. A function that can be generalized should not need a
|
||
variant per numeric type, and [integer?] is what lets the integer-only
|
||
ones say exactly what they need. *)
|
||
let predicate_names = [ "ordered?"; "equal?"; "hashable?"; "numeric?"; "integer?" ]
|
||
|
||
(* ── What a type owns, transitively ────────────────────────────────────
|
||
The one structural ownership question that survived the repeal, because it
|
||
is not about copying at all.
|
||
|
||
What it decides, and the only thing it decides, is whether a container of
|
||
this element type has to be built against a region allocator — see
|
||
[region_only] below and [flan_alloc_region_only] in the runtime. That is a
|
||
question about *release*, so it is asked of every arm a release would have
|
||
to reach and would not: a Vec or Map owns a block outright; an Option,
|
||
a fixed array, a struct or a data type's case owns whatever its payload
|
||
does.
|
||
|
||
It does not live in [Types] for the reason [Types.keyable] gives: that
|
||
module has no field table. The [seen] list is the cycle guard, and the cycle
|
||
is real — the recursive dynamic value this exists for holds a [(Vec Value)],
|
||
so [Value]'s walk reaches [Value]. Answering [false] for a name already on
|
||
the path is right rather than merely terminating: whatever made the outer
|
||
name own something was found by the arm that got here, and a name cannot
|
||
contain itself by value anyway — [check_finite] refuses that — only through
|
||
a container, which is an arm that answers for itself.
|
||
|
||
[env.unions], the untagged ones, are deliberately not consulted: nothing
|
||
anywhere records which member of one is live, so there is no fact this
|
||
walk could read — an untagged union is treated as owning nothing, and what
|
||
its members point at is the program's, through whatever tag it keeps
|
||
beside the union. *)
|
||
(* One edit apart — a substitution, an insertion, a deletion or a
|
||
transposition of neighbours. Bounded at one, because two edits is no longer
|
||
a typo, it is a guess. Shared by the unknown-type near miss and the enum
|
||
member one. *)
|
||
let one_edit a b =
|
||
let la = String.length a and lb = String.length b in
|
||
if abs (la - lb) > 1 then false
|
||
else begin
|
||
(* Walk both until they diverge, then require the tails to match with the
|
||
single edit applied. *)
|
||
let i = ref 0 in
|
||
while !i < la && !i < lb && a.[!i] = b.[!i] do incr i done;
|
||
let ta s k = String.sub s k (String.length s - k) in
|
||
if la = lb then
|
||
!i < la
|
||
&& (ta a (!i + 1) = ta b (!i + 1)
|
||
(* stirng/string: two neighbours swapped. *)
|
||
|| (!i + 1 < la && a.[!i] = b.[!i + 1] && a.[!i + 1] = b.[!i]
|
||
&& ta a (!i + 2) = ta b (!i + 2)))
|
||
else if la < lb then ta a !i = ta b (!i + 1)
|
||
else ta a (!i + 1) = ta b !i
|
||
end
|
||
|
||
(* The enum members' own near miss. One edit away is the usual typo; the
|
||
second rule is for a package whose members carry a disambiguating prefix —
|
||
raylib's Key spells them [key-r], [key-space] — where the natural mistake
|
||
is writing the bare name. [:r] against [key-r] is four edits and one
|
||
thought, so the rule is the thought: a member whose last segment is exactly
|
||
the name written. *)
|
||
let member_near_miss members k =
|
||
List.find_opt
|
||
(fun (m, _) ->
|
||
one_edit k m
|
||
|| (let lm = String.length m and lk = String.length k in
|
||
lm > lk + 1
|
||
&& m.[lm - lk - 1] = '-'
|
||
&& String.sub m (lm - lk) lk = k))
|
||
members
|
||
|
||
let owning_fields env n =
|
||
match Hashtbl.find_opt env.structs n with
|
||
| Some s -> [ s.Tast.fields ]
|
||
| None ->
|
||
match Hashtbl.find_opt env.datas n with
|
||
| Some d -> List.map (fun (c : Tast.variant) -> c.Tast.vfields) d.Tast.cases
|
||
| None -> []
|
||
|
||
let rec owning env ?(seen = []) (t : Types.t) =
|
||
match t with
|
||
| Types.Vec _ | Types.Map _ -> true
|
||
| Types.Option e | Types.Array (_, e) -> owning env ~seen e
|
||
| Types.Named n ->
|
||
not (List.mem n seen)
|
||
&& List.exists
|
||
(List.exists
|
||
(fun (f : Tast.field) -> owning env ~seen:(n :: seen) f.Tast.fty))
|
||
(owning_fields env n)
|
||
| _ -> false
|
||
|
||
(* Does a container of this type have to be built against an allocator that
|
||
cannot free one block? Only the half a release would have to walk is asked:
|
||
a map's key cannot own anything — [map_type] refuses one, because a key that
|
||
owned storage would hash its header rather than what it points at — so the
|
||
value is the whole of the question there. *)
|
||
let region_only env (t : Types.t) =
|
||
match t with
|
||
| Types.Vec e -> owning env e
|
||
| Types.Map (_, v) -> owning env v
|
||
| _ -> false
|
||
|
||
(* Does a concrete type answer yes? Checked at every instantiation, against
|
||
the type the call site asked for. *)
|
||
let pred_holds p (t : Types.t) =
|
||
match p with
|
||
| "ordered?" -> Types.is_comparable t
|
||
| "equal?" -> Types.is_equatable t
|
||
(* [Types.keyable] says yes to a struct and leaves its fields to [key_pair],
|
||
which walks them at the operation. That split is the existing one and is
|
||
kept: a generic declared [hashable?] and instantiated at a struct whose
|
||
fields are not keyable is refused where every other program is, by
|
||
[key_pair]. *)
|
||
| "hashable?" -> Types.keyable t
|
||
| "numeric?" -> Types.is_numeric t
|
||
| "integer?" -> Types.is_integer t
|
||
| _ -> false
|
||
|
||
(* What one declared predicate *also* gives you. These are entailments over
|
||
the type system as it stands, not conveniences: every type [is_comparable]
|
||
admits is a number or an enum, so it is equatable. The table is only sound
|
||
while that is true — an ordered type with no [=] would make it wrong — so
|
||
it lives in one place and says so. The gain is real ergonomics:
|
||
[{:where (ordered? $t)}] is enough for a [sort] that also compares,
|
||
rather than two predicates on one line. *)
|
||
let pred_entails ~declared ~wanted =
|
||
String.equal declared wanted
|
||
|| match wanted, declared with
|
||
| "ordered?", ("numeric?" | "integer?") -> true
|
||
| "equal?", ("numeric?" | "ordered?" | "integer?") -> true
|
||
(* Every integer type is a number, so [integer?] gives a body everything
|
||
[numeric?] does — the arithmetic, the written 0, the untyped integer
|
||
literal — on top of the operations only it admits. The reverse is
|
||
never true: [numeric?] admits floats, which is exactly what a body
|
||
under [integer?] is promising it never meets. *)
|
||
| "numeric?", "integer?" -> true
|
||
| _ -> false
|
||
|
||
let declares preds v wanted =
|
||
List.exists
|
||
(fun (p : Ast.pred) ->
|
||
String.equal p.Ast.pvar v && pred_entails ~declared:p.Ast.pname ~wanted)
|
||
preds
|
||
|
||
(* Which variable, if any, a type bottoms out at. Only a bare variable can
|
||
carry a predicate: [(Vec t)] is a Vec whatever [t] is, and its own
|
||
properties are the Vec's. *)
|
||
let tyvar_of (t : Types.t) = match t with Types.Var v -> Some v | _ -> None
|
||
|
||
(* ── (Map K V), spec-memory.md ──────────────────────────────────────────
|
||
Both halves are checked where the type is written, not where an operation
|
||
is, so that a map nothing ever uses is still refused if it cannot work.
|
||
[Check.key_pair] emits the hash and equality pair later, at the operation,
|
||
and repeats these refusals rather than assuming: the two are reached by
|
||
different paths and a silent disagreement between them would be worse than
|
||
saying the same thing twice. *)
|
||
let map_type ?(preds = []) loc (k : Types.t) (v : Types.t) =
|
||
ignore preds;
|
||
(* The value used to be refused here when it owned anything, in the same
|
||
words [(Vec (Vec T))] used, and the refusal is gone for the reason set out
|
||
over [map-new]: it was about *teardown*, and which tier this map will be
|
||
built against is not knowable where its type is written. The question is
|
||
asked at the construction instead, of the allocator, once. *)
|
||
(* () has no bytes, so a slot for one is a slot of nothing: the cell
|
||
geometry divides the cache line by the element size and there is nothing
|
||
to divide by. It is also the natural spelling of a *set*, which is why
|
||
someone will write it, so it is refused by name rather than by a crash. *)
|
||
if Types.equal v Types.Unit then
|
||
fail loc
|
||
"a map value cannot be () — there is nothing to store. A set of keys \
|
||
is not built yet; use (Map %s bool) and ignore the value"
|
||
(Types.to_string k);
|
||
if Types.equal k Types.Unit then
|
||
fail loc "a map key cannot be () — every key would be the same key";
|
||
(* The key, as far as the type alone can say. A struct passes here and is
|
||
decided at the operation, by [key_pair], which walks its fields — the
|
||
struct table is not necessarily complete while a type is being resolved,
|
||
and every map that exists reaches an operation anyway, because a global
|
||
map starts zeroed and a local needs (map-new). *)
|
||
(* A type variable is a map key exactly when the [where] clause says it is
|
||
hashable. Nothing else about it is knowable here, and falling through to
|
||
[Types.keyable] would answer no for a variable that is about to be
|
||
instantiated at [string]. *)
|
||
if not (match k with
|
||
| Types.Var v -> declares preds v "hashable?"
|
||
| k -> Types.keyable k) then
|
||
fail loc
|
||
"%s is not a map key. The first implementation takes integers, enums, \
|
||
bools, strings, fixed arrays of those, and value structs composed of \
|
||
those (spec-memory.md, \"Maps — first implementation\"). A float has \
|
||
no usable equality — NaN is not equal to itself — and a Ptr, a slice, \
|
||
a Vec or a Map would hash an address rather than what it points at"
|
||
(Types.to_string k);
|
||
Types.Map (k, v)
|
||
|
||
(* The positions a function value may not be written in, and the one reason
|
||
they are all the same position: something zeroes it.
|
||
|
||
ZII is the language's rule — an omitted struct field, a fixed array's
|
||
elements, a [defonce] with no initialiser are all all-bytes-zero — and a
|
||
zeroed function value is a null pointer with a signature on it, which is the
|
||
one kind of zero that cannot be used for anything. Every other type's zero
|
||
is a value: 0, false, an empty slice, [None], a data type's first case. So these
|
||
are refused where they are written rather than left to crash at the call.
|
||
|
||
A parameter, a return type, a [let] binding and an [(Option (Fn ...))] are
|
||
not on the list: none of them is ever conjured, and an [Option]'s zero is a
|
||
[None] whose tag nobody may look past. *)
|
||
let rec no_zeroed_fn loc what (t : Types.t) =
|
||
match t with
|
||
| Types.Fn _ ->
|
||
fail loc
|
||
"%s cannot be %s: it would be zeroed, and a zeroed function value is \
|
||
a null pointer — every other type's zero is a value it can have, and \
|
||
this one is not. Pass it as a parameter, or hold it in a let"
|
||
what (Types.to_string t)
|
||
| Types.Array (_, e) -> no_zeroed_fn loc what e
|
||
| _ -> ()
|
||
|
||
(* ── What may be overwritten with raw bytes ────────────────────────────
|
||
|
||
[(filled b)] and [(sentinel-filled)] are the only two things in the
|
||
language that write a byte pattern over storage the type system has an
|
||
opinion about, so the question they raise is which types survive having
|
||
arbitrary bytes put in them. The answer here is the narrow one: numbers,
|
||
and aggregates built out of numbers. Everything else is refused by name.
|
||
|
||
What is being kept out, and why each one is not a matter of taste:
|
||
|
||
- [dyn]. A struct that holds a dyn is rooted on the collector's root stack
|
||
with a descriptor naming the byte offsets of its dyn words (see the
|
||
per-type descriptor note at the bottom of this file). Filling one leaves
|
||
a word that is not a dyn at an offset the collector is told to walk, and
|
||
the next collection follows it. The refusal is what keeps that from
|
||
being reachable at all.
|
||
- [Vec], [(Map K V)], [Allocator]. An owning header: a pointer, a length, a
|
||
capacity and an allocator the runtime frees through. A filled one is a
|
||
free of a wild pointer the first time it is touched.
|
||
- [string] and a slice. Two words, the second of which is a length every
|
||
bounds check believes. A filled length is a bounds check that passes and
|
||
an access that does not.
|
||
- [Ptr]. Not walked by the collector, and a poisoned pointer is arguably
|
||
the useful case — but it is still a value every [deref] in the language
|
||
trusts, and admitting it would make the rule "plain data, except one
|
||
kind of address". Kept out so the rule is one sentence. This is the arm
|
||
to relax first if the question is reopened.
|
||
- [bool]. The one refusal that is about the backends rather than the
|
||
runtime: a bool is a byte here and an [i1] to LLVM, which reads the low
|
||
bit, where x86 compares the whole byte against zero. 0xDE is false on
|
||
one and true on the other, and byte-identical behaviour across the two
|
||
backends is the property this feature is pinned on.
|
||
- an enum, a data type, a union, an [(Option T)], a function value. Each
|
||
carries a tag or a case index that something later reads as a small
|
||
number with a meaning, and a filled one names a case that does not
|
||
exist.
|
||
|
||
Floats are in: every bit pattern is a float, NaNs included, and both
|
||
backends move one as bytes. *)
|
||
let rec unfillable env seen (t : Types.t) : Types.t option =
|
||
match t with
|
||
| Types.Int _ | Types.Float _ -> None
|
||
| Types.Array (_, e) -> unfillable env seen e
|
||
| Types.Named n when not (List.mem n seen) ->
|
||
(match Hashtbl.find_opt env.structs n with
|
||
| Some s ->
|
||
List.fold_left
|
||
(fun acc (fl : Tast.field) ->
|
||
match acc with
|
||
| Some _ -> acc
|
||
| None -> unfillable env (n :: seen) fl.Tast.fty)
|
||
None s.Tast.fields
|
||
(* A data type or a union, which are the two [Named] things that are not
|
||
in [structs]. Both overlay their members, so the type itself is what
|
||
the refusal names. *)
|
||
| None -> Some t)
|
||
| _ -> Some t
|
||
|
||
let rec resolve env ?(seen = []) (t : Ast.texpr) : Types.t =
|
||
let loc = t.Ast.tloc in
|
||
match t.Ast.t with
|
||
| Ast.Tname n -> resolve_name env ~seen loc n
|
||
| Ast.Tslice e -> Types.Slice (resolve env ~seen e)
|
||
| Ast.Tarray (l, e) ->
|
||
let e = resolve env ~seen e in
|
||
no_zeroed_fn loc "a fixed array's element" e;
|
||
Types.Array (array_len env loc l, e)
|
||
(* {K V} is the type spelling. There is no map *literal*: a bare map form in
|
||
expression position is a struct literal's field list, and giving the same
|
||
braces two meanings is what the colon-to-dot change was for. A map is
|
||
built with (map-new) and filled with (put). *)
|
||
| Ast.Tmap (k, v) ->
|
||
map_type ~preds:env.tvpreds loc (resolve env ~seen k)
|
||
(resolve env ~seen v)
|
||
(* (Fn [T ...] R): a function value, which is one code address and no
|
||
environment beside it. There is no capture — [check_fn] refuses a
|
||
reference to an enclosing local by name — so this is a pointer with a
|
||
signature and nothing about it can dangle.
|
||
|
||
Where one may be *written* is narrower than where the type resolves, and
|
||
the two rules live apart on purpose: this is what the spelling means, and
|
||
[no_zeroed_fn] is where a position that would zero one is refused. A
|
||
parameter, a return type and a let binding are the positions that work. *)
|
||
| Ast.Tfn (ps, r) ->
|
||
Types.Fn (List.map (resolve env ~seen) ps, resolve env ~seen r)
|
||
| Ast.Tapp (name, args) ->
|
||
(match name, args with
|
||
| "Ptr", [ a ] -> Types.Ptr (resolve env ~seen a)
|
||
| "Option", [ a ] -> Types.Option (resolve env ~seen a)
|
||
| ("Ptr" | "Option"), _ -> fail loc "(%s T) takes exactly one type" name
|
||
| "Vec", [ a ] ->
|
||
let e = resolve env ~seen a in
|
||
(* A Vec of a Vec used to be refused here, and the refusal named two
|
||
different failures under one sentence: that [clone] would duplicate
|
||
inner headers instead of copying, and that [free] would drop their
|
||
buffers on the floor. Only the second one was about *teardown*, and
|
||
only the second one an arena answers — [free-all] takes the region
|
||
and the inner blocks with it, because they came out of the same
|
||
region. So the type is admitted, the tier is checked at the
|
||
construction where the allocator is a value that exists (see
|
||
[vec-new]), and [clone] stays refused at the operation, on its own
|
||
merits, in its own words.
|
||
|
||
It cannot be refused *here* because nothing here knows the tier:
|
||
[with-allocator] rebinds a dynamic variable, so which allocator a
|
||
[(vec-new)] meets is not a property of where the type is written. *)
|
||
Types.Vec e
|
||
| "Vec", _ -> fail loc "(Vec T) takes exactly one type"
|
||
| "Map", [ k; v ] ->
|
||
map_type ~preds:env.tvpreds loc (resolve env ~seen k)
|
||
(resolve env ~seen v)
|
||
| "Map", _ -> fail loc "(Map K V) takes exactly two types"
|
||
| "Result", _ -> unimplemented loc "(Result T E)" 6
|
||
| _ ->
|
||
(* Not generics, which are here: a *function* is generic over [$t] and
|
||
instantiated per call site. This is a parameterised named type —
|
||
[(Pair i32 f64)] — and that is a different thing and is not built.
|
||
[Types.Named] is a bare string with no parameters, so there is
|
||
nowhere to put the arguments, and giving it some is a change to
|
||
[Types.t] and therefore to the layout calculator, both backends,
|
||
[Render] and the DWARF path. docs/SPIKE-GENERICS.md, question 4,
|
||
prices it and leaves it out. *)
|
||
fail loc
|
||
"%s takes no type arguments. A generic *function* is written with \
|
||
[$t] in its parameter vector and copied per call site; a generic \
|
||
*type* — (%s ...) — is not there yet"
|
||
name name)
|
||
|
||
(* One edit away from a type that exists — a substitution, an insertion, a
|
||
deletion or a transposition of neighbours. Bounded at one, because two edits
|
||
is no longer a typo, it is a guess. *)
|
||
and near_miss env ?(also = []) n =
|
||
(* [also] widens the candidate list past the types, and exactly one caller
|
||
passes it: the defonce whose third element has to be a type *or* a value,
|
||
whose suggestion is worth nothing if it can only ever name a type. *)
|
||
let candidates =
|
||
also
|
||
@ Types.primitive_names
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.aliases []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.structs []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.datas []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.unions []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.enums []
|
||
in
|
||
List.find_opt (fun c -> c <> n && one_edit n c) candidates
|
||
|
||
and resolve_name env ~seen loc n =
|
||
(* ── Type variables, with a sigil at the binding site ─────────────────
|
||
[$t] *introduces* a variable and bare [t] uses it, which is Odin's
|
||
spelling ([$T] in the signature, [T] in the body). The sigil is read as
|
||
an ordinary symbol character, so the whole decision lives here: nothing
|
||
in the reader, the parser or the AST knows the character means anything.
|
||
|
||
Which names are variables is decided before this is ever called —
|
||
[signature_tyvars] scans the signature for the sigil and puts the bare
|
||
names in [env.tyvars] — so an unknown lowercase name is still the
|
||
unknown-type error it always was. That is the point of the sigil: without
|
||
one, a mistyped type name silently became a type parameter and made the
|
||
function more permissive than it was written to be. *)
|
||
let bare = if n <> "" && n.[0] = '$' then String.sub n 1 (String.length n - 1) else n in
|
||
match List.assoc_opt bare env.subst with
|
||
(* Inside an instantiation: the variable is this concrete type, and every
|
||
node checked under it is as concrete as if it had been written out. *)
|
||
| Some t -> t
|
||
| None ->
|
||
if List.mem bare env.tyvars then Types.Var bare
|
||
else if n <> bare then
|
||
(* A sigil somewhere that is not a [defn] signature: a struct field, a
|
||
global, a [let] annotation. There is nowhere for it to bind, so it is
|
||
the error rather than a variable with no scope. *)
|
||
Loc.failk "check/unbound-type-variable" loc
|
||
"%s introduces a type variable, and only a defn signature can — write \
|
||
the concrete type here" n
|
||
else
|
||
match Types.ikind_of_name n with
|
||
| Some k -> Types.Int k
|
||
| None ->
|
||
match Types.fkind_of_name n with
|
||
| Some k -> Types.Float k
|
||
| None ->
|
||
match n with
|
||
| "bool" -> Types.Bool
|
||
| "string" -> Types.String
|
||
(* Lowercase and concrete, which the rule three screens down says is a
|
||
type variable. It is spelled this way because it is a primitive and
|
||
every other primitive is lowercase — [dyn] beside [i64] and [bool]
|
||
reads as one of them, [Dyn] beside [Vec] and [Option] reads as a
|
||
container over something. The type-variable rule is reached by a
|
||
[when] guard below and this arm is before it, so the spelling costs
|
||
nothing but the note. *)
|
||
| "dyn" -> Types.Dyn
|
||
| "Unit" -> Types.Unit
|
||
| "Never" -> Types.Never
|
||
(* A builtin opaque type, the way [string] is a builtin ptr+len. There is
|
||
no user-writable constructor and no way to name its procedure: see
|
||
Types, and NEXT.md's "the escape is real". *)
|
||
| "Allocator" -> Types.Alloc
|
||
| _ when Hashtbl.mem env.aliases n ->
|
||
if List.mem n seen then
|
||
fail loc "the type alias %s is defined in terms of itself" n
|
||
else resolve env ~seen:(n :: seen) (Hashtbl.find env.aliases n)
|
||
| _ when Hashtbl.mem env.structs n -> Types.Named n
|
||
(* A data type is [Named] exactly as a struct is: one case in [Types.t]
|
||
covers both, and which table the name is in is what tells them apart.
|
||
Keeping them one case is what lets a data type be a field, a parameter, a
|
||
return type and a slot without a single one of those paths learning
|
||
that data types exist. *)
|
||
| _ when Hashtbl.mem env.datas n -> Types.Named n
|
||
(* And so is an untagged union, for the same reason: it is a value of a
|
||
size and an alignment, and nothing that carries one has to know it is
|
||
a union rather than a struct. *)
|
||
| _ when Hashtbl.mem env.unions n -> Types.Named n
|
||
| _ when Hashtbl.mem env.enums n -> Types.Enum n
|
||
(* A typo in a primitive is lowercase too, and the type-variable rule
|
||
below would otherwise report [f65] as unimplemented generics and send
|
||
you to plan.org instead of to the character you mistyped. *)
|
||
| _ when foreign_spelling n <> None ->
|
||
Loc.failk "check/unknown-type" loc "unknown type %s — Flan spells it %s"
|
||
n (Option.get (foreign_spelling n))
|
||
| _ when near_miss env n <> None ->
|
||
Loc.failk "check/unknown-type" loc "unknown type %s — did you mean %s?" n
|
||
(Option.get (near_miss env n))
|
||
(* An unknown lowercase name, and the sentence it gets used to be that
|
||
generics were milestone 5 work. They are not: [$t] binds a type
|
||
variable and bare [t] uses one, and [resolve_name] has already
|
||
consulted [env.tyvars] and [env.subst] before anything reaches here.
|
||
So a lowercase name arriving at this arm is one of exactly two
|
||
things, and the message names both rather than sending somebody to a
|
||
schedule.
|
||
|
||
Either it is a typo too far from any type to be guessed at — the
|
||
near-miss arm above catches the one-edit ones — or it is a type
|
||
variable that was never introduced, which is the sigil's whole
|
||
purpose to notice: without the binding site a mistyped type name
|
||
silently became a type parameter and made the signature more
|
||
permissive than it was written to be. *)
|
||
| _ when n <> "" && n.[0] = Char.lowercase_ascii n.[0] ->
|
||
(* The parameter-vector suggestion is only followable where a
|
||
parameter vector exists. A field has none and never will — only a
|
||
defn signature binds a variable, and a field is built at one type
|
||
for every value — so at a field the message offers the two things
|
||
that can actually be written there. *)
|
||
if env.in_field then
|
||
Loc.failk "check/unknown-type" loc
|
||
"unknown type %s. A lowercase name is a type variable, and a \
|
||
field cannot hold one: only a defn signature introduces type \
|
||
variables, and a field is built at one type for every value — \
|
||
generic types are not there. Write a concrete type here, or dyn \
|
||
to hold any value"
|
||
n
|
||
else
|
||
Loc.failk "check/unknown-type" loc
|
||
"unknown type %s. A lowercase name is a type variable only where a \
|
||
defn signature introduced it — write $%s in the parameter vector \
|
||
to introduce one, and %s reads it from there"
|
||
n n n
|
||
| _ -> Loc.failk "check/unknown-type" loc "unknown type %s" n
|
||
|
||
and array_len env loc = function
|
||
| Ast.Lint n -> n
|
||
| Ast.Lname n ->
|
||
(match Hashtbl.find_opt env.consts n with
|
||
| Some v -> v
|
||
| None ->
|
||
fail loc "%s is not a compile-time integer constant, so it cannot be \
|
||
an array length" n)
|
||
|
||
(* ── Pairing a defn's parameter vector ──────────────────────────────────
|
||
|
||
[(defn f [x y] ...)] is one parameter [x] of type [y] if [y] names a type,
|
||
and two parameters of type [dyn] if it does not. Parse could not tell — the
|
||
long argument is beside its [defn] case — so it handed over the slots
|
||
undecided and this is where they are paired, with every type name in hand:
|
||
every file loaded, every macro expanded, every C header imported.
|
||
|
||
The walk is left to right and takes two slots or one. A name followed by
|
||
something that is a type takes two and is annotated; a name followed by
|
||
another name that is not a type, or by nothing, takes one and is [dyn]. That
|
||
is the whole rule, and it reads the way the vector reads.
|
||
|
||
A name that *is* a type name is refused rather than paired. [(defn f [i64 x]
|
||
...)] has no good reading: taken as written it is a parameter called [i64],
|
||
which shadows nothing but confuses everything, and the likelier intent is a
|
||
pair written backwards. Refusing here costs a rename in the one program that
|
||
meant it and closes the one place where this rule could still hand somebody
|
||
a signature they did not write. *)
|
||
let is_type_name env n =
|
||
Types.ikind_of_name n <> None
|
||
|| Types.fkind_of_name n <> None
|
||
|| List.mem n [ "bool"; "string"; "dyn"; "Unit"; "Never"; "Allocator" ]
|
||
|| Hashtbl.mem env.aliases n
|
||
|| Hashtbl.mem env.structs n
|
||
|| Hashtbl.mem env.datas n
|
||
|| Hashtbl.mem env.unions n
|
||
|| Hashtbl.mem env.enums n
|
||
(* A type variable: [$t] in a signature is generics' binding site, and a
|
||
slot holding one is a type however few of them there are. *)
|
||
|| (n <> "" && n.[0] = '$')
|
||
|
||
(* Before a bare symbol is allowed to become an unannotated parameter, the two
|
||
ways it is more likely to be a type that went wrong.
|
||
|
||
This is the cost dynamic-by-default puts on the parameter vector, and it is
|
||
worth naming plainly: a slot with no type used to be a syntax error, and now
|
||
it is a [dyn] parameter. So [(defn f [x f65] ())] — a typo for [f64] — no
|
||
longer reads as a mistyped type. It reads as two parameters, one of them
|
||
called [f65], and the function silently takes an argument nobody meant to
|
||
give it. An arity that changes because of a typo, with no diagnostic, is the
|
||
failure class Parse's [defn] comment calls the worst available, and the
|
||
feature reintroduces it in a new place.
|
||
|
||
Two rules take most of it back. A name within one edit of a type's name is
|
||
the typo it looks like, and is refused with the same "did you mean" the
|
||
resolver gives — the near-miss table is already there and is exactly the
|
||
right question. And a capitalised name is a type by the convention the whole
|
||
corpus keeps: not one parameter in the language is capitalised, while [Form],
|
||
[Cursor], [Vector2] and the rest appear in these vectors constantly. So an
|
||
unknown capitalised name is an unknown *type*, reported as one, rather than
|
||
a parameter nobody would have spelled that way.
|
||
|
||
What is left uncovered is a lowercase name that resembles no type: [(defn f
|
||
[x widget] ())] is two dyn parameters and there is no evidence in the text
|
||
that it was meant to be one. That case is the feature working as specified,
|
||
and it is the residual the parent owns. *)
|
||
let dyn_param_or_typo env n loc =
|
||
(* [(defn idx [v i] dyn ...)] is two dyn parameters, and [i] is one edit
|
||
from [i8], so the did-you-mean used to accuse a perfectly ordinary
|
||
parameter name of being a mistyped type. What separates the two is the
|
||
digits: this language sizes its machine types in the name, so a typo in
|
||
one keeps them — [f65] for [f64], [i33] for [i32] — while [i], [v], [n]
|
||
and [x] carry none and are what parameters are actually called. A name
|
||
with no digit, one edit from a type that has one, is a parameter; the
|
||
suggestion is dropped and the dyn reading stands, which is the reading
|
||
the writer meant. *)
|
||
let has_digit s = String.exists (fun c -> c >= '0' && c <= '9') s in
|
||
let suggestion =
|
||
match near_miss env n with
|
||
| Some m when has_digit m && not (has_digit n) -> None
|
||
| m -> m
|
||
in
|
||
match suggestion with
|
||
| Some m ->
|
||
Loc.failk "check/unknown-type" loc
|
||
"unknown type %s — did you mean %s? A parameter with no type is dyn, so \
|
||
this would otherwise be read as a second parameter called %s"
|
||
n m n
|
||
| None ->
|
||
if n <> "" && n.[0] = Char.uppercase_ascii n.[0]
|
||
&& n.[0] <> Char.lowercase_ascii n.[0]
|
||
then
|
||
Loc.failk "check/unknown-type" loc
|
||
"unknown type %s. A capitalised name in a parameter vector is a type — \
|
||
a parameter with no type is dyn, and parameters are lowercase"
|
||
n
|
||
|
||
let pair_params env (items : Ast.pitem list) : Ast.field list =
|
||
let dyn loc = { Ast.t = Ast.Tname "dyn"; tloc = loc } in
|
||
let rec go = function
|
||
| [] -> []
|
||
| Ast.Ptype t :: _ ->
|
||
Loc.failk "check/parameter-name-expected" t.Ast.tloc
|
||
"a parameter's name was expected here, and this is a type. \
|
||
Parameters are [name Type ...], and a name with no type is dyn"
|
||
| Ast.Pname (n, loc) :: rest when is_type_name env n ->
|
||
ignore rest;
|
||
Loc.failk "check/parameter-named-type" loc
|
||
"%s names a type, so it cannot also be this parameter's name. If the \
|
||
pair was written backwards it is [name %s]; otherwise rename the \
|
||
parameter" n n
|
||
| Ast.Pname (n, loc) :: Ast.Ptype t :: rest ->
|
||
{ Ast.fname = n; fty = t; floc = loc } :: go rest
|
||
| Ast.Pname (n, loc) :: Ast.Pname (t, tloc) :: rest when is_type_name env t ->
|
||
{ Ast.fname = n; fty = { Ast.t = Ast.Tname t; tloc }; floc = loc } :: go rest
|
||
(* The slot after this one is not a type, so this one is a parameter with
|
||
no type written — unless the slot after it only *looks* unlike a type
|
||
because it was mistyped, which is what the check is for. The next slot
|
||
is the one interrogated, not this one: this one is a name either way. *)
|
||
| Ast.Pname (n, loc) :: (Ast.Pname (t, tloc) :: _ as rest) ->
|
||
dyn_param_or_typo env t tloc;
|
||
{ Ast.fname = n; fty = dyn loc; floc = loc } :: go rest
|
||
| Ast.Pname (n, loc) :: rest ->
|
||
{ Ast.fname = n; fty = dyn loc; floc = loc } :: go rest
|
||
in
|
||
go items
|
||
|
||
(* Every [defn] in the program, with its parameter vector paired. Run as a pass
|
||
of its own, after the type names are registered and before any signature is
|
||
resolved, so that nothing downstream ever sees an unpaired one. *)
|
||
let pair_decls env (decls : Ast.decl list) : Ast.decl list =
|
||
let fn (f : Ast.fn) =
|
||
match f.Ast.praw with
|
||
| None -> f
|
||
| Some items -> { f with Ast.params = pair_params env items; praw = None }
|
||
in
|
||
List.map
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defn f -> { d with Ast.d = Ast.Defn (fn f) }
|
||
| Ast.Declare (f, c) -> { d with Ast.d = Ast.Declare (fn f, c) }
|
||
| Ast.DeclareC (f, c) -> { d with Ast.d = Ast.DeclareC (fn f, c) }
|
||
| _ -> d)
|
||
decls
|
||
|
||
(* ── The third element of a defonce, decided ────────────────────────────
|
||
|
||
The author's rule, 2026-09-20: "if it's 3 atoms then it's dyn", and
|
||
"dispatch the if it's a type do the right thing". [(defonce current-color
|
||
i32)] is the zeroed static it has always been, and [(defonce score 0)] is a
|
||
dyn global holding 0 — the same thing [(defonce score dyn 0)] spells out,
|
||
lowered by the same path and not by a second one.
|
||
|
||
[Parse] settled every shape a shape can settle and handed the rest over
|
||
carrying both readings ([Ast.Ambiguous], beside the type reading in the
|
||
same [Defvar]). What is left is the two forms only a name can settle, and
|
||
this is the first point where every type name is in hand: the same point
|
||
[pair_params] reads, for the same reason — a defonce may name a struct
|
||
declared fifty lines below it.
|
||
|
||
The type reading wins wherever there is one. That is what keeps today's
|
||
programs meaning today's thing: [(defonce p Point)] is a zeroed [Point],
|
||
[(defonce v (Vec i32))] is a zeroed [Vec], and a wrong type argument inside
|
||
one stays a type error rather than becoming an unknown function. It is also
|
||
why a built-in constructor is checked by name rather than by whether
|
||
[resolve] happens to accept it — [(Vec i32 i32)] is a malformed [Vec] and
|
||
not a call to something called [Vec].
|
||
|
||
A type and a value cannot share a name: [collect]'s [claimed] table is over
|
||
every declaration kind there is, so one name is one declaration and the two
|
||
readings can never both be live. *)
|
||
let defvar_reads_as_type env (t : Ast.texpr) =
|
||
match t.Ast.t with
|
||
| Ast.Tname n -> is_type_name env n
|
||
| Ast.Tapp (head, _) ->
|
||
List.mem head [ "Ptr"; "Option"; "Vec"; "Map"; "Result" ]
|
||
(* A slice, a fixed array, a map type or an (Fn ...): [Parse] only carries
|
||
one of these over when it read as a type and had no value reading, so
|
||
there is nothing here to decide. *)
|
||
| _ -> true
|
||
|
||
(* The bare symbol that is neither. Before the rule there was one reading and
|
||
the message was "unknown type"; now the position takes either kind of name,
|
||
so a message naming only one of them would send a reader looking for the
|
||
wrong mistake. Both readings, both spellings, and the near miss over the
|
||
value names as well as the type names. *)
|
||
(* Both readings, and the paragraph that explains them — but only when both
|
||
readings really are open. Three things get in ahead of it, because each one
|
||
knows which of the two the writer meant and the paragraph would bury that
|
||
under a lecture about a fork they are not standing at:
|
||
|
||
a case name, which is a third thing entirely and has its own spelling; a
|
||
name another language spells for a type this one has under a different
|
||
name; and a plain type typo, where a confident one-edit suggestion turns a
|
||
one-line answer into four lines of unrelated reading. The paragraph is for
|
||
the name that genuinely could have been either and is neither. *)
|
||
let defvar_neither env loc ~form gname n ~values ~cases =
|
||
(match List.assoc_opt n cases with
|
||
| Some dname ->
|
||
Loc.failk "check/defvar-case-not-type" loc
|
||
"%s is a case of the data type %s, and a case is not a type of its \
|
||
own — the global's type is the data type: (%s %s %s). Assign the \
|
||
case you want, as (set %s (%s.%s {.field value ...}))"
|
||
n dname form gname dname gname dname n
|
||
| None -> ());
|
||
(match foreign_spelling n with
|
||
| Some m ->
|
||
Loc.failk "check/unknown-type" loc "unknown type %s — Flan spells it %s" n m
|
||
| None -> ());
|
||
(match near_miss env n with
|
||
| Some m ->
|
||
Loc.failk "check/unknown-type" loc "unknown type %s — did you mean %s?" n m
|
||
| None -> ());
|
||
let hint =
|
||
match near_miss env ~also:values n with
|
||
| Some m -> Printf.sprintf " — did you mean %s?" m
|
||
| None -> ""
|
||
in
|
||
Loc.failk "check/defvar-neither-type-nor-value" loc
|
||
"%s is neither a type nor a value, and the third element of a %s has \
|
||
to be one or the other: a type there declares a zeroed global of that \
|
||
type — (%s %s i64) — and a value there declares a dyn global holding \
|
||
it — (%s %s 0). Nothing named %s is declared as either%s"
|
||
n form form gname form gname n hint
|
||
|
||
(* Every name a value could be written under, which is every declaration that
|
||
is not a type plus whatever a session already has. The list is only ever
|
||
asked "is this name declared at all", so a global that is itself a defonce
|
||
still undecided belongs on it: what it resolves to is the next pass's
|
||
question, not this one's. *)
|
||
(* Case name -> the data type it belongs to, read off the declarations rather
|
||
than out of [env.cases]: this runs inside [collect], which has registered
|
||
the data type *names* by here but not resolved their cases, so the table
|
||
would be empty. Last writer wins, exactly as [env.cases] does, and for the
|
||
same reason — this is only ever asked "what is this a case of", and two
|
||
data types may share a case name. *)
|
||
let case_owners (decls : Ast.decl list) =
|
||
List.concat_map
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defdata (dn, vs) ->
|
||
List.map (fun (v : Ast.variant) -> (v.Ast.vname, dn)) vs
|
||
| _ -> [])
|
||
decls
|
||
|
||
let value_names env (decls : Ast.decl list) =
|
||
let declared =
|
||
List.filter_map
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defvar (n, _, _, _) | Ast.Defconst (n, _, _) -> Some n
|
||
| Ast.Defn fn | Ast.Declare (fn, _) | Ast.DeclareC (fn, _) ->
|
||
Some fn.Ast.name
|
||
| _ -> None)
|
||
decls
|
||
in
|
||
declared
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.globals []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.fns []
|
||
|
||
(* The decision, applied: an undecided defonce leaves this pass as one of the
|
||
two forms that already existed, so no pass after it — the signature loop
|
||
below, [check_global], either backend — has a third case to know about. The
|
||
dyn reading is rewritten into exactly [(defonce x dyn <expr>)], which is the
|
||
whole of "it lowers to the same thing": the startup lifting, the re-run
|
||
guard and the collector root are the ones that form already had. *)
|
||
(* A bracket form whose element names a value. [(defonce g [a b])] parses as a
|
||
type and stays one — type wins wherever there is a type reading, which is
|
||
the rule — so the element had to name an element type, and [b] names a
|
||
defonce. Left alone this reaches [resolve_name], where a lowercase name that
|
||
is no type is a type variable, and the answer is a paragraph about generic
|
||
code the writer was not asking for.
|
||
|
||
Both readings, and both spellings, at the element that decided it. The dyn
|
||
spelling is the one that actually works: [(defonce g dyn [a b])] is a dyn
|
||
global holding a vector, which is what the brackets meant to whoever wrote
|
||
them. *)
|
||
let rec bracket_value_element env values (t : Ast.texpr) =
|
||
let elem (e : Ast.texpr) =
|
||
match e.Ast.t with
|
||
| Ast.Tname n when (not (is_type_name env n)) && List.mem n values ->
|
||
Some (n, e.Ast.tloc)
|
||
| _ -> bracket_value_element env values e
|
||
in
|
||
match t.Ast.t with
|
||
| Ast.Tslice e -> elem e
|
||
| Ast.Tarray (_, e) -> elem e
|
||
| _ -> None
|
||
|
||
let settle_defvars env (decls : Ast.decl list) : Ast.decl list =
|
||
let values = lazy (value_names env decls) in
|
||
let cases = lazy (case_owners decls) in
|
||
(* A bracket form never reaches the fork below: [Parse.defvar3] gives it
|
||
[Zeroed] outright, because a bracket that parses as a type has no second
|
||
reading to carry. So the element check runs on both, and it is the only
|
||
thing the [Zeroed] arm does. *)
|
||
let brackets ~form gname (t : Ast.texpr) =
|
||
match bracket_value_element env (Lazy.force values) t with
|
||
| Some (v, vloc) ->
|
||
Loc.failk "check/defvar-bracket-element-is-a-value" vloc
|
||
"%s names a value, not a type, and the brackets around it were read \
|
||
as a type — a %s's third element is a type wherever there is a \
|
||
type reading, so %s had to be the element type. Write a type there \
|
||
for a zeroed global, or put dyn in front of the same brackets — \
|
||
(%s %s dyn ...) — for a dyn global holding the vector you wrote"
|
||
v form v form gname
|
||
| None -> ()
|
||
in
|
||
let word = function Ast.Once -> "defonce" | Ast.Every -> "def" in
|
||
List.map
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defvar (n, Some t, Ast.Zeroed, k) -> brackets ~form:(word k) n t; d
|
||
| Ast.Defvar (n, Some t, Ast.Ambiguous e, k) ->
|
||
let form = word k in
|
||
if defvar_reads_as_type env t then begin
|
||
brackets ~form n t;
|
||
{ d with Ast.d = Ast.Defvar (n, Some t, Ast.Zeroed, k) }
|
||
end
|
||
else begin
|
||
(match t.Ast.t with
|
||
| Ast.Tname s when not (List.mem s (Lazy.force values)) ->
|
||
defvar_neither env t.Ast.tloc ~form n s
|
||
~values:(Lazy.force values) ~cases:(Lazy.force cases)
|
||
| _ -> ());
|
||
let dyn = { Ast.t = Ast.Tname "dyn"; tloc = t.Ast.tloc } in
|
||
{ d with Ast.d = Ast.Defvar (n, Some dyn, Ast.Init e, k) }
|
||
end
|
||
| _ -> d)
|
||
decls
|
||
|
||
(* ── Generics: the four operations monomorphisation needs ───────────────
|
||
Naming a variable, binding one from an argument, substituting the binding
|
||
back in, and spelling the result as a symbol. Everything else about the
|
||
feature is where these are called from. *)
|
||
|
||
(* The variables a signature introduces: every [$t] written in it, in the
|
||
order written, once each. Only a [defn] signature is scanned, which is what
|
||
makes the binding site a *place* and not merely a spelling. *)
|
||
let signature_tyvars (fn : Ast.fn) =
|
||
let acc = ref [] in
|
||
let name loc n =
|
||
if n <> "" && n.[0] = '$' then begin
|
||
let bare = String.sub n 1 (String.length n - 1) in
|
||
if bare = "" then fail loc "$ on its own does not name a type variable";
|
||
(* [$i32] would shadow a machine type inside the body and read as one
|
||
everywhere else. There is no reason to want it. *)
|
||
if List.mem bare Types.primitive_names
|
||
|| Types.ikind_of_name bare <> None
|
||
|| Types.fkind_of_name bare <> None then
|
||
fail loc "%s is a type, so $%s cannot be a type variable" bare bare;
|
||
if not (List.mem bare !acc) then acc := bare :: !acc
|
||
end
|
||
in
|
||
let rec ty (t : Ast.texpr) =
|
||
match t.Ast.t with
|
||
| Ast.Tname n -> name t.Ast.tloc n
|
||
| Ast.Tslice e -> ty e
|
||
| Ast.Tarray (_, e) -> ty e
|
||
| Ast.Tmap (k, v) -> ty k; ty v
|
||
(* The head of an application is a constructor — [Ptr], [Option], [Vec] —
|
||
and a variable cannot stand there: this spike is generic over types,
|
||
not over type constructors. A [$t] inside the arguments is ordinary. *)
|
||
| Ast.Tapp (_, args) -> List.iter ty args
|
||
| Ast.Tfn (ps, r) -> List.iter ty ps; ty r
|
||
in
|
||
List.iter (fun (p : Ast.field) -> ty p.Ast.fty) fn.Ast.params;
|
||
(match fn.Ast.ret with Some r -> ty r | None -> ());
|
||
List.rev !acc
|
||
|
||
(* Bind the variables in a parameter's written type from the type an argument
|
||
turned out to have. Odin's [is_polymorphic_type_assignable], structurally
|
||
and with the same rule: a variable already bound must match what it is
|
||
bound to, so [(pair 1 2.0)] over [a $t b $t] is a refusal and not a
|
||
second instantiation. *)
|
||
let rec bind_ty subst (pat : Types.t) (arg : Types.t) =
|
||
match pat, arg with
|
||
| Types.Var v, a ->
|
||
(match List.assoc_opt v !subst with
|
||
| None -> subst := (v, a) :: !subst; true
|
||
| Some b -> Types.equal a b)
|
||
| Types.Slice p, Types.Slice a
|
||
| Types.Ptr p, Types.Ptr a
|
||
| Types.Vec p, Types.Vec a
|
||
| Types.Option p, Types.Option a -> bind_ty subst p a
|
||
| Types.Array (n, p), Types.Array (m, a) -> Int64.equal n m && bind_ty subst p a
|
||
| Types.Map (k, v), Types.Map (k', v') ->
|
||
bind_ty subst k k' && bind_ty subst v v'
|
||
| Types.Fn (ps, r), Types.Fn (ps', r') ->
|
||
List.length ps = List.length ps'
|
||
&& List.for_all2 (bind_ty subst) ps ps' && bind_ty subst r r'
|
||
(* Nothing generic left on the pattern side: this is ordinary type
|
||
equality, and [Never] fits anywhere exactly as it does elsewhere. *)
|
||
| p, a -> Types.fits ~expected:p ~actual:a
|
||
|
||
let rec subst_ty subst (t : Types.t) =
|
||
match t with
|
||
| Types.Var v -> (match List.assoc_opt v subst with Some c -> c | None -> t)
|
||
| Types.Slice e -> Types.Slice (subst_ty subst e)
|
||
| Types.Array (n, e) -> Types.Array (n, subst_ty subst e)
|
||
| Types.Map (k, v) -> Types.Map (subst_ty subst k, subst_ty subst v)
|
||
| Types.Ptr e -> Types.Ptr (subst_ty subst e)
|
||
| Types.Vec e -> Types.Vec (subst_ty subst e)
|
||
| Types.Option e -> Types.Option (subst_ty subst e)
|
||
| Types.Fn (ps, r) -> Types.Fn (List.map (subst_ty subst) ps, subst_ty subst r)
|
||
| t -> t
|
||
|
||
(* Does this resolved type still mention a variable? *)
|
||
let rec generic_ty (t : Types.t) =
|
||
match t with
|
||
| Types.Var _ -> true
|
||
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
|
||
| Types.Option e -> generic_ty e
|
||
| Types.Map (k, v) -> generic_ty k || generic_ty v
|
||
| Types.Fn (ps, r) -> List.exists generic_ty ps || generic_ty r
|
||
| _ -> false
|
||
|
||
(* Does a type a call site bound a variable to reach a [dyn] anywhere? See the
|
||
refusal in [generic_call]: [dyn] is a concrete type and substitutes like any
|
||
other, so nothing stopped a copy being made at it, and the copies walked
|
||
straight into holes the rest of the language has no [dyn] answer for yet. *)
|
||
let rec reaches_dyn (t : Types.t) =
|
||
match t with
|
||
| Types.Dyn -> true
|
||
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
|
||
| Types.Option e -> reaches_dyn e
|
||
| Types.Map (k, v) -> reaches_dyn k || reaches_dyn v
|
||
| Types.Fn (ps, r) -> List.exists reaches_dyn ps || reaches_dyn r
|
||
| _ -> false
|
||
|
||
(* The refusal plan.org's Types section asks for, in one place so that every
|
||
operator says the same thing: with no constraints a type variable supports
|
||
only what *every* type supports, so [=], [<], [+] and [hash] over one are
|
||
rejected rather than silently instantiated at whatever type the first call
|
||
site happened to use.
|
||
|
||
With [where] there are now two ways out and the message names both: declare
|
||
the predicate, or take the operation as a function value the way
|
||
[sort-by] does. Declaring it is the one that keeps the call site short,
|
||
which is the whole reason predicates exist — under the no-constraint rule
|
||
[(sort xs)] had to become [(sort-by xs (fn [a b] (< a b)))] at every call
|
||
site in the corpus. *)
|
||
let unconstrained env loc op ~needs (t : Types.t) =
|
||
if generic_ty t then
|
||
match tyvar_of t with
|
||
| Some v when declares env.tvpreds v needs -> ()
|
||
| _ ->
|
||
Loc.failk "check/unconstrained-type-variable" loc
|
||
"%s over the type variable %s is refused: a type variable supports \
|
||
only what it is declared to support, and nothing here says %s is \
|
||
%s. Write {:where (%s $%s)} at the head of the body, or take the \
|
||
operation as a parameter — a (Fn [%s %s] ...) — and call it here"
|
||
op (Types.to_string t) (Types.to_string t) needs needs
|
||
(Types.to_string t) (Types.to_string t) (Types.to_string t)
|
||
|
||
|
||
(* How a concrete type is spelled inside an instantiation's name. The prelude
|
||
already writes this by hand — [filter-i32], [sum-f32], [append-i64] — so a
|
||
generated name reads like the handwritten one it replaces, which is what a
|
||
backtrace, a [Reach] edge and a dev-build cell all end up showing.
|
||
[Types.to_string] cannot serve: [[i32]] and [(Vec i32)] are not symbols. *)
|
||
let rec mangle_ty (t : Types.t) =
|
||
match t with
|
||
| Types.Unit -> "unit"
|
||
| Types.Slice e -> "slice-" ^ mangle_ty e
|
||
| Types.Array (n, e) -> Printf.sprintf "arr%Ld-%s" n (mangle_ty e)
|
||
| Types.Map (k, v) -> Printf.sprintf "map-%s-%s" (mangle_ty k) (mangle_ty v)
|
||
| Types.Ptr e -> "ptr-" ^ mangle_ty e
|
||
| Types.Vec e -> "vec-" ^ mangle_ty e
|
||
| Types.Option e -> "opt-" ^ mangle_ty e
|
||
| Types.Fn (ps, r) ->
|
||
Printf.sprintf "fn-%s-to-%s"
|
||
(String.concat "-" (List.map mangle_ty ps)) (mangle_ty r)
|
||
| t -> Types.to_string t
|
||
|
||
(* ── The runaway instantiation, refused by name rather than by depth ────
|
||
[(defn grow [x $t] () (grow [x x]))] asks for a copy at [[t]], which asks
|
||
for one at [[[t]]], forever. Before this the checker did not fail, it
|
||
*hung*, and [Session.eval] runs the same code — so what hung was [C-c C-c],
|
||
with the dev daemon wedged behind it and nothing to show the editor. That
|
||
is the project's stated priority stopped by three lines of ordinary-looking
|
||
Flan, which is why this is a refusal and not a cap.
|
||
|
||
The spike stopped it with a depth counter refusing past 32. A number is the
|
||
wrong thing to say: 32 is not in the program, the programmer cannot act on
|
||
it, and a legitimate deep instantiation and a runaway one look identical in
|
||
the message. **The structural test is exact.** A generic that is already on
|
||
the chain and is being asked for again at a type that *contains* the type
|
||
it was asked for before is growing, and growing without a smaller case is
|
||
not going to stop. A generic that recurses at the *same* types never
|
||
reaches here — the cache entry goes in before the body is checked — and one
|
||
that recurses at a *smaller* or unrelated type is fine and stays fine.
|
||
|
||
The message prints the chain, which is what the programmer can act on: each
|
||
link is a call site and a type, and the place the type started growing is
|
||
visible in the list.
|
||
|
||
Odin has no cap of its own to copy, so there was nothing to borrow and this
|
||
is the whole design. The depth backstop below stays as a backstop only: it
|
||
catches a growth this test does not recognise, and it is never the thing
|
||
the message is about. *)
|
||
let rec occurs_in ~needle (t : Types.t) =
|
||
Types.equal needle t
|
||
||
|
||
match t with
|
||
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
|
||
| Types.Option e -> occurs_in ~needle e
|
||
| Types.Map (k, v) -> occurs_in ~needle k || occurs_in ~needle v
|
||
| Types.Fn (ps, r) ->
|
||
List.exists (occurs_in ~needle) ps || occurs_in ~needle r
|
||
| _ -> false
|
||
|
||
(* [b] is [a] with something built around it: same shape, strictly bigger. *)
|
||
let grows ~from_:a ~to_:b =
|
||
List.length a = List.length b
|
||
&& List.for_all2 (fun x y -> occurs_in ~needle:x y) a b
|
||
&& not (List.for_all2 Types.equal a b)
|
||
|
||
let runaway env loc gname cparams =
|
||
let chain_text () =
|
||
String.concat "\n "
|
||
(List.map
|
||
(fun (g, ps, l) ->
|
||
Printf.sprintf "%s at (%s), asked for at %s" g
|
||
(String.concat " " (List.map Types.to_string ps))
|
||
(Loc.to_string l))
|
||
(env.chain @ [ (gname, cparams, loc) ]))
|
||
in
|
||
let earlier =
|
||
List.find_opt
|
||
(fun (g, ps, _) -> String.equal g gname && grows ~from_:ps ~to_:cparams)
|
||
env.chain
|
||
in
|
||
(match earlier with
|
||
| Some _ ->
|
||
Loc.failk "check/runaway-instantiation" loc
|
||
"%s instantiates itself without end. Each copy asks for another at a \
|
||
type built around the one before, so there is no last copy to \
|
||
generate:\n %s\nA generic function may call itself, but not at a \
|
||
type built out of its own type variable — the argument has to get \
|
||
smaller, or stay the same"
|
||
gname (chain_text ())
|
||
| None -> ());
|
||
(* The backstop. Nothing known reaches it; it exists so that a growth the
|
||
test above does not recognise is still a refusal with the chain in it
|
||
rather than a hang. *)
|
||
if List.length env.chain >= 64 then
|
||
Loc.failk "check/runaway-instantiation" loc
|
||
"%s has been instantiated 64 deep and is still going:\n %s"
|
||
gname (chain_text ())
|
||
|
||
(* [check_fn] is defined after the expression checker and an instantiation is
|
||
made from inside it, so the knot is tied here and closed at the bottom of
|
||
the file. One forward reference rather than moving a 90-line function. *)
|
||
let check_fn_ref : (env -> Ast.fn -> Tast.fn) ref =
|
||
ref (fun _ _ -> assert false)
|
||
|
||
(* ── Small helpers over the AST ────────────────────────────────────── *)
|
||
|
||
(* Untyped literals: their machine type comes from context, so when one is an
|
||
operand of a binary operator we look at the *other* operand first. *)
|
||
let is_literal (e : Ast.expr) =
|
||
match e.Ast.e with Ast.Int _ | Ast.Float _ | Ast.Byte _ -> true | _ -> false
|
||
|
||
(* [addr] takes the address of a place, but the parser only builds places for
|
||
[set]. Recover one from the expression it parsed instead. *)
|
||
let place_of_expr (e : Ast.expr) : Ast.place option =
|
||
match e.Ast.e with
|
||
| Ast.Var s -> Some (Ast.Pvar s)
|
||
| Ast.Field (t, f) -> Some (Ast.Pfield (t, f))
|
||
| Ast.Call ({ Ast.e = Ast.Var "at"; _ }, t :: idx) when idx <> [] ->
|
||
Some (Ast.Pindex (t, idx))
|
||
| Ast.Call ({ Ast.e = Ast.Var "deref"; _ }, [ p ]) -> Some (Ast.Pderef p)
|
||
| _ -> None
|
||
|
||
let mk loc ty e : Tast.expr = { Tast.e; ty; loc }
|
||
|
||
let unit_at loc = mk loc Types.Unit Tast.Unit
|
||
|
||
(* A source location as a value, for a runtime trap that has to name the site
|
||
rather than the runtime. The bounds and slice traps get theirs from [Emit],
|
||
which renders the [Loc.t] it is already carrying; a trap reached through a
|
||
plain runtime call has no such carrier, so the string is built here and
|
||
crosses as ptr+len like any other. *)
|
||
let here loc = mk loc Types.String (Tast.Str (Loc.to_string loc))
|
||
|
||
(* A runtime call, with the result type spelled at the site. *)
|
||
let rt loc ty sym args = mk loc ty (Tast.Prim (Tast.Rt sym, args))
|
||
|
||
(* ── The allocation registry's note, NEXT.md ───────────────────────────
|
||
|
||
One after every operation that may have allocated — which is *here*, and
|
||
nowhere else, because here is the only place the concrete type is known. A
|
||
Flan struct is exactly its C layout with no header and no tag word, so
|
||
nothing at run time can say what is at an address; the allocator's caller
|
||
knew, and this is the caller writing it down.
|
||
|
||
The type is spelled with [Types.to_string], the same spelling a slot
|
||
fingerprint and a DWARF node already key on, so a name that appears in a
|
||
registry answer is a name the programmer wrote.
|
||
|
||
It is built unconditionally and dropped by the backend in a release build
|
||
(see [Emit]'s [Rt] arm). The checker does not know which kind of build this
|
||
is and must not learn: a note that existed only in a dev build would make
|
||
the two builds different *trees*, and every pass between here and the
|
||
backend would have to agree about which one it was looking at.
|
||
|
||
[target] is the container, passed by address like every other container
|
||
operation; the extent comes off its header in the runtime, because the
|
||
header is the only thing that knows where the storage landed. *)
|
||
let reg_note loc sym (target : Tast.expr) sizes ty =
|
||
rt loc Types.Unit sym
|
||
((target :: sizes) @ [ mk loc Types.String (Tast.Str (Types.to_string ty)) ])
|
||
|
||
(* [(do attempt note)] — the note runs only once the guard's retry loop has
|
||
stopped, so it describes the storage the program ended up with rather than
|
||
one of the attempts that failed. *)
|
||
let with_note loc (guarded : Tast.expr) (note : Tast.expr) =
|
||
mk loc Types.Unit (Tast.Do [ guarded; note ])
|
||
|
||
(* ── Reading a file at compile time, decision 1 ────────────────────────
|
||
The path is a *literal*, because the bytes have to be in hand before any
|
||
value exists — this is Odin's rule too (check_load_directive rejects
|
||
anything that is not Addressing_Constant) and it is what makes the result
|
||
cost nothing at run time.
|
||
|
||
It resolves relative to the directory of the file the form is written in,
|
||
which is again Odin's rule (dir_from_path of the call's file). Relative to
|
||
the compiler's working directory would make a package's assets depend on
|
||
where flan was invoked from, which is the thing that cannot be right. An
|
||
absolute path is taken as written. *)
|
||
let embed_path loc (p : Ast.expr) =
|
||
match p.Ast.e with
|
||
| Ast.Str "" -> Loc.fail p.Ast.loc "an embedded path cannot be empty"
|
||
| Ast.Str s when Filename.is_relative s ->
|
||
let base = Filename.dirname loc.Loc.file in
|
||
if String.equal base "" then s else Filename.concat base s
|
||
| Ast.Str s -> s
|
||
| _ ->
|
||
Loc.fail p.Ast.loc
|
||
"an embedded path must be a literal string — the bytes are read at \
|
||
compile time, so there is nothing here to compute it from"
|
||
|
||
(* The whole read is guarded, not only the open. On Linux [open_in_bin] on a
|
||
*directory* succeeds and [in_channel_length] answers a number; the read is
|
||
where EISDIR arrives. Guarding only the open therefore turned (embed "dir")
|
||
— someone who meant embed-dir — into an uncaught OCaml exception out of the
|
||
checker, which is the one way a user can make the compiler crash rather than
|
||
refuse. *)
|
||
let read_embed_file path loc =
|
||
match
|
||
let ch = open_in_bin path in
|
||
Fun.protect ~finally:(fun () -> close_in_noerr ch)
|
||
(fun () -> really_input_string ch (in_channel_length ch))
|
||
with
|
||
| s -> s
|
||
| exception Sys_error msg ->
|
||
if Sys.file_exists path && (try Sys.is_directory path with Sys_error _ -> false)
|
||
then
|
||
Loc.fail loc
|
||
"cannot embed %s: it is a directory — (embed-dir \"...\") embeds one \
|
||
of those, as a [n EmbedFile]"
|
||
path
|
||
else Loc.fail loc "cannot embed %s: %s" path msg
|
||
| exception End_of_file ->
|
||
Loc.fail loc "cannot embed %s: it changed size while being read" path
|
||
|
||
(* Non-recursive, files only, sorted by name — the three things Odin's
|
||
#load_directory settles, and the sort is the one that matters most here:
|
||
readdir order is filesystem-dependent, so an unsorted embed would make the
|
||
emitted .ll differ between two builds of identical sources. *)
|
||
let read_embed_dir path loc =
|
||
let names =
|
||
match Sys.readdir path with
|
||
| exception Sys_error msg -> Loc.fail loc "cannot embed %s: %s" path msg
|
||
| a -> Array.to_list a
|
||
in
|
||
(* [Sys.is_directory] *raises* on a path that does not resolve, so the
|
||
existence test has to come first: a dangling symlink in an embedded
|
||
directory would otherwise crash the compiler before it was ever asked
|
||
about. Non-recursive and files only, which is Odin's rule too. *)
|
||
let files =
|
||
List.filter
|
||
(fun n ->
|
||
let full = Filename.concat path n in
|
||
Sys.file_exists full
|
||
&& not (try Sys.is_directory full with Sys_error _ -> true))
|
||
names
|
||
in
|
||
List.map
|
||
(fun n -> (n, read_embed_file (Filename.concat path n) loc))
|
||
(List.sort String.compare files)
|
||
|
||
let i64_at loc n = mk loc (Types.Int Types.I64) (Tast.Int (n, Types.I64))
|
||
|
||
(* spec-memory.md, "Alignment": the number is produced where the concrete
|
||
element type is known, which without generics is simply the call site. *)
|
||
let size_of loc t = mk loc (Types.Int Types.I64) (Tast.Prim (Tast.SizeOf t, []))
|
||
let align_of loc t = mk loc (Types.Int Types.I64) (Tast.Prim (Tast.AlignOf t, []))
|
||
|
||
(* The address of an expression, place or not: the type-erased runtime takes
|
||
the element [push] copies by pointer. *)
|
||
let addr_of loc (e : Tast.expr) =
|
||
mk loc (Types.Ptr e.Tast.ty) (Tast.Prim (Tast.AddrOf, [ e ]))
|
||
|
||
(* ── Where a rendered number's bytes live ──────────────────────────────
|
||
|
||
The three number-to-text conversions used to answer a slice into one static
|
||
buffer in the runtime, shared by every call in the process, and nothing
|
||
copied it: (print a) (print b) over two of them printed the second number
|
||
twice. No crash and nothing for a sanitizer to find, because the read was
|
||
inside a buffer that was perfectly alive — the wrong bytes, alive.
|
||
|
||
The buffer is now the caller's, one frame slot per call site, and it is
|
||
allocated here rather than in either backend on purpose: a slot is a
|
||
function-lifetime frame location in both of them — an entry-block alloca in
|
||
[Emit], a prologue-allocated offset in [X86] — where a backend temporary in
|
||
[X86] is bump-allocated and reclaimed at the end of the expression that made
|
||
it, which is exactly the lifetime a returned slice must outlive. Doing it
|
||
once here also keeps the two backends symmetric by construction: each gains
|
||
one pointer argument and no lifetime reasoning of its own.
|
||
|
||
64 bytes is agreed with flan_rt.c's FLAN_NUM_BYTES, which clamps the length
|
||
it publishes to it. The zeroing the [Let] does is one 64-byte clear beside
|
||
an snprintf. *)
|
||
let num_bytes = 64L
|
||
|
||
let to_bytes ctx loc pr (x : Tast.expr) =
|
||
let bty = Types.Array (num_bytes, Types.Int Types.U8) in
|
||
let bslice = Types.Slice (Types.Int Types.U8) in
|
||
let s = fresh_slot ctx bty in
|
||
mk loc bslice
|
||
(Tast.Let
|
||
([ (s, mk loc bty (Tast.Zero bty)) ],
|
||
[ mk loc bslice
|
||
(Tast.Prim (pr, [ x; addr_of loc (mk loc bty (Tast.Local s)) ])) ]))
|
||
|
||
(* ── The region requirement, emitted ───────────────────────────────────
|
||
spec-memory.md's arena rule, and the whole of what replaced the three
|
||
refusals a container of owning elements used to meet at its *type*. The
|
||
question those refusals asked was about teardown: the type-erased runtime
|
||
copies and releases slots bytewise, so a [free] would release the slots and
|
||
leave everything inside them stranded. A region never releases a slot —
|
||
[free-all] takes the whole thing, inner blocks included, because they came
|
||
out of the same region — so the premise does not hold there and the refusal
|
||
was over-broad.
|
||
|
||
What could not move with it is *where* the question is asked. [can-free] is
|
||
a capability on an allocator value, read at run time, and [with-allocator]
|
||
rebinds a dynamic variable, so the tier a [(vec-new)] will meet is not a
|
||
property of the place its type is written. The compile-time half is
|
||
therefore only the decision to ask — [region_only], a property of the
|
||
element type and settled here — and the run-time half is the answer.
|
||
|
||
One branch per container and never per element, which spec-memory.md fixes
|
||
and which is a performance decision before it is a safety one: 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.
|
||
|
||
Emitted at every site that can *allocate* for such a container, not only at
|
||
its construction, and the extra sites are not belt and braces. ZII means a
|
||
container can exist without ever passing through [vec-new]: a data type
|
||
case's field left out of a literal, a [(defonce xs (Vec Value))] a global
|
||
starts as. Those are zeroed, they have no allocator at all, and the first
|
||
[push] is what adopts the context — so a guard only at the construction
|
||
would have a hole exactly the width of ZII.
|
||
|
||
The *container* is what is asked in both places, and at a construction that
|
||
means the guard runs immediately after the init rather than before it. The
|
||
allocator is right there as an argument of the site, but naming it twice in
|
||
the emitted tree is not free: it is an arbitrary expression, and [(vec-new
|
||
Value (arena-new 4096))] would build two arenas and guard the one it threw
|
||
away. The container has recorded it by the time the init returns, so asking
|
||
the container asks that one expression exactly once. It is still the point
|
||
of construction and still before anything is put in; what a trap there costs
|
||
is the empty block just taken, on a process that is about to die.
|
||
|
||
At a growth the guard runs first, because there the container already exists
|
||
and the allocation is what has to be stopped. A zeroed one answers from the
|
||
context it is about to adopt, which is not a guess — [flan_vec_adopt] is the
|
||
code that will take it, on this same call. *)
|
||
let region_sym (t : Types.t) =
|
||
match t with
|
||
| Types.Vec _ -> "flan_vec_region_only"
|
||
| Types.Map _ -> "flan_map_region_only"
|
||
| _ -> assert false
|
||
|
||
let region_check env loc (target : Tast.expr) (after : Tast.expr) =
|
||
if not (region_only env target.Tast.ty) then after
|
||
else
|
||
mk loc after.Tast.ty
|
||
(Tast.Do
|
||
[ rt loc Types.Unit (region_sym target.Tast.ty) [ target; here loc ];
|
||
after ])
|
||
|
||
(* Every integer index into an array or slice is i32 at milestone 2. *)
|
||
let index_ty = Types.Int Types.I32
|
||
|
||
(* A condition's type at run time is a number, and it has to be the *same*
|
||
number in a module compiled later against a program already running. So it
|
||
is a hash of the name and not an index into anything: an index would shift
|
||
the moment a struct were added, and every handler pushed by the old code
|
||
would then match the wrong type. FNV-1a over the name, 32 bits. *)
|
||
let type_id name =
|
||
let h = ref 0x811c9dc5 in
|
||
String.iter
|
||
(fun c ->
|
||
h := (!h lxor Char.code c) land 0xffffffff;
|
||
h := (!h * 0x01000193) land 0xffffffff)
|
||
name;
|
||
!h
|
||
|
||
(* How a restart's parameter list is spelled, and with it what the two ends of
|
||
an [invoke-restart] compare — spec-conditions.md §3's run-time check. A
|
||
restart is found by name on a dynamic stack, so neither end can see the
|
||
other and nothing static can be checked: what is compared at run time is
|
||
this string's hash, alongside the count, and the string itself is carried so
|
||
that a mismatch can say what was wanted and what was given.
|
||
|
||
Comparing a 32-bit hash means two different parameter lists could in
|
||
principle collide. The count is checked separately, which rules out every
|
||
practical case (a collision would have to be between two lists of the same
|
||
length), and the types are parenthesised so that [(Option i32)] cannot read
|
||
as two parameters. *)
|
||
let restart_sig tys =
|
||
"(" ^ String.concat " " (List.map Types.to_string tys) ^ ")"
|
||
|
||
(* ── The dyn boundary ───────────────────────────────────────────────────
|
||
|
||
Typed to dyn is implicit and dyn to typed is not. That asymmetry is the
|
||
whole of the design and it is worth saying why it is not arbitrary.
|
||
|
||
Boxing loses nothing: the value goes in and the runtime records what it was.
|
||
It can happen anywhere a dyn is wanted without a reader being surprised,
|
||
because nothing about the program's meaning turns on it. Unboxing can fail,
|
||
at run time, on a value the compiler cannot inspect -- so it happens only
|
||
where somebody *wrote a type*: a typed parameter, a typed binding, a typed
|
||
field. Those are the places a reader already understands as a claim about
|
||
what a value is, and a claim that can be wrong is exactly what a trap is
|
||
for. Nowhere else does the compiler decide a dyn is an i64 on its own.
|
||
|
||
Both directions go through [expect], because [expect] is already the one
|
||
place a wanted type meets a produced one. Every site that annotates -- and
|
||
only those sites -- calls it with [~want].
|
||
|
||
Milestone 1 boxes the scalars and refuses everything else by name. A typed
|
||
container crossing into dyn is the interesting refusal: [(Vec i64)] has a
|
||
representation the dyn runtime does not know how to walk, and heterogeneity
|
||
at milestone 1 is served by the runtime's own vector behind
|
||
[flan_dyn_vec_new] instead. That is a "not yet" and says so. *)
|
||
|
||
let dyn_i64 = Types.Int Types.I64
|
||
let dyn_f64 = Types.Float Types.F64
|
||
|
||
(* Converting to whatever width the other side of the boundary wants, with a
|
||
[Cast] and not a silent reinterpretation. The name is for the direction it
|
||
was written for: runtime/flan_dyn.h boxes integers as [i64] and floats as
|
||
[f64] and offers no other width, so *into* a box this only ever widens, and
|
||
a widening cast loses nothing.
|
||
|
||
Coming back *out* it is used in both directions, and that is deliberate:
|
||
[cast_dyn] below unboxes to [i64]/[f64] and then hands the value to this to
|
||
reach the cast's target, which may be narrower ([(i32 d)]) or a different
|
||
kind ([(f32 d)]). Nothing is lost quietly there either — the [Cast] emitted
|
||
is the same node [(i32 x)] on a typed value emits, so the narrowing rule,
|
||
the fptosi range check and NaN are the emitter's, identical to the typed
|
||
spelling. This helper picks the node; it does not promise the conversion is
|
||
free. *)
|
||
let widen loc (want : Types.t) (e : Tast.expr) =
|
||
if Types.equal want e.Tast.ty then e
|
||
else mk loc want (Tast.Prim (Tast.Cast want, [ e ]))
|
||
|
||
let unboxable t =
|
||
match t with
|
||
| Types.Int Types.I64 | Types.Float Types.F64 | Types.Bool -> true
|
||
| _ -> false
|
||
|
||
(* The sentence a refusal at this boundary gives. It names the type and says
|
||
which direction failed, because "expected dyn, found (Vec i64)" would read
|
||
as a type error the programmer could fix by writing something else, and
|
||
there is nothing else to write -- the feature is not there yet. *)
|
||
let no_dyn_yet loc ~into t extra =
|
||
Loc.failk "check/dyn-not-yet" loc
|
||
"%s does not cross into %s yet%s"
|
||
(Types.to_string t) (if into then "dyn" else "a written type") extra
|
||
|
||
(* M2 item 3: a typed container crossing into dyn as a view. The element set
|
||
is exactly [unboxable] above — i64, f64, bool — and that is not a smaller
|
||
version of the same cut for the same reason: every other element type
|
||
would need [box] to run on IT too, and a string element's dyn form is a
|
||
pointer into the collector's heap, while a typed container's storage is
|
||
arena or stack memory the collector never scans. Writing that pointer
|
||
into memory nobody roots is a live reference the collector could free out
|
||
from under — the hazard runtime/flan_dyn.h's view section states at
|
||
length — and i64/f64/bool carry no pointer, so a view restricted to them
|
||
cannot manufacture it. It is a compile-time refusal here rather than a
|
||
run-time one because the element type is exactly what the checker already
|
||
knows at the crossing. The FLAN_VIEW_* constants are runtime/flan_dyn.h's;
|
||
this is the compiler's one copy of the same table. *)
|
||
let view_elem (t : Types.t) : int64 option =
|
||
match t with
|
||
| Types.Int Types.I64 -> Some 0L (* FLAN_VIEW_I64 *)
|
||
| Types.Float Types.F64 -> Some 1L (* FLAN_VIEW_F64 *)
|
||
| Types.Bool -> Some 2L (* FLAN_VIEW_BOOL *)
|
||
| _ -> None
|
||
|
||
let view_elem_lit loc (k : int64) =
|
||
mk loc (Types.Int Types.I32) (Tast.Int (k, Types.I32))
|
||
|
||
let view_not_yet loc (container : Types.t) (elem : Types.t) =
|
||
no_dyn_yet loc ~into:true container
|
||
(Printf.sprintf
|
||
". A container view at this milestone holds i64, f64 or bool \
|
||
elements only, and %s is not one of the three. The restriction \
|
||
exists for the string case: a dyn string's form is a pointer into \
|
||
the collector's heap, and a typed container's storage is memory the \
|
||
collector never scans, so a write through a view over strings could \
|
||
plant a pointer where nothing will ever trace it. Every other \
|
||
element type is refused with it rather than admitted one width at a \
|
||
time"
|
||
(Types.to_string elem))
|
||
|
||
(* M2 item 3's second guard, added on review: a view's descriptor holds an
|
||
address into the container's own storage, chased fresh on every
|
||
operation, which is what makes a Vec's growth safe — but it is also what
|
||
makes a *dangling* container's storage a live hazard nothing catches
|
||
until somebody reads through the view. A view returned from the function
|
||
whose frame the Vec lived in, stashed in a global and read after that
|
||
frame is gone, or left behind when a condition transfer unwinds it, are
|
||
all stack-use-after-return once box stopped refusing containers outright
|
||
— reachable now for the first time, not a pre-existing hole this lane
|
||
merely inherited.
|
||
|
||
On the dynamic side Flan aims where Clojure and Common Lisp are: holding
|
||
a value should not hand you garbage. Treating a view as a bare pointer and
|
||
calling the lifetime the programmer's problem is the Odin answer, and
|
||
neither Odin nor C stops it — this guard is the trade going the other way,
|
||
refused rather than merely documented.
|
||
|
||
What it is NOT is a proof. runtime/flan_dyn.h states the actual property:
|
||
the view is exactly as stale-safe as the thing it is a view of, no more
|
||
and no less. This guard narrows what a view can be taken of; it does not
|
||
make the underlying storage outlive anything. A global [[T]] slice whose
|
||
data was cut from a frame that has since returned still passes here, and
|
||
reading through the view then reads a dead frame. So this is a guard that
|
||
closes the routes the checker can see, not a guarantee that a dyn value
|
||
never dangles.
|
||
|
||
[permanent_root] asks whether an expression's own address — the one a
|
||
view's pointer will chase — is guaranteed to outlive every frame, which is
|
||
true of exactly one thing at this milestone: a global. A field of a
|
||
permanent value is permanent at the same fixed offset from it, and so is
|
||
an element of a permanent *array* — both are still inside the permanent
|
||
value's own storage. An element of a permanent *slice* is not: a slice is
|
||
ptr+len, so a global [[T]] holds only the two words, and the storage they
|
||
point at can be a frame that has already gone. The [At] arm below is where
|
||
that distinction is made, and it is made per index rather than once: an
|
||
[(at g i j)] is a single node carrying the whole index list, so the arm
|
||
steps the list the way [indexed] does and an array level at every step is
|
||
what it demands. Reading only the target's type would settle level zero
|
||
and let a slice at any later level through — which it did, and the
|
||
accepted program printed a returned frame's contents. A slice built directly from
|
||
[(slice T lo hi)] inherits the
|
||
permanence of the [T] it was cut from — unwrapped here because that is
|
||
the one shape still carrying the trace back to it; once a slice has been
|
||
bound to a name the trace is gone and it is refused; the spelling that
|
||
keeps it is to view the slice expression directly, the way this file's
|
||
own survey program does.
|
||
|
||
Everything else — a local, a parameter, a temporary, anything reached
|
||
through a [Ptr] — answers false. A [Ptr] is refused rather than trusted
|
||
because a heap-allocated block and a frame slot are the same type: a
|
||
[(Ptr (Vec i64))] taken from a heap allocation would be sound to view, but
|
||
the same type is what [(addr some-local)] answers too, and the checker
|
||
cannot tell the two apart. Admitting one admits the other, which is the
|
||
whole hazard this guard exists to close — so until a Flan type exists
|
||
that says "durably heap-owned" and a [Ptr] does not, a container reached
|
||
through one is refused rather than guessed at. An arena-held container is
|
||
not a separate case: an arena changes where a Vec's *elements* live, never
|
||
where its own header — the value a name is bound to — lives, so a Vec
|
||
grown from an arena is exactly as permanent as the binding that holds it,
|
||
already covered by the cases above. *)
|
||
let rec permanent_root (e : Tast.expr) : bool =
|
||
match e.Tast.e with
|
||
| Tast.Global _ -> true
|
||
| Tast.Field (target, _) -> permanent_root target
|
||
| Tast.Prim (Tast.At, target :: idx) ->
|
||
(* [(at g i j)] is ONE node carrying every index, so the target's own type
|
||
is only level zero and asking about it alone misses a slice reached at
|
||
any later level. Step the list the way [indexed] does — that walk is
|
||
the definition of which levels exist — and require every level stepped
|
||
to be an array. *)
|
||
let rec all_array ty = function
|
||
| [] -> true
|
||
| _ :: rest ->
|
||
(match ty with
|
||
| Types.Array (_, elem) -> all_array elem rest
|
||
| _ -> false)
|
||
in
|
||
all_array target.Tast.ty idx && permanent_root target
|
||
| Tast.Prim (Tast.Slice, [ target; _; _ ]) -> permanent_root target
|
||
| _ -> false
|
||
|
||
let view_not_permanent loc (container : Types.t) =
|
||
Loc.failk "check/dyn-view-lifetime" loc
|
||
"%s does not cross into dyn as a view here — its storage is not known \
|
||
to outlive the view, and a view is exactly as stale-safe as the thing \
|
||
it is a view of, no more and no less. A global's storage does outlive \
|
||
it: (defonce g %s ...) viewed from anywhere reads storage fixed for the \
|
||
process, and so does a field or an array element of one. A local, a \
|
||
parameter, a temporary, anything reached through a slice at any index \
|
||
level — even a global one, which holds only ptr+len and can point at a \
|
||
frame that is gone — or \
|
||
anything reached through a (Ptr T) is refused: the checker cannot tell \
|
||
a heap-durable pointer from a frame's own, and admitting one admits \
|
||
the other"
|
||
(Types.to_string container) (Types.to_string container)
|
||
|
||
let box loc (e : Tast.expr) : Tast.expr =
|
||
let dyn sym args = rt loc Types.Dyn sym args in
|
||
match e.Tast.ty with
|
||
| Types.Dyn -> e
|
||
| Types.Int _ -> dyn "flan_dyn_from_i64" [ widen loc dyn_i64 e ]
|
||
| Types.Float _ -> dyn "flan_dyn_from_f64" [ widen loc dyn_f64 e ]
|
||
(* The ABI takes an [int32_t], because a C signature that says [_Bool] is a
|
||
width argument nobody wants to have. *)
|
||
| Types.Bool -> dyn "flan_dyn_from_bool" [ widen loc (Types.Int Types.I32) e ]
|
||
(* A string is ptr+len and arrives as two arguments, the way every other
|
||
(ptr, len) entry point in the runtime takes one. The runtime copies: the
|
||
bytes may be a literal or a slice of a buffer the program goes on to
|
||
write. *)
|
||
| Types.String -> dyn "flan_dyn_from_bytes" [ e ]
|
||
(* Unit does not box. A value of the zero-sized type carries nothing for a
|
||
dyn word to hold, and [nil] -- the absent dyn value, writable as the
|
||
literal [nil] -- is a different thing with a different constructor. The
|
||
two get confused if unit is allowed to become one. *)
|
||
| Types.Unit ->
|
||
Loc.failk "check/dyn-unit" loc
|
||
"() does not box into dyn — a value of the zero-sized type carries \
|
||
nothing a dyn could hold. The absent dyn value is nil, which is a \
|
||
literal here: write nil"
|
||
| Types.Never -> e
|
||
(* A view, not a copy: the box holds one word naming where the elements
|
||
live and what one of them is, and every read or write goes straight
|
||
through to the container's own storage — see runtime/flan_dyn.h's
|
||
view section for the whole of the argument, including why the
|
||
descriptor points AT the container (a Vec's own header address)
|
||
rather than snapshotting its ptr+len. That is what makes a push
|
||
through the view safe even though a Vec can grow and move: there is
|
||
no snapshot for the growth to invalidate. A slice and a fixed array
|
||
cannot grow, so a snapshot taken once at the crossing is sound for
|
||
both, and they share [flan_dyn_view_flat]. *)
|
||
(* The element check runs before the lifetime one in all three arms, and
|
||
the order is load-bearing rather than incidental: the lifetime message
|
||
points at [(defonce g ...)] as the spelling that works, and for an
|
||
element type no view can carry — a string, an i32 — the global spelling
|
||
is refused too, so the wrong order hands the programmer advice that
|
||
fails when they take it. Whichever refusal is unconditional wins. *)
|
||
| Types.Vec elem ->
|
||
(match view_elem elem with
|
||
| None -> view_not_yet loc e.Tast.ty elem
|
||
| Some k ->
|
||
if not (permanent_root e) then view_not_permanent loc e.Tast.ty
|
||
else dyn "flan_dyn_view_vec" [ e; view_elem_lit loc k ])
|
||
| Types.Slice elem ->
|
||
(match view_elem elem with
|
||
| None -> view_not_yet loc e.Tast.ty elem
|
||
| Some k ->
|
||
if not (permanent_root e) then view_not_permanent loc e.Tast.ty
|
||
else dyn "flan_dyn_view_flat" [ e; view_elem_lit loc k ])
|
||
| Types.Array (n, elem) ->
|
||
(match view_elem elem with
|
||
| None -> view_not_yet loc e.Tast.ty elem
|
||
| Some k ->
|
||
if not (permanent_root e) then view_not_permanent loc e.Tast.ty
|
||
else
|
||
dyn "flan_dyn_view_flat"
|
||
[ e; mk loc dyn_i64 (Tast.Int (n, Types.I64)); view_elem_lit loc k ])
|
||
| Types.Map _ ->
|
||
no_dyn_yet loc ~into:true e.Tast.ty
|
||
". The dyn container at this milestone is the runtime's own, from \
|
||
(map-new dyn); a typed (Map K V) has a representation the dyn \
|
||
runtime cannot walk"
|
||
(* [Option] is on this list in name only: [expect] intercepts it before
|
||
[box] ever sees one — [box_option] is the real answer, M2 item 4 — so
|
||
this arm only fires for a direct caller that hands [box] an Option
|
||
itself, and none does today. Left refused rather than removed, so a
|
||
caller that starts doing that gets a sentence instead of a silent
|
||
mis-lowering. *)
|
||
| Types.Named _ | Types.Enum _ | Types.Option _ | Types.Ptr _
|
||
| Types.Alloc | Types.Fn _ | Types.Var _ ->
|
||
no_dyn_yet loc ~into:true e.Tast.ty ""
|
||
|
||
let unbox loc (want : Types.t) (e : Tast.expr) : Tast.expr =
|
||
let need sym ty = rt loc ty sym [ e ] in
|
||
match want with
|
||
| Types.Int Types.I64 -> need "flan_dyn_need_i64" dyn_i64
|
||
| Types.Float Types.F64 -> need "flan_dyn_need_f64" dyn_f64
|
||
| Types.Bool ->
|
||
(* The ABI answers an [int32_t]; [bool] is an [i1]. The narrowing is the
|
||
language's own cast and cannot fail — the runtime already decided the
|
||
value was a bool, so what comes back is 0 or 1. *)
|
||
widen loc Types.Bool (need "flan_dyn_need_bool" (Types.Int Types.I32))
|
||
(* Every other width is refused rather than served by a need_i64 and a
|
||
truncation. Narrowing is written or it does not happen — that survives
|
||
widening becoming implicit (FIX.org 2026-09-20) untouched, and this is the
|
||
boundary where it matters most: the value's type was *already* uncertain
|
||
here, so an annotation that quietly discarded the high bits would read as
|
||
a check and be the opposite of one.
|
||
|
||
Nor does widening reach this arm from the other side. The box carries one
|
||
integer width and one float width, so there is no narrower source here to
|
||
widen from — a u32 want is asking the i64 in the box to fit in half of
|
||
itself, which is the refusal above and not a conversion the lattice has.
|
||
The ABI grows a per-width entry point when there is a reason to; until
|
||
then the spelling that works is an i64 and a written conversion after
|
||
it. *)
|
||
| Types.Int _ | Types.Float _ ->
|
||
no_dyn_yet loc ~into:false want
|
||
(Printf.sprintf
|
||
" — the dyn runtime carries integers as i64 and floats as f64, so \
|
||
take it as %s and convert"
|
||
(if Types.is_numeric want && (match want with Types.Float _ -> true | _ -> false)
|
||
then "f64" else "i64"))
|
||
| _ -> no_dyn_yet loc ~into:false want ""
|
||
|
||
(* ── A numeric cast written on a dyn, FIX.org 2026-09-20 ───────────────
|
||
*
|
||
[(f64 d)] where [d] is dyn. Until this, the only place in the language
|
||
that opened a box was a typed parameter, which is why a program that
|
||
wanted a number out of a dyn had to define a one-line function whose
|
||
parameter slot did the unboxing and call *that*. A cast is already the
|
||
operator for "convert this to that", so it is the spelling that should
|
||
have worked, and now does.
|
||
|
||
What is built is a branch on the box's tag, not a call that converts:
|
||
|
||
(let ([s d])
|
||
(if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1)
|
||
(u32 (flan_dyn_need_f64 s))
|
||
(u32 (flan_dyn_need_i64 s))))
|
||
|
||
Each arm is an ordinary [Cast] over an ordinary [need], so the conversion
|
||
is *the same node* a typed operand of that type would have produced.
|
||
That is the point of this shape rather than a coercing runtime entry point
|
||
that answers the finished number: [(i64 2.5)] is not a bare [fptosi] in
|
||
this compiler — [Emit.check_cast] range-checks it first and signals
|
||
ArithError when the value will not fit, and the x86 backend does the same
|
||
— so a C function returning an [int64_t] would have had to grow its own
|
||
second opinion about range and NaN, in a second place, for two backends.
|
||
Here there is nothing to keep in step: [(i64 float-box)] *is* [(i64 x)]
|
||
with an unbox in front of it, which is exactly what the semantics say.
|
||
|
||
[need_f64] and [need_i64] are the trapping entry points, and neither can
|
||
trap here: each is reached only on the arm where the tag has already been
|
||
read as its own. The trap that can happen is the runtime's, for a box
|
||
holding a non-number, and [flan_dyn_cast_kind] owns that sentence — bool
|
||
included, which is what [flan_dyn_need_i64] already does with a bool at a
|
||
typed parameter. The cross-kind case does not trap: it converts and warns
|
||
once for the site, the author's call, recorded in FIX.org.
|
||
|
||
The slot exists because the value is read three times — once for the tag,
|
||
once on whichever arm runs — and the argument may be an arbitrary
|
||
expression. [unbox_option] above builds the same shape for the same
|
||
reason. *)
|
||
let cast_dyn ctx loc (target : Types.t) (got : Tast.expr) : Tast.expr =
|
||
let s = fresh_slot ctx Types.Dyn in
|
||
let sv = mk loc Types.Dyn (Tast.Local s) in
|
||
let want_float = match target with Types.Float _ -> 1 | _ -> 0 in
|
||
let kind =
|
||
rt loc (Types.Int Types.I32) "flan_dyn_cast_kind"
|
||
[ sv; here loc;
|
||
mk loc Types.String (Tast.Str (Types.to_string target));
|
||
mk loc (Types.Int Types.I32) (Tast.Int (Int64.of_int want_float, Types.I32)) ]
|
||
in
|
||
let is_float =
|
||
mk loc Types.Bool
|
||
(Tast.Prim (Tast.Eq,
|
||
[ kind;
|
||
mk loc (Types.Int Types.I32) (Tast.Int (1L, Types.I32)) ]))
|
||
in
|
||
let arm sym ty = widen loc target (rt loc ty sym [ sv ]) in
|
||
mk loc target
|
||
(Tast.Let ([ (s, got) ],
|
||
[ mk loc target
|
||
(Tast.If (is_float,
|
||
arm "flan_dyn_need_f64" dyn_f64,
|
||
arm "flan_dyn_need_i64" dyn_i64)) ]))
|
||
|
||
(* nil is written [nil] and nothing else produces it, so this is the whole of
|
||
"the checker can see a nil reaching here" — a name, not a dataflow fact.
|
||
There is no propagation through a [let] or a call in this checker (see
|
||
"Ownership tracking repealed" — flow analysis was removed on purpose), so a
|
||
[nil] bound to a name and used later is exactly the case the runtime trap
|
||
below exists for. That is the intended split, not a gap: the syntax a
|
||
reader can see is refused where they are looking at it, and everything one
|
||
step removed from the syntax is caught when the program runs. *)
|
||
let is_nil_lit (e : Tast.expr) =
|
||
match e.Tast.e with
|
||
| Tast.Prim (Tast.Rt "flan_dyn_nil", []) -> true
|
||
| _ -> false
|
||
|
||
(* ── nil <-> None at (Option T) ────────────────────────────────────────────
|
||
|
||
The dyn absence and the typed one are the same absence at the one boundary
|
||
where both are meaningful, M2 item 4. Both directions build the same
|
||
[If]-over-a-tag shape [get] and [map-remove] already build (check.ml
|
||
4780-4900): the tag says which of [Some]/[None] it is, and the payload,
|
||
when there is one, crosses the scalar boundary [box]/[unbox] already own.
|
||
|
||
(Option (Option T)) does not cross either direction. Boxing [Some] of an
|
||
inner [None] would box that [None] as nil — the same nil an outer [None]
|
||
becomes — which is exactly the ambiguity [(Some nil)] is refused for one
|
||
level down; unboxing has the mirror problem, one dyn absence asked to tell
|
||
two levels of it apart. The type stays legal on the typed side (it is
|
||
already constructible: nothing here refuses it), only the crossing does
|
||
not exist for it.
|
||
|
||
(Option dyn) needs no case of its own. Its payload is already dyn, so
|
||
boxing it is the identity and unboxing it is the identity; the only thing
|
||
that has to hold is that the payload is never nil, which is [(Some nil)]'s
|
||
refusal below, not this boundary's. *)
|
||
let box_option ctx loc (t : Types.t) (got : Tast.expr) : Tast.expr =
|
||
match t with
|
||
| Types.Option inner ->
|
||
Loc.failk "check/option-nested-dyn" loc
|
||
"(Option (Option %s)) does not cross into dyn — boxing Some of an \
|
||
inner None would box it as nil, the same nil an outer None becomes, \
|
||
which is the ambiguity (Some nil) is refused for"
|
||
(Types.to_string inner)
|
||
| _ ->
|
||
(* A literal [Some]/[None] built right here skips the runtime check: the
|
||
checker already knows which case it is, so there is nothing to test at
|
||
run time and the conversion is free on both backends. *)
|
||
match got.Tast.e with
|
||
| Tast.None_ -> rt loc Types.Dyn "flan_dyn_nil" []
|
||
| Tast.Some_ x -> if Types.equal t Types.Dyn then x else box loc x
|
||
| _ ->
|
||
let s = fresh_slot ctx (Types.Option t) in
|
||
let sv = mk loc (Types.Option t) (Tast.Local s) in
|
||
let tag = mk loc (Types.Int Types.I8) (Tast.Field (sv, 0)) in
|
||
let is_some =
|
||
mk loc Types.Bool
|
||
(Tast.Prim (Tast.Ne,
|
||
[ tag; mk loc (Types.Int Types.I8) (Tast.Int (0L, Types.I8)) ]))
|
||
in
|
||
let payload = mk loc t (Tast.Field (sv, 1)) in
|
||
let some_dyn = if Types.equal t Types.Dyn then payload else box loc payload in
|
||
let none_dyn = rt loc Types.Dyn "flan_dyn_nil" [] in
|
||
mk loc Types.Dyn
|
||
(Tast.Let ([ (s, got) ],
|
||
[ mk loc Types.Dyn (Tast.If (is_some, some_dyn, none_dyn)) ]))
|
||
|
||
let unbox_option ctx loc (t : Types.t) (got : Tast.expr) : Tast.expr =
|
||
let oty = Types.Option t in
|
||
match t with
|
||
| Types.Option inner ->
|
||
Loc.failk "check/option-nested-dyn" loc
|
||
"(Option (Option %s)) does not cross from dyn — a dyn value is nil or \
|
||
it is not, one absence, and that cannot tell None from Some None apart"
|
||
(Types.to_string inner)
|
||
| _ when is_nil_lit got -> mk loc oty Tast.None_
|
||
| _ ->
|
||
let s = fresh_slot ctx Types.Dyn in
|
||
let sv = mk loc Types.Dyn (Tast.Local s) in
|
||
let not_nil =
|
||
mk loc Types.Bool
|
||
(Tast.Prim (Tast.Eq,
|
||
[ rt loc (Types.Int Types.I32) "flan_dyn_is_nil" [ sv ];
|
||
mk loc (Types.Int Types.I32) (Tast.Int (0L, Types.I32)) ]))
|
||
in
|
||
let none = mk loc oty Tast.None_ in
|
||
let payload = if Types.equal t Types.Dyn then sv else unbox loc t sv in
|
||
let some = mk loc oty (Tast.Some_ payload) in
|
||
mk loc oty
|
||
(Tast.Let ([ (s, got) ], [ mk loc oty (Tast.If (not_nil, some, none)) ]))
|
||
|
||
(* What a numeric mismatch has left to say, now that widening is silent.
|
||
FIX.org 2026-09-20, "Implicit widening": every conversion that cannot change
|
||
the number happens by itself, so a numeric pair that still reaches a refusal
|
||
is one of exactly two things, and this tells them apart.
|
||
|
||
Either the wanted type is *narrower* — the conversion can lose, which is
|
||
what the language has always refused to do without being told, and the
|
||
sentence names the cast and points out that the other direction needed
|
||
nothing. Or there is no direction at all: i32 and u32 are the same width and
|
||
each holds values the other cannot, so neither widens and the program has to
|
||
say which half it means to keep.
|
||
|
||
Written once and used by both refusals that can report one — [expect]'s, and
|
||
the binary operators' when their two operands have no join. *)
|
||
let numeric_note ~(want : Types.t) ~(got : Types.t) =
|
||
if not (Types.is_numeric want && Types.is_numeric got) then ""
|
||
else if Types.widens_to ~from:want ~into:got then
|
||
Printf.sprintf
|
||
" — %s into %s can lose, so it has to be written: (%s x). The other way \
|
||
round, %s widens into %s by itself"
|
||
(Types.to_string got) (Types.to_string want) (Types.to_string want)
|
||
(Types.to_string want) (Types.to_string got)
|
||
else
|
||
Printf.sprintf
|
||
" — neither widens into the other, so the conversion has to be written: \
|
||
(%s x)"
|
||
(Types.to_string want)
|
||
|
||
let expect ctx loc ~want (got : Tast.expr) =
|
||
match want with
|
||
| None -> got
|
||
| Some w ->
|
||
(* The boundary: where a wanted type meets a produced one, and the one
|
||
place the language's implicit conversions live. It runs before [fits]
|
||
rather than instead of it: what comes back is an ordinary expression of
|
||
the wanted type, and if the coercion did not produce one the usual
|
||
message is still the one that reports it. *)
|
||
let got =
|
||
match w, got.Tast.ty with
|
||
| Types.Dyn, Types.Dyn -> got
|
||
| Types.Dyn, Types.Option t -> box_option ctx loc t got
|
||
| Types.Dyn, _ -> box loc got
|
||
| Types.Option t, Types.Dyn -> unbox_option ctx loc t got
|
||
(* A bare T has no None to become, and this nil is one the checker can
|
||
actually see — the literal, written right where the mismatch is.
|
||
Refused here, at the offending line, instead of waiting for the
|
||
runtime trap [unbox] would otherwise reach for two arms down. *)
|
||
| w, Types.Dyn when is_nil_lit got ->
|
||
fail loc
|
||
"nil has no None to become at %s — nil only converts to (Option T) \
|
||
or to dyn itself; wrap the type in Option, or keep the value dyn"
|
||
(Types.to_string w)
|
||
| _, Types.Dyn when Types.fits ~expected:w ~actual:Types.Dyn -> got
|
||
| _, Types.Dyn -> unbox loc w got
|
||
(* Implicit widening, and this single arm is the whole of its surface.
|
||
[expect] is called by every site that annotates and by nothing else,
|
||
so an argument, a return, a let or defonce with a type, a struct field
|
||
initialiser, a push into a Vec and a C import's parameter all get it
|
||
here at once and none of them had to learn about it.
|
||
|
||
The conversion is performed, not waved through: [widen] emits the same
|
||
[Cast] node the written (i64 x) emits, so the backends sext or zext by
|
||
the *source* type's signedness and nothing downstream sees a node
|
||
whose type disagrees with its bits. [widens_to] is what keeps that
|
||
honest — it admits only conversions that cannot change the number, so
|
||
the cast this inserts is one no program can tell happened. *)
|
||
| _ when Types.widens_to ~from:got.Tast.ty ~into:w -> widen loc w got
|
||
| _ -> got
|
||
in
|
||
if Types.fits ~expected:w ~actual:got.Tast.ty then got
|
||
else
|
||
(* Kinded so that the one caller who knows more — a call argument, which
|
||
can name the function and the parameter — can recognise this exact
|
||
refusal at this exact span and say the rest. Every other reader of a
|
||
diagnostic ignores [kind].
|
||
|
||
[numeric_note] is the rest of the sentence when both sides are
|
||
numbers, and it is on this message rather than beside it because a
|
||
reader who has just been told i64 and i32 are different types needs
|
||
to be told, in the same breath, which direction needed nothing. *)
|
||
Loc.failk "check/type-mismatch" loc "expected %s, found %s%s"
|
||
(Types.to_string w) (Types.to_string got.Tast.ty)
|
||
(numeric_note ~want:w ~got:got.Tast.ty)
|
||
|
||
(* Something a [break] may not jump out of, named so the refusal can say which.
|
||
See [lentry]: it is a barrier and not a blanket refusal, so a loop written
|
||
wholly inside one keeps its own perfectly good local break. Outside the
|
||
recursive group below because its callers hand it bodies of two shapes — one
|
||
expression and a list of them — and inside it would be monomorphic. *)
|
||
let barrier ctx what f =
|
||
let loops = ctx.loops in
|
||
ctx.loops <- Lbarrier what :: loops;
|
||
let r = f () in
|
||
ctx.loops <- loops;
|
||
r
|
||
|
||
(* ── Expressions ───────────────────────────────────────────────────── *)
|
||
|
||
(* ── (Map K V): the key's hash and equality pair ────────────────────────
|
||
spec-memory.md restricts the first implementation to built-in structural
|
||
key types — integers, enums, strings, fixed arrays, and value structs
|
||
composed recursively from those — and makes equality and hashing for them
|
||
compiler-provided structural operations rather than type classes. So there
|
||
is no dispatch to design: every key type resolves, here, to a pair of
|
||
symbols, and the pair is passed to the type-erased runtime the way Odin
|
||
hangs its two contextless procs off a Map_Info.
|
||
|
||
Most key types need no emitted function at all. A key whose equality is
|
||
bytewise and whose bytes are all present is served by one runtime pair over
|
||
(pointer, size), which is what [bytewise_key] identifies. Two kinds are not:
|
||
|
||
- a [string] is ptr+len and its bytes are elsewhere, so two equal strings at
|
||
different addresses must still hash the same;
|
||
- a struct may have padding, whose bytes are indeterminate, so two structs
|
||
that are equal field by field can differ bytewise — and it may hold a
|
||
string, which brings the first problem inside it.
|
||
|
||
A struct therefore gets a pair emitted for it, walking its fields, and that
|
||
is the only case that does. *)
|
||
|
||
let rec bytewise_key = function
|
||
| Types.Int _ | Types.Enum _ | Types.Bool -> true
|
||
| Types.Array (_, t) -> bytewise_key t
|
||
| _ -> false
|
||
|
||
let hash_ty = Types.Int Types.U64
|
||
|
||
(* The fresh context a frame starts from: no outer scope, no defers, no loops,
|
||
and [defer_ok] false. As written it is a function the checker invented —
|
||
nothing is reachable from it because none of it is a body anyone wrote — and
|
||
that is how the emitted hashers and the constant-inference pass take it.
|
||
|
||
A body someone *did* write starts here too and then reattaches the few
|
||
fields that make it theirs: [owner] is the function's name, and an fn or a
|
||
handler sets [outer] to the enclosing scope so that a reference to the
|
||
enclosing function's locals is refused for the reason it is really refused
|
||
for. Those are [with] clauses on this record rather than a literal of their
|
||
own, so that a field added here is added to all of them.
|
||
|
||
Each caller calls it again rather than sharing one value: [slots] and
|
||
[slot_tys] are counted up per frame, and two frames that shared a context
|
||
would share a slot counter. *)
|
||
let invented_ctx env ret =
|
||
{ env; ret; slots = 0; slot_tys = []; slot_names = []; scope = [];
|
||
defers = []; defer_slot = None; outer = []; outer_what = None; in_frames = None; loops = []; tail = false;
|
||
in_defer = false; defer_ok = false; defer_block = "a nested form";
|
||
owner = "<none>" }
|
||
|
||
(* The address of field [i] of the struct the pointer in slot [p] points at. *)
|
||
let field_addr_of loc sty fty p i =
|
||
let target = mk loc sty (Tast.Deref (mk loc (Types.Ptr sty) (Tast.Local p))) in
|
||
mk loc (Types.Ptr fty) (Tast.Addr (Tast.Pfield (target, i)))
|
||
|
||
(* The pointer form is what a Map_Info holds; the direct form is what an
|
||
emitted hasher calls. See flan_rt.c on why they are two symbols. *)
|
||
let direct = function
|
||
| "flan_hash_flat" -> "flan_key_hash_flat"
|
||
| "flan_eq_flat" -> "flan_key_eq_flat"
|
||
| "flan_hash_str" -> "flan_key_hash_str"
|
||
| "flan_eq_str" -> "flan_key_eq_str"
|
||
| s -> s
|
||
|
||
(* The pair for [k]: (hash, equality), each a symbol to be taken the address
|
||
of. Emits a function for a struct key the first time it sees one, and finds
|
||
it in [env.lifted] every time after — the name is derived from the type, so
|
||
two maps with the same key type share one pair. *)
|
||
let rec key_pair env loc (k : Types.t) : Tast.fnref * Tast.fnref =
|
||
match k with
|
||
(* A hash and an equality for a type variable would have to be *chosen*,
|
||
and nothing here can choose: the pair is emitted as concrete symbols and
|
||
the concrete type does not exist until the instantiation. Refused rather
|
||
than assumed — falling through to [bytewise_key] would hash whatever
|
||
bytes the variable turned out to have, which is the wrong answer for a
|
||
[string] and for any struct with padding.
|
||
|
||
**This arm is a backstop and nothing normal reaches it.** Two things get
|
||
there first. Inside an instantiation [env.subst] has already made [k]
|
||
concrete, so there is no variable left. Outside one — in the abstract
|
||
pass over a generic body — the map operations are *deferred*
|
||
([deferred_key] below): a key that is a variable declared [hashable?]
|
||
never asks for a pair here, and a variable that is not declared it never
|
||
gets as far as a [(Map $t V)] to operate on, because [map_type] refuses
|
||
the type where it is written. What is left for this arm is a key that is
|
||
a variable by some route neither of those covers, and the honest answer
|
||
to that is still a refusal rather than a guessed pair. *)
|
||
| Types.Var v ->
|
||
Loc.failk "check/generic-map-key" loc
|
||
"a map keyed by the type variable %s has no hash and no equality here: \
|
||
both are emitted as concrete symbols chosen from the concrete key \
|
||
type, and there is none until this generic is instantiated. The map \
|
||
operations are deferred to the instantiation when {:where (hashable? \
|
||
$%s)} is declared — declare it, or write the operation in a function \
|
||
over the concrete key type and call that" v v
|
||
| Types.String -> Tast.Rtfn "flan_hash_str", Tast.Rtfn "flan_eq_str"
|
||
| t when bytewise_key t ->
|
||
Tast.Rtfn "flan_hash_flat", Tast.Rtfn "flan_eq_flat"
|
||
| Types.Named n when Hashtbl.mem env.structs n -> struct_key_pair env loc n
|
||
(* A data type key would have to hash the tag and then only the bytes the
|
||
case in hand actually uses — the rest of the payload is indeterminate,
|
||
exactly as a struct's padding is, so hashing the blob would make two equal values
|
||
hash differently. That is a per-case walk driven by a switch, which is a
|
||
different shape from the field list [struct_key_pair] emits and which
|
||
nothing has yet wanted. Refused by name rather than written untested. *)
|
||
| Types.Named n when Hashtbl.mem env.datas n ->
|
||
fail loc
|
||
"%s is a data type, and a data type is not a map key: the payload past \
|
||
the case in hand is indeterminate, so hashing the bytes would make two equal \
|
||
values hash differently. Hashing one needs a per-case walk, which is \
|
||
not written — key on the tag, or on a struct holding what you meant" n
|
||
(* And an untagged union is refused for the half of that reason which has
|
||
nothing to do with a tag: a member smaller than the union leaves the rest
|
||
of the storage indeterminate, so two values that agree about every byte
|
||
anybody wrote hash differently. There is no per-member walk to write here
|
||
either — nothing records which member was written, which is the type. *)
|
||
| Types.Named n when Hashtbl.mem env.unions n ->
|
||
fail loc
|
||
"%s is a union, and a union is not a map key: a member narrower than \
|
||
the union leaves the rest of the bytes indeterminate, so two values \
|
||
that agree about everything written would still hash differently. Key \
|
||
on the member you meant" n
|
||
| Types.Array (_, e) ->
|
||
(* A fixed array of a struct or of strings would need the same per-element
|
||
walk a struct key gets, driven by a loop rather than by a field list.
|
||
Nothing has wanted one, so it is refused by name rather than written
|
||
untested — and refused with the shape that does work named beside it. *)
|
||
fail loc
|
||
"a fixed array is a map key only when its elements are compared \
|
||
bytewise, and %s is not — a struct or a string element needs a \
|
||
per-element walk that is not written. A struct key holding the array \
|
||
works, because a struct key is walked field by field"
|
||
(Types.to_string e)
|
||
| Types.Float _ ->
|
||
(* Not a milestone question, which is why it is said separately: NaN is not
|
||
equal to itself, and 0.0 and -0.0 are equal while differing bytewise. A
|
||
float key therefore has no equality for a hash map to use, whatever the
|
||
implementation does. *)
|
||
fail loc
|
||
"a float is not a map key: NaN is not equal to itself, and 0.0 and -0.0 \
|
||
are equal but differ bytewise, so there is no equality here for a map \
|
||
to hash. Key on an integer, or on a quantised integer of your choosing"
|
||
| other ->
|
||
fail loc
|
||
"%s is not a map key. The first implementation takes integers, enums, \
|
||
bools, strings, fixed arrays of those, and value structs composed of \
|
||
those (spec-memory.md, \"Maps — first implementation\"). A Ptr, a \
|
||
slice, a Vec or a Map would hash an address rather than what it points \
|
||
at, which is a different operation"
|
||
(Types.to_string other)
|
||
|
||
and struct_key_pair env loc n =
|
||
let hname = "map/hash/" ^ n and ename = "map/eq/" ^ n in
|
||
let known name =
|
||
List.exists (fun (f : Tast.fn) -> f.Tast.name = name) env.lifted
|
||
in
|
||
if known hname then Tast.Flanfn hname, Tast.Flanfn ename
|
||
else begin
|
||
let sty = Types.Named n in
|
||
let fields = (Hashtbl.find env.structs n).Tast.fields in
|
||
if fields = [] then
|
||
fail loc
|
||
"%s has no fields, so every value of it is equal to every other — a \
|
||
map keyed on it holds at most one entry, which is not a map" n;
|
||
let hparams = [ Types.Ptr sty; hash_ty; Types.Int Types.I64 ] in
|
||
let eparams = [ Types.Ptr sty; Types.Ptr sty; Types.Int Types.I64 ] in
|
||
(* Registered before the fields are walked, so a struct reached twice
|
||
through two different fields emits one pair and not two. A struct cannot
|
||
contain itself by value, so there is no cycle to break — only sharing.
|
||
The body is filled in below; nothing can call these in between. *)
|
||
let placeholder name ret params =
|
||
{ Tast.name; params; slots = Array.of_list params;
|
||
snames = Array.make (List.length params) None;
|
||
ret; body = []; fdefers = []; fparent = None; floc = loc }
|
||
in
|
||
env.lifted <-
|
||
placeholder hname hash_ty hparams
|
||
:: placeholder ename (Types.Int Types.I8) eparams
|
||
:: env.lifted;
|
||
|
||
(* The hash: seed, then one combine per field, in declaration order. Each
|
||
field is hashed by its own pair — the same recursion, so a string field
|
||
hashes its bytes and a nested struct hashes field by field. Padding is
|
||
never reached, because nothing here addresses anything but a field. *)
|
||
let hctx = invented_ctx env hash_ty in
|
||
let kp = fresh_slot ~name:"key" hctx (Types.Ptr sty) in
|
||
let seed = fresh_slot ~name:"seed" hctx hash_ty in
|
||
ignore (fresh_slot ~name:"size" hctx (Types.Int Types.I64));
|
||
let acc = fresh_slot ~name:"h" hctx hash_ty in
|
||
let steps =
|
||
List.mapi
|
||
(fun i (fl : Tast.field) ->
|
||
let fty = fl.Tast.fty in
|
||
let h, _ = key_pair env loc fty in
|
||
let args =
|
||
[ field_addr_of loc sty fty kp i;
|
||
mk loc hash_ty (Tast.Local seed); size_of loc fty ]
|
||
in
|
||
let one =
|
||
match h with
|
||
| Tast.Rtfn s -> rt loc hash_ty (direct s) args
|
||
| Tast.Flanfn s | Tast.Fnval s ->
|
||
mk loc hash_ty (Tast.Call (s, args))
|
||
in
|
||
mk loc Types.Unit
|
||
(Tast.Set (Tast.Plocal acc,
|
||
rt loc hash_ty "flan_hash_combine"
|
||
[ mk loc hash_ty (Tast.Local acc); one ])))
|
||
fields
|
||
in
|
||
let hbody =
|
||
(mk loc Types.Unit
|
||
(Tast.Set (Tast.Plocal acc, mk loc hash_ty (Tast.Local seed))))
|
||
:: steps
|
||
@ [ mk loc hash_ty (Tast.Local acc) ]
|
||
in
|
||
|
||
(* The equality: one early return per field, then true. Written as returns
|
||
rather than as a conjunction so that the comparison stops at the first
|
||
field that differs, which for a struct with a string field is the
|
||
difference between one memcmp and two. *)
|
||
let ectx = invented_ctx env (Types.Int Types.I8) in
|
||
let ap = fresh_slot ~name:"a" ectx (Types.Ptr sty) in
|
||
let bp = fresh_slot ~name:"b" ectx (Types.Ptr sty) in
|
||
ignore (fresh_slot ~name:"size" ectx (Types.Int Types.I64));
|
||
let i8 v = mk loc (Types.Int Types.I8) (Tast.Int (v, Types.I8)) in
|
||
let checks =
|
||
List.mapi
|
||
(fun i (fl : Tast.field) ->
|
||
let fty = fl.Tast.fty in
|
||
let _, eq = key_pair env loc fty in
|
||
let args =
|
||
[ field_addr_of loc sty fty ap i;
|
||
field_addr_of loc sty fty bp i; size_of loc fty ]
|
||
in
|
||
let call =
|
||
match eq with
|
||
| Tast.Rtfn s -> rt loc (Types.Int Types.I8) (direct s) args
|
||
| Tast.Flanfn s | Tast.Fnval s ->
|
||
mk loc (Types.Int Types.I8) (Tast.Call (s, args))
|
||
in
|
||
let differs =
|
||
mk loc Types.Bool (Tast.Prim (Tast.Eq, [ call; i8 0L ]))
|
||
in
|
||
mk loc Types.Unit
|
||
(Tast.If (differs,
|
||
mk loc Types.Never (Tast.Return (Some (i8 0L))),
|
||
unit_at loc)))
|
||
fields
|
||
in
|
||
let ebody = checks @ [ i8 1L ] in
|
||
|
||
let finish name ret params ctx body =
|
||
{ Tast.name; params;
|
||
slots = Array.of_list (List.rev ctx.slot_tys);
|
||
snames = Array.of_list (List.rev ctx.slot_names);
|
||
ret; body; fdefers = []; fparent = None; floc = loc }
|
||
in
|
||
env.lifted <-
|
||
finish hname hash_ty hparams hctx hbody
|
||
:: finish ename (Types.Int Types.I8) eparams ectx ebody
|
||
:: List.filter
|
||
(fun (f : Tast.fn) ->
|
||
f.Tast.name <> hname && f.Tast.name <> ename)
|
||
env.lifted;
|
||
Tast.Flanfn hname, Tast.Flanfn ename
|
||
end
|
||
|
||
(* The pair as two expressions, ready to be passed. Their Flan type is
|
||
[Alloc]: an opaque pointer-width value with no user-writable constructor,
|
||
which is all the backend needs and all any Flan type ever says about it. *)
|
||
let key_fns env loc k =
|
||
let h, e = key_pair env loc k in
|
||
mk loc Types.Alloc (Tast.FnAddr h), mk loc Types.Alloc (Tast.FnAddr e)
|
||
|
||
(* ── The map operations, deferred to the instantiation ─────────────────
|
||
True when the key is a type variable, which means the operation cannot be
|
||
built here and must be answered by the copy: [key_fns] emits concrete
|
||
symbols and there is no concrete key type yet. The caller checks its
|
||
arguments first and then returns a placeholder of the operation's own type,
|
||
exactly as [print] does — see the allow-list comment at the [print] arm for
|
||
what being on that list costs and why these are on it.
|
||
|
||
The predicate is *required* before deferring, and that is the whole safety
|
||
argument: with {:where (hashable? $t)} in the signature, the instantiation
|
||
refuses at the call site against a requirement the author wrote down. A
|
||
variable with no such clause is refused here and now, at the definition,
|
||
which is where the abstract pass wants every refusal that has nothing to
|
||
point at. In practice [map_type] has already refused such a signature where
|
||
the type was written; this repeats it rather than relying on that, the same
|
||
way [key_pair] repeats [map_type]'s key check. *)
|
||
let deferred_key env loc what (k : Types.t) =
|
||
match k with
|
||
| Types.Var v ->
|
||
if not (declares env.tvpreds v "hashable?") then
|
||
Loc.failk "check/generic-map-key" loc
|
||
"%s over a map keyed by the type variable %s is refused: the hash and \
|
||
the equality are emitted as concrete symbols chosen from the \
|
||
concrete key type, and nothing here declares %s hashable. Write \
|
||
{:where (hashable? $%s)} at the head of the body — then the \
|
||
operation is deferred to each instantiation, and a call site that \
|
||
asks for a key type that cannot be hashed is refused there, against \
|
||
the clause"
|
||
what (Types.to_string k) (Types.to_string k) v;
|
||
true
|
||
| _ -> false
|
||
|
||
(* The body shared by [get] and [map-remove]: both answer an (Option V), both
|
||
ask the runtime one question over (key address, out address, sizes, hash,
|
||
equality), and the only thing that differs between them is which symbol is
|
||
called. [sym] is therefore the whole of the difference, and the entry points
|
||
above keep their own preambles — the dyn case and the deferred-key case are
|
||
not the same on the two sides.
|
||
|
||
The key is checked and the out slot zeroed by the caller's [k] and by
|
||
[Tast.Zero] here; the Option is built here rather than in the runtime,
|
||
because the runtime answers 1/0 and fills [out] only when it answers 1. It
|
||
has no idea what an Option's layout is, and keeping it that way is what lets
|
||
one entry point serve every value type. *)
|
||
let map_lookup ctx ~want loc sym target kt vt k =
|
||
let hash, eq = key_fns ctx.env loc kt in
|
||
let ks = fresh_slot ctx kt in
|
||
let out = fresh_slot ctx vt in
|
||
let found =
|
||
rt loc (Types.Int Types.I8) sym
|
||
[ target; addr_of loc (mk loc kt (Tast.Local ks));
|
||
addr_of loc (mk loc vt (Tast.Local out));
|
||
size_of loc kt; size_of loc vt; hash; eq; here loc ]
|
||
in
|
||
let oty = Types.Option vt in
|
||
let some = mk loc oty (Tast.Some_ (mk loc vt (Tast.Local out))) in
|
||
let none = mk loc oty Tast.None_ in
|
||
let cond =
|
||
mk loc Types.Bool
|
||
(Tast.Prim (Tast.Ne,
|
||
[ found;
|
||
mk loc (Types.Int Types.I8) (Tast.Int (0L, Types.I8)) ]))
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc oty
|
||
(Tast.Let ([ (ks, k);
|
||
(out, mk loc vt (Tast.Zero vt)) ],
|
||
[ mk loc oty (Tast.If (cond, some, none)) ])))
|
||
|
||
(* The braced-pairs scan a struct literal, a union value and a data case all
|
||
do: each key is refused if it has already been given, with a note at the
|
||
first mention, and the table of what was given comes back for the fill that
|
||
follows. [noun] is the word the message uses — a union's are members, the
|
||
other two have fields — and [~known] is the caller's own unknown-key
|
||
refusal, run after the duplicate check on the same pair so that a key given
|
||
twice is reported as the duplicate it is rather than as whatever the second
|
||
mention is. A caller that wants its unknown-key pass run over all the pairs
|
||
first, before any of this, passes no [~known] and keeps its own loop. *)
|
||
let given_once ~noun ?(known = fun _ _ -> ()) kvs =
|
||
let seen = Hashtbl.create 8 in
|
||
List.iter
|
||
(fun (k, (v : Ast.expr)) ->
|
||
(match Hashtbl.find_opt seen k with
|
||
| Some (first : Ast.expr) ->
|
||
Loc.failk "check/duplicate-field" v.Ast.loc
|
||
~notes:[ Loc.note first.Ast.loc (k ^ " is given here first") ]
|
||
"%s %s is given twice" noun k
|
||
| None -> ());
|
||
known k v;
|
||
Hashtbl.add seen k v)
|
||
kvs;
|
||
seen
|
||
|
||
let rec check ctx ?want (e : Ast.expr) : Tast.expr =
|
||
let loc = e.Ast.loc in
|
||
(* Read the permission this form was given and withdraw it in the same
|
||
breath, so that nothing reached from here inherits it. The two callers
|
||
that may grant it — [check_fn]'s body walk and [check_let]'s, below —
|
||
grant it again before the *next* form rather than once around the body. *)
|
||
let defer_ok = ctx.defer_ok in
|
||
ctx.defer_ok <- false;
|
||
(* The same read-and-withdraw, for the same reason: a [recur] is in tail
|
||
position only if *this* form was, and nothing reached from here inherits
|
||
it unless the arm below hands it on deliberately. *)
|
||
let tail = ctx.tail in
|
||
ctx.tail <- false;
|
||
match e.Ast.e with
|
||
| Ast.Int n -> int_literal loc ~want ~preds:ctx.env.tvpreds n
|
||
| Ast.Byte b ->
|
||
int_literal loc ~want ~preds:ctx.env.tvpreds ~default:Types.U8
|
||
(Int64.of_int b)
|
||
(* The float literal's own dyn case, for the reason the integer's has one:
|
||
the ABI carries one width and the literal is built at it. f64 is already
|
||
what an unconstrained float literal defaults to, so this only has to stop
|
||
the "expected dyn, found the float literal" arm below from firing. *)
|
||
| Ast.Float x when want = Some Types.Dyn ->
|
||
box loc (mk loc dyn_f64 (Tast.Float (x, Types.F64)))
|
||
| Ast.Float x ->
|
||
let k =
|
||
match want with
|
||
| Some (Types.Float k) -> k
|
||
(* A float literal at a type variable, refused even under [numeric?] —
|
||
the asymmetry with the integer literal above is deliberate and is the
|
||
same asymmetry the concrete arms already have. An untyped integer
|
||
constant is usable wherever a float is wanted; a float literal is
|
||
never usable where an integer is wanted (Odin's rule, stated at the
|
||
[Int] case). So [numeric?] admits integers, and a body written with a
|
||
float literal has no meaning at the integer half of its own bound.
|
||
Refusing here keeps that a refusal at the definition rather than one
|
||
that surprises whichever call site first instantiates at [i32]. *)
|
||
| Some (Types.Var v) ->
|
||
(* Under {:where (integer? $t)} the sentence is simpler and its own:
|
||
the bound has no float half at all, so the literal has no meaning
|
||
at *any* type the variable can become, not merely at some. *)
|
||
if declares ctx.env.tvpreds v "integer?" then
|
||
Loc.failk literal_at_want loc
|
||
"the float literal %g cannot stand where $%s is wanted: \
|
||
{:where (integer? $%s)} admits no float type, so there is no \
|
||
instantiation at which this literal means anything. Write an \
|
||
integer literal, or take the value as a parameter"
|
||
x v v
|
||
else
|
||
Loc.failk literal_at_want loc
|
||
"the float literal %g cannot stand where $%s is wanted: %s may be \
|
||
instantiated at an integer type, and a float literal is never \
|
||
usable where an integer is wanted. Write the constant as an \
|
||
integer literal — that one is admitted under {:where (numeric? \
|
||
$%s)} at every numeric type — or take the value as a parameter"
|
||
x v
|
||
(if declares ctx.env.tvpreds v "numeric?" then
|
||
Printf.sprintf "{:where (numeric? $%s)} admits integers too, so $%s" v v
|
||
else Printf.sprintf "$%s" v)
|
||
v
|
||
| Some other when other <> Types.Never ->
|
||
Loc.failk literal_at_want loc "expected %s, found the float literal %g"
|
||
(Types.to_string other) x
|
||
| _ -> Types.F64
|
||
in
|
||
mk loc (Types.Float k) (Tast.Float (x, k))
|
||
| Ast.Str s -> expect ctx loc ~want (mk loc Types.String (Tast.Str s))
|
||
| Ast.Kw k ->
|
||
(* Two keywords in one spelling, told apart by the expectation. Where an
|
||
enum type is expected, :space resolves at compile time against its
|
||
members and a typo is an error here rather than a wrong number at run
|
||
time (plan.org, settled: keywords at typed call sites) — no runtime
|
||
value exists at all. Everywhere else :foo is a first-class dyn value,
|
||
interned by the runtime so two spellings of one name are one word and
|
||
equality is an identity compare. The enum reading keeps priority
|
||
because it existed first and costs nothing; nothing is lost, since a
|
||
site that wants the dyn keyword against an enum expectation has none. *)
|
||
(match want with
|
||
| Some (Types.Enum name) ->
|
||
let members = Hashtbl.find ctx.env.enums name in
|
||
(match List.assoc_opt k members with
|
||
| Some v -> mk loc (Types.Enum name) (Tast.Int (v, Types.I32))
|
||
| None ->
|
||
(* The near miss first, because the raylib enums carry a
|
||
disambiguating member prefix — [:r] is four edits from [:key-r]
|
||
and one thought, and the list alone makes the reader do the
|
||
thought. The list still follows: the suggestion can be wrong. *)
|
||
(match member_near_miss members k with
|
||
| Some (m, _) ->
|
||
fail loc "%s has no member :%s — did you mean :%s? It has %s"
|
||
name k m
|
||
(String.concat " "
|
||
(List.map (fun (m, _) -> ":" ^ m) members))
|
||
| None ->
|
||
fail loc "%s has no member :%s — it has %s" name k
|
||
(String.concat " "
|
||
(List.map (fun (m, _) -> ":" ^ m) members))))
|
||
| Some Types.Dyn | None ->
|
||
expect ctx loc ~want
|
||
(rt loc Types.Dyn "flan_dyn_kw" [ mk loc Types.String (Tast.Str k) ])
|
||
| Some other ->
|
||
fail loc
|
||
":%s is an enum member where an enum is expected and a dyn keyword \
|
||
elsewhere, but %s is expected here" k
|
||
(Types.to_string other))
|
||
(* {:a 1 :b s} — a dyn map, built where it stands. Always dyn: the
|
||
runtime owns the storage the way (vec-new dyn) does, keys and values are
|
||
both dyn words, and a typed want other than dyn refuses through [expect]
|
||
like any other dyn value would. The literal lowers to a fresh slot — a
|
||
rooted one, because a slot of type dyn is what [dyn_roots] counts — so
|
||
the map stays reachable across the allocations its own entries make. *)
|
||
| Ast.MapLit (tag, kvs) ->
|
||
let m = fresh_slot ctx Types.Dyn in
|
||
let mval = mk loc Types.Dyn (Tast.Local m) in
|
||
let sets =
|
||
List.map
|
||
(fun (k, v) ->
|
||
rt loc Types.Unit "flan_dyn_map_set"
|
||
[ mval; check ctx ~want:Types.Dyn k;
|
||
check ctx ~want:Types.Dyn v ])
|
||
kvs
|
||
in
|
||
(* A shape tag, if this is the literal a class's constructor was written
|
||
from. It is the class's name interned as a keyword, and it goes into
|
||
the object's header rather than into the entries — so everything below
|
||
this line, the rooting included, is the untagged case unchanged. *)
|
||
let empty =
|
||
match tag with
|
||
| None -> rt loc Types.Dyn "flan_dyn_map_new" []
|
||
| Some cls ->
|
||
rt loc Types.Dyn "flan_dyn_map_new_class"
|
||
[ rt loc Types.Dyn "flan_dyn_kw" [ mk loc Types.String (Tast.Str cls) ] ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Dyn (Tast.Let ([ (m, empty) ], sets @ [ mval ])))
|
||
| Ast.Quote _ ->
|
||
unimplemented loc "a quoted symbol (restart names)" 6
|
||
| Ast.Var name -> var ctx loc ~want name
|
||
| Ast.Do body -> ctx.tail <- tail; block ctx ?want loc body
|
||
(* [defer_ok] rides through: a [let] at the top level of a function body has
|
||
exactly the function's extent, and so does a [let] nested inside one.
|
||
[tail] rides through for the same shape of reason: a [recur] written as
|
||
the last form of a [let] inside a loop body is in the loop's tail. *)
|
||
| Ast.Let (bs, body) -> check_let ctx ~tail ?want ~defer_ok loc bs body
|
||
| Ast.If (c, t, e') -> check_if ctx ~tail ?want loc c t e'
|
||
| Ast.While (label, c, body) ->
|
||
(* The condition is part of the loop even though it is written outside the
|
||
braces — emit puts it in the header block, so it is re-evaluated at the
|
||
top of every trip — but it stays outside [in_loop], because a [break]
|
||
in a condition still means the enclosing loop and a [defer] there is
|
||
still the outer block's. *)
|
||
let c = check_truthy ctx c in
|
||
let body = in_loop ctx ?label (fun () ->
|
||
scoped ctx (fun () -> map_lr (fun b -> check ctx b) body))
|
||
in
|
||
(* No latch: a [while] has nothing to run between the body and the test, so
|
||
a [continue] can branch straight at the condition. *)
|
||
expect ctx loc ~want (mk loc Types.Unit (Tast.While (c, body, [])))
|
||
(* [Never], as [exit] and [return] are: nothing after one of these runs, and
|
||
an [if] arm that ends in a break does not have to agree with the other. *)
|
||
(* (loop [x 0 acc 1] body ...) — a loop that answers with the value of its
|
||
body, and the only place a [recur] may stand. Not an IR node: it is a
|
||
[let] over the names, a [While] whose condition is [true], and a jump.
|
||
See [check_loop]. *)
|
||
| Ast.Loop (bs, body) -> check_loop ctx ?want loc bs body
|
||
| Ast.Recur args -> check_recur ctx ~tail loc args
|
||
| Ast.Break label ->
|
||
mk loc Types.Never (Tast.Break (loop_target ctx loc "break" label))
|
||
| Ast.Continue label ->
|
||
mk loc Types.Never (Tast.Continue (loop_target ctx loc "continue" label))
|
||
| Ast.Return v when ctx.in_frames <> None ->
|
||
ignore v;
|
||
(* The frames are pushed and popped around the body, so an early exit would
|
||
leave them on the handler or restart stack pointing into a frame that
|
||
has gone. Rejected rather than left to corrupt it, the same rule as
|
||
defer inside a block. *)
|
||
fail loc
|
||
"return is not allowed inside %s yet — the frames it established are \
|
||
popped on the way out and an early exit would leave them on the stack"
|
||
(match ctx.in_frames with Some n -> n | None -> assert false)
|
||
|
||
| Ast.Return v ->
|
||
let v =
|
||
match v with
|
||
| None ->
|
||
if not (Types.equal ctx.ret Types.Unit) then
|
||
fail loc "this function returns %s, so return needs a value"
|
||
(Types.to_string ctx.ret);
|
||
None
|
||
| Some v -> Some (check ctx ~want:ctx.ret v)
|
||
in
|
||
(* Whatever has been deferred *so far* runs first: a defer written below
|
||
this return has not executed yet and must not fire. *)
|
||
let r = mk loc Types.Never (Tast.Return v) in
|
||
(match ctx.defers with
|
||
| [] -> r
|
||
| ds -> mk loc Types.Never (Tast.Do (ds @ [ r ])))
|
||
(* (set (at target i) x) against a dyn target — a dyn vec from (vec-new
|
||
dyn), or a typed container's own view (M2 item 3) — is a call and not a
|
||
place: [flan_dyn_set_at] tag-checks [x]'s dyn tag against what the vec
|
||
or the view holds and traps on a mismatch, which is not a memory write
|
||
[Tast.Set] could express through a pointer. [target] is checked once,
|
||
here, and handed to [vec_at]/[indexed] unchecked in the [else] branch
|
||
below rather than re-checked by [check_place] — checking it twice would
|
||
evaluate a target with a side effect twice. A dyn target indexed more
|
||
than once — nothing in this milestone builds one — still falls to the
|
||
ordinary [Ast.Set] arm below, and [indexed] refuses it by name. *)
|
||
| Ast.Set (Ast.Pindex (target, [ idx ]), v) ->
|
||
let target = check ctx target in
|
||
if target.Tast.ty = Types.Dyn then
|
||
let i = check ctx ~want:Types.Dyn idx in
|
||
let v = check ctx ~want:Types.Dyn v in
|
||
expect ctx loc ~want
|
||
(rt loc Types.Unit "flan_dyn_set_at" [ target; i; v ])
|
||
else begin
|
||
let p, pty =
|
||
match target.Tast.ty with
|
||
| Types.Vec _ ->
|
||
let pp, ty = vec_at ctx loc target [ idx ] in
|
||
Tast.Pderef pp, ty
|
||
| _ ->
|
||
(* [~place], for the reason [check_place] passes it: [indexed]
|
||
accepts a string, and this arm never reaches [check_place]. *)
|
||
let iidx, ty = indexed ~place:loc ctx target [ idx ] in
|
||
Tast.Pindex (target, iidx), ty
|
||
in
|
||
let v = check ctx ~want:pty v in
|
||
expect ctx loc ~want (mk loc Types.Unit (Tast.Set (p, v)))
|
||
end
|
||
| Ast.Set (p, v) ->
|
||
let p, pty = check_place ctx loc p in
|
||
let v = check ctx ~want:pty v in
|
||
expect ctx loc ~want (mk loc Types.Unit (Tast.Set (p, v)))
|
||
| Ast.Field (target, name) ->
|
||
let target, sname = struct_target ctx target in
|
||
let s = Option.get (fields_named ctx.env sname) in
|
||
(match Tast.field_index s name with
|
||
| None ->
|
||
Loc.failk "check/unknown-field" loc ~notes:(declared_note ctx.env sname)
|
||
"%s has no field %s" sname name
|
||
| Some i ->
|
||
let fty = (List.nth s.Tast.fields i).Tast.fty in
|
||
expect ctx loc ~want (mk loc fty (Tast.Field (target, i))))
|
||
| Ast.Struct (name, kvs) -> check_struct ctx ~want loc name kvs
|
||
| Ast.Bare kvs -> check_bare ctx ~want loc kvs
|
||
(* A bracket literal where a dyn is wanted is the runtime's own vec, built
|
||
where it stands — the same lowering the map literal gets, and what makes
|
||
{:xs [1 2]} mean what it reads as. Everywhere else brackets stay the
|
||
fixed-array literal they always were. *)
|
||
| Ast.Arr items when want = Some Types.Dyn ->
|
||
let v = fresh_slot ctx Types.Dyn in
|
||
let vval = mk loc Types.Dyn (Tast.Local v) in
|
||
let pushes =
|
||
List.map
|
||
(fun x ->
|
||
rt loc Types.Unit "flan_dyn_push"
|
||
[ vval; check ctx ~want:Types.Dyn x ])
|
||
items
|
||
in
|
||
mk loc Types.Dyn
|
||
(Tast.Let ([ (v, rt loc Types.Dyn "flan_dyn_vec_new" []) ],
|
||
pushes @ [ vval ]))
|
||
| Ast.Arr items -> check_arr ctx ~want loc items
|
||
(* (array 4 rl/Vector2). Parse already assembled the whole array type, so
|
||
there is nothing to infer: resolve it and hand back its all-bytes-zero
|
||
value, which is what a declared array with no initialiser gets. *)
|
||
| Ast.ArrayOf t ->
|
||
let ty = resolve ctx.env t in
|
||
expect ctx loc ~want (mk loc ty (Tast.Zero ty))
|
||
| Ast.ArrayFill (dims, v) -> check_array_fill ctx ~want loc dims v
|
||
| Ast.ArrayGen (dims, f) -> check_array_gen ctx ~want loc dims f
|
||
| Ast.Match (scrutinee, arms) -> check_match ctx ~tail ?want loc scrutinee arms
|
||
| Ast.Call (head, args) -> check_call ctx ~want loc head args
|
||
| Ast.Unwrap (Ast.Usome, v) ->
|
||
(* Unwrap Some, else early-return None from the enclosing function, so the
|
||
enclosing function must itself return an Option (plan.org). *)
|
||
(match ctx.ret with
|
||
| Types.Option _ ->
|
||
let v = check ctx v in
|
||
(match v.Tast.ty with
|
||
| Types.Option t ->
|
||
expect ctx loc ~want (mk loc t (Tast.UnwrapSome v))
|
||
| other ->
|
||
fail loc "some takes an (Option T), found %s" (Types.to_string other))
|
||
| other ->
|
||
fail loc
|
||
"some early-returns None, so the enclosing function must return an \
|
||
Option; this one returns %s" (Types.to_string other))
|
||
| Ast.Unwrap (Ast.Utry, _) -> unimplemented loc "try (Result)" 6
|
||
| Ast.Fn (params, body) -> check_fn ctx ~want loc params body
|
||
| Ast.Dotimes (label, name, bounds, body) ->
|
||
check_dotimes ctx ~want loc label name bounds body
|
||
(* (signal c) : Unit, always — spec-conditions.md §1. A handler that returns
|
||
normally leaves the signalling function to carry on, and with nothing
|
||
matching this is a no-op, so nothing about it alters control flow. That is
|
||
what makes it checkable here rather than needing the transfer machinery
|
||
restart-case will want. *)
|
||
| Ast.Signal (kind, c) ->
|
||
let c = check ctx c in
|
||
let name =
|
||
match c.Tast.ty with
|
||
| Types.Named n -> n
|
||
(* Its own arm ahead of the general one, because "a condition is a
|
||
struct, not dyn" would read as a rule about shape when the answer is a
|
||
milestone. A condition crosses a handler boundary as a pointer to a
|
||
frame that is still alive, and a dyn payload has to stay rooted across
|
||
that transfer — which is the collector's question, not this one's, and
|
||
it is milestone 2's. *)
|
||
| Types.Dyn ->
|
||
fail c.Tast.loc
|
||
"a condition is matched by its type and dyn is not one — write the \
|
||
condition's struct type, whose dyn fields are fine"
|
||
| t ->
|
||
fail c.Tast.loc
|
||
"a condition is a struct, not %s — matching is by type and there is \
|
||
no condition hierarchy"
|
||
(Types.to_string t)
|
||
in
|
||
(* A condition that *holds* a dyn used to be refused here for the same
|
||
reason. It is not any more: the condition crosses as a pointer to a
|
||
value in the signalling frame, and that value is on the collector's
|
||
root stack with its type's descriptor beside it — which is exactly the
|
||
shape the transfer needed and could not have. Where the condition is
|
||
not a place, the backends evaluate it into a rooted slot rather than a
|
||
scratch temporary, so a handler that allocates cannot collect the
|
||
payload it was handed. See [Emit.addr_rooted]. *)
|
||
(* §1 and §2. [signal] is Unit whatever it finds; [error] is Never,
|
||
because the only way past it is a handler that transfers — one that
|
||
returns normally has not answered it, and the program stops. *)
|
||
let ty, kind =
|
||
match kind with
|
||
| Ast.Ssignal -> (Types.Unit, Tast.Ssignal)
|
||
| Ast.Serror -> (Types.Never, Tast.Serror)
|
||
in
|
||
expect ctx loc ~want (mk loc ty (Tast.Signal (kind, type_id name, c)))
|
||
|
||
| Ast.HandlerBind (clauses, body) -> check_handler_bind ctx ?want loc clauses body
|
||
| Ast.HandlerCase (body, clauses) -> check_handler_case ctx ?want loc body clauses
|
||
|
||
(* spec-conditions.md §3–§6: the transfer. Neither of these is a call — one
|
||
establishes frames around a body, and the other leaves the function it is
|
||
written in — so both are their own nodes all the way down. *)
|
||
| Ast.RestartCase (body, clauses) -> check_restart_case ctx ?want loc body clauses
|
||
| Ast.InvokeRestart (name, args) ->
|
||
(* Never: control resumes at the restart-case, which yields the clause's
|
||
value to *its* continuation, so nothing here has a value and nothing
|
||
after it runs. The lookup is at run time because restarts are
|
||
dynamically scoped and named — §4 — and so, for the same reason, is the
|
||
check that these arguments are the ones the clause takes (§3). *)
|
||
if ctx.in_defer then
|
||
fail loc
|
||
"invoke-restart is not allowed inside a defer — a defer is the cleanup \
|
||
a transfer runs on its way out, so starting one there would leave \
|
||
this function's defers half run with two targets and no way to \
|
||
choose";
|
||
let args = map_lr (fun a -> check ctx a) args in
|
||
List.iter
|
||
(fun (a : Tast.expr) ->
|
||
match a.Tast.ty with
|
||
| Types.Unit | Types.Never ->
|
||
fail a.Tast.loc
|
||
"a restart argument must be a value, and this one is %s"
|
||
(Types.to_string a.Tast.ty)
|
||
| _ -> ())
|
||
args;
|
||
let sg = restart_sig (List.map (fun (a : Tast.expr) -> a.Tast.ty) args) in
|
||
(* Evaluated into slots first, so that an argument which transfers on its
|
||
own is guarded before this form aims the channel, and so that a call
|
||
written in an argument is on the ordinary walk rather than hidden
|
||
inside a node that [Reach] and [Load] treat as a leaf. *)
|
||
let binds =
|
||
List.map (fun (a : Tast.expr) -> (fresh_slot ctx a.Tast.ty, a)) args
|
||
in
|
||
let locals =
|
||
List.map
|
||
(fun (s, (a : Tast.expr)) -> mk a.Tast.loc a.Tast.ty (Tast.Local s))
|
||
binds
|
||
in
|
||
let invoke =
|
||
mk loc Types.Never
|
||
(Tast.InvokeRestart (type_id name, name, locals, sg, type_id sg, loc))
|
||
in
|
||
expect ctx loc ~want
|
||
(if binds = [] then invoke
|
||
else mk loc Types.Never (Tast.Let (binds, [ invoke ])))
|
||
|
||
| Ast.Defer forms ->
|
||
(* Registering is the whole of it: the forms are checked here, where they
|
||
can see the scope they are written in, and the node left behind is
|
||
[unit]. [check_fn] splices the registered list onto both exit paths.
|
||
|
||
[defer_ok] is true for a top-level form of the body and for a form in a
|
||
[let] whose extent is the body — see the field's comment. Anywhere else
|
||
the cleanup would run at function exit rather than at the exit of the
|
||
construct it was written in, so it is refused, and named. *)
|
||
if not defer_ok then
|
||
fail loc
|
||
"defer is not allowed inside %s — a defer is copied into every exit \
|
||
path of the function, so it always registers and always runs at \
|
||
function exit. Write it at the top level of the function body, or in \
|
||
a let that is (a let has the function's extent, because nothing is \
|
||
released at scope exit)"
|
||
ctx.defer_block;
|
||
register_defer ctx loc forms
|
||
|
||
and int_literal loc ~want ?(preds = []) ?(default = Types.I32) n =
|
||
match want with
|
||
| Some (Types.Int k) -> mk loc (Types.Int k) (Tast.Int (in_range loc k n, k))
|
||
(* An integer literal where a *type variable* is wanted: the abstract pass
|
||
over a generic body, checking [(> x 0)] or [(+ x 1)] with [x] at [$t].
|
||
|
||
It is admitted exactly when [$t] is declared [numeric?], and that bound is
|
||
what makes it sound rather than optimistic: every type [numeric?] admits
|
||
is an integer or a float, and an untyped integer constant is usable at all
|
||
of them — the same rule the [Float k] arm below encodes for a concrete
|
||
float. So there is no instantiation of a [numeric?] variable at which this
|
||
literal has no meaning, which is the promise the abstract pass exists to
|
||
make.
|
||
|
||
The node built here is never emitted. A generic body produces no code; the
|
||
instantiation re-checks the same form with [$t] substituted, and then the
|
||
[Int k] or [Float k] arm above builds the literal at the concrete type and
|
||
runs the range check. [I64] is the placeholder width and is chosen only so
|
||
that a value too wide for [I32] survives the abstract pass to be ranged at
|
||
the instantiation that actually has a type — [(defn f [x $t] $t (+ x 300))]
|
||
is fine at [i32] and a refusal at [u8], and [u8] is where it is refused. *)
|
||
| Some (Types.Var v) when declares preds v "numeric?" ->
|
||
mk loc (Types.Var v) (Tast.Int (n, Types.I64))
|
||
(* A literal in dyn position takes i64 and not the i32 an unconstrained one
|
||
defaults to. This is where "dyn integers are i64" stops being a statement
|
||
about the ABI and becomes one about the language: [(defonce x dyn 5)] holds
|
||
an i64 five, and the defaulting question a wider set of boxes would raise
|
||
never arises because there is only the one box. Handled here rather than
|
||
left to [expect] so the literal is *built* at the right width — the range
|
||
check below is the one that matters, and 3000000000 is a dyn integer even
|
||
though it is not an i32. *)
|
||
| Some Types.Dyn ->
|
||
box loc (mk loc dyn_i64 (Tast.Int (in_range loc Types.I64 n, Types.I64)))
|
||
(* An untyped integer constant is usable where a float is wanted, as in
|
||
Odin. A float literal is never usable where an integer is wanted. *)
|
||
| Some (Types.Float k) ->
|
||
mk loc (Types.Float k) (Tast.Float (Int64.to_float n, k))
|
||
(* The same position without the bound. An unconstrained variable supports
|
||
only what every type supports, and holding a number is not that, so the
|
||
refusal names the bound that would admit it rather than reporting a type
|
||
mismatch the programmer cannot act on. *)
|
||
| Some (Types.Var v) ->
|
||
Loc.failk literal_at_want loc
|
||
"the integer literal %Ld cannot stand where $%s is wanted: an \
|
||
unconstrained type variable may be instantiated at a type that holds \
|
||
no number. Declare the bound — {:where (numeric? $%s)} — and the \
|
||
literal is admitted at every type $%s can then be"
|
||
n v v v
|
||
| Some other when other <> Types.Never ->
|
||
Loc.failk literal_at_want loc "expected %s, found the integer literal %Ld"
|
||
(Types.to_string other) n
|
||
| _ -> mk loc (Types.Int default) (Tast.Int (in_range loc default n, default))
|
||
|
||
(* Arithmetic wraps, but a literal that does not fit its type is a typo, not a
|
||
wrap — 300 is never what someone meant by a u8. *)
|
||
and in_range loc k n =
|
||
let bits = Types.bits k in
|
||
let ok =
|
||
if Types.signed k then
|
||
bits = 64
|
||
|| (Int64.compare n (Int64.neg (Int64.shift_left 1L (bits - 1))) >= 0
|
||
&& Int64.compare n (Int64.shift_left 1L (bits - 1)) < 0)
|
||
else if bits = 64 then
|
||
(* A u64 literal is its 64-bit pattern, so anything at or above 2^63
|
||
arrives here as a negative [int64] and is still in range —
|
||
0xcbf29ce484222325 is a real u64 and not an error. The cost is that a
|
||
negative *decimal* literal is accepted as a u64 too, because the
|
||
reader records only the value and not how it was written. Narrower
|
||
unsigned types keep the strict check, which is where a typo like 300
|
||
for a u8 actually shows up. *)
|
||
true
|
||
else
|
||
Int64.compare n 0L >= 0
|
||
&& Int64.compare n (Int64.shift_left 1L bits) < 0
|
||
in
|
||
if ok then n
|
||
else Loc.failk literal_at_want loc "%Ld does not fit in %s" n
|
||
(Types.ikind_name k)
|
||
|
||
(* The arms that are names rather than calls, and the same rule holds for them:
|
||
each is in [builtins] below, and test_flan reads this match to check it. *)
|
||
and var ctx ?(qualified = false) loc ~want name =
|
||
match name with
|
||
(* The qualifier in a value position: [builtin/nil], [builtin/true],
|
||
[builtin/context/allocator] — the last one falls out for free, since
|
||
stripping one prefix off it leaves a name this match has an arm for.
|
||
|
||
A qualified name that gets past these arms must be refused and not
|
||
handed on to the tables below, which is the whole difference between
|
||
this and the call path. Falling through would look up [len] in
|
||
[env.fns] and answer with the address of the very definition the reader
|
||
wrote [builtin/] to get away from — the feature inverted, silently. So
|
||
the catch-all arm below asks [qualified] before it looks anything up. *)
|
||
| _ when not qualified && qualified_builtin name <> None ->
|
||
let bare = Option.get (qualified_builtin name) in
|
||
if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
|
||
var ctx ~qualified:true loc ~want bare
|
||
| "true" | "false" ->
|
||
expect ctx loc ~want (mk loc Types.Bool (Tast.Bool (name = "true")))
|
||
(* The dyn absence value, written down. It arrived with maps — (get m k) on
|
||
a key the map does not hold answers it — and this is its producer, so a
|
||
program can store one, compare against one, and put one in a map. It is
|
||
always dyn here: whatever it becomes at a typed want — None at an
|
||
(Option T), a refusal at a bare T — is [expect]'s boundary logic, M2
|
||
item 4. *)
|
||
| "nil" ->
|
||
expect ctx loc ~want (rt loc Types.Dyn "flan_dyn_nil" [])
|
||
| "None" ->
|
||
(match want with
|
||
| Some (Types.Option t) -> mk loc (Types.Option t) Tast.None_
|
||
(* The mirror of [nil] becoming [None]: at a dyn want, None *is* nil,
|
||
with nothing to build and nothing to check — there is only one dyn
|
||
absence and this is it, not an (Option T) that then gets boxed. *)
|
||
| Some Types.Dyn -> rt loc Types.Dyn "flan_dyn_nil" []
|
||
| Some other when other <> Types.Never ->
|
||
fail loc "expected %s, found None" (Types.to_string other)
|
||
| _ ->
|
||
fail loc
|
||
"nothing here says what None is an Option of — annotate the \
|
||
function's return type or the binding")
|
||
(* spec-memory.md puts the allocator in the calling convention as
|
||
[context/allocator] and [context/temp]. They read as names rather than
|
||
calls because that is how the spec writes them, and they are dynamic
|
||
variables at run time rather than extra parameters — see docs/BUILT.md for why
|
||
the literal reading of "calling convention" is deferred. *)
|
||
| "context/allocator" ->
|
||
expect ctx loc ~want
|
||
(mk loc Types.Alloc (Tast.Prim (Tast.Rt "flan_context_allocator", [])))
|
||
| "context/temp" ->
|
||
expect ctx loc ~want
|
||
(mk loc Types.Alloc (Tast.Prim (Tast.Rt "flan_context_temp", [])))
|
||
| _ when qualified ->
|
||
(* In [builtin_set] — the arm above checked — but not one of the four
|
||
arms above this one, so it is a builtin that exists only as a call.
|
||
There is no value to hand back: a builtin is an arm in the compiler,
|
||
not a function in the program, so it has no address for a [Tast.FnAddr]
|
||
to carry. Bare [len] in this position says "unknown name"; this says
|
||
the true thing instead, which is that the name is real and the position
|
||
is wrong. *)
|
||
fail loc
|
||
"%s%s is the builtin %s, which is a call and not a value — a builtin \
|
||
has no address to pass. Write (%s%s ...) at the call, or wrap it in a \
|
||
defn to pass that" builtin_prefix name name builtin_prefix name
|
||
| _ ->
|
||
match lookup ctx name with
|
||
| Some b ->
|
||
expect ctx loc ~want (mk loc b.bty (Tast.Local b.slot))
|
||
| None ->
|
||
match Hashtbl.find_opt ctx.env.globals name with
|
||
| Some (ty, _) ->
|
||
expect ctx loc ~want (mk loc ty (Tast.Global name))
|
||
| None ->
|
||
match Hashtbl.find_opt ctx.env.cases name with
|
||
(* A case with no fields is a whole value on its own, so it is written
|
||
as a name and not as a call — the same shape [None] has, and for the
|
||
same reason: there is nothing to put in the braces. A case that does
|
||
have fields is refused here rather than silently zeroed, because ZII
|
||
on a constructor would quietly produce a value nobody wrote. *)
|
||
| Some (dname, c) when String.contains name '.' ->
|
||
if c.Tast.vfields <> [] then
|
||
fail loc
|
||
"%s has fields, so it needs them — write (%s {.%s ...})"
|
||
name name
|
||
(List.hd c.Tast.vfields).Tast.fname;
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Named dname)
|
||
(Tast.MakeCase (dname, c.Tast.vname, [])))
|
||
| Some (dname, c) ->
|
||
fail loc
|
||
"%s is a case of the data type %s, and a data type value names both — \
|
||
write %s.%s" name dname dname c.Tast.vname
|
||
| None ->
|
||
(* A bare function name *is* the function. This is a Lisp-1 — one
|
||
top-level namespace, enforced, so a defn and a defonce cannot share
|
||
a name — and that is exactly what makes (map double xs) safe to
|
||
read: there is no second binding of [double] for it to have meant
|
||
instead, so Common Lisp's #'double would be punctuation answering
|
||
a question this language does not ask. *)
|
||
(match Hashtbl.find_opt ctx.env.fns name with
|
||
| Some (params, ret) ->
|
||
(* A foreign function is in [fns] too, and its emitted signature
|
||
is C's: no transfer channel, and an aggregate flattened by the
|
||
shim. Nothing could call the resulting pointer correctly, so it
|
||
is refused for what it is rather than handed out. *)
|
||
if Hashtbl.mem ctx.env.externs name then
|
||
fail loc
|
||
"%s is a foreign function, and its address is not a Flan \
|
||
function value: a Flan function's signature ends with the \
|
||
transfer channel and a C one does not. Wrap it in a defn \
|
||
and pass that" name;
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Fn (params, ret)) (Tast.FnAddr (Tast.Fnval name)))
|
||
| None -> captured ctx loc name; unknown_name ctx loc name)
|
||
|
||
(* What remains of spec-memory.md's ownership section after the repeals of
|
||
2026-09-18 is the allocator's side alone: the region rule decides where a
|
||
container of owning elements may be built, and the allocator's capability
|
||
decides what a free means at run time. Everything copies — a container as
|
||
its header, the copies aliasing one buffer — and which frees run, and in
|
||
what order, is the program's own business, the same contract Odin ships
|
||
with; the dev build's epoch words are the net under it. The flow analysis
|
||
that used to live here (a per-function dead set, a borrow flag, a
|
||
loop-iteration diff) went in the first repeal; the move-only concept
|
||
itself — copy refusals, [copyable?], the struct/union owning rules — went
|
||
in the second. See spec-memory.md, "The repeal". *)
|
||
|
||
(* [defer_ok] is granted again before *every* form, not once before the block:
|
||
[check] withdraws it as it starts, so granting it once would let the first
|
||
form carry a defer and refuse the second — and two resources acquired in one
|
||
[let] is the case the relaxation exists for. *)
|
||
and block ctx ?want ?(defer_ok = false) loc body =
|
||
match body with
|
||
(* Withdrawn here too. An empty body has no last form to be the tail, so
|
||
leaving the permission set would hand it to whatever is checked next. *)
|
||
| [] -> ctx.tail <- false; expect ctx loc ~want (unit_at loc)
|
||
| _ ->
|
||
(* A block's tail is its last form and nothing else. Callers that must not
|
||
pass one on need do nothing: [check] withdrew it before they were
|
||
reached, so [tail] is already false here for all of them. *)
|
||
let tail = ctx.tail in
|
||
let rec go = function
|
||
| [ last ] ->
|
||
ctx.defer_ok <- defer_ok;
|
||
ctx.tail <- tail;
|
||
let l = check ctx ?want last in [ l ], l.Tast.ty
|
||
| x :: rest ->
|
||
ctx.defer_ok <- defer_ok;
|
||
ctx.tail <- false;
|
||
let x = check ctx x in
|
||
let rest, ty = go rest in x :: rest, ty
|
||
| [] -> assert false
|
||
in
|
||
let body, ty = go body in
|
||
mk loc ty (Tast.Do body)
|
||
|
||
(* (fn [x y] BODY...) — a function value, lifted into a function of its own.
|
||
|
||
The same arrangement a handler clause already uses, and deliberately so:
|
||
this compiler has built and called function values internally since the Map
|
||
landed, and the surface feature is that machinery given a name rather than a
|
||
second one invented beside it.
|
||
|
||
**No capture, and that is the scope of this milestone.** The body sees its
|
||
parameters and the program's globals and nothing else; a reference to a
|
||
local of the enclosing function is refused by name (see [captured]) rather
|
||
than resolved to something it did not mean. That is what makes the value a
|
||
bare code address with no environment behind it, which in turn is what makes
|
||
it safe to pass down, return, and store: there is nothing that can outlive
|
||
anything. spec-memory.md's capture cases, and escaping closures with them,
|
||
stay deferred.
|
||
|
||
**The parameter types come from the position.** [Ast.Fn] carries names and
|
||
no types — that is the surface syntax, not an omission here — so an fn is
|
||
checkable exactly where something says what is wanted. An argument position
|
||
does, because [named_call] threads the callee's parameter type into each
|
||
argument; a bare [(let [f (fn [x] x)])] does not, and is refused saying so. *)
|
||
and check_fn ctx ~want ?gen loc (params : string list) body =
|
||
(* [gen] is (array-gen ...)'s way in for an inline fn: the *form* knows the
|
||
parameter types — one i32 index per dimension — without there being a
|
||
[(Fn ...)] want to say so, and the return is the annotated element type,
|
||
or [None] to take the body's own. Everything else threads [want]. The
|
||
caller has already checked the arity, in its own words. *)
|
||
let pts, ret0 =
|
||
match gen with
|
||
| Some (pts, r) -> pts, r
|
||
| None ->
|
||
match want with
|
||
| Some (Types.Fn (ps, r)) when List.length ps = List.length params ->
|
||
ps, Some r
|
||
| Some (Types.Fn (ps, r)) ->
|
||
fail loc
|
||
"this fn has %d parameter%s and %s was wanted here"
|
||
(List.length params)
|
||
(if List.length params = 1 then "" else "s")
|
||
(Types.to_string (Types.Fn (ps, r)))
|
||
| Some other when other <> Types.Never ->
|
||
fail loc "expected %s, found an fn" (Types.to_string other)
|
||
| _ ->
|
||
fail loc
|
||
"nothing here says what this fn's parameters are — an fn takes its \
|
||
types from the position it is written in, so it goes in an argument \
|
||
whose parameter is a (Fn [T ...] R), and a name already written as \
|
||
a defn goes anywhere"
|
||
in
|
||
(* Its own frame and its own empty scope, with [outer] kept only so that a
|
||
reference to the enclosing function's locals is refused for the reason it
|
||
is really refused for. When the return is being inferred the context gets
|
||
Unit provisionally — a [return] inside such a body would check against
|
||
it, which is a rough edge left rough on purpose: the body of a generator
|
||
is an expression, and no machinery is built for the form nobody writes. *)
|
||
let fctx =
|
||
{ (invented_ctx ctx.env (Option.value ret0 ~default:Types.Unit)) with
|
||
outer = ctx.scope; outer_what = Some "an fn"; owner = ctx.owner }
|
||
in
|
||
List.iter2
|
||
(fun n t -> ignore (bind fctx n t ~assignable:false)) params pts;
|
||
let fbody = map_lr (fun e -> check fctx e) body in
|
||
(* The same rule an ordinary defn's body follows: the last form is the
|
||
answer, and it has to be the declared return type — or, when nothing
|
||
declared one ([ret0] is [None]), the last form's own type *is* the
|
||
return, which is what lets a bare generator's element type be read off
|
||
its body. *)
|
||
let fbody, ret =
|
||
match List.rev fbody with
|
||
(* An fn with no body answers unit, the same as a defn whose declared
|
||
return type is () and whose body is empty. Unlike a defn it declares no
|
||
return type of its own, so there is nothing here to contradict — but
|
||
the *position* names one, and a position wanting a value is the case
|
||
[Check] has to refuse. Without this the empty body would simply fall
|
||
through and the call would read a return value nothing ever wrote. *)
|
||
| [] ->
|
||
(match ret0 with
|
||
| Some r when not (Types.equal r Types.Unit) ->
|
||
fail loc
|
||
"an fn with no body answers (), and this one is in a position \
|
||
that wants %s — write the value it should answer"
|
||
(Types.to_string r)
|
||
| _ -> fbody, Types.Unit)
|
||
| last :: rest ->
|
||
(match ret0 with
|
||
| Some r ->
|
||
List.rev (expect fctx last.Tast.loc ~want:(Some r) last :: rest), r
|
||
| None -> fbody, last.Tast.ty)
|
||
in
|
||
(* Named after the function it was written in and numbered within it, which
|
||
is the handler clause's rule and is stable for the same reason: a
|
||
redefinition module emits the lifted functions belonging to the bodies it
|
||
replaces, and an index into the whole program's list could not say which
|
||
those were. *)
|
||
let fname =
|
||
(* Counted per *kind*, not over everything this function has lifted. A
|
||
handler clause and an fn share one list, and a shared counter would
|
||
renumber every fn in a function the moment a handler-bind was added
|
||
above one — a rename for a body that did not change, in the names a
|
||
redefinition module emits. Two counters, two stable sequences. *)
|
||
let mine =
|
||
List.filter
|
||
(fun (l : Tast.fn) ->
|
||
l.Tast.fparent = Some ctx.owner
|
||
&& String.length l.Tast.name >= 3
|
||
&& String.sub l.Tast.name 0 3 = "fn/")
|
||
ctx.env.lifted
|
||
in
|
||
Printf.sprintf "fn/%s/%d" ctx.owner (List.length mine)
|
||
in
|
||
ctx.env.lifted <-
|
||
{ Tast.name = fname; params = pts;
|
||
slots = Array.of_list (List.rev fctx.slot_tys);
|
||
snames = Array.of_list (List.rev fctx.slot_names);
|
||
ret; body = fbody; fdefers = [];
|
||
fparent = Some ctx.owner; floc = loc }
|
||
:: ctx.env.lifted;
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Fn (pts, ret)) (Tast.FnAddr (Tast.Fnval fname)))
|
||
|
||
(* A handler runs where the *signal* was, not where it was established, so it
|
||
cannot be a branch in the function that wrote it: it is lifted into a
|
||
function of its own and reached through a pointer.
|
||
|
||
Which means it cannot see the establishing function's locals. Capturing them
|
||
is a closure with an explicit environment — the non-escaping kind, captured
|
||
by value onto this frame — and until that exists a reference to one is
|
||
rejected by name rather than silently resolving to something else. Globals and the condition itself are
|
||
in scope, which is enough for the accumulation case §1 is about.
|
||
|
||
The body may not [return] either. The frames are pushed and popped around
|
||
it, and an early exit would leave them on the stack pointing into a function
|
||
that has gone. *)
|
||
and check_handler_bind ctx ?want ?(what = "handler-bind") loc clauses body =
|
||
let frames =
|
||
(* Left to right, and not [List.map], whose order is unspecified: each of
|
||
these calls lifts a function onto [ctx.env.lifted] and names it after
|
||
the count already there, so an order nobody chose would number the
|
||
clauses of one handler-bind differently between builds. The names go in
|
||
a redefinition module, which is where that would be noticed — see the
|
||
argument in [check_fn]. *)
|
||
map_lr
|
||
(fun (c : Ast.hclause) ->
|
||
let ty = resolve ctx.env c.Ast.hty in
|
||
let name =
|
||
match ty with
|
||
| Types.Named n -> n
|
||
| t ->
|
||
fail c.Ast.hloc
|
||
"a handler matches a struct type, not %s" (Types.to_string t)
|
||
in
|
||
(* Its own context: a fresh frame, an empty scope, and no way to reach
|
||
the enclosing one. *)
|
||
let hctx =
|
||
{ (invented_ctx ctx.env Types.Unit) with
|
||
outer = ctx.scope; outer_what = Some "a handler" }
|
||
in
|
||
(* The condition crosses as a pointer, because the handler runs while
|
||
the signalling frame is still alive and there is nothing to copy.
|
||
What the clause binds is the condition itself, though, so the
|
||
pointer is a hidden parameter and the name is a slot loaded from
|
||
it — a handler that passed [c] to something expecting the struct
|
||
would otherwise be handed an address. *)
|
||
let pslot = fresh_slot hctx (Types.Ptr ty) in
|
||
let cslot = bind hctx c.Ast.hname ty ~assignable:false in
|
||
let hbody = map_lr (fun e -> check hctx e) c.Ast.hbody in
|
||
let hbody =
|
||
[ mk c.Ast.hloc Types.Unit
|
||
(Tast.Let
|
||
([ (cslot,
|
||
mk c.Ast.hloc ty
|
||
(Tast.Deref
|
||
(mk c.Ast.hloc (Types.Ptr ty) (Tast.Local pslot)))) ],
|
||
hbody)) ]
|
||
in
|
||
(* Named after the function it came out of, and numbered within it:
|
||
stable against an unrelated handler-bind being added elsewhere,
|
||
which an index into the whole program's lifted list would not be. *)
|
||
let fname =
|
||
(* Per kind, for the reason [check_fn] gives: an fn lifted out of
|
||
the same function must not shift this sequence. *)
|
||
let mine =
|
||
List.filter
|
||
(fun (l : Tast.fn) ->
|
||
l.Tast.fparent = Some ctx.owner
|
||
&& String.length l.Tast.name >= 8
|
||
&& String.sub l.Tast.name 0 8 = "handler/")
|
||
ctx.env.lifted
|
||
in
|
||
Printf.sprintf "handler/%s/%d/%s" ctx.owner (List.length mine) name
|
||
in
|
||
ctx.env.lifted <-
|
||
{ Tast.name = fname; params = [ Types.Ptr ty ];
|
||
slots = Array.of_list (List.rev hctx.slot_tys);
|
||
snames = Array.of_list (List.rev hctx.slot_names);
|
||
ret = Types.Unit; body = hbody; fdefers = [];
|
||
fparent = Some ctx.owner; floc = c.Ast.hloc }
|
||
:: ctx.env.lifted;
|
||
{ Tast.htype = type_id name; hfn = fname })
|
||
clauses
|
||
in
|
||
(* The flag is set on [ctx] itself and restored, not on a copy: [ctx.slots]
|
||
and [ctx.slot_tys] are mutable, so a copy would allocate the body's slots
|
||
into a record the function never sees again and the indices would
|
||
collide. *)
|
||
let saved = ctx.in_frames in
|
||
(* [what] is the form the reader wrote. A handler-case establishes its
|
||
frames through this function, so a [return] under one has to be refused
|
||
naming handler-case rather than naming the machinery underneath it. *)
|
||
ctx.in_frames <- Some what;
|
||
(* The body's last form is the form's value, which is [with-allocator]'s
|
||
shape and for the same reason: both wrap a body in something established
|
||
around it and taken off after, and neither is a reason for the body to
|
||
stop being an expression. §3 needs it — a [restart-case] whose body is a
|
||
[handler-bind] has to agree in type with its clauses, which is how all
|
||
four of this repository's crossing probes are written — and it is what
|
||
[handler-case] is *not*: that one's value is its clause's, which is the
|
||
whole difference between the two. [check_handler_case] below builds one
|
||
out of this form and a [restart-case], so both spellings run through
|
||
here and only the clause's landing place differs.
|
||
|
||
This used to be [ignore want] and a flat [Types.Unit], and nothing
|
||
complained, because a unit in value position is only caught where the
|
||
expectation is checked. So the two backends each answered a caller that
|
||
asked anyway, and answered differently: [emit.ml] a literal zero, this
|
||
machine whatever the body's last form had left in the slot. Neither was a
|
||
value; one of them merely looked like one. *)
|
||
let body, ty =
|
||
barrier ctx ("a " ^ what) (fun () ->
|
||
let rec go = function
|
||
| [] -> [ unit_at loc ], Types.Unit
|
||
| [ last ] -> let l = check ctx ?want last in [ l ], l.Tast.ty
|
||
| e :: rest ->
|
||
let e = check ctx e in
|
||
let rest, ty = go rest in
|
||
e :: rest, ty
|
||
in
|
||
go body)
|
||
in
|
||
ctx.in_frames <- saved;
|
||
expect ctx loc ~want (mk loc ty (Tast.Handled (frames, body)))
|
||
|
||
(* (restart-case BODY (name [] BODY-1) ...) — spec-conditions.md §3 and §6.
|
||
|
||
Unlike a handler, a clause runs *at* the restart-case, which is where it was
|
||
written, so it is a branch in this function and sees this function's scope.
|
||
What arrives from elsewhere is only the answer to "which clause": a transfer
|
||
names the frame it is aimed at, and this form compares that against the
|
||
frames it itself pushed.
|
||
|
||
Every clause body and the body have the same type, and that is the type of
|
||
the whole form — which is what makes the fall-through path visible in the
|
||
source (§1): a restart-case in value position has to produce its type when
|
||
no restart is invoked too. *)
|
||
and check_restart_case ctx ?want loc body clauses =
|
||
let saved = ctx.in_frames in
|
||
ctx.in_frames <- Some "restart-case";
|
||
let tbody = barrier ctx "a restart-case" (fun () -> check ctx ?want body) in
|
||
ctx.in_frames <- saved;
|
||
restart_clauses ctx ?want ~what:"restart-case" loc tbody clauses
|
||
|
||
(* The clauses of a [restart-case], checked against a body that has already
|
||
been checked. Separate from the form above because [handler-case] supplies
|
||
its own body — a [handler-bind] it built — and has to name itself in the
|
||
refusals rather than naming the machinery it is made of. *)
|
||
and restart_clauses ctx ?want ~what loc (tbody : Tast.expr) clauses =
|
||
(* With no expectation from outside, the body's own type is the expectation
|
||
the clauses are checked against — unless it produced no value at all, in
|
||
which case the first clause that does decides. *)
|
||
let want =
|
||
match want with
|
||
| Some _ -> want
|
||
| None -> if tbody.Tast.ty = Types.Never then None else Some tbody.Tast.ty
|
||
in
|
||
let ty = ref (match want with Some t -> Some t | None -> None) in
|
||
let seen = ref [] in
|
||
let clauses =
|
||
map_lr
|
||
(fun (c : Ast.rclause) ->
|
||
(* Two clauses of one name would make §4's "the first frame offering
|
||
the name" pick between them by an order nothing in the source
|
||
shows. *)
|
||
if List.mem c.Ast.rname !seen then
|
||
fail c.Ast.rloc "this %s offers %s twice" what c.Ast.rname;
|
||
seen := c.Ast.rname :: !seen;
|
||
(* §3's parameters. They are slots in *this* function — a clause runs
|
||
here, not where the invoke was — and the invoker stores into a
|
||
buffer this frame owns, because its own frame is gone by the time
|
||
the clause body starts (§5). Bound like a function's parameters:
|
||
visible only in the clause, and not assignable. *)
|
||
let params, b =
|
||
scoped ctx (fun () ->
|
||
let params =
|
||
List.map
|
||
(fun (p : Ast.field) ->
|
||
let ty = resolve ctx.env p.Ast.fty in
|
||
(match ty with
|
||
| Types.Unit | Types.Never ->
|
||
fail p.Ast.floc
|
||
"%s would be a restart parameter of type %s, which is \
|
||
not a value" p.Ast.fname (Types.to_string ty)
|
||
| _ -> ());
|
||
(bind ctx p.Ast.fname ty ~assignable:false, ty))
|
||
c.Ast.rparams
|
||
in
|
||
(* Each is checked against what the form has settled on so far, so
|
||
a clause that disagrees fails where it is written. The first one
|
||
to produce a value is what settles it when nothing outside
|
||
did. *)
|
||
(params,
|
||
(* The same barrier the body gets, and for the same reason: a
|
||
clause runs after a transfer landed at this restart-case, with
|
||
its frames still to be popped. *)
|
||
barrier ctx ("a " ^ what)
|
||
(fun () -> block ctx ?want:!ty c.Ast.rloc c.Ast.rbody)))
|
||
in
|
||
if !ty = None && b.Tast.ty <> Types.Never then ty := Some b.Tast.ty;
|
||
let sg = restart_sig (List.map snd params) in
|
||
{ Tast.rname_id = type_id c.Ast.rname; rname = c.Ast.rname;
|
||
rparams = params; rsig = sg; rsig_id = type_id sg; rbody = [ b ] })
|
||
clauses
|
||
in
|
||
let ty = match !ty with Some t -> t | None -> Types.Never in
|
||
mk loc ty (Tast.RestartCase (clauses, tbody))
|
||
|
||
(* (handler-case BODY [(Type [c] BODY-1) ...]) — the unwinding handler, and
|
||
spec-conditions.md's one remaining open question about it, answered: it *is*
|
||
a handler-bind plus a transfer, built here rather than given nodes and
|
||
backends of its own.
|
||
|
||
(handler-case B [(T [c] A)])
|
||
== (restart-case (handler-bind [(T [c] (invoke-restart 'R c))] B)
|
||
(R [c T] A))
|
||
|
||
That is Common Lisp's own definition of the operator, and every property
|
||
this form is supposed to have falls out of the two it is made of rather
|
||
than being re-implemented beside them.
|
||
|
||
- The clause runs *here*, at the handler-case, because a restart clause
|
||
does; so it sees this function's locals, which a handler clause cannot,
|
||
and its value is the whole form's, because a restart-case's clause value
|
||
is the whole restart-case's.
|
||
- The stack below is gone by then. §5's defers, and the allocator a
|
||
[with-allocator] rebound, are honoured on the way out because that is
|
||
what a transfer already does for every frame it leaves.
|
||
- A condition matching no clause installs no frame, so nothing here sees
|
||
it and it keeps going outward exactly as it would have.
|
||
- The body and every clause agree on one type, because §3 already says a
|
||
restart-case's body and clauses do. A clause that disagrees is refused
|
||
where it is written, like an [if] whose arms disagree.
|
||
|
||
The condition crosses as a restart argument, which means by value into a
|
||
buffer this frame owns — which is the only thing that can work, since §5
|
||
kills the signalling frame the condition was living on the moment the
|
||
transfer starts.
|
||
|
||
The restart the two halves meet over is named after this function and
|
||
numbered within it, and the number is the count of handler clauses already
|
||
lifted out of this function. That count never goes down, and every
|
||
handler-case lifts at least one clause before the next one can read it, so
|
||
within a name's own bucket the numbers are strictly increasing and no two
|
||
forms can mint the same name. A clause body written inside another handler
|
||
clause counts against the [<none>] bucket rather than against a function's,
|
||
which is the same argument again and not a hole: that bucket is one list
|
||
for the whole program and it only grows.
|
||
|
||
Uniqueness is the requirement rather than a nicety. Two handler-cases
|
||
sharing a name, one inside the other's extent, would have the inner frame
|
||
shadow the outer one (§4), which lands a condition at the wrong form —
|
||
and, because the two would be expecting different condition types, lands it
|
||
as a run-time signature refusal rather than as a wrong answer. *)
|
||
and check_handler_case ctx ?want loc body clauses =
|
||
let what = "handler-case" in
|
||
(* Resolved once here, for the refusal below; [check_handler_bind] resolves
|
||
them again for the frames, which is cheap and keeps that function whole. *)
|
||
let names =
|
||
List.map
|
||
(fun (c : Ast.hclause) ->
|
||
match resolve ctx.env c.Ast.hty with
|
||
| Types.Named n -> n
|
||
| t ->
|
||
fail c.Ast.hloc
|
||
"a handler matches a struct type, not %s" (Types.to_string t))
|
||
clauses
|
||
in
|
||
(* Two clauses for one condition type: the first would take every one of
|
||
them and the second could never run, and nothing in the source says which
|
||
the reader meant. The same refusal a duplicate restart name gets, and for
|
||
the same reason. *)
|
||
let seen = ref [] in
|
||
List.iter2
|
||
(fun (c : Ast.hclause) n ->
|
||
if List.mem n !seen then
|
||
fail c.Ast.hloc "this handler-case handles %s twice" n;
|
||
seen := n :: !seen)
|
||
clauses names;
|
||
let k =
|
||
List.length
|
||
(List.filter
|
||
(fun (l : Tast.fn) ->
|
||
l.Tast.fparent = Some ctx.owner
|
||
&& String.length l.Tast.name >= 8
|
||
&& String.sub l.Tast.name 0 8 = "handler/")
|
||
ctx.env.lifted)
|
||
in
|
||
let rnames =
|
||
List.map (Printf.sprintf "handler-case/%s/%d/%s" ctx.owner k) names
|
||
in
|
||
(* The handler half: one clause per arm, whose whole body is the transfer.
|
||
It is lifted into a function of its own like any handler clause, and the
|
||
condition it was handed is copied into the restart frame's buffer on its
|
||
way out. *)
|
||
let handlers =
|
||
List.map2
|
||
(fun (c : Ast.hclause) r ->
|
||
{ c with
|
||
Ast.hbody =
|
||
[ { Ast.e =
|
||
Ast.InvokeRestart
|
||
(r, [ { Ast.e = Ast.Var c.Ast.hname; loc = c.Ast.hloc } ]);
|
||
loc = c.Ast.hloc } ] })
|
||
clauses rnames
|
||
in
|
||
(* The landing half: one restart clause per arm, taking the condition as its
|
||
single parameter and running what the reader actually wrote. *)
|
||
let landings =
|
||
List.map2
|
||
(fun (c : Ast.hclause) r ->
|
||
{ Ast.rname = r;
|
||
rparams =
|
||
[ { Ast.fname = c.Ast.hname; fty = c.Ast.hty;
|
||
floc = c.Ast.hloc } ];
|
||
rbody = c.Ast.hbody; rloc = c.Ast.hloc })
|
||
clauses rnames
|
||
in
|
||
let tbody = check_handler_bind ctx ?want ~what loc handlers [ body ] in
|
||
restart_clauses ctx ?want ~what loc tbody landings
|
||
|
||
(* The forms of a [defer], checked in place and hung on the function. What is
|
||
left where it stands is one store: this defer's number into the counter
|
||
[defer_slot] describes, which is how the transfer exit tells a defer that
|
||
has registered from one the text has not reached yet. The form's type is
|
||
still [unit], which is all a reader of the value can see. *)
|
||
and register_defer ctx loc forms =
|
||
(* Saved and put back rather than cleared, which is the same thing today and
|
||
will not be the day a defer may hold one. Nothing reaches here from
|
||
inside a defer now — [defer_ok] is false in there — so the saved value is
|
||
always false; written this way so that if it ever is not, the flag comes
|
||
back rather than being dropped. *)
|
||
let was_in_defer = ctx.in_defer in
|
||
ctx.in_defer <- true;
|
||
(* A barrier, for the reason [defer] itself exists: these forms are *copied*
|
||
into every exit path of the function, where the loop they were written
|
||
beside is not running. A loop written inside the defer is below the
|
||
barrier and breaks out of itself perfectly well. *)
|
||
let forms =
|
||
barrier ctx "a defer" (fun () -> map_lr (fun d -> check ctx d) forms)
|
||
in
|
||
ctx.in_defer <- was_in_defer;
|
||
ctx.defers <- mk loc Types.Unit (Tast.Do forms) :: ctx.defers;
|
||
let slot =
|
||
match ctx.defer_slot with
|
||
| Some s -> s
|
||
| None ->
|
||
let s = fresh_slot ctx (Types.Int Types.I64) in
|
||
ctx.defer_slot <- Some s;
|
||
s
|
||
in
|
||
mk loc Types.Unit
|
||
(Tast.Set
|
||
(Tast.Plocal slot,
|
||
mk loc (Types.Int Types.I64)
|
||
(Tast.Int (Int64.of_int (List.length ctx.defers), Types.I64))))
|
||
|
||
(* The transfer exit's copy of the defers, each under the count that says it
|
||
registered. [ds] is innermost first, so the last one registered is at the
|
||
head and the [j]th from the end is defer number [j].
|
||
|
||
The zero the counter starts at is written by [defer_counter_zero] below, at
|
||
the top of the body: a slot is an alloca like any other and holds whatever
|
||
the stack held until something stores to it, which at -O2 is not zero and
|
||
is exactly how this was found. *)
|
||
and guarded_defers slot (ds : Tast.expr list) =
|
||
let n = List.length ds in
|
||
List.mapi
|
||
(fun i (d : Tast.expr) ->
|
||
let loc = d.Tast.loc in
|
||
let i64 = Types.Int Types.I64 in
|
||
let test =
|
||
mk loc Types.Bool
|
||
(Tast.Prim
|
||
(Tast.Ge,
|
||
[ mk loc i64 (Tast.Local slot);
|
||
mk loc i64 (Tast.Int (Int64.of_int (n - i), Types.I64)) ]))
|
||
in
|
||
mk loc Types.Unit (Tast.If (test, d, unit_at loc)))
|
||
ds
|
||
|
||
and defer_counter_zero slot loc =
|
||
mk loc Types.Unit
|
||
(Tast.Set
|
||
(Tast.Plocal slot,
|
||
mk loc (Types.Int Types.I64) (Tast.Int (0L, Types.I64))))
|
||
|
||
(* [defer_ok] says whether *this* let has the function's extent. If it does, so
|
||
does every form in its body, including a nested let — which is why the flag
|
||
is handed to the body rather than consumed here. *)
|
||
and check_let ctx ?(tail = false) ?want ?(defer_ok = false) loc bs body =
|
||
scoped ctx (fun () ->
|
||
let bs =
|
||
map_lr
|
||
(fun (b : Ast.binding) ->
|
||
let want = Option.map (resolve ctx.env) b.Ast.bty in
|
||
let v = check ctx ?want b.Ast.bval in
|
||
(match v.Tast.ty with
|
||
| Types.Unit | Types.Never ->
|
||
fail b.Ast.bloc "%s would be bound to %s, which is not a value"
|
||
b.Ast.bname (Types.to_string v.Tast.ty)
|
||
| _ -> ());
|
||
(* Locals are assignable places; parameters are not. *)
|
||
let slot = bind ctx b.Ast.bname v.Tast.ty ~assignable:true in
|
||
(slot, v))
|
||
bs
|
||
in
|
||
(* After the bindings, because checking each of them withdrew it. *)
|
||
ctx.tail <- tail;
|
||
let body = block ctx ?want ~defer_ok loc body in
|
||
mk loc body.Tast.ty (Tast.Let (bs, [ body ])))
|
||
|
||
(* (dotimes [i n] body...) is a counting loop, not a new IR node: bind [i] to 0
|
||
and the bound to a hidden slot — [n] is evaluated once, before the loop, so
|
||
a body that changes it cannot change the trip count — then step [i] at the
|
||
end of the body. [i] is not assignable, so the step below is the only writer. *)
|
||
(* What a loop still contributes to checking after the repeal is scoping, not
|
||
ownership: the entry below is what [break] and [continue] resolve against,
|
||
and the defer_block name is what makes a [defer] in here refused as "a loop
|
||
body" — it would fire once at function exit rather than once per iteration,
|
||
and the message says so. [fresh] and the iteration move-diff that used it
|
||
are gone with the flow analysis. *)
|
||
and in_loop ctx ?label ?entry f =
|
||
(* The loop goes on the stack before the body is checked and comes off after,
|
||
so a [break] inside it can see it and one outside it cannot. *)
|
||
let loops = ctx.loops in
|
||
ctx.loops <- (match entry with Some e -> e | None -> Lloop label) :: loops;
|
||
let blocker = ctx.defer_block in
|
||
ctx.defer_block <- "a loop body";
|
||
let r = f () in
|
||
ctx.defer_block <- blocker;
|
||
ctx.loops <- loops;
|
||
r
|
||
|
||
(* Which loop a [break] or a [continue] means, as a count of loops outwards
|
||
from the innermost — which is what [Tast.Break] carries and what [emit]
|
||
indexes. Refuses three things, each by its own reason: nothing to break out
|
||
of, a label naming no loop this form is inside, and a jump that would cross
|
||
a barrier. *)
|
||
and loop_target ctx loc verb label =
|
||
let rec go depth = function
|
||
| [] ->
|
||
(match label with
|
||
| None ->
|
||
fail loc "%s is only allowed inside a loop" verb
|
||
| Some l ->
|
||
fail loc
|
||
"no loop named :%s encloses this %s. A label names one of the loops \
|
||
this form is written inside — it is not a goto, so it cannot name a \
|
||
loop somewhere else" l verb)
|
||
| Lloop name :: rest ->
|
||
(match label with
|
||
| None -> depth
|
||
| Some l when name = Some l -> depth
|
||
| Some _ -> go (depth + 1) rest)
|
||
(* A [loop] answers with the value of its body. A jump out of one would
|
||
have to produce that value from somewhere and there is nowhere, so it is
|
||
a barrier like the others, named as what it is. A [while] written inside
|
||
a loop sits below this entry and keeps its own break. *)
|
||
| Lrecur _ :: _ ->
|
||
(match label with
|
||
| None ->
|
||
fail loc
|
||
"%s is not allowed here: the nearest loop is a (loop ...), which \
|
||
answers with the value of its body, so leaving it this way would \
|
||
have no value to give. Answer with the value, or use a while"
|
||
verb
|
||
| Some l ->
|
||
fail loc
|
||
"%s :%s would leave a (loop ...), which it may not: a loop answers \
|
||
with the value of its body and a jump out of one has no value to \
|
||
give" verb l)
|
||
| Lbarrier what :: rest ->
|
||
(* Crossing it would skip whatever the construct does on the way out —
|
||
the handler or restart frames it pushed, or, for a defer, would jump
|
||
to a loop that is not there on the path the forms were copied into.
|
||
A loop nested inside the construct is below this entry and is never
|
||
reached here, which is the whole point of the rule being relative. *)
|
||
ignore rest;
|
||
(match label with
|
||
| None ->
|
||
fail loc
|
||
"%s is not allowed here: the nearest loop is outside %s, and leaving \
|
||
it that way would skip what %s does on the way out. Write the loop \
|
||
inside it, or leave with a value and test that after"
|
||
verb what what
|
||
| Some l ->
|
||
fail loc
|
||
"%s :%s would leave %s, which it may not: whatever %s does on the way \
|
||
out would be skipped. A break may only leave loops that are inside \
|
||
the same %s it is"
|
||
verb l what what what)
|
||
in
|
||
go 0 ctx.loops
|
||
|
||
(* [(dotimes [i n] ...)], [(dotimes [i start stop] ...)] and
|
||
[(dotimes [i start stop step] ...)].
|
||
|
||
**The convention.** [stop] is exclusive, so [(dotimes [i 0 n] ...)] is the
|
||
same loop as [(dotimes [i n] ...)] — one rule rather than two, and the
|
||
shorter form stays the longer one with its defaults left off. A negative
|
||
step counts down and tests with [>] instead of [<], which is what makes
|
||
[(dotimes [i 9 -1 -1] ...)] run 9 down to 0.
|
||
|
||
**Each bound once, before the loop.** [start] is the counter's initial
|
||
value, [stop] and a non-literal [step] each get a hidden slot, and all three
|
||
are checked — and therefore evaluated — left to right, outside the counter's
|
||
scope. A body that assigns to what they were computed from cannot change the
|
||
trip count.
|
||
|
||
**The sign of the step.** When it is a literal the direction is known here
|
||
and the condition is the one comparison it always was, so nothing changed
|
||
for every loop anyone has written. A literal 0 is refused: it is an infinite
|
||
loop spelled as an accident. A step that is only known at run time gets a
|
||
condition that asks the sign first, and the cost lands on exactly the loops
|
||
that need it. A run-time 0 falls out of that test as a loop that runs no
|
||
times at all — neither arm of the sign test holds — which is deterministic
|
||
and terminating, the two things an accidental hang is not. *)
|
||
and check_dotimes ctx ~want loc label name (b : Ast.bounds) body =
|
||
(* Left to right, and all three before the counter is bound: they are
|
||
evaluated before it exists, so [(dotimes [i i (* outer i *)] ...)] reads
|
||
the outer name and the order a counter function sees is the written one. *)
|
||
let start = Option.map (fun e -> check ctx ~want:index_ty e) b.Ast.dstart in
|
||
let stop = check ctx ~want:index_ty b.Ast.dstop in
|
||
let step = Option.map (fun e -> check ctx ~want:index_ty e) b.Ast.dstep in
|
||
let int k = mk loc index_ty (Tast.Int (k, Types.I32)) in
|
||
(* A literal step, if that is what was written. The default is 1, which is a
|
||
literal too, so the one-bound form takes this path and emits exactly what
|
||
it has always emitted. *)
|
||
let literal =
|
||
match step with
|
||
| None -> Some 1L
|
||
| Some { Tast.e = Tast.Int (k, _); _ } -> Some k
|
||
| Some _ -> None
|
||
in
|
||
(match literal, step with
|
||
| Some 0L, Some s ->
|
||
fail s.Tast.loc
|
||
"a step of 0 never moves the counter, so this loop would never end. \
|
||
Give it a step that moves, as in (dotimes [i 0 10 2] (print i)). \
|
||
Left out, the step is 1"
|
||
| _ -> ());
|
||
scoped ctx (fun () ->
|
||
let i = bind ctx name index_ty ~assignable:false in
|
||
let limit = fresh_slot ctx index_ty in
|
||
(* A slot only when the step is not a literal: a literal needs no slot to
|
||
be evaluated once, and the one-bound form's frame keeps the shape it
|
||
had. *)
|
||
let stepslot =
|
||
match literal with None -> Some (fresh_slot ctx index_ty) | Some _ -> None
|
||
in
|
||
let body = in_loop ctx ?label (fun () -> map_lr (fun b -> check ctx b) body) in
|
||
let iv = mk loc index_ty (Tast.Local i) in
|
||
let limitv = mk loc index_ty (Tast.Local limit) in
|
||
let stepv =
|
||
match literal, stepslot with
|
||
| Some k, _ -> int k
|
||
| None, Some s -> mk loc index_ty (Tast.Local s)
|
||
| None, None -> assert false
|
||
in
|
||
let cmp op = mk loc Types.Bool (Tast.Prim (op, [ iv; limitv ])) in
|
||
let cond =
|
||
match literal with
|
||
| Some k when k > 0L -> cmp Tast.Lt
|
||
| Some _ -> cmp Tast.Gt
|
||
| None ->
|
||
(* Both directions, asked in the order that leaves 0 with neither: the
|
||
counter has not passed the stop *and* the step is going that way.
|
||
Written as nested [If]s because that is what [and] and [or] already
|
||
become, so nothing new reaches a backend. *)
|
||
let sign op =
|
||
mk loc Types.Bool (Tast.Prim (op, [ stepv; int 0L ]))
|
||
in
|
||
mk loc Types.Bool
|
||
(Tast.If (sign Tast.Gt, cmp Tast.Lt,
|
||
mk loc Types.Bool
|
||
(Tast.If (sign Tast.Lt, cmp Tast.Gt,
|
||
mk loc Types.Bool (Tast.Bool false)))))
|
||
in
|
||
let advance =
|
||
mk loc Types.Unit
|
||
(Tast.Set (Tast.Plocal i,
|
||
mk loc index_ty (Tast.Prim (Tast.Add, [ iv; stepv ]))))
|
||
in
|
||
(* The step is the *latch* and not the last form of the body. Folded onto
|
||
the body it would be skipped by a [continue], which branches past the
|
||
rest of the body — so [i] would never advance and the loop would hang.
|
||
That is the whole reason [Tast.While] carries a third list. *)
|
||
let loop = mk loc Types.Unit (Tast.While (cond, body, [ advance ])) in
|
||
let binds =
|
||
(i, match start with Some s -> s | None -> int 0L)
|
||
:: (limit, stop)
|
||
:: (match stepslot, step with
|
||
| Some s, Some v -> [ (s, v) ]
|
||
| _ -> [])
|
||
in
|
||
expect ctx loc ~want (mk loc Types.Unit (Tast.Let (binds, [ loop ]))))
|
||
|
||
(* ── (loop [...] ...) and (recur ...) ───────────────────────────────────
|
||
|
||
A loop is a [let] over its names, a [While] whose condition is [true], and
|
||
two jumps: [recur] rebinds every name and continues, and falling off the end
|
||
of the body breaks. Nothing new reaches the backend, which is the whole
|
||
argument for [recur] over tail calls — the machinery is the one [while] and
|
||
the labelled [break]/[continue] already needed.
|
||
|
||
**The value.** A loop answers with the value of its body, so the result is
|
||
written into a slot of its own on the way out and read after the loop. A
|
||
body that is [Unit] needs no slot, and a body that is [Never] — one that
|
||
only ever recurs or returns — needs neither a slot nor the break, because
|
||
nothing falls off the end of it.
|
||
|
||
**Why [Set] of a [Never] body is safe.** [emit] closes a block at its
|
||
terminator and drops what follows ([ins] checks [f.live]), so when the body
|
||
ends in a jump the store is simply never written. The one ordering that
|
||
matters is inside [Tast.Set]: the place is resolved before the value, and a
|
||
local's place is an address with no instruction behind it.
|
||
|
||
**Why the invented [While] may carry jumps.** [tast.ml] says a [While] the
|
||
checker invents contains none, because the depths it would carry were minted
|
||
against a stack it is not on. This one is different and the difference is
|
||
the licence: it is pushed on [ctx.loops] like any other, so the [Break 0]
|
||
below and every [continue] a [recur] mints count from the same stack [emit]
|
||
indexes. *)
|
||
and check_loop ctx ?want loc bs body =
|
||
scoped ctx (fun () ->
|
||
(* Each initial value is evaluated once, before the loop, exactly as a
|
||
[let]'s is and as [dotimes]'s bound is. *)
|
||
let inits =
|
||
map_lr
|
||
(fun (n, v) ->
|
||
let v = check ctx v in
|
||
(match v.Tast.ty with
|
||
| Types.Unit | Types.Never ->
|
||
fail v.Tast.loc "%s would be bound to %s, which is not a value" n
|
||
(Types.to_string v.Tast.ty)
|
||
| _ -> ());
|
||
(n, v))
|
||
bs
|
||
in
|
||
let binds =
|
||
List.map (fun (n, v) -> (bind ctx n v.Tast.ty ~assignable:true, v)) inits
|
||
in
|
||
let names = List.map (fun (slot, v) -> (slot, v.Tast.ty)) binds in
|
||
(* The singleton is [in_loop]'s doing: it sits in this recursive group and
|
||
is therefore monomorphic, and every other caller hands it a list. *)
|
||
let tbody =
|
||
match
|
||
in_loop ctx ~entry:(Lrecur names) (fun () ->
|
||
[ scoped ctx (fun () ->
|
||
(* The body's last form is the loop's tail, which is the only
|
||
place a [recur] may stand. [block] distributes it. *)
|
||
ctx.tail <- true;
|
||
block ctx ?want loc body) ])
|
||
with
|
||
| [ b ] -> b
|
||
| _ -> assert false
|
||
in
|
||
let ty = tbody.Tast.ty in
|
||
let yes = mk loc Types.Bool (Tast.Bool true) in
|
||
let leave = mk loc Types.Never (Tast.Break 0) in
|
||
let inner, result =
|
||
if ty = Types.Never then ([ tbody ], None)
|
||
else if ty = Types.Unit then ([ tbody; leave ], None)
|
||
else
|
||
let r = fresh_slot ctx ty in
|
||
([ mk loc Types.Unit (Tast.Set (Tast.Plocal r, tbody)); leave ], Some r)
|
||
in
|
||
let loop = mk loc Types.Unit (Tast.While (yes, inner, [])) in
|
||
match result with
|
||
| None -> expect ctx loc ~want (mk loc ty (Tast.Let (binds, [ loop ])))
|
||
| Some r ->
|
||
expect ctx loc ~want
|
||
(mk loc ty
|
||
(Tast.Let (binds @ [ (r, mk loc ty (Tast.Zero ty)) ],
|
||
[ loop; mk loc ty (Tast.Local r) ]))))
|
||
|
||
(* Which loop a [recur] means, and what it has to rebind. The same walk
|
||
[break] and [continue] make, over the same stack and refusing on the same
|
||
barriers — [recur] asks "may this jump cross that" and gets the answer that
|
||
was already settled, not a second mechanism. *)
|
||
and recur_target ctx loc =
|
||
let rec go depth = function
|
||
| [] ->
|
||
fail loc
|
||
"recur is only allowed inside a (loop ...). There are no tail calls in \
|
||
this compiler, so a function cannot recur into itself and two \
|
||
functions cannot recur into each other — write the repetition as a \
|
||
loop with a recur in its tail"
|
||
| Lrecur names :: _ -> (depth, names)
|
||
(* Unreachable while the tail rule holds — a loop body is not a tail
|
||
position, so no [recur] is ever written inside one — but the depth is
|
||
counted rather than assumed, because it is what [emit] indexes. *)
|
||
| Lloop _ :: rest -> go (depth + 1) rest
|
||
| Lbarrier what :: _ ->
|
||
fail loc
|
||
"recur would leave %s, which it may not: whatever %s does on the way \
|
||
out would be skipped. Write the loop inside it, or leave with a value \
|
||
and test that after"
|
||
what what
|
||
in
|
||
go 0 ctx.loops
|
||
|
||
and check_recur ctx ~tail loc args =
|
||
let depth, names = recur_target ctx loc in
|
||
(* Checked, which is the whole of why this is better than a silent TCO: a
|
||
recur that is not in tail position is a compile error here, where under
|
||
tail calls it would have been a stack overflow at run time. *)
|
||
if not tail then
|
||
fail loc
|
||
"recur must be in the tail position of its loop — the last thing the \
|
||
body does, or the last thing in an if, match or let arm that is itself \
|
||
in the tail. Here something would still have to run afterwards, and a \
|
||
recur is a jump back to the top, not a call that returns";
|
||
let want = List.length names and got = List.length args in
|
||
if want <> got then
|
||
fail loc "this loop binds %d name%s and this recur passes %d" want
|
||
(if want = 1 then "" else "s") got;
|
||
let vals = List.map2 (fun a (_, ty) -> check ctx ~want:ty a) args names in
|
||
(* Every name is rebound at once. The new values go into temporaries first,
|
||
so that (recur y x) swaps rather than writing y over x and then reading it
|
||
back — the same reason Clojure's recur is simultaneous. *)
|
||
let temps = List.map2 (fun v (_, ty) -> (fresh_slot ctx ty, v)) vals names in
|
||
let sets =
|
||
List.map2
|
||
(fun (t, _) (slot, ty) ->
|
||
mk loc Types.Unit
|
||
(Tast.Set (Tast.Plocal slot, mk loc ty (Tast.Local t))))
|
||
temps names
|
||
in
|
||
mk loc Types.Never
|
||
(Tast.Let (temps, sets @ [ mk loc Types.Never (Tast.Continue depth) ]))
|
||
|
||
(* Every boolean-position test in the language funnels through here: [if]'s
|
||
own condition, [while]'s, and [not]'s argument. ([when] and [cond] are
|
||
sugar built out of [Ast.If] in parse.ml, so they get this for free
|
||
without a separate case. [and] and [or] are sugar too, and both their
|
||
tests and their answers get this for free the same way — see
|
||
[shortcircuit] in parse.ml, where each binds its test to a temp and
|
||
answers that temp on the path it decides, so the deciding operand
|
||
itself comes back rather than a bare bool.)
|
||
|
||
A dyn scrutinee is tested for truthiness, Clojure's rule: nil and false
|
||
are the only falsey values, and everything else — 0, "", an empty vec, an
|
||
empty map, a keyword — is truthy. A typed scrutinee stays strictly bool,
|
||
exactly as before.
|
||
|
||
The scrutinee is checked with no expectation first so its own type
|
||
decides which rule applies. That is fine for the passing cases — dyn, or
|
||
already bool — but a *refused* one has to be re-checked with the old
|
||
[want:Bool] rather than reported from here, and that covers two shapes of
|
||
"asked the wrong question first": a bare integer or float literal answers
|
||
differently to "what type is this" than to "is this a bool" (check.ml's
|
||
int_literal/float arms only give the nicer answer, "expected bool, found
|
||
the integer literal 5", when asked the second way), and [None] does not
|
||
even have an answer to the first question — "nothing here says what None
|
||
is an Option of" — where the second gets straight to "expected bool,
|
||
found None". Asking the first way first is what makes the dyn case work,
|
||
so the refusal path — success or exception both — asks the second way
|
||
again, after the fact, purely to get the sentence a typed if has always
|
||
given.
|
||
|
||
[loc] comes from [c] itself, not from the caller's [if]/[while]/[not] —
|
||
the built-in rt call and cast have to sit at the condition's own position
|
||
or an --x86 disassembly and the dev inspector point at the wrong column
|
||
when the two differ (an [if] whose test is not its first token).
|
||
|
||
The [exception Loc.Error _] arm swallows a rejection from arbitrary depth
|
||
inside [c] and re-runs the whole of [check ctx c] a second time, which is
|
||
sound only because every one of [ctx]'s save-restore sites — [barrier],
|
||
and the [in_frames]/[in_defer]/[loops]/[scope] plumbing [check] itself
|
||
uses — is not exception-safe: a failure mid-walk can leave one of those
|
||
pushed without its pop. Today that is harmless, because the second call
|
||
always either succeeds outright or raises again and this function's own
|
||
caller then aborts the compile — nothing downstream ever reads [ctx]
|
||
again on that path. It would stop being harmless the day some later
|
||
want-sensitive elaboration on this path can *succeed* by yielding a
|
||
concrete [Bool] on retry rather than failing a second time: then the
|
||
first, swallowed pass's half-restored state and any name or slot it
|
||
registered before raising would both still be live.
|
||
|
||
That same retry-on-failure is also, deliberately, not made cheaper by
|
||
only retrying at the leaf that actually needs a nicer message (an int or
|
||
float literal, or [None]) and re-raising everywhere else: the shorter
|
||
path was tried and shelved, because "everywhere else" is not safe to
|
||
generalise past [not]'s one level of nesting — a condition that is
|
||
itself a compound form carrying its own literals arbitrarily deep (an
|
||
[if] or [let] standing where a condition is expected) would need [want]
|
||
threaded through exactly as far as this function's own second call
|
||
already threads it, and stopping short changes which of *those*
|
||
literals gets the nicer message, not just the speed. The cost that
|
||
buys is real: nested [not] on a program that does not type-check re-runs
|
||
this whole function once per level of nesting inside the level above it,
|
||
which is exponential in how deep the nesting goes — moot for a program
|
||
that compiles, since neither retry ever fires, and moot for ordinary
|
||
nesting depths, but visible within a second or so around twenty levels
|
||
of a [not] wrapped in a [not] wrapped in .... The dev daemon is the one
|
||
caller that could feel this, recompiling a half-typed form on every
|
||
edit; nobody has hit it in practice and it is not fixed here.
|
||
|
||
Keywords are a separate, deliberate loss rather than a bug: a bare
|
||
[:kw] used to be checked here with [want:Types.Bool] from the start, so
|
||
it hit the keyword arm's [Some other] case and refused by name — "is an
|
||
enum member where an enum is expected and a dyn keyword elsewhere, but
|
||
bool is expected here". Checking it here with no expectation first, as
|
||
every other scrutinee now is, resolves it as the dyn keyword instead
|
||
(there being no enum in play), and dyn keywords are unconditionally
|
||
truthy — so [(if :kw a b)] now takes [a], where it used to refuse
|
||
outright. The author's call: lispy truthiness wins wherever it can, so
|
||
this refusal is given up on purpose and not special-cased back in;
|
||
test_flan.ml pins the new answer down so it is not lost again by
|
||
accident. *)
|
||
and check_truthy ctx c =
|
||
let loc = c.Ast.loc in
|
||
match check ctx c with
|
||
| c0 when c0.Tast.ty = Types.Dyn ->
|
||
widen loc Types.Bool (rt loc (Types.Int Types.I32) "flan_dyn_truthy" [ c0 ])
|
||
| c0 when Types.fits ~expected:Types.Bool ~actual:c0.Tast.ty -> c0
|
||
| c0 ->
|
||
(* Re-checked at [bool] first, and the answer is kept only when it is a
|
||
message that knows something this one does not: a literal names itself
|
||
("found the integer literal 1"), and [None] names itself, and both of
|
||
those point at the mistake better than a type name would. What comes
|
||
back as the *generic* mismatch — "expected bool, found i32", which is
|
||
true and tells a reader nothing they did not have — is the one replaced
|
||
below.
|
||
|
||
The rule, rather than the fact. [expected bool, found i32] is true and
|
||
says nothing a reader did not already know; what they do not know is
|
||
that this language has exactly two things a condition may be, and that
|
||
the dyn one is not the typed one. A dyn condition is Clojure's — nil
|
||
and false are false and 0 is true — so a message that told somebody to
|
||
compare against zero *in general* would be wrong about half the
|
||
language. It is said only of the typed side, which is where they are.
|
||
|
||
The comparison is spelled with the condition's own name where there is
|
||
one, because [(!= x 0)] is a thing to type and [(!= … 0)] is not.
|
||
Anything more complicated than a name gets the operator and no
|
||
template: a reconstructed expression would be a guess at code the
|
||
reader can see for themselves. *)
|
||
(match check ctx ~want:Types.Bool c with
|
||
| c1 -> c1
|
||
| exception Loc.Error d when not (String.equal d.Loc.kind "check/type-mismatch") ->
|
||
raise (Loc.Error d)
|
||
| exception Loc.Error _ ->
|
||
let how =
|
||
let zero = match c0.Tast.ty with Types.Float _ -> "0.0" | _ -> "0" in
|
||
let comparable =
|
||
match c0.Tast.ty with Types.Int _ | Types.Float _ -> true | _ -> false
|
||
in
|
||
match c.Ast.e, comparable with
|
||
| Ast.Var n, true -> Printf.sprintf " — test it, as (!= %s %s)" n zero
|
||
| _, true -> Printf.sprintf " — test it against %s with !=" zero
|
||
| _ -> ""
|
||
in
|
||
Loc.failk "check/condition-not-bool" loc
|
||
"a condition is a bool or a dyn, and this is %s%s"
|
||
(Types.to_string c0.Tast.ty) how)
|
||
| exception Loc.Error _ -> check ctx ~want:Types.Bool c
|
||
|
||
and check_if ctx ?(tail = false) ?want loc c t e =
|
||
let c = check_truthy ctx c in
|
||
(* Both arms are the tail, and a one-armed [if] counts: [(when c (recur ...))]
|
||
is how nearly every loop is written, and the branch is still the last
|
||
thing the body does. *)
|
||
let in_tail f = ctx.tail <- tail; f () in
|
||
match e with
|
||
| None ->
|
||
(* A one-armed if produces Unit whatever the branch evaluates to: there is
|
||
no value on the missing side. `when` desugars to this. *)
|
||
let t = branch ctx (fun () -> in_tail (fun () -> check ctx t)) in
|
||
expect ctx loc ~want (mk loc Types.Unit (Tast.If (c, t, unit_at loc)))
|
||
| Some e ->
|
||
let t = branch ctx (fun () -> in_tail (fun () -> check ctx ?want t)) in
|
||
(* With no expectation the then-branch supplies one for the else-branch,
|
||
unless it diverges, in which case the else-branch decides. *)
|
||
let ewant =
|
||
match want with
|
||
| Some _ -> want
|
||
| None -> if t.Tast.ty = Types.Never then None else Some t.Tast.ty
|
||
in
|
||
(* [(and a b c)] is [(let [t a] (if t (let [u b] (if u c u)) t))], so the
|
||
*last* operand of an [and] is the then arm and the sentinel that carries
|
||
the previous operand's location is the else arm. With no expectation
|
||
the then arm supplies one, the sentinel is checked against it, and the
|
||
mismatch was reported at the sentinel — which is a caret on the operand
|
||
before the one that is wrong. FIX.org records three fixes for this that
|
||
were rejected and one that was not: prefer the arm that is not a
|
||
compiler temp when deciding which to blame. That is this.
|
||
|
||
Only [and] needs it. In an [or] the chain sits in the else arm and the
|
||
sentinel in the then arm, so every operand is already blamed at its own
|
||
location; and with an expectation in hand both arms are checked against
|
||
it rather than against each other, so nothing here runs. *)
|
||
let and_sentinel (x : Ast.expr) =
|
||
match x.Ast.e with
|
||
| Ast.Var n ->
|
||
String.length n > 4 && String.sub n 0 4 = "and~"
|
||
| _ -> false
|
||
in
|
||
let e =
|
||
match branch ctx (fun () -> in_tail (fun () -> check ctx ?want:ewant e)) with
|
||
| v -> v
|
||
| exception Loc.Error d
|
||
when want = None && and_sentinel e
|
||
&& String.equal d.Loc.kind "check/type-mismatch" ->
|
||
Loc.failk "check/shortcircuit-operand" t.Tast.loc
|
||
"an and answers false when it stops early and its last operand \
|
||
otherwise, so the two have to be one type — this operand is %s, \
|
||
and false is a bool"
|
||
(Types.to_string t.Tast.ty)
|
||
in
|
||
let ty =
|
||
if t.Tast.ty = Types.Never then e.Tast.ty
|
||
else if e.Tast.ty = Types.Never then t.Tast.ty
|
||
else if Types.equal t.Tast.ty e.Tast.ty then t.Tast.ty
|
||
else
|
||
fail loc "the branches of this if have different types: %s and %s"
|
||
(Types.to_string t.Tast.ty) (Types.to_string e.Tast.ty)
|
||
in
|
||
mk loc ty (Tast.If (c, t, e))
|
||
|
||
(* Whether a name would reach a callee if it were called — a global function, a
|
||
generic, or a local holding a function value. The three sources [named_call]
|
||
itself consults, in its own order; builtins are deliberately not among them,
|
||
so that [(println {.f v})] still reports what it reports today. *)
|
||
and callable ctx name =
|
||
Hashtbl.mem ctx.env.fns name
|
||
|| Hashtbl.mem ctx.env.gsigs name
|
||
|| (match lookup ctx name with
|
||
| Some b -> (match b.bty with Types.Fn _ -> true | _ -> false)
|
||
| None -> false)
|
||
|
||
(* [(Cell 1 2)] — a struct built from its fields in declaration order.
|
||
|
||
The parser cannot make this one either, and for a sharper reason than the
|
||
bare literal above: [(Cell 1 2)] is character-for-character an ordinary call
|
||
and only the symbol table tells the two apart. So it is decided here, on the
|
||
last arm of [named_call], which is to say *after* a local of function type,
|
||
after a generic and after the global function table. Nothing can be shadowed
|
||
into a struct constructor by accident, because a name is one declaration:
|
||
[collect]'s [claimed] table spans every declaration kind, so a [defstruct
|
||
Cell] and a [defn Cell] cannot both exist. A [(defclass point [x y])]
|
||
constructor is a real [defn] that [Classes.expand] wrote before checking
|
||
began, so [(point 1 2)] resolves in [env.fns] two arms above this one and
|
||
never reaches here.
|
||
|
||
ARITY IS EXACT, and that is the decision worth writing down. ZII is not
|
||
withdrawn — it is what the designated form does, and [(Cell {.row 1})]
|
||
still zeroes [.col]. What positional construction cannot do is *say* which
|
||
field was left out: [(Cell 1)] reads as a Cell with one field given, and
|
||
which one depends on a declaration order that the author is free to change
|
||
later. A trailing field silently zeroed there is the field-reorder hazard
|
||
at its worst, so a short argument list is a refusal that names the first
|
||
field it did not reach, and points at the spelling that does mean "zero the
|
||
rest". Odin's positional literal takes the same line. *)
|
||
and positional_struct ctx ~want loc name args =
|
||
let s = Hashtbl.find ctx.env.structs name in
|
||
let fields = s.Tast.fields in
|
||
let n = List.length fields in
|
||
let given = List.length args in
|
||
let note = declared_note ctx.env name in
|
||
if given < n then begin
|
||
let missing = List.nth fields given in
|
||
Loc.failk "check/positional-too-few" loc ~notes:note
|
||
"%s has %d field%s and %d %s given positionally — .%s has no value. \
|
||
Positional construction gives every field, in declaration order; to \
|
||
give some of them and zero the rest, a struct value is written (%s \
|
||
{.field value ...})"
|
||
name n (if n = 1 then "" else "s") given
|
||
(if given = 1 then "was" else "were") missing.Tast.fname name
|
||
end;
|
||
if given > n then begin
|
||
let extra = List.nth args n in
|
||
Loc.failk "check/positional-too-many" extra.Ast.loc ~notes:note
|
||
"%s has %d field%s, and this is argument %d — a struct value is written \
|
||
(%s {.field value ...}) or (%s %s)"
|
||
name n (if n = 1 then "" else "s") (n + 1) name name
|
||
(String.concat " " (List.map (fun (f : Tast.field) -> f.Tast.fname) fields))
|
||
end;
|
||
(* Left to right, each against its own field's type, exactly as the argument
|
||
list of a call is checked against its parameters — same [map2_lr], same
|
||
[~want], so an untyped literal takes the field's type and a nested bare
|
||
literal (feature A above) gets an expectation here as well. The mismatch
|
||
is reported at the argument, by [expect], in the words a call's argument
|
||
already gets; what is added is a note saying which field that argument
|
||
was, because at a positional call site the field name is the one thing
|
||
the source does not show. The note is attached only to a failure raised
|
||
at this argument's own location, and it says nothing about the failure —
|
||
it names the position, which is true whatever went wrong there. *)
|
||
let fields =
|
||
map2_lr
|
||
(fun (f : Tast.field) (a : Ast.expr) ->
|
||
try check ctx ~want:f.Tast.fty a with
|
||
| Loc.Error d when d.Loc.dloc = a.Ast.loc ->
|
||
Loc.raise_diag
|
||
{ d with
|
||
Loc.notes =
|
||
d.Loc.notes
|
||
@ [ Loc.note a.Ast.loc
|
||
(Printf.sprintf "this is %s's field .%s" name
|
||
f.Tast.fname) ]
|
||
@ note })
|
||
fields args
|
||
in
|
||
expect ctx loc ~want (mk loc (Types.Named name) (Tast.Make (name, fields)))
|
||
|
||
(* [{.f v}] with no type written in front of it, checked against whatever type
|
||
the position it stands in expects.
|
||
|
||
The whole of the feature is the one line that hands [kvs] to [check_struct]
|
||
with the expected type's name: from there a bare literal and a named one are
|
||
the same literal, checked by the same code. Duplicate fields, unknown
|
||
fields, their notes and their error kinds, and ZII zero-fill for the fields
|
||
left out are therefore not "the same rules" as the named form's — they are
|
||
the named form's, reached through the same call.
|
||
|
||
A named type is the only expectation that says anything. [Dyn] deliberately
|
||
does not: braces at a dyn want are the dyn map literal ({:key value}, and
|
||
[Ast.MapLit]) and always have been, and a [.field]-keyed brace was never
|
||
part of that spelling. So a dyn want is refused here rather than quietly
|
||
given a second meaning, and the message points at the map spelling because
|
||
that is what someone at a dyn want almost certainly wanted.
|
||
|
||
With no expectation at all there is nothing to infer from and the refusal
|
||
names both ways out: write the type, or move the literal somewhere a type
|
||
is known. *)
|
||
and check_bare ctx ~want loc kvs =
|
||
let written =
|
||
"{" ^ String.concat " " (List.map (fun (k, _) -> "." ^ k) kvs) ^ " ...}"
|
||
in
|
||
match want with
|
||
| Some (Types.Named n) -> check_struct ctx ~want loc n kvs
|
||
| Some Types.Dyn ->
|
||
Loc.failk "check/bare-struct-dyn" loc
|
||
"a dyn is expected here, and %s is a struct field list, not a dyn map — \
|
||
a dyn map's keys are keywords, as {:%s value ...}"
|
||
written
|
||
(match kvs with (k, _) :: _ -> k | [] -> "key")
|
||
| Some other ->
|
||
Loc.failk "check/bare-struct-want" loc
|
||
"%s is a struct field list and %s is expected here, which is not a \
|
||
struct type"
|
||
written (Types.to_string other)
|
||
| None ->
|
||
Loc.failk "check/bare-struct-untyped" loc
|
||
"%s does not say which struct it builds — the fields alone do not name \
|
||
a type. Write it, as (Type %s), or put the literal where a type is \
|
||
already known: a function's return position, an argument of a call, a \
|
||
field of another literal, or a typed place being set. A let binding is \
|
||
none of those — a local takes its type from its value, so there is \
|
||
nothing there to read one off"
|
||
written written
|
||
|
||
(* A record-shaped literal: one form for both, because [(Name {.f v})] is the
|
||
same syntax whether [Name] is a struct or a data type case, and the two differ
|
||
only in what is built at the end. Deciding here rather than in the parser is
|
||
what lets the decision be made against the tables, exactly. *)
|
||
and check_struct ctx ~want loc name kvs =
|
||
match Hashtbl.find_opt ctx.env.structs name with
|
||
| None when Hashtbl.mem ctx.env.unions name ->
|
||
check_union ctx ~want loc name kvs
|
||
| None ->
|
||
(match Hashtbl.find_opt ctx.env.cases name with
|
||
(* The full spelling [U.C], which is how a data type value is written. Checked
|
||
before the diagnostics below, since the bare-name entry in the same
|
||
table is only ever a hint. *)
|
||
| Some (dname, c) when String.contains name '.' ->
|
||
check_case ctx ~want loc dname c kvs
|
||
(* A bare case name. This is the bug NEXT.md listed under "Bugs found and
|
||
not yet fixed": [(A {.x 1})] on a case of a data type reported "unknown
|
||
struct A", because nothing in [env] could tell a case name from a
|
||
misspelling. It can now, so it says what was meant. *)
|
||
| Some (dname, c) ->
|
||
fail loc
|
||
"%s is a case of the data type %s, not a struct — a data type value names \
|
||
both, as (%s.%s {.field value ...})"
|
||
name dname dname c.Tast.vname
|
||
| None ->
|
||
if Hashtbl.mem ctx.env.datas name then
|
||
fail loc
|
||
"%s is a data type, and a data type value names the case as well as the \
|
||
type — write (%s.%s {.field value ...}) for one of %s"
|
||
name name (first_case_name ctx.env name) (case_list ctx.env name)
|
||
(* [is_struct_map] (parse.ml) has two blind spots, not one: [(name
|
||
{})] reads as a struct literal with no fields regardless of what
|
||
[name] turns out to be, and so does [(name {.f v ...})] at ANY
|
||
size — a [.field]-first map is never a dyn map argument, only ever
|
||
this struct-literal shape, whatever [name] is. [(name {:a 1})] is
|
||
the one that keeps [name] an ordinary call, because a
|
||
keyword-keyed map is never mistaken for a struct literal.
|
||
|
||
The [.field]-keyed blind spot is no longer a blind spot, and that
|
||
is the argument half of the bare-literal feature. [(g {.row 2})] is
|
||
a call to [g] whose one argument is a bare struct literal — the
|
||
parameter is the expectation that names its type — and the only
|
||
reason it arrives here wearing a struct literal's clothes is that
|
||
the parser had to guess and guessed by shape. So it is handed back
|
||
to [named_call] as the call it was written as, with the fields
|
||
rebuilt into the [Ast.Bare] node the parser would have made had the
|
||
braces stood anywhere else. Only for a name that is actually
|
||
callable: an unknown name keeps the "unknown struct" report below,
|
||
because a misspelled struct name is what that shape usually is.
|
||
|
||
The empty braces keep their old refusal, because they are still
|
||
genuinely ambiguous — [{}] is the zero-field struct literal AND the
|
||
empty dyn map, with nothing in the shape to separate them — and
|
||
that one does have the let-binding fix the message names. *)
|
||
else if kvs <> [] && callable ctx name then
|
||
named_call ctx ~want loc name [ { Ast.e = Ast.Bare kvs; loc } ]
|
||
else if Hashtbl.mem ctx.env.fns name then
|
||
(match kvs with
|
||
| [] ->
|
||
fail loc
|
||
"%s is a function, not a struct — {} on its own is read as \
|
||
the zero-field struct literal, so it cannot be passed here \
|
||
as an empty map; bind it first, as (let [m {}] (%s m))"
|
||
name name
|
||
| _ ->
|
||
fail loc
|
||
"%s is a function, not a struct — {.field value ...} only \
|
||
ever builds a struct literal, never a map value, so it \
|
||
cannot be passed here as an argument; a dyn map's keys are \
|
||
keywords, as {:field value ...}"
|
||
name)
|
||
else
|
||
Loc.failk "check/unknown-struct" loc ~notes:(declared_note ctx.env name)
|
||
"unknown struct %s" name)
|
||
| Some s ->
|
||
let seen =
|
||
given_once ~noun:"field" kvs
|
||
~known:(fun k (v : Ast.expr) ->
|
||
if Tast.field_index s k = None then
|
||
Loc.failk "check/unknown-field" v.Ast.loc
|
||
~notes:(declared_note ctx.env name)
|
||
"%s has no field %s" name k)
|
||
in
|
||
let fields = zii_fill ctx loc seen s.Tast.fields in
|
||
expect ctx loc ~want (mk loc (Types.Named name) (Tast.Make (name, fields)))
|
||
|
||
(* [(U {.member v})] — an untagged union value.
|
||
|
||
At most one member, because the members are one storage: giving two would
|
||
be writing two values over each other and the result would be whichever the
|
||
compiler happened to store last. That is a real question with no answer, so
|
||
it is refused rather than ordered. Giving none is the ordinary ZII value and
|
||
is all-bytes-zero, the same as a struct with every field omitted.
|
||
|
||
The one member is lowered here into a zeroed temporary and a store, rather
|
||
than into a node of its own. A union value *is* a store into overlaid
|
||
storage — [Set] over [Pfield] is exactly that operation and every backend
|
||
already has it — so a [MakeUnion] node would have been the same three
|
||
instructions written a fourth and fifth time, in each backend, with the
|
||
layout rule spelled out again in each. Nothing downstream learns anything
|
||
new from this form. *)
|
||
and check_union ctx ~want loc name kvs =
|
||
let u = Hashtbl.find ctx.env.unions name in
|
||
List.iter
|
||
(fun (k, (v : Ast.expr)) ->
|
||
if Tast.field_index u k = None then
|
||
Loc.failk "check/unknown-field" v.Ast.loc
|
||
~notes:(declared_note ctx.env name)
|
||
"%s has no member %s" name k)
|
||
kvs;
|
||
(* Before the two-member refusal below, so [(U {.i 1 .i 2})] is told it named
|
||
one member twice rather than that [i] and [i] are the same bytes — which is
|
||
true and useless. Same helper, same note and the same [check/duplicate-
|
||
field] kind as the struct path, because it is the same mistake; only the
|
||
noun changes. The unknown-member refusal above stays its own full pass
|
||
rather than being handed to [~known], so that [(U {.i 1 .i 1 .bad 2})] is
|
||
still told about [.bad] first, as it is today. *)
|
||
ignore (given_once ~noun:"member" kvs : (string, Ast.expr) Hashtbl.t);
|
||
(match kvs with
|
||
| (a, _) :: (b, (second : Ast.expr)) :: _ ->
|
||
Loc.failk "check/union-two-members" second.Ast.loc
|
||
"%s is a union, so %s and %s are the same bytes and only one of them \
|
||
can be written — give the one this value is, and read the other \
|
||
member when you want to see those bytes that way"
|
||
name a b
|
||
| _ -> ());
|
||
match kvs with
|
||
(* The two-member case left above, so this sees one or none. *)
|
||
| _ :: _ :: _ -> assert false
|
||
| [] -> expect ctx loc ~want (mk loc (Types.Named name) (Tast.Zero (Types.Named name)))
|
||
| [ (k, v) ] ->
|
||
let i = Option.get (Tast.field_index u k) in
|
||
let fty = (List.nth u.Tast.fields i).Tast.fty in
|
||
let v = check ctx ~want:fty v in
|
||
let slot = fresh_slot ctx (Types.Named name) in
|
||
let here = mk loc (Types.Named name) (Tast.Local slot) in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Named name)
|
||
(Tast.Let
|
||
([ (slot, mk loc (Types.Named name) (Tast.Zero (Types.Named name))) ],
|
||
[ mk loc Types.Unit (Tast.Set (Tast.Pfield (here, i), v)); here ])))
|
||
|
||
(* The cases of a data type, as written, for a message that has to name them. *)
|
||
and case_list env dname =
|
||
match Hashtbl.find_opt env.datas dname with
|
||
| None -> "its cases"
|
||
| Some u ->
|
||
String.concat ", "
|
||
(List.map (fun (c : Tast.variant) -> dname ^ "." ^ c.Tast.vname)
|
||
u.Tast.cases)
|
||
|
||
and first_case_name env dname =
|
||
match Hashtbl.find_opt env.datas dname with
|
||
| Some { Tast.cases = c :: _; _ } -> c.Tast.vname
|
||
| _ -> "Case"
|
||
|
||
(* [(U.C {.f v ...})]. The fields are checked and filled in exactly as a
|
||
struct's are — literally so: the same [given_once] and [zii_fill], with only
|
||
the index function and the name in the unknown-field message differing — and
|
||
the only other difference is the node at the end and the type it carries. *)
|
||
and check_case ctx ~want loc dname (c : Tast.variant) kvs =
|
||
let full = dname ^ "." ^ c.Tast.vname in
|
||
let seen =
|
||
given_once ~noun:"field" kvs
|
||
~known:(fun k (v : Ast.expr) ->
|
||
if Tast.vfield_index c k = None then
|
||
Loc.failk "check/unknown-field" v.Ast.loc
|
||
~notes:(declared_note ctx.env dname)
|
||
"%s has no field %s" full k)
|
||
in
|
||
let fields = zii_fill ctx loc seen c.Tast.vfields in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Named dname) (Tast.MakeCase (dname, c.Tast.vname, fields)))
|
||
|
||
(* Omitted fields are zeroed — ZII, the same rule as a declaration with no
|
||
initialiser (plan.org, Data model). Every field is present from here on, in
|
||
declaration order, so no backend has to know about omission. [seen] is what
|
||
[given_once] collected; [fields] is the declaration, and it is the
|
||
declaration that fixes the order. *)
|
||
and zii_fill ctx loc seen fields =
|
||
map_lr
|
||
(fun (f : Tast.field) ->
|
||
match Hashtbl.find_opt seen f.Tast.fname with
|
||
| Some v -> check ctx ~want:f.Tast.fty v
|
||
| None -> mk loc f.Tast.fty (Tast.Zero f.Tast.fty))
|
||
fields
|
||
|
||
and check_arr ctx ~want loc items =
|
||
let elem_want =
|
||
match want with
|
||
| Some (Types.Array (_, t)) -> Some t
|
||
| Some (Types.Slice t) -> Some t
|
||
| _ -> None
|
||
in
|
||
let items = map_lr (fun i -> check ctx ?want:elem_want i) items in
|
||
let n = Int64.of_int (List.length items) in
|
||
let elem =
|
||
match elem_want, items with
|
||
| Some t, _ -> t
|
||
| None, first :: _ -> first.Tast.ty
|
||
| None, [] ->
|
||
fail loc "an empty array literal needs a type — annotate the binding"
|
||
in
|
||
List.iter
|
||
(fun (i : Tast.expr) ->
|
||
if not (Types.fits ~expected:elem ~actual:i.Tast.ty) then
|
||
fail i.Tast.loc "this array's elements are %s, but this one is %s"
|
||
(Types.to_string elem) (Types.to_string i.Tast.ty))
|
||
items;
|
||
(match want with
|
||
| Some (Types.Array (m, _)) when not (Int64.equal m n) ->
|
||
fail loc "expected %Ld elements, found %Ld" m n
|
||
| _ -> ());
|
||
(* [n T] and [T] are distinct in type and in ownership (spec-memory.md), so
|
||
an array literal does not satisfy a slice expectation. *)
|
||
expect ctx loc ~want (mk loc (Types.Array (n, elem)) (Tast.Arr items))
|
||
|
||
(* ── (array-fill [r c] v) and (array-gen [r c] f) ──────────────────────
|
||
|
||
DISCUSS.org's "need a value-producing array constructor". [(array 4 T)] is
|
||
the zeroed array and [dotimes] is Unit, so between them there was no way to
|
||
write "an array of these" as an *expression* — which is what a defonce
|
||
initialiser has to be. These are that expression, at any rank.
|
||
|
||
**The lowering, and why it is not an aggregate value.** [Tast.Arr] is the
|
||
one the backends already have, and both build it element by element from a
|
||
list that is as long as the array: an [insertvalue] chain on LLVM, a store
|
||
per element on x86. A fill of [[600 800 u8]] is half a million elements and
|
||
there is no list to be had. So these lower to a *loop over a slot*: bind the
|
||
array to a slot, zero it, run one loop per dimension writing each element
|
||
through [Tast.Set] of a [Pindex], and answer with the slot. Nothing new
|
||
reaches a backend — it is [While], [Set] and [Pindex], which is the same
|
||
argument [check_loop] makes for [recur] — and both backends get the form
|
||
with no edit, the js one included.
|
||
|
||
The value stays value-like for all that: the slot is the form's own, nothing
|
||
else can name it, and the [Local] at the end is copied out exactly as any
|
||
other array-typed expression is. In a [defonce] initialiser the copy is the
|
||
store into the global that the startup function does; in a [let] it is the
|
||
binding's own store. An in-place fill of the *destination*, skipping the
|
||
temporary, would be the faster lowering and is deliberately not what this
|
||
does — the destination is not a thing an expression may know about, and
|
||
[mem2reg] plus the store-to-load forwarding both backends already get is
|
||
where that cost goes.
|
||
|
||
The slot is zeroed before the loops rather than left [Uninit]. An element
|
||
type of [dyn] is the reason it has to be: between the binding and the
|
||
store that overwrites it the collector may run, and it would read whatever
|
||
the frame happened to hold as a dyn word. The double write is the price and
|
||
it is one memset.
|
||
|
||
**Row-major, pinned.** The first dimension is the outermost loop, so
|
||
[[i][j]] runs with [j] fastest. A generator that prints, or counts, or
|
||
appends, observes that order, so it is a promise: this is the order, not
|
||
the order the nesting happened to come out in.
|
||
|
||
**Evaluated once.** The fill value and the generator *value* are each bound
|
||
to a slot before any loop starts, so [(array-fill [n] (next-id))] is one
|
||
call and n copies of its answer — not n calls. A generator's *body*, of
|
||
course, runs once per element; that is what it is for. *)
|
||
|
||
(* The dimensions, resolved by the same rule the [n T] type spelling uses —
|
||
[array_len] is literally that rule — with the one extra condition this form
|
||
has and the type spelling does not: the fill counts in i32, because every
|
||
index in the language is an i32, so a dimension that does not fit one has no
|
||
loop that could reach its end. *)
|
||
and array_dims ctx loc (dims : Ast.len list) =
|
||
List.map
|
||
(fun d ->
|
||
let n = array_len ctx.env loc d in
|
||
if n < 0L || Int64.compare n 2147483647L > 0 then
|
||
fail loc
|
||
"%Ld is not a dimension a fill can count to: an index in this \
|
||
language is an i32, and so is the loop that writes the elements"
|
||
n;
|
||
n)
|
||
dims
|
||
|
||
(* [r c] and an element type make [r [c T]], outermost first. *)
|
||
and array_of_dims ns elem =
|
||
List.fold_right (fun n t -> Types.Array (n, t)) ns elem
|
||
|
||
(* The element type an annotation asks for, peeled one [Array] per dimension.
|
||
[None] where the annotation is not an array of at least this rank: the
|
||
mismatch is then [expect]'s to report against the whole type, which is the
|
||
message that names both shapes rather than one of their leaves. *)
|
||
and array_elem_want rank want =
|
||
if rank = 0 then want
|
||
else
|
||
match want with
|
||
| Some (Types.Array (_, t)) -> array_elem_want (rank - 1) (Some t)
|
||
| _ -> None
|
||
|
||
(* The shared lowering. [pre] is bound before any loop runs — that is what
|
||
"evaluated once" means — and [element] is handed the index locals, in
|
||
dimension order, to build the value one element takes. *)
|
||
and array_build ctx loc ns elem ~pre ~element =
|
||
let aty = array_of_dims ns elem in
|
||
let arr = fresh_slot ctx aty in
|
||
let arrv = mk loc aty (Tast.Local arr) in
|
||
let islots = List.map (fun _ -> fresh_slot ctx index_ty) ns in
|
||
let ivals = List.map (fun s -> mk loc index_ty (Tast.Local s)) islots in
|
||
let zero = mk loc index_ty (Tast.Int (0L, Types.I32)) in
|
||
let one = mk loc index_ty (Tast.Int (1L, Types.I32)) in
|
||
let store =
|
||
mk loc Types.Unit (Tast.Set (Tast.Pindex (arrv, ivals), element ivals))
|
||
in
|
||
(* One [Let] and one [While] per dimension, the first dimension outermost.
|
||
The counter is bound *inside* the enclosing loop's body so that it is
|
||
re-zeroed on every pass of it, and the increment is the latch for the
|
||
reason [check_dotimes] gives. These loops carry no [break] and no
|
||
[continue], which is the condition [tast.ml] puts on a [While] the
|
||
checker invents. *)
|
||
let rec nest ns islots =
|
||
match ns, islots with
|
||
| [], [] -> store
|
||
| n :: ns, i :: islots ->
|
||
let iv = mk loc index_ty (Tast.Local i) in
|
||
let limit = mk loc index_ty (Tast.Int (n, Types.I32)) in
|
||
let cond = mk loc Types.Bool (Tast.Prim (Tast.Lt, [ iv; limit ])) in
|
||
let step =
|
||
mk loc Types.Unit
|
||
(Tast.Set (Tast.Plocal i,
|
||
mk loc index_ty (Tast.Prim (Tast.Add, [ iv; one ]))))
|
||
in
|
||
let loop =
|
||
mk loc Types.Unit (Tast.While (cond, [ nest ns islots ], [ step ]))
|
||
in
|
||
mk loc Types.Unit (Tast.Let ([ (i, zero) ], [ loop ]))
|
||
| _, _ -> fail loc "array fill: one counter per dimension"
|
||
in
|
||
mk loc aty
|
||
(Tast.Let (pre @ [ (arr, mk loc aty (Tast.Zero aty)) ],
|
||
[ nest ns islots; arrv ]))
|
||
|
||
and check_array_fill ctx ~want loc dims v =
|
||
let ns = array_dims ctx loc dims in
|
||
let elem_want = array_elem_want (List.length ns) want in
|
||
(* The annotation's element type is the [want] the value is checked against,
|
||
so a disagreement is reported at the value, in the ordinary
|
||
expected/found words, rather than as a whole-array mismatch a line up. *)
|
||
let v = check ctx ?want:elem_want v in
|
||
let elem = match elem_want with Some t -> t | None -> v.Tast.ty in
|
||
(* [resolve] refuses a fixed array of function values, because the elements
|
||
this form does not write would be zeroed and a zeroed function value is a
|
||
null pointer. The type is built here without going through [resolve], so
|
||
the same guard has to be asked here. *)
|
||
no_zeroed_fn loc "a fixed array's element" elem;
|
||
let vs = fresh_slot ctx elem in
|
||
let vv = mk loc elem (Tast.Local vs) in
|
||
expect ctx loc ~want
|
||
(array_build ctx loc ns elem ~pre:[ (vs, v) ] ~element:(fun _ -> vv))
|
||
|
||
and check_array_gen ctx ~want loc dims f =
|
||
let ns = array_dims ctx loc dims in
|
||
let rank = List.length ns in
|
||
let plural n = if n = 1 then "" else "s" in
|
||
let f =
|
||
match f.Ast.e with
|
||
(* The canonical inline form, [(array-gen [3 4] (fn [i j] ...))]. On its
|
||
own [check] would refuse the fn — it takes its types from its position,
|
||
and only an argument position names them — but *this* position knows
|
||
them just as well: one i32 index per dimension, and the annotated
|
||
element type as the return where the annotation reaches this deep.
|
||
With no annotation the return is left for the body to say, which is
|
||
the same inference the fill value gets. Arity is checked here so the
|
||
refusal talks about dimensions and indices, not about parameters some
|
||
(Fn ...) want expected. *)
|
||
| Ast.Fn (ps, fbody) ->
|
||
let got = List.length ps in
|
||
if got <> rank then
|
||
fail f.Ast.loc
|
||
"this array-gen has %d dimension%s, so its generator is called with \
|
||
%d index%s — and this one takes %d argument%s"
|
||
rank (plural rank) rank
|
||
(if rank = 1 then "" else "es") got (plural got);
|
||
check_fn ctx ~want:None
|
||
~gen:(List.init rank (fun _ -> index_ty), array_elem_want rank want)
|
||
f.Ast.loc ps fbody
|
||
| _ -> check ctx f
|
||
in
|
||
let elem =
|
||
match f.Tast.ty with
|
||
| Types.Fn (ps, r) ->
|
||
let got = List.length ps in
|
||
if got <> rank then
|
||
fail f.Tast.loc
|
||
"this array-gen has %d dimension%s, so its generator is called with \
|
||
%d index%s — and this one takes %d argument%s"
|
||
rank (plural rank) rank
|
||
(if rank = 1 then "" else "es") got (plural got);
|
||
List.iteri
|
||
(fun k p ->
|
||
if not (Types.equal p index_ty) then
|
||
fail f.Tast.loc
|
||
"an index is an i32, and this generator's argument %d is %s"
|
||
(k + 1) (Types.to_string p))
|
||
ps;
|
||
r
|
||
| other ->
|
||
fail f.Tast.loc
|
||
"array-gen's second element is a function value, called once per \
|
||
element with one i32 index per dimension, and this is %s — for one \
|
||
value repeated, write array-fill"
|
||
(Types.to_string other)
|
||
in
|
||
no_zeroed_fn loc "a fixed array's element" elem;
|
||
let fs = fresh_slot ctx f.Tast.ty in
|
||
let fv = mk loc f.Tast.ty (Tast.Local fs) in
|
||
expect ctx loc ~want
|
||
(array_build ctx loc ns elem ~pre:[ (fs, f) ]
|
||
~element:(fun idxs -> mk loc elem (Tast.CallPtr (fv, idxs))))
|
||
|
||
and check_match ctx ?(tail = false) ?want loc scrutinee arms =
|
||
let s = check ctx scrutinee in
|
||
(* What the arms are alternatives over. An [Option] is a two-case data type
|
||
wearing a special coat, so the two shapes below are the same shape: a set
|
||
of case names, an arity and a payload type per case, and a tag. Keeping
|
||
them apart here rather than desugaring [Option] into a declared data type is
|
||
deliberate — [Option] is generic and no declared data type is, so the coat is
|
||
the part that cannot yet be taken off. *)
|
||
let subject =
|
||
match s.Tast.ty with
|
||
| Types.Option t -> `Option t
|
||
| Types.Named n when Hashtbl.mem ctx.env.datas n ->
|
||
`Data (Hashtbl.find ctx.env.datas n)
|
||
(* An enum is the one scrutinee that is not a milestone away: it is an i32
|
||
at run time and its members are all known, so the arms would be a chain
|
||
of [=] with an exhaustiveness check over [env.enums] — a desugaring, not
|
||
a new IR node. What blocks it is upstream of here: a keyword has no case
|
||
in [Ast.pattern], and [lib/load.ml] matches that type exhaustively, so
|
||
the variant cannot be added. Said as itself rather than folded into the
|
||
milestone answer below, because the milestone is not the reason. *)
|
||
| Types.Enum n ->
|
||
fail loc
|
||
"match over the enum %s is not implemented — the lowering is a chain \
|
||
of (= k :member), but a keyword has no case in the pattern type yet. \
|
||
Use cond" n
|
||
(* An untagged union has nothing for the arms to be alternatives over.
|
||
This is not a milestone and not a missing lowering: [match] reads a tag
|
||
and decides, and the absence of a tag is the whole definition of this
|
||
type. Said by name, because the two kinds of union are one keyword
|
||
apart in the source and someone will write it. *)
|
||
| Types.Named n when Hashtbl.mem ctx.env.unions n ->
|
||
fail loc
|
||
"%s is a union, and there is nothing in one to match on: its members \
|
||
overlay the same bytes and nothing records which was written. Read \
|
||
the member you mean with (.member u), or keep a tag of your own \
|
||
beside it in a struct and match on that. A tagged alternative is \
|
||
what defdata is" n
|
||
| other ->
|
||
fail loc "match works on an Option or a data type, not on %s"
|
||
(Types.to_string other)
|
||
in
|
||
(* Which case each arm names, and the type of each name it binds. This is the
|
||
whole of what differs between the two subjects; everything below it is
|
||
shared. *)
|
||
let resolve_pat (a : Ast.arm) =
|
||
match subject, a.Ast.pat with
|
||
| _, Ast.Pwild -> None, []
|
||
| `Option elem, Ast.Pctor ("Some", [ x ]) -> Some "Some", [ (x, elem) ]
|
||
| `Option _, Ast.Pctor ("Some", _) ->
|
||
fail a.Ast.aloc "the Some pattern binds exactly one name"
|
||
| `Option _, Ast.Pctor ("None", []) -> Some "None", []
|
||
| `Option _, Ast.Pctor ("None", _) -> fail a.Ast.aloc "None binds no names"
|
||
| `Option _, Ast.Pctor (c, _) ->
|
||
fail a.Ast.aloc
|
||
"%s is not a case of Option — the cases are Some and None" c
|
||
| `Data u, Ast.Pctor (c, names) ->
|
||
(* A pattern names the case bare: the scrutinee's type already says which
|
||
data type, so [(Node l r)] is unambiguous even where two data types
|
||
share the case name. The qualified spelling is accepted too, since
|
||
that is how
|
||
the value was written and writing it again should not be an error. *)
|
||
let bare =
|
||
let full = u.Tast.dname ^ "." in
|
||
let n = String.length full in
|
||
if String.length c > n && String.sub c 0 n = full then
|
||
String.sub c n (String.length c - n)
|
||
else c
|
||
in
|
||
(match Tast.case_index u bare with
|
||
| None ->
|
||
fail a.Ast.aloc "%s is not a case of %s — the cases are %s" c
|
||
u.Tast.dname
|
||
(String.concat ", "
|
||
(List.map (fun (v : Tast.variant) -> v.Tast.vname) u.Tast.cases))
|
||
| Some (_, v) ->
|
||
(* Positional, in declaration order, and all of them or none: a
|
||
pattern that bound some of a case's fields would be silently
|
||
reading the wrong one after a field is inserted. Refused with the
|
||
count, which is the thing that is wrong. *)
|
||
if List.length names <> List.length v.Tast.vfields then
|
||
fail a.Ast.aloc
|
||
"%s.%s has %d field%s, and this pattern binds %d — a case pattern \
|
||
binds every field, in declaration order (%s)"
|
||
u.Tast.dname bare (List.length v.Tast.vfields)
|
||
(if List.length v.Tast.vfields = 1 then "" else "s")
|
||
(List.length names)
|
||
(String.concat " "
|
||
(List.map (fun (f : Tast.field) -> f.Tast.fname)
|
||
v.Tast.vfields));
|
||
Some bare,
|
||
List.map2 (fun n (f : Tast.field) -> (n, f.Tast.fty))
|
||
names v.Tast.vfields)
|
||
in
|
||
let want = ref want in
|
||
let seen = Hashtbl.create 8 in
|
||
let saw_wild = ref false in
|
||
let arms =
|
||
map_lr
|
||
(fun (a : Ast.arm) ->
|
||
let ctor, binds = resolve_pat a in
|
||
(match ctor with
|
||
| None -> saw_wild := true
|
||
| Some c ->
|
||
if Hashtbl.mem seen c then
|
||
fail a.Ast.aloc "this match has two %s arms" c;
|
||
Hashtbl.add seen c ());
|
||
branch ctx (fun () ->
|
||
(* What each name in this arm is, in words, for the one refusal
|
||
that needs it: a case pattern binds fields positionally, so the
|
||
i'th name is the i'th field of the case the arm named. Derived
|
||
here rather than carried out of [resolve_pat], because the case
|
||
and the subject are both still in hand and the alternative was
|
||
widening that function's result for one message. *)
|
||
let fields =
|
||
match subject, ctor with
|
||
| `Data u, Some c ->
|
||
(match Tast.case_index u c with
|
||
| Some (_, v) ->
|
||
List.map
|
||
(fun (fd : Tast.field) ->
|
||
Printf.sprintf "%s.%s's field %s" u.Tast.dname c
|
||
fd.Tast.fname)
|
||
v.Tast.vfields
|
||
| None -> [])
|
||
| `Option _, Some "Some" -> [ "the Option's payload" ]
|
||
| _ -> []
|
||
in
|
||
let binds =
|
||
List.mapi
|
||
(fun i (n, ty) ->
|
||
let what = List.nth_opt fields i in
|
||
bind ctx ?what n ty ~assignable:false)
|
||
binds
|
||
in
|
||
(* Every arm is the tail, exactly as an [if]'s two arms are.
|
||
Restored here because checking the scrutinee withdrew it. *)
|
||
ctx.tail <- tail;
|
||
let body = block ctx ?want:!want a.Ast.aloc a.Ast.body in
|
||
if !want = None && body.Tast.ty <> Types.Never then
|
||
want := Some body.Tast.ty;
|
||
{ Tast.acase = ctor; binds; abody = [ body ] }))
|
||
arms
|
||
in
|
||
(* Exhaustiveness is refused, not defaulted. A match that silently fell
|
||
through would have to produce a value of the match's type out of nothing,
|
||
and there is no such value for most types; and the case a data type grows
|
||
tomorrow is exactly the one a reader wants to be told about today. A [_]
|
||
arm is the way to say "the rest", written where it can be seen. *)
|
||
let missing =
|
||
match subject with
|
||
| `Option _ -> List.filter (fun c -> not (Hashtbl.mem seen c)) [ "Some"; "None" ]
|
||
| `Data u ->
|
||
List.filter_map
|
||
(fun (c : Tast.variant) ->
|
||
if Hashtbl.mem seen c.Tast.vname then None
|
||
else Some (u.Tast.dname ^ "." ^ c.Tast.vname))
|
||
u.Tast.cases
|
||
in
|
||
if not !saw_wild && missing <> [] then
|
||
(* The data type's declaration, because that is where the case list this match
|
||
failed to cover actually lives, and because adding a case there is what
|
||
makes a match non-exhaustive in the first place. *)
|
||
Loc.failk "check/non-exhaustive-match" loc
|
||
~notes:(match subject with `Data u -> declared_note ctx.env u.Tast.dname
|
||
| _ -> [])
|
||
"this match is not exhaustive — %s %s no arm. Add %s, or a _ arm for \
|
||
the rest"
|
||
(String.concat ", " missing)
|
||
(if List.length missing = 1 then "has" else "have")
|
||
(if List.length missing = 1 then "it" else "them");
|
||
let ty = match !want with Some t -> t | None -> Types.Never in
|
||
mk loc ty (Tast.Match (s, arms))
|
||
|
||
(* ── Places ────────────────────────────────────────────────────────── *)
|
||
|
||
(* The fields a name has, whether it is a struct or an untagged union. The two
|
||
are one record and differ only in what the offsets come out as, which is a
|
||
question for the layout and not for this — so [.x] is one path and not two,
|
||
and a union member is read with the accessor everything else is read with.
|
||
That is the whole of what makes punning ordinary code. *)
|
||
(* Every name a value could be standing under here: what is in scope, the
|
||
globals, the functions — generic ones included, since a call to one is
|
||
written exactly like a call to any other. No type names: a symbol written
|
||
where a value goes was not a mistyped struct, and offering one would send
|
||
the reader to the wrong file. *)
|
||
and value_candidates ctx =
|
||
List.map fst ctx.scope
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) ctx.env.globals []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) ctx.env.fns []
|
||
@ Hashtbl.fold (fun k _ acc -> k :: acc) ctx.env.gsigs []
|
||
|
||
(* The name nothing answers to, refused with whatever this position can still
|
||
tell the reader.
|
||
|
||
Two readings get in ahead of the bare refusal. The first is the dot: [p.x]
|
||
is how C, Go and Odin spell field access and it is the habit everyone
|
||
arrives with, so a symbol with a dot in it and a lowercase head is almost
|
||
never a name — it is an accessor written the way the last language wrote
|
||
it. The head is looked up, so the sentence can say what [p] actually is
|
||
rather than guess, and the struct's declaration comes along as a note when
|
||
there is one. Capitalised heads are left alone: [Shape.Circle] is a real
|
||
spelling in this language and a typo in one is a mistyped case, not a
|
||
dot-infix habit.
|
||
|
||
The second is the near miss, over values only — see [value_candidates]. *)
|
||
and unknown_name : 'a. ?setting:bool -> ctx -> Loc.t -> string -> 'a =
|
||
fun ?(setting = false) ctx loc name ->
|
||
let dot = String.index_opt name '.' in
|
||
let head, field =
|
||
match dot with
|
||
| Some i when i > 0 && i + 1 < String.length name ->
|
||
String.sub name 0 i, String.sub name (i + 1) (String.length name - i - 1)
|
||
| _ -> "", ""
|
||
in
|
||
let lower = head <> "" && head.[0] = Char.lowercase_ascii head.[0]
|
||
&& head.[0] <> Char.uppercase_ascii head.[0] in
|
||
if lower then begin
|
||
let ty =
|
||
match lookup ctx head with
|
||
| Some b -> Some b.bty
|
||
| None -> Option.map fst (Hashtbl.find_opt ctx.env.globals head)
|
||
in
|
||
let sname =
|
||
match ty with
|
||
| Some (Types.Named n) when fields_named ctx.env n <> None -> Some n
|
||
| Some (Types.Ptr (Types.Named n)) when fields_named ctx.env n <> None -> Some n
|
||
| _ -> None
|
||
in
|
||
match sname, ty with
|
||
| Some sn, _ ->
|
||
let s = Option.get (fields_named ctx.env sn) in
|
||
let notes = declared_note ctx.env sn in
|
||
(* In a [set] the accessor is the *place*, so the spelling to give is
|
||
[(set (.x p) 1)] and not [(.x p)] on its own. Saying "read" at an
|
||
assignment would be a sentence that does not apply to the form it is
|
||
printed under. *)
|
||
let how =
|
||
if setting then Printf.sprintf "a field is assigned through an \
|
||
accessor, so write (set (.%s %s) ...)"
|
||
field head
|
||
else Printf.sprintf "a field is read with an accessor, so write (.%s %s)"
|
||
field head
|
||
in
|
||
if Tast.field_index s field <> None then
|
||
Loc.failk "check/dot-access" loc ~notes "unknown name %s — %s" name how
|
||
else
|
||
Loc.failk "check/dot-access" loc ~notes
|
||
"unknown name %s — %s, and %s has no field %s" name how sn field
|
||
| None, Some t ->
|
||
Loc.failk "check/dot-access" loc
|
||
"unknown name %s — a dot is part of the name here, not field access. \
|
||
A field is reached through an accessor, (.%s %s), and %s is %s, \
|
||
which has no fields"
|
||
name field head head (Types.to_string t)
|
||
| None, None ->
|
||
Loc.failk "check/unknown-name" loc
|
||
"unknown name %s — nothing named %s is in scope either. A field is \
|
||
reached through an accessor, (.%s %s), not with a dot"
|
||
name head field head
|
||
end
|
||
else
|
||
match nearest (value_candidates ctx) name with
|
||
| Some m ->
|
||
Loc.failk "check/unknown-name" loc "unknown name %s — did you mean %s?" name m
|
||
| None -> Loc.failk "check/unknown-name" loc "unknown name %s" name
|
||
|
||
(* 1st, 2nd, 3rd, and every other one. *)
|
||
and ordinal n =
|
||
let suffix =
|
||
if n mod 100 >= 11 && n mod 100 <= 13 then "th"
|
||
else match n mod 10 with 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th"
|
||
in
|
||
string_of_int n ^ suffix
|
||
|
||
(* Check one argument of a call to [name], and if the refusal is the plain
|
||
type mismatch raised against *this* argument's own span, say the two things
|
||
the caret cannot: which argument of which function this is, and where the
|
||
parameter that wanted the other type is declared.
|
||
|
||
The span test is what keeps the claim true. A mismatch deeper inside the
|
||
argument — an element of a vec literal, an argument of a nested call — is
|
||
raised against its own location and is re-raised untouched, because calling
|
||
that "the 2nd argument of add" would be a sentence that reads well and
|
||
points at the wrong form. The rekind is what stops a nested call from being
|
||
named twice: once enriched, it is no longer the kind this looks for. *)
|
||
and check_arg ctx name i (want : Types.t) (a : Ast.expr) =
|
||
match check ctx ~want a with
|
||
| e -> e
|
||
| exception Loc.Error d
|
||
when String.equal d.Loc.kind "check/type-mismatch"
|
||
&& d.Loc.dloc == a.Ast.loc ->
|
||
let which = ordinal (i + 1) in
|
||
let notes =
|
||
match Hashtbl.find_opt ctx.env.fparams name with
|
||
| Some ps when List.length ps > i ->
|
||
let p = List.nth ps i in
|
||
[ Loc.note p.Ast.floc
|
||
(Printf.sprintf "%s's %s parameter %s is declared %s"
|
||
name which p.Ast.fname (Types.to_string want)) ]
|
||
| _ -> []
|
||
in
|
||
Loc.raise_diag
|
||
(Loc.diag ~kind:"check/argument-type" ~notes a.Ast.loc
|
||
(Printf.sprintf "%s — this is the %s argument of %s" d.Loc.dmsg which name))
|
||
|
||
and fields_named env n : Tast.structure option =
|
||
match Hashtbl.find_opt env.structs n with
|
||
| Some s -> Some s
|
||
| None -> Hashtbl.find_opt env.unions n
|
||
|
||
(* The target of [.field] is a struct or an untagged union, or one level of
|
||
pointer to one. The auto-deref is inserted here as a real node, so no
|
||
backend re-derives it. *)
|
||
and struct_target ctx (target : Ast.expr) : Tast.expr * string =
|
||
let t = check ctx target in
|
||
let has n = fields_named ctx.env n <> None in
|
||
match t.Tast.ty with
|
||
| Types.Named n when has n -> t, n
|
||
| Types.Ptr (Types.Named n) when has n ->
|
||
mk t.Tast.loc (Types.Named n) (Tast.Deref t), n
|
||
(* A data type's fields belong to one case, and which case it is holding is
|
||
only known after the tag has been read. [.field] would have to be a read
|
||
that might be reading something else, so it is not one: [match] is how a
|
||
data type is opened, and it binds the fields it has proved are there. *)
|
||
| (Types.Named n | Types.Ptr (Types.Named n))
|
||
when Hashtbl.mem ctx.env.datas n ->
|
||
fail target.Ast.loc
|
||
"%s is a data type, and a data type's fields belong to a case — which \
|
||
one it is holding is what the tag says, so they are reached by (match ...), \
|
||
whose arms bind the fields of the case they matched"
|
||
n
|
||
| other ->
|
||
(* The pattern bound this, and it looks like a destructuring that did not
|
||
take: [(match s (Circle c) (.r c))] over a one-field case binds [c] to
|
||
the payload itself. "f64 is not a struct" is true and is a type fact
|
||
where the reader needs to be told the value is already in hand. *)
|
||
(match target.Ast.e with
|
||
| Ast.Var n ->
|
||
(match lookup ctx n with
|
||
| Some { bwhat = Some w; _ } ->
|
||
fail target.Ast.loc
|
||
"%s is %s — the pattern bound it to %s, so the value is already \
|
||
in hand and there is no field left to read"
|
||
n (Types.to_string other) w
|
||
| _ -> ())
|
||
| _ -> ());
|
||
fail target.Ast.loc "%s is not a struct, so it has no fields"
|
||
(Types.to_string other)
|
||
|
||
(* (at s i) reads a string's byte, and reading is the whole of what a string
|
||
does here: it is a view of bytes the program does not own — a literal's
|
||
are in constant storage, where a store is dropped on one backend and
|
||
faults on the other — so there is no address of one to hand out either.
|
||
[addr] asks the same question and gets the same answer, so the refusal
|
||
names taking the address rather than only assigning.
|
||
|
||
It is a function and not a case inside [check_place] because [check_place]
|
||
is no longer the only way to a [Pindex]: the single-index [set] arm checks
|
||
its target itself and calls [indexed] directly, and [indexed] accepts a
|
||
string. Both call this, so neither can drift away from the other. *)
|
||
and refuse_string_place loc (ty : Types.t) =
|
||
if Types.equal ty Types.String then
|
||
fail loc
|
||
"a string is a read-only view of bytes it does not own, so (at s i) is \
|
||
a value and not a place — there is nothing to assign into or take the \
|
||
address of. Copy the bytes into a buffer you own and use that"
|
||
|
||
and check_place ctx loc (p : Ast.place) : Tast.place * Types.t =
|
||
match p with
|
||
| Ast.Pvar name ->
|
||
(match lookup ctx name with
|
||
| Some b ->
|
||
if not b.assignable then
|
||
fail loc
|
||
"%s is a parameter, and a parameter is not a place you can assign \
|
||
to — bind a local with let" name;
|
||
Tast.Plocal b.slot, b.bty
|
||
| None ->
|
||
match Hashtbl.find_opt ctx.env.globals name with
|
||
| Some (_, true) ->
|
||
(* Four words, before: the name and the fact, and nothing about what
|
||
to do or where the decision was made. [no_container_defconst] is
|
||
the house's own shape for this and the note is [declared_note]'s.
|
||
A defconst is what the linker writes into the image, so there is
|
||
no assignment to allow — the fix is the other declaration. *)
|
||
let notes =
|
||
match Hashtbl.find_opt ctx.env.global_locs name with
|
||
| Some at -> [ Loc.note at (name ^ " is declared a constant here") ]
|
||
| None -> []
|
||
in
|
||
Loc.failk "check/set-constant" loc ~notes
|
||
"%s is a constant, and a constant is not assignable — it is written \
|
||
into the image and there is nothing to assign to. Declare it with \
|
||
defonce if it has to change" name
|
||
| Some (ty, false) -> Tast.Pglobal name, ty
|
||
| None -> captured ctx loc name; unknown_name ~setting:true ctx loc name)
|
||
| Ast.Pfield (target, name) ->
|
||
let target, sname = struct_target ctx target in
|
||
let s = Option.get (fields_named ctx.env sname) in
|
||
(match Tast.field_index s name with
|
||
| None ->
|
||
Loc.failk "check/unknown-field" loc ~notes:(declared_note ctx.env sname)
|
||
"%s has no field %s" sname name
|
||
| Some i -> Tast.Pfield (target, i), (List.nth s.Tast.fields i).Tast.fty)
|
||
| Ast.Pindex (target, idx) ->
|
||
let target = check ctx target in
|
||
(match target.Tast.ty with
|
||
(* The same bounds and epoch check the value form gets, through the same
|
||
helper: an element of a Vec is a place because a Vec element is
|
||
assignable, and a set that skipped the checks would be the asymmetry
|
||
[nth] was removed for. *)
|
||
| Types.Vec _ ->
|
||
let p, ty = vec_at ctx loc target idx in
|
||
Tast.Pderef p, ty
|
||
| _ ->
|
||
let idx, ty = indexed ~place:loc ctx target idx in
|
||
Tast.Pindex (target, idx), ty)
|
||
| Ast.Pderef target ->
|
||
let target = check ctx target in
|
||
(match target.Tast.ty with
|
||
| Types.Ptr t -> Tast.Pderef target, t
|
||
| other ->
|
||
fail loc "deref takes a (Ptr T), found %s" (Types.to_string other))
|
||
|
||
(* An index or a slice bound that is a literal is known now, so it is an error
|
||
now rather than a trap later. Only literals: a [defconst] is a global in the
|
||
typed IR, not a folded constant, so [(at arr size)] still traps at runtime —
|
||
which is what the emitted bounds check is for. A negative literal is wrong
|
||
whatever the target, but a length is static only for [n T]. *)
|
||
and static_index loc (ty : Types.t) ~past_end what k =
|
||
if k < 0L then
|
||
fail loc "%s %Ld is negative — indices count from 0" what k;
|
||
match ty with
|
||
(* [past_end] is the difference between an index and a slice bound: the last
|
||
valid index is len - 1, but a slice may end at len. *)
|
||
| Types.Array (n, _) when if past_end then k > n else k >= n ->
|
||
fail loc "%s %Ld is out of bounds for length %Ld" what k n
|
||
| _ -> ()
|
||
|
||
(* The literal value of a checked expression, if it has one. *)
|
||
and literal (e : Tast.expr) =
|
||
match e.Tast.e with Tast.Int (k, _) -> Some k | _ -> None
|
||
|
||
(* An index is [i32] internally, but a *narrower* integer may be written as
|
||
one: indexing is not arithmetic on the value, so there is nothing for a
|
||
visible cast to warn about, and requiring (i32 c) at every subscript would
|
||
be noise. A u32 is included because it cannot lose a value the bounds check
|
||
would then miss — anything above 2^31 truncates to a negative i32, which
|
||
the unsigned comparison rejects. i64 and u64 are not: 2^32 + 5 truncates to
|
||
5 and would read the wrong element with no trap at all, so those need the
|
||
cast written out. *)
|
||
and index_expr ctx (e : Ast.expr) =
|
||
(* No [want]: an expectation of [i32] would reject a [u32] index outright,
|
||
before there is anything here to convert. An untyped literal still
|
||
defaults to [i32] on its own. *)
|
||
let v = check ctx e in
|
||
match v.Tast.ty with
|
||
| Types.Int Types.I32 -> v
|
||
| Types.Int k when Types.bits k <= 32 ->
|
||
{ v with Tast.ty = index_ty;
|
||
Tast.e = Tast.Prim (Tast.Cast index_ty, [ v ]) }
|
||
| Types.Int k ->
|
||
fail e.Ast.loc
|
||
"an index is an i32, and %s is wider — write (i32 …), because a value \
|
||
that does not fit truncates to one that does and would read the wrong \
|
||
element without tripping the bounds check" (Types.ikind_name k)
|
||
| other ->
|
||
fail e.Ast.loc "an index is an integer, found %s" (Types.to_string other)
|
||
|
||
(* [(at a i)] and [(at grid row col)]: one index per dimension.
|
||
|
||
[~place] carries the location of the form being assigned into, and is
|
||
given by the two callers that are building somewhere to store. It is asked
|
||
at *every* dimension rather than once about the target: [(at g 0 0)] over a
|
||
[[2 string]] reaches a string at the last step and nowhere before it, so a
|
||
question asked only of [g] would miss it. *)
|
||
and indexed ?place ctx (target : Tast.expr) (idx : Ast.expr list) =
|
||
let rec go ty = function
|
||
| [] -> [], ty
|
||
| i :: rest ->
|
||
let elem =
|
||
match ty with
|
||
| Types.Array (_, t) | Types.Slice t -> t
|
||
(* A string indexes to its bytes, and only to read them. *)
|
||
| Types.String ->
|
||
Option.iter (fun l -> refuse_string_place l ty) place;
|
||
Types.Int Types.U8
|
||
| other ->
|
||
fail i.Ast.loc "%s cannot be indexed" (Types.to_string other)
|
||
in
|
||
let loc = i.Ast.loc in
|
||
let i = index_expr ctx i in
|
||
(match literal i with
|
||
| Some k -> static_index loc ty ~past_end:false "index" k
|
||
| None -> ());
|
||
let rest, ty = go elem rest in
|
||
i :: rest, ty
|
||
in
|
||
go target.Tast.ty idx
|
||
|
||
(* ── Calls ─────────────────────────────────────────────────────────── *)
|
||
|
||
and check_call ctx ~want loc (head : Ast.expr) (args : Ast.expr list) =
|
||
match head.Ast.e with
|
||
| Ast.Var name -> named_call ctx ~want loc name args
|
||
(* A computed head: ((choose k) 3). The head is an ordinary expression and
|
||
the only thing asked of it is that it be a function. *)
|
||
| _ -> call_value ctx ~want loc (check ctx head) args
|
||
|
||
(* The indirect call, once the callee is checked. Shared by the computed head
|
||
above and by a name that resolved to a local or a parameter of function
|
||
type, which is the shape every caller of [map] has. *)
|
||
and call_value ctx ~want loc (callee : Tast.expr) args =
|
||
match callee.Tast.ty with
|
||
| Types.Fn (params, ret) ->
|
||
if List.length args <> List.length params then
|
||
fail loc "this function value takes %d argument%s, given %d"
|
||
(List.length params)
|
||
(if List.length params = 1 then "" else "s")
|
||
(List.length args);
|
||
let args = map2_lr (fun p a -> check ctx ~want:p a) params args in
|
||
expect ctx loc ~want (mk loc ret (Tast.CallPtr (callee, args)))
|
||
| other ->
|
||
fail loc "this is a %s and not a function, so it cannot be called"
|
||
(Types.to_string other)
|
||
|
||
(* A builtin's arity. The count is the builtin's and can only be the
|
||
builtin's: a defn of the same name written in the program now takes the
|
||
call over before any builtin arm is reached ([shadows_builtin] at the top
|
||
of [named_call]), so a call measured here is a call to the builtin and
|
||
there is no second signature for the reader to have meant. The note that
|
||
used to say otherwise — "this is the builtin get, which a defn of the same
|
||
name does not replace" — described a resolution order this compiler no
|
||
longer has. *)
|
||
and arity _ctx loc name n args =
|
||
if List.length args <> n then
|
||
fail loc "%s takes %d argument%s, given %d" name n
|
||
(if n = 1 then "" else "s") (List.length args)
|
||
|
||
(* The operators that fold: [+ - * /], [min]/[max] and the three bitwise
|
||
combining operators all take two operands or more, and mean the same thing
|
||
applied left to right. [%] and the shifts are not in that set — a chain of
|
||
remainders or of shifts has no reading a reader would agree on in advance,
|
||
so there the arity error is the useful answer.
|
||
|
||
Two is the floor, and the two missing cases are refused rather than
|
||
invented. Zero operands would have to mean an identity element, 0 for + and
|
||
1 for *, and a sum with no terms in it is a typo far more often than it is
|
||
an intent. One operand would have to mean negation for [-] and reciprocal
|
||
for [/], and this language has no unary minus anywhere: the prelude writes
|
||
every negation as [(- 0 n)] or [(- 0.0 x)], and [(- x)] meaning something
|
||
else than the [-] two lines above it is a rule a reader has to carry rather
|
||
than see. *)
|
||
and fold_arity loc name args =
|
||
match args with
|
||
| _ :: _ :: _ -> ()
|
||
| [ _ ] when String.equal name "-" ->
|
||
fail loc
|
||
"- takes two arguments or more, given 1 — there is no unary minus; \
|
||
write (- 0 x) to negate, which is what the prelude does"
|
||
| [ _ ] when String.equal name "/" ->
|
||
fail loc
|
||
"/ takes two arguments or more, given 1 — there is no reciprocal; \
|
||
write (/ 1.0 x)"
|
||
| _ ->
|
||
fail loc "%s takes two arguments or more, given %d" name (List.length args)
|
||
|
||
(* The first two operands decide the type — [binary] picks which of them is
|
||
allowed to, and that decision is not re-made per pair — and every operand
|
||
after them is checked against it. *)
|
||
(* The operand, not the form. "[+] takes numbers, found string" with the caret
|
||
over the whole [(+ "a" "b")] is the exact "whole form vs operand" shape the
|
||
[check_truthy] work already fixed once: the reader has to find which of the
|
||
operands is the one being talked about, and the compiler knew.
|
||
|
||
And a text operand gets the extra clause, because [+] on two strings is a
|
||
reach for concatenation and the answer is a function rather than an
|
||
operator here. Named without a call shape on purpose: [concat] and [join]
|
||
take a slice of byte slices and the spelling that builds one from string
|
||
literals is not a clause in a sentence. *)
|
||
and not_numeric name what (a : Tast.expr) =
|
||
let text =
|
||
match a.Tast.ty with
|
||
| Types.String -> true
|
||
| Types.Slice (Types.Int Types.U8) -> true
|
||
| _ -> false
|
||
in
|
||
let where = a.Tast.loc in
|
||
if text then
|
||
fail where
|
||
"%s takes %s, and this is %s — there is no %s on text. The prelude \
|
||
concatenates with concat and join"
|
||
name what (Types.to_string a.Tast.ty) name
|
||
else
|
||
fail where "%s takes %s, found %s" name what (Types.to_string a.Tast.ty)
|
||
|
||
and fold_left_prim ctx ~want loc name p ~needs ok what args =
|
||
let x, y, rest =
|
||
match args with x :: y :: rest -> x, y, rest | _ -> assert false
|
||
in
|
||
let a, b = binary ctx ~dyn_ok:true name loc ~want:(numeric_want want) [ x; y ] in
|
||
(* One dyn operand makes the whole fold dyn, whichever side it is on. The
|
||
typed side is boxed by [dyn_fold]; a literal was already built at dyn by
|
||
[binary], so [(+ x 1)] over a dyn x folds an i64 one. *)
|
||
if a.Tast.ty = Types.Dyn || b.Tast.ty = Types.Dyn then
|
||
dyn_fold ctx ~want loc name [ a; b ] rest
|
||
else begin
|
||
(* [~needs] is the operator's own bound: [numeric?] for the arithmetic,
|
||
[integer?] for the bitwise fold. Asking the tighter question here is what
|
||
keeps a bitwise body's refusal at the *definition* — under [numeric?] the
|
||
abstract pass admitted [(bit-and x 1)] and the refusal arrived from
|
||
inside the generic's source at whichever call site first instantiated at
|
||
a float, which is the misplaced diagnostic the pass exists to avoid. *)
|
||
unconstrained ctx.env loc name ~needs a.Tast.ty;
|
||
(* Past [unconstrained] a variable here is one the [where] clause admitted,
|
||
so the concrete predicate below has nothing to say about it — it is
|
||
answered again, per copy, at the instantiation. *)
|
||
if not (ok a.Tast.ty || generic_ty a.Tast.ty) then not_numeric name what a;
|
||
let ty = a.Tast.ty in
|
||
let acc =
|
||
List.fold_left
|
||
(fun acc arg ->
|
||
mk loc ty (Tast.Prim (p, [ acc; check ctx ~want:ty arg ])))
|
||
(mk loc ty (Tast.Prim (p, [ a; b ])))
|
||
rest
|
||
in
|
||
expect ctx loc ~want acc
|
||
end
|
||
|
||
(* The dyn lowering of a fold: one call per operator application, left to
|
||
right, each taking and answering a dyn word. The typed side of a mixed pair
|
||
is boxed on the way in — [box] is the identity on something already dyn, so
|
||
this needs no case analysis of its own. *)
|
||
and dyn_fold ctx ~want loc name first rest =
|
||
let sym =
|
||
match name with
|
||
| "+" -> "flan_dyn_add" | "-" -> "flan_dyn_sub"
|
||
| "*" -> "flan_dyn_mul" | "/" -> "flan_dyn_div"
|
||
| "%" -> "flan_dyn_rem"
|
||
| _ ->
|
||
(* Bitwise and shift operators land here if they ever admit a dyn
|
||
operand. They do not: the runtime carries no bitwise entry points,
|
||
and an integer operation on a value that might be a float is not
|
||
something to guess at. *)
|
||
no_dyn_yet loc ~into:false Types.Dyn
|
||
(Printf.sprintf " — %s has no dyn form" name)
|
||
in
|
||
(* The site travels with the operands. A dyn arithmetic trap is this
|
||
language's type error, and until now it printed with no file, no line and
|
||
no column — [here loc] is the same string literal [cast_dyn] hands the
|
||
runtime, and the runtime prints it as a GNU prefix. *)
|
||
let apply acc b = rt loc Types.Dyn sym [ acc; box loc b; here loc ] in
|
||
let acc =
|
||
match first with
|
||
| [ a; b ] -> apply (box loc a) b
|
||
| _ -> assert false
|
||
in
|
||
let acc =
|
||
List.fold_left (fun acc arg -> apply acc (check ctx ~want:Types.Dyn arg))
|
||
acc rest
|
||
in
|
||
expect ctx loc ~want acc
|
||
|
||
(* ── Allocation failure, spec-memory.md ────────────────────────────────
|
||
No allocating operation returns an error and none can fail silently. When
|
||
the allocator cannot satisfy a request the operation signals
|
||
|
||
(StorageExhausted {.bytes n .align a .allocator id})
|
||
|
||
with [error] — whose type is Never — inside a [restart-case] offering
|
||
[retry]. That is one rule over every allocating operation, which is what
|
||
keeps [push] and [reserve] at Unit, [clone] at the container, and no
|
||
signature anywhere growing a Result. Odin's [append] returns an ignorable
|
||
Allocator_Error and its type-erased path returns the old length on a failed
|
||
reserve; an append that appends nothing and says nothing is the outcome this
|
||
rule exists to make impossible.
|
||
|
||
It is *compiler-emitted at the point of failure*, which spec-memory.md names
|
||
as the exception to plan.org's "restarts go at the resync point, once": a
|
||
restart established at a parser's top-level loop cannot re-attempt an
|
||
allocation, and only the allocation site can.
|
||
|
||
The shape is built out of nodes that already exist — a while, a restart-case
|
||
and an error — so the backend learns nothing new about allocation:
|
||
|
||
(let [ok false]
|
||
(while (not ok)
|
||
(restart-case
|
||
(do (set ok ATTEMPT)
|
||
(if (not ok) (error (StorageExhausted {...}))))
|
||
(retry []))))
|
||
|
||
A handler that frees something, releases a scratch region or grows the arena
|
||
and then invokes [retry] lands in the clause, the clause falls through, and
|
||
the while re-tests and re-attempts the *same* request. With nothing handling
|
||
it, [error] stops the program on the frame that erred, as §2 says.
|
||
|
||
[attempt] must be a call that can be repeated: every argument to it is bound
|
||
to a slot before the loop, so a retry does not re-evaluate the element
|
||
expression a push was given. *)
|
||
and alloc_guard ctx loc (attempt : Tast.expr) =
|
||
let ok = fresh_slot ctx Types.Bool in
|
||
let okv = mk loc Types.Bool (Tast.Local ok) in
|
||
let notok () = mk loc Types.Bool (Tast.Prim (Tast.Not, [ okv ])) in
|
||
let i8 n = mk loc (Types.Int Types.I8) (Tast.Int (n, Types.I8)) in
|
||
(* The runtime answers 1 or 0 and never reports failure any other way. *)
|
||
let attempt = mk loc Types.Bool (Tast.Prim (Tast.Ne, [ attempt; i8 0L ])) in
|
||
(* A value struct on the signalling frame's stack, with fixed numeric fields
|
||
and no rendered message: formatting would allocate, and this is the one
|
||
path that must not. Rendering happens in the handler or the break loop,
|
||
where a working allocator is known. *)
|
||
let cond =
|
||
mk loc (Types.Named "StorageExhausted")
|
||
(Tast.Make
|
||
("StorageExhausted",
|
||
[ rt loc (Types.Int Types.I64) "flan_alloc_fail_bytes" [];
|
||
rt loc (Types.Int Types.I64) "flan_alloc_fail_align" [];
|
||
rt loc (Types.Int Types.I64) "flan_alloc_fail_id" [] ]))
|
||
in
|
||
let signal =
|
||
mk loc Types.Never
|
||
(Tast.Signal (Tast.Serror, type_id "StorageExhausted", cond))
|
||
in
|
||
let attempt_then_signal =
|
||
mk loc Types.Unit
|
||
(Tast.Do
|
||
[ mk loc Types.Unit (Tast.Set (Tast.Plocal ok, attempt));
|
||
mk loc Types.Unit (Tast.If (notok (), signal, unit_at loc)) ])
|
||
in
|
||
let clause =
|
||
(* Compiler-emitted, so it takes no parameters: nothing outside can hand
|
||
this one a value. [rsig] is therefore the empty signature, and its hash
|
||
the same one a written [(retry [] ...)] gets — the two must agree, since
|
||
an [invoke-restart] cannot tell them apart. *)
|
||
let sg = restart_sig [] in
|
||
{ Tast.rname_id = type_id "retry"; rname = "retry"; rparams = [];
|
||
rsig = sg; rsig_id = type_id sg; rbody = [ unit_at loc ] }
|
||
in
|
||
let body =
|
||
mk loc Types.Unit (Tast.RestartCase ([ clause ], attempt_then_signal))
|
||
in
|
||
mk loc Types.Unit
|
||
(Tast.Let ([ (ok, mk loc Types.Bool (Tast.Bool false)) ],
|
||
[ mk loc Types.Unit (Tast.While (notok (), [ body ], [])) ]))
|
||
|
||
(* ── File failure, decisions 2 and 5 ───────────────────────────────────
|
||
The same shape [alloc_guard] has, for the same reason and out of the same
|
||
nodes: the operation signals inside a [restart-case] it establishes itself,
|
||
so nothing anywhere grows a Result and neither [slurp] nor [barf] can fail
|
||
silently. Compiler-emitted at the point of failure, which spec-memory.md
|
||
already names as the exception to plan.org's "restarts go at the resync
|
||
point, once" — a restart at an outer loop cannot re-open a file.
|
||
|
||
Two restarts, and they are the textbook pair Common Lisp establishes for a
|
||
file-error:
|
||
|
||
retry the file may be there now — the handler made a
|
||
directory, mounted something, or waited.
|
||
use-value [p string] try this other path instead.
|
||
|
||
[use-value]'s parameter *is* the path slot, so the clause body is [unit]:
|
||
emit.ml's [bind_params] stores the invoker's argument straight into the slot
|
||
the attempt reads, the clause falls through, and the while re-tests and
|
||
re-attempts against the new path. Typed restarts landed this session and
|
||
this is the first thing the compiler itself emits one for.
|
||
|
||
[attempt] must be repeatable, so the path is a slot read at each turn of the
|
||
loop rather than an expression re-evaluated. *)
|
||
and file_guard ctx loc ~path_slot ~op mk_steps =
|
||
let ok = fresh_slot ctx Types.Bool in
|
||
let okv = mk loc Types.Bool (Tast.Local ok) in
|
||
let notok () = mk loc Types.Bool (Tast.Prim (Tast.Not, [ okv ])) in
|
||
let i8 n = mk loc (Types.Int Types.I8) (Tast.Int (n, Types.I8)) in
|
||
(* Fixed fields and no rendered message, exactly as StorageExhausted: the
|
||
condition is built on the failing frame's stack and formatting is the
|
||
handler's job. [path] is whatever the attempt last used, so a handler that
|
||
supplied one through [use-value] sees the path that actually failed. *)
|
||
let cond =
|
||
mk loc (Types.Named "FileError")
|
||
(Tast.Make
|
||
("FileError",
|
||
[ mk loc Types.String (Tast.Local path_slot);
|
||
mk loc (Types.Int Types.I32) (Tast.Int (Int64.of_int op, Types.I32));
|
||
mk loc (Types.Int Types.I32)
|
||
(Tast.Prim (Tast.Cast (Types.Int Types.I32),
|
||
[ rt loc (Types.Int Types.I64)
|
||
"flan_file_fail_reason" [] ])) ]))
|
||
in
|
||
let signal () =
|
||
mk loc Types.Never (Tast.Signal (Tast.Serror, type_id "FileError", cond))
|
||
in
|
||
(* One step of the attempt: run the runtime call, record whether it worked,
|
||
and signal if it did not. The last step a caller gives is what leaves [ok]
|
||
true, which is what stops the loop. *)
|
||
let try_ (attempt : Tast.expr) =
|
||
mk loc Types.Unit
|
||
(Tast.Do
|
||
[ mk loc Types.Unit
|
||
(Tast.Set (Tast.Plocal ok,
|
||
mk loc Types.Bool (Tast.Prim (Tast.Ne, [ attempt; i8 0L ]))));
|
||
mk loc Types.Unit (Tast.If (notok (), signal (), unit_at loc)) ])
|
||
in
|
||
let clause name params =
|
||
let sg = restart_sig (List.map snd params) in
|
||
{ Tast.rname_id = type_id name; rname = name; rparams = params;
|
||
rsig = sg; rsig_id = type_id sg; rbody = [ unit_at loc ] }
|
||
in
|
||
let body =
|
||
mk loc Types.Unit
|
||
(Tast.RestartCase
|
||
([ clause "retry" [];
|
||
clause "use-value" [ (path_slot, Types.String) ] ],
|
||
mk loc Types.Unit (Tast.Do (mk_steps try_))))
|
||
in
|
||
mk loc Types.Unit
|
||
(Tast.Let ([ (ok, mk loc Types.Bool (Tast.Bool false)) ],
|
||
[ mk loc Types.Unit (Tast.While (notok (), [ body ], [])) ]))
|
||
|
||
(* Is this bare symbol the name of a type? Every table [resolve_name] will look
|
||
in, and the data type table is one of them: a data type is [Named] exactly as a
|
||
struct is, so (vec-new Form) is as ordinary as (vec-new Cell). It was left
|
||
out when data types landed, which made the prelude's own (vec-new Form) fail
|
||
with "nothing here says what (vec-new) is a Vec of" — a message about a
|
||
missing annotation for a program that had written one. One list, read by
|
||
both callers, so the next kind of type added cannot be added to one of
|
||
them. *)
|
||
and type_named ctx n =
|
||
(* A type variable names a type here too, which is what lets [(vec-new t)]
|
||
be written in a generic body: inside an instantiation [resolve_name]
|
||
answers with the concrete element type, and during the abstract pass it
|
||
answers [Var t] and the [Vec] that comes back is a [(Vec t)] — generic,
|
||
and refused by anything that needs a size. *)
|
||
List.mem n ctx.env.tyvars
|
||
|| List.mem_assoc n ctx.env.subst
|
||
|| List.mem n Types.primitive_names
|
||
|| Hashtbl.mem ctx.env.structs n
|
||
|| Hashtbl.mem ctx.env.datas n
|
||
|| Hashtbl.mem ctx.env.unions n
|
||
|| Hashtbl.mem ctx.env.enums n
|
||
|| Hashtbl.mem ctx.env.aliases n
|
||
|
||
(* The element type for [vec-new]: a leading bare symbol naming a type, or the
|
||
expectation at the site. A bare symbol shadowed by a local or a global is
|
||
that binding — an allocator, in practice — and not a type. *)
|
||
and vec_new_elem ctx ~want loc args =
|
||
let named =
|
||
match args with
|
||
| { Ast.e = Ast.Var n; _ } :: rest
|
||
when lookup ctx n = None
|
||
&& (not (Hashtbl.mem ctx.env.globals n))
|
||
&& type_named ctx n ->
|
||
Some (resolve_name ctx.env ~seen:[] loc n, rest)
|
||
| _ -> None
|
||
in
|
||
match named with
|
||
| Some (t, rest) -> t, rest
|
||
| None ->
|
||
(match want with
|
||
| Some (Types.Vec t) -> t, args
|
||
| _ ->
|
||
fail loc
|
||
"nothing here says what (vec-new) is a Vec of — write the element \
|
||
type, as (vec-new i32), or give the binding a type")
|
||
|
||
(* The key and value types, or the reason this is not a Map. *)
|
||
and map_kv loc what (t : Types.t) =
|
||
match t with
|
||
| Types.Map (k, v) -> k, v
|
||
| other -> fail loc "%s takes a (Map K V), found %s" what (Types.to_string other)
|
||
|
||
(* The key and value for [map-new]: two leading bare symbols naming types, or
|
||
the expectation at the site. The same rule [vec-new] uses, with the same
|
||
escape for a symbol that is really a binding — an allocator, in practice —
|
||
and the pair is written together or not at all, because (map-new string)
|
||
says half of a type and half is not a type. *)
|
||
and map_new_types ctx ~want loc args =
|
||
let is_type n =
|
||
lookup ctx n = None
|
||
&& (not (Hashtbl.mem ctx.env.globals n))
|
||
&& type_named ctx n
|
||
in
|
||
match args with
|
||
| { Ast.e = Ast.Var k; _ } :: { Ast.e = Ast.Var v; _ } :: rest
|
||
when is_type k && is_type v ->
|
||
resolve_name ctx.env ~seen:[] loc k, resolve_name ctx.env ~seen:[] loc v, rest
|
||
| { Ast.e = Ast.Var k; _ } :: rest when is_type k && rest = [] ->
|
||
fail loc
|
||
"(map-new %s) names a key and no value — write both, as (map-new %s \
|
||
i32), or give the binding a type" k k
|
||
| _ ->
|
||
(match want with
|
||
| Some (Types.Map (k, v)) -> k, v, args
|
||
| _ ->
|
||
fail loc
|
||
"nothing here says what (map-new) maps — write the key and value \
|
||
types, as (map-new string i32), or give the binding a type")
|
||
|
||
(* The element type, or the reason this is not a Vec. *)
|
||
and vec_elem loc what (t : Types.t) =
|
||
match t with
|
||
| Types.Vec e -> e
|
||
| other -> fail loc "%s takes a (Vec T), found %s" what (Types.to_string other)
|
||
|
||
(* The allocator an operation uses: the one named at the site, or the current
|
||
implicit one. spec-memory.md: an operation never falls back to a hidden
|
||
global allocator, and an explicit allocator can override the context. *)
|
||
and allocator_arg ctx loc = function
|
||
| [] -> rt loc Types.Alloc "flan_context_allocator" []
|
||
| [ a ] -> check ctx ~want:Types.Alloc a
|
||
| _ -> fail loc "at most one allocator may be named here"
|
||
|
||
(* The address of an element, bounds-checked, with the allocator's epoch
|
||
checked first. Both the value form [(at v i)] and the place form
|
||
[(set (at v i) x)] come through here, so they cannot drift apart — which is
|
||
the asymmetry [nth] was removed for. *)
|
||
and vec_at ctx loc (target : Tast.expr) (idx : Ast.expr list) =
|
||
let elem = vec_elem loc "at" target.Tast.ty in
|
||
match idx with
|
||
| [ i ] ->
|
||
let i = index_expr ctx i in
|
||
rt loc (Types.Ptr elem) "flan_vec_at"
|
||
[ target; i; size_of loc elem; here loc ], elem
|
||
| _ ->
|
||
fail loc
|
||
"a Vec takes exactly one index — (at v i) — and its element is indexed \
|
||
separately"
|
||
|
||
(* Every arm below is a name an editor can be asked about and no program ever
|
||
wrote down, so each one needs a line in [builtins] further down this file.
|
||
A new arm without an entry fails the build — test_flan reads both. *)
|
||
and named_call ?(qualified = false) ctx ~want loc name args =
|
||
let prim p ty args = expect ctx loc ~want (mk loc ty (Tast.Prim (p, args))) in
|
||
match name with
|
||
(* [builtin/len], before anything else including the shadowing guard below.
|
||
The prefix is stripped and the same dispatch runs again with [qualified],
|
||
which is the one thing the guard consults: a qualified call has said
|
||
which of the two it means, so there is nothing left for shadowing to
|
||
decide. Everything after this point sees the bare name, so an arity or a
|
||
type refusal on [(builtin/len 1 2)] reads exactly as it does on
|
||
[(len 1 2)] — which is the point of the spelling, not a loss of detail.
|
||
|
||
Recursion rather than a flag threaded through the arms because there is
|
||
only one thing to skip. It cannot loop: the stripped name has no second
|
||
[builtin/] on it unless somebody wrote [builtin/builtin/len], which is
|
||
stripped once and then refused by name. *)
|
||
| _ when not qualified && qualified_builtin name <> None ->
|
||
let bare = Option.get (qualified_builtin name) in
|
||
if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
|
||
named_call ~qualified:true ctx ~want loc bare args
|
||
(* The user's own definition, ahead of every builtin arm below — and behind
|
||
the qualifier above, which is the one spelling it does not take over.
|
||
Clojure's rule: a [(defn get ...)] takes the name over, and a call
|
||
written in the program that defines it reaches that definition rather
|
||
than the builtin it is named after. The defn site is warned about once
|
||
(see [shadowed_builtins]); the call sites say nothing, because at a call
|
||
site there is nothing surprising left — the name means what the file
|
||
says it means.
|
||
|
||
What this arm does NOT do is let one file's definition reach into
|
||
another's: [shadows_builtin] answers false for a call in the prelude and
|
||
for a call in imported package code, which is the same visibility rule a
|
||
defn has everywhere else. *)
|
||
| _ when (not qualified) && shadows_builtin ctx loc name ->
|
||
ordinary_call ctx ~want loc name args
|
||
(* ── arithmetic and comparison ─────────────────────────────────── *)
|
||
| "+" | "-" | "*" | "/" ->
|
||
let p = match name with
|
||
| "+" -> Tast.Add | "-" -> Tast.Sub | "*" -> Tast.Mul
|
||
| _ -> Tast.Div
|
||
in
|
||
fold_arity loc name args;
|
||
fold_left_prim ctx ~want loc name p ~needs:"numeric?" Types.is_numeric
|
||
"numbers" args
|
||
(* Remainder stays at two: (% a b c) is (% (% a b) c), which is a thing
|
||
nobody writes on purpose. *)
|
||
| "%" ->
|
||
arity ctx loc name 2 args;
|
||
let a, b = binary ctx ~dyn_ok:true name loc ~want:(numeric_want want) args in
|
||
if a.Tast.ty = Types.Dyn || b.Tast.ty = Types.Dyn then
|
||
dyn_fold ctx ~want loc name [ a; b ] []
|
||
else begin
|
||
unconstrained ctx.env loc name ~needs:"numeric?" a.Tast.ty;
|
||
if not (Types.is_numeric a.Tast.ty || generic_ty a.Tast.ty) then
|
||
not_numeric name "numbers" a;
|
||
prim Tast.Rem a.Tast.ty [ a; b ]
|
||
end
|
||
| "=" | "!=" | "<" | "<=" | ">" | ">=" ->
|
||
let p = match name with
|
||
| "=" -> Tast.Eq | "!=" -> Tast.Ne | "<" -> Tast.Lt
|
||
| "<=" -> Tast.Le | ">" -> Tast.Gt | _ -> Tast.Ge
|
||
in
|
||
arity ctx loc name 2 args;
|
||
let a, b = binary ctx ~dyn_ok:true name loc ~want:None args in
|
||
(* A comparison with a dyn operand answers a *bool*, not a dyn, even though
|
||
the runtime's own entry point answers a dyn holding one. The reason is
|
||
where the result goes: a comparison is overwhelmingly the test of an
|
||
[if] or a [while], and those want an i1. So the need_bool is applied
|
||
here, once, and a program that really wants the comparison as a dyn
|
||
value boxes it again on the way into wherever it is going — which [box]
|
||
does for free at that boundary.
|
||
|
||
[=] and [!=] are the pair that never traps: the runtime compares
|
||
structurally and answers false for values of unrelated types, because
|
||
two things being unalike is the answer to "are these equal", not an
|
||
error. The orderings do trap, and rightly — there is no true answer to
|
||
whether a string is less than a vector. *)
|
||
if a.Tast.ty = Types.Dyn || b.Tast.ty = Types.Dyn then begin
|
||
let sym =
|
||
match name with
|
||
| "=" | "!=" -> "flan_dyn_eq"
|
||
| "<" -> "flan_dyn_lt" | "<=" -> "flan_dyn_le"
|
||
| ">" -> "flan_dyn_gt" | _ -> "flan_dyn_ge"
|
||
in
|
||
(* [eq] never traps and takes no site; the four orderings do, and get
|
||
one, for the reason [dyn_fold] gives. *)
|
||
let site = if String.equal sym "flan_dyn_eq" then [] else [ here loc ] in
|
||
let cmp =
|
||
unbox loc Types.Bool
|
||
(rt loc Types.Dyn sym ([ box loc a; box loc b ] @ site))
|
||
in
|
||
(* [!=] has no entry point of its own: there is one structural equality
|
||
and the negation is an [i1] flip the backend folds away. *)
|
||
let r =
|
||
if String.equal name "!=" then mk loc Types.Bool (Tast.Prim (Tast.Not, [ cmp ]))
|
||
else cmp
|
||
in
|
||
expect ctx loc ~want r
|
||
end else begin
|
||
(* [=] and [!=] admit types [<] does not. A handle is one: a pair of
|
||
numbers in one word and where being the same entity is the question the
|
||
type exists to answer — ordering handles would order a slot index,
|
||
which is a free-list artefact and means nothing. A string is the other,
|
||
and for the opposite reason: being the same entity is not the question
|
||
at all, being the same bytes is, and that has a true answer with no
|
||
ordering attached — plan.org, Types calls out an ordering as a
|
||
collation the language has not picked. Backend codegen (emit.ml,
|
||
x86.ml) has a [Types.String] case in the [Eq]/[Ne] arm and nowhere
|
||
else. *)
|
||
let ok =
|
||
match name with
|
||
| "=" | "!=" -> Types.is_equatable a.Tast.ty
|
||
| _ -> Types.is_comparable a.Tast.ty
|
||
in
|
||
unconstrained ctx.env loc name
|
||
~needs:(match name with "=" | "!=" -> "equal?" | _ -> "ordered?")
|
||
a.Tast.ty;
|
||
if not (ok || generic_ty a.Tast.ty) then
|
||
(match name with
|
||
| "=" | "!=" ->
|
||
fail loc
|
||
"%s compares machine numbers, enums and strings, and %s is none of \
|
||
those" name (Types.to_string a.Tast.ty)
|
||
| _ ->
|
||
fail loc
|
||
"%s orders machine numbers and enums, and %s is neither" name
|
||
(Types.to_string a.Tast.ty));
|
||
prim p Types.Bool [ a; b ]
|
||
end
|
||
| "not" ->
|
||
arity ctx loc name 1 args;
|
||
(* Same truthiness as [if]: a dyn argument is negated on nil/false vs.
|
||
everything else, not narrowed to a strict bool first. *)
|
||
prim Tast.Not Types.Bool [ check_truthy ctx (List.hd args) ]
|
||
(* Bitwise operators are integers-only. They take the ordinary join — an
|
||
operand that widens into the other does, so (bit-and u8-flags u32-mask) is
|
||
a u32 and — and the shifts below do not, which is the one carve-out
|
||
widening has (FIX.org 2026-09-20). *)
|
||
| "bit-and" | "bit-or" | "bit-xor" ->
|
||
let p = match name with
|
||
| "bit-and" -> Tast.BitAnd | "bit-or" -> Tast.BitOr
|
||
| _ -> Tast.BitXor
|
||
in
|
||
fold_arity loc name args;
|
||
fold_left_prim ctx ~want loc name p ~needs:"integer?" Types.is_integer
|
||
"integers" args
|
||
(* The shifts stay at two, and not only because a shift chain reads badly:
|
||
each count would be checked against the same width below, so (<< x 30 30)
|
||
would pass two legal shifts and still shift the value away entirely.
|
||
|
||
[~join:false] is the one place widening is deliberately not symmetric.
|
||
The count still widens *to* the value's type — (<< i64-x u8-n) is fine —
|
||
but the value never widens to the count's, which the general rule would do
|
||
for (<< u8-x i32-n). It would be the wrong answer twice over: the result's
|
||
type and the width the shift wraps at would be taken from a number that is
|
||
only saying how far, and the range check just below, along with [emit]'s
|
||
mask, is keyed to the *value's* width. A count wider than the value is
|
||
refused and is told to write the cast. *)
|
||
| "<<" | ">>" ->
|
||
let p = if String.equal name "<<" then Tast.Shl else Tast.Shr in
|
||
arity ctx loc name 2 args;
|
||
let a, b = binary ctx ~join:false name loc ~want:(numeric_want want) args in
|
||
(match a.Tast.ty with
|
||
| Types.Int _ -> ()
|
||
(* A type variable under {:where (integer? $t)}: every type the bound
|
||
admits has a width to shift within, so the abstract pass lets the
|
||
body through and each instantiation meets the concrete checks below
|
||
at its own width. Anything weaker — [numeric?] included — is refused
|
||
here, at the definition, because a shift at f32 means nothing. *)
|
||
| t when generic_ty t ->
|
||
unconstrained ctx.env loc name ~needs:"integer?" t
|
||
| other -> fail loc "%s takes integers, found %s" name
|
||
(Types.to_string other));
|
||
(* A shift by the operand's own width or more is poison in LLVM, which at
|
||
-O2 turns the whole function into an undefined value rather than into a
|
||
wrong number. A literal count is rejected here — that is the typo — and
|
||
[emit] masks a computed one, so no shift can reach the hardware out of
|
||
range. *)
|
||
(match a.Tast.ty, b.Tast.e with
|
||
| Types.Int k, Tast.Int (n, _) when p = Tast.Shl || p = Tast.Shr ->
|
||
let w = Int64.of_int (Types.bits k) in
|
||
if Int64.unsigned_compare n w >= 0 then
|
||
fail loc
|
||
"%s by %Ld is out of range for %s, which is %d bits wide" name n
|
||
(Types.to_string a.Tast.ty) (Types.bits k)
|
||
| _ -> ());
|
||
prim p a.Tast.ty [ a; b ]
|
||
(* (min a b) and (max a b) evaluate each operand once — hence the slots —
|
||
because a min over two calls must not call either of them twice.
|
||
|
||
Which is also why this one does not go through [fold_left_prim]: there is
|
||
no Prim to fold, and the pair it folds is a whole comparison. Each step
|
||
puts *both* of its sides in slots, the accumulated pick included, so the
|
||
three-operand form is two nested lets and still exactly one evaluation of
|
||
each operand — where reusing the previous [If] as an operand of the next
|
||
would have duplicated everything inside it. *)
|
||
| "min" | "max" ->
|
||
fold_arity loc name args;
|
||
let x, y, rest =
|
||
match args with x :: y :: rest -> x, y, rest | _ -> assert false
|
||
in
|
||
let a, b = binary ctx name loc ~want:(numeric_want want) [ x; y ] in
|
||
(* [min] and [max] are [<] with a pick, so [ordered?] is what they want —
|
||
not [numeric?]. A generic that declares [ordered?] gets both.
|
||
|
||
They stay builtins now that generics could express them, and the reason
|
||
is the two lines above rather than the type system: they are variadic,
|
||
and each step puts both of its sides in slots so that every operand is
|
||
evaluated exactly once. A prelude [(defn min [a $t b $t] $t ...)] would
|
||
be binary and would have to be nested at the call site, which is where
|
||
the double evaluation this arm exists to prevent would come back. The
|
||
generic half is already theirs — [ordered?] admits them inside any
|
||
body that declares it — so collapsing them would cost the arity and
|
||
the evaluation rule and buy nothing. *)
|
||
unconstrained ctx.env loc name ~needs:"ordered?" a.Tast.ty;
|
||
if not (Types.is_numeric a.Tast.ty || generic_ty a.Tast.ty) then
|
||
not_numeric name "numbers" a;
|
||
let ty = a.Tast.ty in
|
||
let cmp = if String.equal name "min" then Tast.Lt else Tast.Gt in
|
||
let pick a b =
|
||
let sa = fresh_slot ctx ty and sb = fresh_slot ctx ty in
|
||
let la = mk loc ty (Tast.Local sa) and lb = mk loc ty (Tast.Local sb) in
|
||
let test = mk loc Types.Bool (Tast.Prim (cmp, [ la; lb ])) in
|
||
mk loc ty (Tast.Let ([ (sa, a); (sb, b) ],
|
||
[ mk loc ty (Tast.If (test, la, lb)) ]))
|
||
in
|
||
expect ctx loc ~want
|
||
(List.fold_left (fun acc arg -> pick acc (check ctx ~want:ty arg))
|
||
(pick a b) rest)
|
||
(* (zeroed) is the all-bytes-zero value of whatever it is being stored into,
|
||
so it only means anything where a type is expected of it. *)
|
||
| "zeroed" ->
|
||
arity ctx loc name 0 args;
|
||
(match want with
|
||
| Some ty when ty <> Types.Never ->
|
||
no_zeroed_fn loc "this" ty;
|
||
mk loc ty (Tast.Zero ty)
|
||
| _ ->
|
||
fail loc
|
||
"zeroed needs to know the type it is zeroing — use it where one is \
|
||
expected, as in (set grid (zeroed))")
|
||
|
||
(* [zeroed]'s two siblings, and the same shape exactly: a value of whatever
|
||
type is expected of it, so [(set grid (filled 0xFF))] is how a place is
|
||
filled and there is no second spelling to learn. What they add over
|
||
[zeroed] is the bytes — [(filled b)] repeats one the program picks, and
|
||
[(dead-beef)] repeats four — and with them the question [zeroed] never
|
||
has to ask: zero is a value every type can have, and 0xDE is not.
|
||
[unfillable] above is the whole of the answer.
|
||
|
||
[dead-beef] takes the pattern or leaves it out, and leaving it out is
|
||
defined as writing the default: the [None] arm below builds the same
|
||
literal the source would have, so [(dead-beef)] and
|
||
[(dead-beef 0xDEADBEEF)] are the same node by construction and no
|
||
backend has a second path for the bare form.
|
||
|
||
The pattern is an ordinary u32 expression, which is the byte arm's rule
|
||
at four times the width — a literal out of range meets [in_range]'s
|
||
located "does not fit in u32", and anything computed is guaranteed by
|
||
its type instead. Both builtins take a value rather than only a literal
|
||
for the same reason: refusing one would be a restriction with no
|
||
mechanism behind it, since neither backend needs the number early. *)
|
||
| "filled" | "dead-beef" ->
|
||
let is_byte = String.equal name "filled" in
|
||
if is_byte then arity ctx loc name 1 args
|
||
else if List.length args > 1 then
|
||
fail loc "%s takes the pattern or nothing at all, given %d arguments"
|
||
name (List.length args);
|
||
(match want with
|
||
| Some ty when ty <> Types.Never ->
|
||
(match unfillable ctx.env [] ty with
|
||
| Some bad ->
|
||
Loc.failk "check/fill-not-plain-data" loc
|
||
"%s writes raw bytes over %s, and %s is not plain data — %s. \
|
||
Fill only numbers, and structs and fixed arrays built out of \
|
||
them"
|
||
name (Types.to_string ty)
|
||
(if Types.equal bad ty then "it" else Types.to_string bad)
|
||
(match bad with
|
||
| Types.Dyn ->
|
||
"a dyn is one word the collector walks by descriptor, and a \
|
||
filled one is a root pointing at nothing"
|
||
| Types.Vec _ | Types.Map _ | Types.Alloc ->
|
||
"it owns its storage through a pointer and an allocator, and \
|
||
a filled header frees a wild address"
|
||
| Types.String | Types.Slice _ ->
|
||
"it is a pointer and a length every bounds check believes"
|
||
| Types.Ptr _ ->
|
||
"it is an address every deref trusts"
|
||
| Types.Bool ->
|
||
"a bool is an i1 to LLVM and a whole byte to the x86 backend, \
|
||
so a filled one would not even agree with itself across the \
|
||
two"
|
||
| Types.Option _ ->
|
||
"it carries a tag saying whether the value is there, and a \
|
||
filled one says yes over a payload nobody wrote"
|
||
| Types.Fn _ ->
|
||
"it is a code address, and a call through a filled one jumps \
|
||
into whatever 0xDE bytes happen to address"
|
||
| Types.Named n when Hashtbl.mem ctx.env.datas n ->
|
||
"it carries a tag that names a case, and no byte pattern \
|
||
names a real one"
|
||
| Types.Named n when Hashtbl.mem ctx.env.unions n ->
|
||
(* Untagged, per [env.unions]'s own note — so the reason is
|
||
not a tag. It is that a union's members overlay, and this
|
||
rule walks a struct's fields rather than a union's members:
|
||
nothing here has shown they are all plain data, and a
|
||
member that is not would be filled through the one that
|
||
is. *)
|
||
"a union's members overlay, and this rule does not walk them \
|
||
— so nothing here has shown that every member is plain data"
|
||
| Types.Enum _ ->
|
||
"an enum's values are the members it declared, and no byte \
|
||
pattern is one of them"
|
||
| _ ->
|
||
"it is not one of the types this rule admits")
|
||
| None -> ());
|
||
if is_byte then
|
||
let b = check ctx ~want:(Types.Int Types.U8) (List.hd args) in
|
||
mk loc ty (Tast.Fill (ty, b))
|
||
else
|
||
let pat =
|
||
match args with
|
||
| [ a ] -> check ctx ~want:(Types.Int Types.U32) a
|
||
(* The bare form, written out. Not a default a backend applies:
|
||
the node that leaves here is the one the spelled-out call would
|
||
have left, which is what makes the equivalence a fact about the
|
||
IR rather than a promise two emitters keep separately.
|
||
|
||
Masked to 32 bits, and that is the whole of why this is not
|
||
[Int64.of_int32] on its own: [dead_beef_default] is an [int32]
|
||
whose top bit is set, so widening it signed would put
|
||
-559038737 on a node tagged [u32] — where the same pattern
|
||
*written out* arrives as 3735928559, because [in_range] admits
|
||
it as the unsigned value it is. Two spellings of one builtin
|
||
would then carry two different payloads, and "the same node by
|
||
construction" would be false for anything that reads one. *)
|
||
| _ ->
|
||
mk loc (Types.Int Types.U32)
|
||
(Tast.Int
|
||
(Int64.logand
|
||
(Int64.of_int32 Tast.dead_beef_default) 0xFFFFFFFFL,
|
||
Types.U32))
|
||
in
|
||
mk loc ty (Tast.DeadBeef (ty, pat))
|
||
| _ ->
|
||
fail loc
|
||
"%s needs to know the type it is filling — use it where one is \
|
||
expected, as in (set grid (%s))"
|
||
name (if is_byte then "filled 0xFF" else name))
|
||
|
||
(* The one half of a destructuring [let] that [Parse] cannot do on its own.
|
||
Everything else about a pattern is bindings and field accesses it already
|
||
wrote; the arity is a *type* question — how many elements the value has —
|
||
and there are no types in the parser. So the pattern's shape travels here
|
||
as arguments: which element this binding wants, how many names the pattern
|
||
binds, and whether that count is exact or a minimum (it is a minimum when
|
||
the pattern ends in [& rest]).
|
||
|
||
No source symbol can contain a [~] — the reader makes it a delimiter — so
|
||
this name is unspellable and nothing but [Parse] can reach it. *)
|
||
| "destructure~nth" ->
|
||
(match args with
|
||
| [ target;
|
||
{ Ast.e = Ast.Int i; _ }; { Ast.e = Ast.Int n; _ };
|
||
{ Ast.e = Ast.Int exact; _ } ] ->
|
||
let plural k = if Int64.equal k 1L then "" else "s" in
|
||
let target = check ctx target in
|
||
(match target.Tast.ty with
|
||
| Types.Array (m, elem) ->
|
||
if Int64.equal exact 1L && not (Int64.equal m n) then
|
||
fail loc
|
||
"this pattern binds %Ld name%s, but %s has %Ld element%s — a \
|
||
pattern over a fixed array names every element, or ends in \
|
||
[& rest]"
|
||
n (plural n) (Types.to_string target.Tast.ty) m (plural m);
|
||
if Int64.equal exact 0L && Int64.compare m n < 0 then
|
||
fail loc
|
||
"this pattern binds %Ld name%s before the &, but %s has only %Ld \
|
||
element%s" n (plural n) (Types.to_string target.Tast.ty) m
|
||
(plural m);
|
||
prim Tast.At elem
|
||
[ target; mk loc index_ty (Tast.Int (i, Types.I32)) ]
|
||
(* The asymmetry is real and is the reason this is refused rather than
|
||
lowered to a bounds-checked [at]: a fixed array's length is in its
|
||
type, so [[a b]] over a [[2 f32]] is a claim the checker can settle,
|
||
and over a [[T]] it is a claim about a number that does not exist
|
||
until the program runs. Turning it into a runtime trap would be a
|
||
pattern that type checks and then kills the program, which is the
|
||
trade this language does not make. *)
|
||
| Types.Slice _ ->
|
||
fail loc
|
||
"a pattern cannot destructure %s: a slice's length is a runtime \
|
||
value, so nothing here can check that it has %Ld element%s. Use \
|
||
(at s i) and test (len s) yourself"
|
||
(Types.to_string target.Tast.ty) n (plural n)
|
||
| other ->
|
||
fail loc
|
||
"%s is not a fixed array, so [a b ...] cannot destructure it"
|
||
(Types.to_string other))
|
||
| _ ->
|
||
fail loc
|
||
"destructure~nth is written by the compiler and cannot be called")
|
||
|
||
(* ── allocators, spec-memory.md ────────────────────────────────── *)
|
||
(* Every one of these is an ordinary named call, which is the whole of the
|
||
escape NEXT.md describes: [check_call] already routes a named call through
|
||
here, so none of the four function-value refusals is anywhere near it. *)
|
||
(* A *user-written* allocator, and the reason it is still refused now that
|
||
function values exist. NEXT.md said it needed "a defn's name in value
|
||
position"; it has that, and it is still two things short, both of them
|
||
nameable and neither of them a function-value question any more.
|
||
|
||
The built-in set needs none of it: heap-allocator and arena-new are C
|
||
symbols the emitter names, and no Flan type mentions them. *)
|
||
| "make-allocator" | "allocator-from" | "allocator" ->
|
||
fail loc
|
||
"a user-written allocator is not implemented yet, and a defn's name in \
|
||
value position — which is what this used to wait for — is no longer \
|
||
what is missing. Two things are. The runtime calls an allocator as \
|
||
proc(a, mode, p, old, size, align): six C arguments and no transfer \
|
||
channel, and every Flan function value's signature ends with one, so \
|
||
the pointer would be called with the wrong shape (the same mismatch a \
|
||
foreign function's address is refused for). And Allocator is opaque \
|
||
and pointer-width, so there is nowhere for a program to put the \
|
||
flan_allocator the pointer would have to point at. Use \
|
||
(arena-new ...) with a backing buffer, which is the parameterised \
|
||
allocator that does exist"
|
||
| "heap-allocator" ->
|
||
arity ctx loc name 0 args;
|
||
expect ctx loc ~want
|
||
(mk loc Types.Alloc (Tast.Prim (Tast.Rt "flan_heap_allocator", [])))
|
||
(* The capacity is explicit and there is no growing backing store: an arena
|
||
whose size is decided by the program is one a program can reason about,
|
||
and it is the only shape under which "exhausted" is a state a test can
|
||
reach on purpose. *)
|
||
| "arena-new" ->
|
||
arity ctx loc name 1 args;
|
||
let cap = check ctx ~want:(Types.Int Types.I64) (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Alloc (Tast.Prim (Tast.Rt "flan_arena_new", [ cap ])))
|
||
(* Hands the pages back, which [free-all] deliberately does not — see
|
||
docs/BUILT.md, "free-all is retain-capacity". *)
|
||
| "arena-destroy" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit (Tast.Prim (Tast.Rt "flan_arena_destroy", [ a ])))
|
||
(* One of spec-memory.md's two release points. It takes the source location
|
||
as a string so that an allocator with no region to release names the site
|
||
rather than the runtime. *)
|
||
| "free-all" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit
|
||
(Tast.Prim (Tast.Rt "flan_alloc_free_all", [ a; here loc ])))
|
||
(* The capability set, read off the allocator value. Odin asks its procedure
|
||
(Query_Features returning an Allocator_Mode_Set); a field is the same
|
||
answer without the round trip, which is NEXT.md's call. *)
|
||
| "can-free?" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Bool
|
||
(Tast.Prim (Tast.Ne,
|
||
[ mk loc (Types.Int Types.I8)
|
||
(Tast.Prim (Tast.Rt "flan_alloc_can_free", [ a ]));
|
||
mk loc (Types.Int Types.I8) (Tast.Int (0L, Types.I8)) ])))
|
||
| "can-free-all?" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Bool
|
||
(Tast.Prim (Tast.Ne,
|
||
[ mk loc (Types.Int Types.I8)
|
||
(Tast.Prim (Tast.Rt "flan_alloc_can_free_all", [ a ]));
|
||
mk loc (Types.Int Types.I8) (Tast.Int (0L, Types.I8)) ])))
|
||
(* The counter [free-all] bumps. A container records it and traps if it
|
||
moved; this is the same number, readable, so a program can say what it
|
||
saw. *)
|
||
| "alloc-epoch" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Int Types.I64)
|
||
(Tast.Prim (Tast.Rt "flan_alloc_epoch", [ a ])))
|
||
(* The allocator's identity — its address — which is what the condition's
|
||
:allocator field carries, so a handler holding several regions can tell
|
||
which one ran out. *)
|
||
| "alloc-id" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Int Types.I64) (Tast.Prim (Tast.Rt "flan_alloc_id", [ a ])))
|
||
(* A ceiling on live bytes, 0 for none. spec-memory.md's retry restart is
|
||
answerable only by a handler that can make the *same* request succeed, and
|
||
for a fixed backing store the handler that works is the one that grows it:
|
||
releasing the region a container lives in invalidates the container, which
|
||
is what the epoch check catches. So the spec's "grows the arena and then
|
||
invokes retry" needs a ceiling to raise, and this is it. It is also how a
|
||
program exhausts an allocator on purpose. *)
|
||
| "alloc-budget" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Int Types.I64)
|
||
(Tast.Prim (Tast.Rt "flan_alloc_budget", [ a ])))
|
||
| "set-alloc-budget" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ a; n ] ->
|
||
let a = check ctx ~want:Types.Alloc a in
|
||
let n = check ctx ~want:(Types.Int Types.I64) n in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit (Tast.Prim (Tast.Rt "flan_alloc_set_budget", [ a; n ])))
|
||
| _ -> assert false)
|
||
(* "Did you forget to free" is an allocator-tier question and this is the
|
||
tier answering it — spec-memory.md, "Leaking is defined behaviour". *)
|
||
| "alloc-live-blocks" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx ~want:Types.Alloc (List.hd args) in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Int Types.I64)
|
||
(Tast.Prim (Tast.Rt "flan_alloc_live_blocks", [ a ])))
|
||
(* (with-allocator A BODY...). It rebinds and releases nothing: not at the
|
||
end of the body, not anywhere. spec-memory.md is explicit that this is not
|
||
a scope-end release point and that it is the point on which Odin's
|
||
[defer delete] and Carp's scope-end frees were both rejected. *)
|
||
| "with-allocator" ->
|
||
(match args with
|
||
| [] -> fail loc "with-allocator is (with-allocator allocator body ...)"
|
||
| a :: body ->
|
||
let a = check ctx ~want:Types.Alloc a in
|
||
let body, ty =
|
||
scoped ctx (fun () ->
|
||
match body with
|
||
| [] -> [ unit_at loc ], Types.Unit
|
||
| _ ->
|
||
let rec go = function
|
||
| [ last ] -> let l = check ctx ?want last in [ l ], l.Tast.ty
|
||
| e :: rest ->
|
||
let e = check ctx e in
|
||
let rest, ty = go rest in
|
||
e :: rest, ty
|
||
| [] -> assert false
|
||
in
|
||
go body)
|
||
in
|
||
expect ctx loc ~want (mk loc ty (Tast.WithAlloc (a, body))))
|
||
|
||
(* ── (Vec T), spec-memory.md ───────────────────────────────────── *)
|
||
(* Every one of these is a named call over a type-erased runtime, with
|
||
size_of and align_of produced here because here is where the concrete
|
||
element type is known. No generics are involved and none are needed. *)
|
||
(* (vec-new), (vec-new T), (vec-new a), (vec-new T a).
|
||
[let] has no type annotation — parse.ml settles that a triple binding is
|
||
ambiguous and types are inferred — so a local Vec has nowhere to say what
|
||
it holds, and the element type is written at the call instead. This is not
|
||
the explicit instantiation syntax the generics section rules out: nothing
|
||
here is generic, and the name is resolved as an ordinary type, not bound
|
||
to a type variable. Where the context does say — a defonce's type, a
|
||
function's return type, an argument — it is not needed and may be left
|
||
out. *)
|
||
| "vec-new" ->
|
||
let elem, args = vec_new_elem ctx ~want loc args in
|
||
(* [(vec-new dyn)] is not a [(Vec dyn)]. At milestone 1 the heterogeneous
|
||
container is the dyn runtime's own object, and its type is [dyn] like
|
||
everything else the runtime hands back — which is what lets [push], [at]
|
||
and [len] on it go through the dyn operations rather than through a
|
||
type-erased Vec over eight-byte elements.
|
||
|
||
The two could be made to coincide later, and the reason not to now is
|
||
the collector: a Flan Vec's storage comes from an allocator the program
|
||
named, and the words in it would be roots the collector has to find
|
||
inside a block it does not own. The runtime's own vector is storage the
|
||
collector already knows about. *)
|
||
if elem = Types.Dyn then begin
|
||
if args <> [] then
|
||
fail loc
|
||
"(vec-new dyn) takes no allocator — the dyn container's storage is \
|
||
the dyn runtime's, which is what lets the collector find the values \
|
||
inside it";
|
||
expect ctx loc ~want (rt loc Types.Dyn "flan_dyn_vec_new" [])
|
||
end else begin
|
||
let a = allocator_arg ctx loc args in
|
||
let v = fresh_slot ctx (Types.Vec elem) in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_vec_init"
|
||
[ mk loc (Types.Vec elem) (Tast.Local v); a; i64_at loc 0L;
|
||
size_of loc elem; align_of loc elem; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Vec elem)
|
||
(Tast.Let ([ (v, mk loc (Types.Vec elem) (Tast.Zero (Types.Vec elem))) ],
|
||
[ with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_vec"
|
||
(mk loc (Types.Vec elem) (Tast.Local v))
|
||
[ size_of loc elem ] elem);
|
||
region_check ctx.env loc
|
||
(mk loc (Types.Vec elem) (Tast.Local v))
|
||
(mk loc (Types.Vec elem) (Tast.Local v)) ])))
|
||
end
|
||
(* Unit, not a Result and not an ignorable error code: see [alloc_guard]. *)
|
||
| "push" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ target; x ] ->
|
||
let target = check ctx target in
|
||
(* A push into a dyn container is a call and nothing else: no allocation
|
||
guard, no restart, no region check. The dyn runtime owns the storage
|
||
and answers a failure to grow it on its own terms — the guard and the
|
||
retry restart exist for an allocator the *program* named, and here
|
||
there is none to name. *)
|
||
if target.Tast.ty = Types.Dyn then
|
||
expect ctx loc ~want
|
||
(rt loc Types.Unit "flan_dyn_push"
|
||
[ target; check ctx ~want:Types.Dyn x ])
|
||
else begin
|
||
let elem = vec_elem loc "push" target.Tast.ty in
|
||
let x = check ctx ~want:elem x in
|
||
(* The element is bound before the loop so that a [retry] re-attempts
|
||
the allocation and not the expression that produced the value. *)
|
||
let e = fresh_slot ctx elem in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_vec_push"
|
||
[ target; addr_of loc (mk loc elem (Tast.Local e));
|
||
size_of loc elem; align_of loc elem; here loc ]
|
||
in
|
||
(* Re-noted after every push, not only the first: a push that grows the
|
||
Vec moves the storage, and the note is keyed on the base address, so
|
||
an unmoved block costs a probe and an overwrite with the same
|
||
numbers. This is the insert per allocation NEXT.md settles on, and
|
||
the settled answer to what it costs is "measure a real program". *)
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit
|
||
(Tast.Let ([ (e, x) ],
|
||
[ region_check ctx.env loc target
|
||
(with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_vec" target
|
||
[ size_of loc elem ] elem)) ])))
|
||
end
|
||
| _ -> assert false)
|
||
| "reserve" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ target; n ] ->
|
||
let target = check ctx target in
|
||
let n = check ctx ~want:index_ty n in
|
||
let n64 =
|
||
mk loc (Types.Int Types.I64) (Tast.Prim (Tast.Cast (Types.Int Types.I64), [ n ]))
|
||
in
|
||
(* Deferred: the sizes are known abstractly but the hash is not, so
|
||
the node is a unit no-op and the copy builds the real one. *)
|
||
if (match target.Tast.ty with
|
||
| Types.Map (k, _) -> deferred_key ctx.env loc "reserve" k
|
||
| _ -> false) then
|
||
expect ctx loc ~want (mk loc Types.Unit Tast.Unit)
|
||
else
|
||
let attempt, note =
|
||
match target.Tast.ty with
|
||
(* For a map the number is entries, not slots: the runtime sizes the
|
||
block so that [n] still sits under the 75% load factor, which is
|
||
the only reading of "room for n" that does not reallocate on the
|
||
nth put. *)
|
||
| Types.Map (k, v) ->
|
||
let hash, _ = key_fns ctx.env loc k in
|
||
rt loc (Types.Int Types.I8) "flan_map_reserve"
|
||
[ target; n64; size_of loc k; size_of loc v; hash; here loc ],
|
||
reg_note loc "flan_dev_reg_note_map" target
|
||
[ size_of loc k; size_of loc v ] target.Tast.ty
|
||
| _ ->
|
||
let elem = vec_elem loc "reserve" target.Tast.ty in
|
||
rt loc (Types.Int Types.I8) "flan_vec_reserve"
|
||
[ target; n64; size_of loc elem; align_of loc elem; here loc ],
|
||
reg_note loc "flan_dev_reg_note_vec" target
|
||
[ size_of loc elem ] elem
|
||
in
|
||
expect ctx loc ~want
|
||
(region_check ctx.env loc target
|
||
(with_note loc (alloc_guard ctx loc attempt) note))
|
||
| _ -> assert false)
|
||
(* (as-slice v) and (as-slice v lo hi) — spec-memory.md, "Borrowing". The
|
||
result is a non-owning view: copying it copies ptr+len and never the
|
||
elements, and it carries no allocator, so freeing through one is not
|
||
expressible. A push, a put or a reserve may invalidate it; that is the
|
||
explicit Zig/Odin contract the spec chose over a borrow checker. *)
|
||
| "as-slice" ->
|
||
(match args with
|
||
| target :: rest when List.length rest = 0 || List.length rest = 2 ->
|
||
let target = check ctx target in
|
||
let elem = vec_elem loc "as-slice" target.Tast.ty in
|
||
let lo, hi =
|
||
match rest with
|
||
| [] ->
|
||
mk loc index_ty (Tast.Int (0L, Types.I32)),
|
||
(* -1 is "to the end": (as-slice v) has no static length to pass. *)
|
||
mk loc index_ty (Tast.Int (-1L, Types.I32))
|
||
| [ lo; hi ] -> index_expr ctx lo, index_expr ctx hi
|
||
| _ -> assert false
|
||
in
|
||
let out = fresh_slot ctx (Types.Slice elem) in
|
||
let fill =
|
||
rt loc Types.Unit "flan_vec_as_slice"
|
||
[ target; addr_of loc (mk loc (Types.Slice elem) (Tast.Local out));
|
||
lo; hi; size_of loc elem; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Slice elem)
|
||
(Tast.Let ([ (out, mk loc (Types.Slice elem)
|
||
(Tast.Zero (Types.Slice elem))) ],
|
||
[ fill; mk loc (Types.Slice elem) (Tast.Local out) ])))
|
||
| _ -> fail loc "as-slice is (as-slice v) or (as-slice v lo hi)")
|
||
(* spec-memory.md's first release point. Since the repeal, what it consumes
|
||
it consumes at run time only: nothing marks the binding dead, so a second
|
||
[free] or a read after this one type-checks and misbehaves at run time —
|
||
the allocator aborts on a double free it can see, and the epoch word
|
||
traps a read through a released region. That is the Odin contract: free
|
||
is a thing you write, and writing it twice is yours to not do. *)
|
||
| "free" ->
|
||
arity ctx loc name 1 args;
|
||
let target = check ctx (List.hd args) in
|
||
(* A container of owning elements is refused here, and a reader will
|
||
assume the opposite — that [free] recurses — so this says why it does
|
||
not and what does.
|
||
|
||
The bytes this container holds are element *headers*, and releasing the
|
||
block those headers sit in says nothing about the blocks they point at.
|
||
Nothing type-erased can walk them: the runtime sees a size and an
|
||
alignment and has never heard of the element type. That is the same
|
||
fact the type-level refusals used to state, and the arena did not change
|
||
it — what the arena changed is that it no longer matters there, because
|
||
the inner blocks came out of the same region and [free-all] takes them
|
||
with everything else.
|
||
|
||
So the honest answer is not to recurse, and it is not to release the
|
||
backing store quietly either. Releasing the outer block alone would be
|
||
"I freed it" spelt over a program that leaked everything inside, and
|
||
this file refuses that collapse everywhere else — [flan_alloc_free_all]
|
||
traps rather than no-op for the same reason. It is refused instead, at
|
||
the one place a reader is looking when they want to know.
|
||
|
||
The guard at construction is what makes the advice reachable: such a
|
||
container is region-allocated or it does not exist, so there is always a
|
||
[free-all] to point at. *)
|
||
(match target.Tast.ty with
|
||
| (Types.Vec _ | Types.Map _)
|
||
when region_only ctx.env target.Tast.ty ->
|
||
fail loc
|
||
"%s holds elements that own storage, and free releases the block \
|
||
those elements sit in — not the blocks they point at, which nothing \
|
||
type-erased can reach. This container was built against a region \
|
||
allocator, because the guard at its construction admits no other, so \
|
||
release the region: (free-all a) takes it and everything its \
|
||
elements own, in one operation and with no per-element teardown"
|
||
(Types.to_string target.Tast.ty)
|
||
| Types.Vec elem ->
|
||
expect ctx loc ~want
|
||
(rt loc Types.Unit "flan_vec_free"
|
||
[ target; size_of loc elem; align_of loc elem; here loc ])
|
||
| Types.Map (k, v) ->
|
||
expect ctx loc ~want
|
||
(rt loc Types.Unit "flan_map_free"
|
||
[ target; size_of loc k; size_of loc v; here loc ])
|
||
| other ->
|
||
(* A field is never freed on its own: it would leave its owner partly
|
||
dead with no way to say so. *)
|
||
fail loc
|
||
"free takes an owning container — a Vec or a Map — found %s. A \
|
||
resource type with a drop hook is step 5 and does not exist yet"
|
||
(Types.to_string other))
|
||
(* (clone v) uses the current allocator, (clone v a) names one. A deep,
|
||
independent copy: spec-memory.md's "copying is always explicit". *)
|
||
| "clone" ->
|
||
(match args with
|
||
| target :: rest when List.length rest <= 1 ->
|
||
(* Checked once, then dispatched on what it turned out to be: checking
|
||
it inside a guard as well would allocate the target's slots twice and
|
||
evaluate whatever it was written as twice. *)
|
||
let target = check ctx target in
|
||
let a = allocator_arg ctx loc rest in
|
||
(match target.Tast.ty with
|
||
(* The refusal that did *not* come down with the type-level ones, and
|
||
the distinction is worth being exact about, because the sentence
|
||
they all used to share bundled two different failures: that a clone
|
||
would duplicate inner headers instead of copying, and that a free
|
||
would leak what those headers own. Only the second was about
|
||
teardown, and only the second is answered by a region.
|
||
|
||
What disqualifies [clone] is not that it copies a header — so do
|
||
[at] and [get], and they are fine, because they promise nothing and
|
||
hand back an alias into a region nobody individually frees. It is
|
||
that [clone] *allocates a new block and promises independence*.
|
||
spec-memory.md calls it a deep copy; what a memcpy of the slots
|
||
delivers is a second container whose elements still point into the
|
||
first one's blocks. Two containers, one set of inner buffers, under
|
||
a name that says otherwise — and putting the copy in a second arena
|
||
makes it worse, not better, because tipping that arena leaves the
|
||
copy's elements pointing into an arena that is still live while its
|
||
own storage is gone.
|
||
|
||
Refused at the operation rather than at the type, because that is
|
||
where the promise is made. *)
|
||
| (Types.Vec _ | Types.Map _) when region_only ctx.env target.Tast.ty ->
|
||
fail loc
|
||
"%s cannot be cloned: clone is a deep, independent copy, and the \
|
||
type-erased runtime copies slots bytewise — so the copy's \
|
||
elements would still point at the original's blocks, which is an \
|
||
alias under a name that promises the opposite. Nothing here can \
|
||
walk an element to copy what it owns. Build a second container \
|
||
and insert into it, or keep the one you have — a region makes \
|
||
sharing safe, not copying"
|
||
(Types.to_string target.Tast.ty)
|
||
(* A map's clone reinserts rather than copying the block, because the
|
||
seed is derived from the block's address — see flan_rt.c. That is
|
||
the runtime's business; from here it is one more allocating call
|
||
under the same guard. *)
|
||
| Types.Map (k, v) when deferred_key ctx.env loc "clone" k ->
|
||
(* Deferred, and the placeholder is a zeroed map of the same type —
|
||
the value a (map-new) starts from, so everything written around
|
||
the clone still checks against the type it will have. *)
|
||
let mty = Types.Map (k, v) in
|
||
expect ctx loc ~want (mk loc mty (Tast.Zero mty))
|
||
| Types.Map (k, v) ->
|
||
let mty = Types.Map (k, v) in
|
||
let hash, _ = key_fns ctx.env loc k in
|
||
let d = fresh_slot ctx mty in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_map_clone"
|
||
[ mk loc mty (Tast.Local d); target; a;
|
||
size_of loc k; size_of loc v; hash; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc mty
|
||
(Tast.Let ([ (d, mk loc mty (Tast.Zero mty)) ],
|
||
[ with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_map"
|
||
(mk loc mty (Tast.Local d))
|
||
[ size_of loc k; size_of loc v ] mty);
|
||
mk loc mty (Tast.Local d) ])))
|
||
| _ ->
|
||
let elem = vec_elem loc "clone" target.Tast.ty in
|
||
let d = fresh_slot ctx (Types.Vec elem) in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_vec_clone"
|
||
[ mk loc (Types.Vec elem) (Tast.Local d); target; a;
|
||
size_of loc elem; align_of loc elem; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Vec elem)
|
||
(Tast.Let ([ (d, mk loc (Types.Vec elem)
|
||
(Tast.Zero (Types.Vec elem))) ],
|
||
[ with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_vec"
|
||
(mk loc (Types.Vec elem) (Tast.Local d))
|
||
[ size_of loc elem ] elem);
|
||
mk loc (Types.Vec elem) (Tast.Local d) ]))))
|
||
| _ -> fail loc "clone is (clone v) or (clone v allocator)")
|
||
|
||
(* ── (Map K V), spec-memory.md ─────────────────────────────────── *)
|
||
(* Every one of these is a named call over the same type-erased runtime the
|
||
Vec uses, with the two sizes and the key's hash and equality pair produced
|
||
here because here is where the concrete types are known. No generics are
|
||
involved and none are needed — which is exactly what Odin's Map_Info says
|
||
too, being two sizes and two contextless procs. *)
|
||
|
||
(* (map-new), (map-new K V), (map-new a), (map-new K V a). The same shape
|
||
[vec-new] has and for the same reason: a [let] has no type annotation, so
|
||
a local map has nowhere else to say what it holds. Where the context does
|
||
say — a defonce's type, a parameter, a return type — the pair may be left
|
||
out. *)
|
||
| "map-new" ->
|
||
let k, v, args = map_new_types ctx ~want loc args in
|
||
let a = allocator_arg ctx loc args in
|
||
let mty = map_type ~preds:ctx.env.tvpreds loc k v in
|
||
let m = fresh_slot ctx mty in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_map_init"
|
||
[ mk loc mty (Tast.Local m); a; size_of loc k; size_of loc v;
|
||
here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc mty
|
||
(Tast.Let ([ (m, mk loc mty (Tast.Zero mty)) ],
|
||
[ with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_map"
|
||
(mk loc mty (Tast.Local m))
|
||
[ size_of loc k; size_of loc v ] mty);
|
||
region_check ctx.env loc (mk loc mty (Tast.Local m))
|
||
(mk loc mty (Tast.Local m)) ])))
|
||
|
||
(* (put m k v) — the upsert. Unit, not a Result and not an ignorable error
|
||
code: see [alloc_guard]. spec-memory.md is explicit that it either
|
||
inserts or replaces, and that (set (get m k) v) is not map syntax. *)
|
||
| "put" ->
|
||
arity ctx loc name 3 args;
|
||
(match args with
|
||
| [ target; k; v ] ->
|
||
let target = check ctx target in
|
||
(* A put into a dyn map is a call and nothing else, the way a push into
|
||
a dyn vec is: the runtime owns the storage, so there is no guard, no
|
||
restart and no region check. An equal key's value is replaced. *)
|
||
if target.Tast.ty = Types.Dyn then
|
||
expect ctx loc ~want
|
||
(rt loc Types.Unit "flan_dyn_map_set"
|
||
[ target; check ctx ~want:Types.Dyn k;
|
||
check ctx ~want:Types.Dyn v ])
|
||
else begin
|
||
let kt, vt = map_kv loc "put" target.Tast.ty in
|
||
let k = check ctx ~want:kt k in
|
||
let v = check ctx ~want:vt v in
|
||
(* Deferred: the arguments are checked — so a move here is still a move
|
||
and a borrow still a borrow — and the node itself is a unit no-op,
|
||
thrown away with the rest of the abstract pass. *)
|
||
if deferred_key ctx.env loc "put" kt then
|
||
expect ctx loc ~want (mk loc Types.Unit Tast.Unit)
|
||
else
|
||
(* Both are bound before the loop, so that a [retry] re-attempts the
|
||
allocation and not the expressions that produced the key and the
|
||
value. The same rule [push] follows for its element. *)
|
||
let ks = fresh_slot ctx kt and vs = fresh_slot ctx vt in
|
||
let hash, eq = key_fns ctx.env loc kt in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_map_put"
|
||
[ target; addr_of loc (mk loc kt (Tast.Local ks));
|
||
addr_of loc (mk loc vt (Tast.Local vs));
|
||
size_of loc kt; size_of loc vt; hash; eq; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit
|
||
(Tast.Let ([ (ks, k); (vs, v) ],
|
||
[ region_check ctx.env loc target
|
||
(with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_map" target
|
||
[ size_of loc kt; size_of loc vt ]
|
||
target.Tast.ty)) ])))
|
||
end
|
||
| _ -> assert false)
|
||
|
||
(* (get m k) -> (Option V). Absence is None, not an untyped nil, and the
|
||
answer is a copy of the value's bytes — for an owning value, a copy of
|
||
its header, aliasing what the map's slot points at.
|
||
There is no allocation here and therefore no guard: a lookup that finds
|
||
nothing is an answer, not a failure. *)
|
||
| "get" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ target; k ] ->
|
||
let target = check ctx target in
|
||
(* A dyn map's absence is nil, not None: the typed map can promise an
|
||
(Option V) because V was written down, and a dyn map has nothing to
|
||
write. nil is an ordinary dyn value the caller compares against —
|
||
and (contains? m k) is the question to ask when nil might also be
|
||
stored under the key. *)
|
||
if target.Tast.ty = Types.Dyn then
|
||
expect ctx loc ~want
|
||
(rt loc Types.Dyn "flan_dyn_map_get"
|
||
[ target; check ctx ~want:Types.Dyn k ])
|
||
else begin
|
||
let kt, vt = map_kv loc "get" target.Tast.ty in
|
||
let k = check ctx ~want:kt k in
|
||
(* Deferred, and the placeholder is [None] rather than [Unit]: this
|
||
form answers an (Option V), and the abstract pass still has to
|
||
type-check whatever the body does with the answer. *)
|
||
if deferred_key ctx.env loc "get" kt then
|
||
expect ctx loc ~want (mk loc (Types.Option vt) Tast.None_)
|
||
else
|
||
map_lookup ctx ~want loc "flan_map_get" target kt vt k
|
||
end
|
||
| _ -> assert false)
|
||
|
||
(* (keyword s) -> the interned dyn keyword named by the bytes, for a name
|
||
that only exists at run time — a reader building :texture-path out of a
|
||
token's text. A literal :foo never comes through here. *)
|
||
| "keyword" ->
|
||
arity ctx loc name 1 args;
|
||
(match args with
|
||
| [ s ] ->
|
||
let s = check ctx s in
|
||
(match s.Tast.ty with
|
||
| Types.String | Types.Slice (Types.Int Types.U8) ->
|
||
expect ctx loc ~want (rt loc Types.Dyn "flan_dyn_kw" [ s ])
|
||
| other ->
|
||
fail loc "keyword takes a string or a [u8], found %s"
|
||
(Types.to_string other))
|
||
| _ -> assert false)
|
||
|
||
(* (class-of v) -> the class's name as a keyword, or nil. It is the dyn
|
||
side's one question about shape, and the dispatch a (defgeneric ...)
|
||
compiles to is this call and a comparison — so what a class dispatcher
|
||
is, exactly, is the shape tag of the first argument as the dispatch
|
||
function, which is what makes the CLOS half and the Clojure half one
|
||
mechanism rather than two.
|
||
|
||
Anything that is not an instance answers nil rather than trapping: an
|
||
ordinary map, a number, nil itself. Asking is not a claim, and the
|
||
question is askable of every value — the same line [get] takes about an
|
||
absent key. *)
|
||
| "class-of" ->
|
||
arity ctx loc name 1 args;
|
||
(match args with
|
||
| [ v ] ->
|
||
expect ctx loc ~want
|
||
(rt loc Types.Dyn "flan_dyn_class_of" [ check ctx ~want:Types.Dyn v ])
|
||
| _ -> assert false)
|
||
|
||
(* (map-remove m k) -> (Option V): the value that was there, or None when
|
||
the key was not. The same answer [get] gives, for the same reason — a key
|
||
that is not in the map is an answer and not a failure — and the value
|
||
comes back rather than being dropped on the floor, which is what makes
|
||
"take this out and use it" one call instead of a get and a remove that
|
||
hash the key twice.
|
||
|
||
It allocates nothing and releases nothing, so unlike [put] there is no
|
||
alloc_guard and no region check around it: a key and a value live inside
|
||
the one block the map allocated, and removal moves entries within that
|
||
block. That is what makes it mean the same thing on a map backed by an
|
||
arena — or by any allocator that refuses can-free — as on a heap-backed
|
||
one. Nothing is freed per entry because nothing was allocated per entry. *)
|
||
| "map-remove" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ target; k ] ->
|
||
let target = check ctx target in
|
||
let kt, vt = map_kv loc "map-remove" target.Tast.ty in
|
||
let k = check ctx ~want:kt k in
|
||
(* Deferred exactly as [get] is, and with [None] for the same reason:
|
||
the abstract pass still has to check whatever the body does with the
|
||
answer. *)
|
||
if deferred_key ctx.env loc "map-remove" kt then
|
||
expect ctx loc ~want (mk loc (Types.Option vt) Tast.None_)
|
||
else
|
||
map_lookup ctx ~want loc "flan_map_remove" target kt vt k
|
||
| _ -> assert false)
|
||
|
||
(* (map-next m (addr cur) (addr k) (addr v)) -> bool, and the whole of map
|
||
iteration. Before it there was no way to read a map's keys or its values
|
||
at all: every other map operation addresses one entry by hashing it, and
|
||
nothing walked the block.
|
||
|
||
Three out-pointers rather than a returned pair, because there are no
|
||
tuples and a (Option K) would answer only half of an entry — the value
|
||
would then cost a second hash of the key just answered. The cursor is an
|
||
i64 the caller owns and the loop reads as one:
|
||
|
||
(let [cur 0 k 0 v 0]
|
||
(while (map-next m (addr cur) (addr k) (addr v))
|
||
...))
|
||
|
||
It is *not* a generic (map-keys m): a Vec of them needs a signature naming
|
||
K, and a prelude defn cannot be written at every K. That one is generics,
|
||
not iteration, and it stays refused for that reason.
|
||
|
||
No hash and no equality pair go with it — walking asks nothing about a
|
||
key — so this is the one map entry point whose signature carries neither,
|
||
and the sizes are still needed because the runtime is type-erased. *)
|
||
| "map-next" ->
|
||
arity ctx loc name 4 args;
|
||
(match args with
|
||
| [ target; cur; k; v ] ->
|
||
let target = check ctx target in
|
||
let kt, vt = map_kv loc "map-next" target.Tast.ty in
|
||
let cur = check ctx ~want:(Types.Ptr (Types.Int Types.I64)) cur in
|
||
let k = check ctx ~want:(Types.Ptr kt) k in
|
||
let v = check ctx ~want:(Types.Ptr vt) v in
|
||
let found =
|
||
rt loc (Types.Int Types.I8) "flan_map_next"
|
||
[ target; cur; k; v; size_of loc kt; size_of loc vt; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Bool
|
||
(Tast.Prim
|
||
(Tast.Ne,
|
||
[ found;
|
||
mk loc (Types.Int Types.I8) (Tast.Int (0L, Types.I8)) ])))
|
||
| _ -> assert false)
|
||
|
||
(* (has-key? m k). (get m k) answers the same question, but through an
|
||
Option the caller then has to match; this is the form a condition wants,
|
||
and it copies no value. *)
|
||
| "has-key?" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ target; k ] ->
|
||
let target = check ctx target in
|
||
(* The dyn map's question, one word with the typed one. It exists on
|
||
the dyn side because absence there is nil, and a map can also store
|
||
nil under a key — (get m k) answering nil cannot tell the two
|
||
apart, and this can. The bool comes back unboxed the way a dyn
|
||
comparison does, because a presence test is overwhelmingly an if's
|
||
condition. *)
|
||
if target.Tast.ty = Types.Dyn then
|
||
expect ctx loc ~want
|
||
(unbox loc Types.Bool
|
||
(rt loc Types.Dyn "flan_dyn_map_contains"
|
||
[ target; check ctx ~want:Types.Dyn k ]))
|
||
else begin
|
||
let kt, vt = map_kv loc "has-key?" target.Tast.ty in
|
||
let k = check ctx ~want:kt k in
|
||
(* Deferred, and the placeholder is a [bool] — the form a condition
|
||
wants, so the condition around it still has to check. *)
|
||
if deferred_key ctx.env loc "has-key?" kt then
|
||
expect ctx loc ~want (mk loc Types.Bool (Tast.Bool false))
|
||
else
|
||
let hash, eq = key_fns ctx.env loc kt in
|
||
let ks = fresh_slot ctx kt in
|
||
let found =
|
||
rt loc (Types.Int Types.I8) "flan_map_has"
|
||
[ target; addr_of loc (mk loc kt (Tast.Local ks));
|
||
size_of loc kt; size_of loc vt; hash; eq; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Bool
|
||
(Tast.Let ([ (ks, k) ],
|
||
[ mk loc Types.Bool
|
||
(Tast.Prim
|
||
(Tast.Ne,
|
||
[ found;
|
||
mk loc (Types.Int Types.I8)
|
||
(Tast.Int (0L, Types.I8)) ])) ])))
|
||
end
|
||
| _ -> assert false)
|
||
|
||
(* ── Assets, decision 1: embedded at compile time ──────────────
|
||
Odin's #load and #load_directory are the model (src/parser.cpp,
|
||
src/check_builtin.cpp's check_load_directive), and the reason it is the
|
||
right answer here is the one NEXT.md gives: it is a *compiler* feature, so
|
||
it needs no build flags, no linker arguments and no per-target packaging,
|
||
and it works identically on desktop and web. That matters more here than
|
||
it does for Odin, because [Load] gives link flags only to a directory
|
||
package — the single file doing (rl/load-texture "brush.png") is
|
||
structurally the one file with no link channel. Embedding has no such
|
||
hole.
|
||
|
||
Odin's `#` is not imported. An s-expression language already has a head
|
||
position for a name, so these are ordinary named calls spelled [embed] and
|
||
[embed-dir], resolved here exactly as [vec-new] and [heap-allocator] are.
|
||
|
||
The result costs nothing at run time: the bytes become a
|
||
`private unnamed_addr constant` string, the same one every string literal
|
||
already becomes, and emit.ml's [escape] is byte-exact, so a PNG survives
|
||
the round trip through the .ll. Bound with [defconst], an [embed-dir]
|
||
becomes an LLVM constant outright (emit.ml's [const]).
|
||
|
||
The one sharp edge, and it is not new: the slice this hands back points
|
||
into .rodata, so a store through it either segfaults at -O0 or is deleted
|
||
at -O2 — the same measured trap the prelude's ASCII-case note describes
|
||
for (bytes-view "Hi"). Copy the bytes — (bytes s) does exactly that for a
|
||
string — for a mutable buffer. Nothing here widens that hole; it inherits
|
||
it, and read-only slice types are what would close it (FIX.org). *)
|
||
| "embed" ->
|
||
(match args with
|
||
| [ p ] | [ p; _ ] ->
|
||
(* The spelling is settled before the file is opened, so a program that
|
||
asks for a type embed cannot read a file as is told that, rather than
|
||
being told the file is missing and left to discover the other half
|
||
after fixing it. *)
|
||
(match args with
|
||
| [ _; { Ast.e = Ast.Var "string"; _ } ] | [ _ ] -> ()
|
||
| [ _; t ] ->
|
||
fail t.Ast.loc
|
||
"embed's second argument is the type to read the file as, and \
|
||
`string` is the only one — (embed \"p\") is the [u8]"
|
||
| _ -> ());
|
||
let data = read_embed_file (embed_path loc p) p.Ast.loc in
|
||
let as_string () = mk loc Types.String (Tast.Str data) in
|
||
(* A [Str] node typed [u8] rather than a [Bytes] prim over one. [Bytes]
|
||
is identity — emit.ml lowers String and Slice _ to the same %slice —
|
||
and the prim would make the node non-constant, so an (embed-dir) in a
|
||
defconst could not be an LLVM constant. Both of emit.ml's string
|
||
emitters take the bytes and ignore the node's type, so this is the
|
||
same constant either way, and it is one a global can hold. *)
|
||
let as_bytes () = mk loc (Types.Slice (Types.Int Types.U8)) (Tast.Str data) in
|
||
(* Two spellings rather than one that changes type with its context.
|
||
Odin threads a type_hint everywhere and can afford (embed "p") to
|
||
mean a string here and a []u8 there; with structural equality and a
|
||
container that never converts to another container -- implicit
|
||
widening is numbers only, FIX.org 2026-09-20 -- the same text meaning
|
||
two types would be a wart. [want] is a fallback only, and nothing
|
||
depends on it. *)
|
||
(match args with
|
||
| [ _; { Ast.e = Ast.Var "string"; _ } ] ->
|
||
expect ctx loc ~want (as_string ())
|
||
| _ ->
|
||
(match want with
|
||
| Some Types.String -> as_string ()
|
||
| _ -> expect ctx loc ~want (as_bytes ())))
|
||
| _ ->
|
||
fail loc
|
||
"embed is (embed \"path\") for a [u8], or (embed \"path\" string)")
|
||
(* ── What a macro says when it has to refuse ───────────────────
|
||
The one thing a macro could not do, written down in the prelude where
|
||
[unless] settles for it: "a macro has no error facility: it runs inside
|
||
the compiler and anything it signals aborts the compile with no location.
|
||
So a malformed (unless) answers a name nothing defines, and the report is
|
||
'unknown name unless-takes-a-test-and-a-body' at the call site, which is
|
||
the right place and the wrong sentence."
|
||
|
||
A name nothing defines carries a name. It cannot carry a sentence, and a
|
||
type provider's refusals are all sentence: the third element of this
|
||
vector is a string where the first two were integers; there is no file at
|
||
assets/x.edn; :size is a map with keys of two kinds. Those name a position
|
||
in a *data* file, which no symbol the expansion could invent will hold.
|
||
|
||
So a macro that has to refuse expands to a call to this, and the string is
|
||
the report. [Loc.from_macro] has already stamped the call site onto every
|
||
node of the expansion, so the location is the [defedn] the author wrote
|
||
and the sentence is the macro's — which is the two halves the prelude's
|
||
note says are never both right at once.
|
||
|
||
A builtin and not a declaration, because it has to fail *here*: a declared
|
||
function would compile, link and run, and the compile it was meant to stop
|
||
would have succeeded. The whole of it is one arm, and the argument is a
|
||
literal for the same reason [embed]'s path is one — there is nothing at
|
||
this point in a compile to compute a string from. *)
|
||
| "compile-error" ->
|
||
arity ctx loc name 1 args;
|
||
(match (List.hd args).Ast.e with
|
||
| Ast.Str s -> fail loc "%s" s
|
||
| _ ->
|
||
fail (List.hd args).Ast.loc
|
||
"compile-error takes a literal string — it is reported while the \
|
||
program is being checked, so there is nothing here to build one \
|
||
from. A macro that has to refuse builds the sentence as it expands \
|
||
and puts it in the form")
|
||
| "embed-dir" ->
|
||
arity ctx loc name 1 args;
|
||
let arg = List.hd args in
|
||
let entries = read_embed_dir (embed_path loc arg) arg.Ast.loc in
|
||
if not (Hashtbl.mem ctx.env.structs "EmbedFile") then
|
||
fail loc
|
||
"embed-dir answers a [n EmbedFile] and EmbedFile is not in scope — it \
|
||
is a prelude type and something has replaced the prelude";
|
||
let ety = Types.Named "EmbedFile" in
|
||
let elems =
|
||
List.map
|
||
(fun (nm, data) ->
|
||
mk loc ety
|
||
(Tast.Make
|
||
("EmbedFile",
|
||
[ mk loc Types.String (Tast.Str nm);
|
||
mk loc (Types.Slice (Types.Int Types.U8)) (Tast.Str data) ])))
|
||
entries
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Array (Int64.of_int (List.length entries), ety))
|
||
(Tast.Arr elems))
|
||
|
||
(* ── slurp and barf, decisions 2 and 5 ─────────────────────────
|
||
[slurp] reads a whole file and answers a (Vec u8). It allocates, which is
|
||
why it waited for Vec, and it follows spec-memory.md's rule to the letter:
|
||
no allocating operation returns an error, so there is no Result here and
|
||
no out-parameter — a failure to allocate is StorageExhausted under [retry]
|
||
and a failure to read is FileError under [retry] and [use-value].
|
||
|
||
The two guards nest rather than merge, and that is the point: they are two
|
||
different failures with two different answerable questions, and a handler
|
||
that grows an arena is not the handler that supplies another path.
|
||
|
||
Everything is inside the file loop, so a [use-value] that names a
|
||
different file re-measures it and re-allocates for its size. The Vec is
|
||
freed at the top of each turn, which is why a retry does not leak; freeing
|
||
a Vec that never allocated is a no-op (flan_rt.c, flan_vec_free). *)
|
||
| "slurp" ->
|
||
(match args with
|
||
| path :: rest when List.length rest <= 1 ->
|
||
let path = check ctx ~want:Types.String path in
|
||
let a = allocator_arg ctx loc rest in
|
||
let ps = fresh_slot ctx Types.String in
|
||
let psv () = mk loc Types.String (Tast.Local ps) in
|
||
let u8 = Types.Int Types.U8 in
|
||
let vt = Types.Vec u8 in
|
||
let v = fresh_slot ctx vt in
|
||
let vv () = mk loc vt (Tast.Local v) in
|
||
let n = fresh_slot ctx (Types.Int Types.I64) in
|
||
let nv () = mk loc (Types.Int Types.I64) (Tast.Local n) in
|
||
let steps try_ =
|
||
[ (* The size first, because it is the step that does not allocate:
|
||
a missing file is found before any storage is committed to it. *)
|
||
try_ (rt loc (Types.Int Types.I8) "flan_file_size"
|
||
[ psv (); addr_of loc (nv ()) ]);
|
||
(* Previous turn's storage, if a retry brought us back here. *)
|
||
rt loc Types.Unit "flan_vec_free"
|
||
[ vv (); size_of loc u8; align_of loc u8; here loc ];
|
||
with_note loc
|
||
(alloc_guard ctx loc
|
||
(rt loc (Types.Int Types.I8) "flan_vec_init"
|
||
[ vv (); a; nv (); size_of loc u8; align_of loc u8;
|
||
here loc ]))
|
||
(reg_note loc "flan_dev_reg_note_vec" (vv ())
|
||
[ size_of loc u8 ] u8);
|
||
(* Fills the Vec the line above sized. A file that grew since the
|
||
measurement is truncated to the buffer; one that shrank leaves a
|
||
shorter Vec. Both are successful reads of what was there. *)
|
||
try_ (rt loc (Types.Int Types.I8) "flan_slurp_into"
|
||
[ vv (); psv (); size_of loc u8; here loc ]) ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc vt
|
||
(Tast.Let
|
||
([ (ps, path);
|
||
(n, i64_at loc 0L);
|
||
(v, mk loc vt (Tast.Zero vt)) ],
|
||
[ file_guard ctx loc ~path_slot:ps ~op:0 steps; vv () ])))
|
||
| _ -> fail loc "slurp is (slurp path) or (slurp path allocator)")
|
||
(* [barf] writes a whole file, and on the web target it signals — every time,
|
||
with the path in the condition. Decision 2, and the reason is worth having
|
||
at the call site: Flan has NO conditional compilation, so "isolate this to
|
||
desktop" is not expressible in source and a build-time refusal would be
|
||
unusable; a silent no-op is worse than either, because that is how a save
|
||
file disappears with nothing said. So the program gets a condition and
|
||
decides. Nothing here reads the target — the refusal is flan_rt.c's, one
|
||
#ifdef in the host layer, which is exactly where the two targets are
|
||
already implemented twice. *)
|
||
| "barf" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ path; data ] ->
|
||
let path = check ctx ~want:Types.String path in
|
||
let data = byte_slice ctx data in
|
||
let ps = fresh_slot ctx Types.String in
|
||
let ds = fresh_slot ctx (Types.Slice (Types.Int Types.U8)) in
|
||
let steps try_ =
|
||
[ try_ (rt loc (Types.Int Types.I8) "flan_file_write"
|
||
[ mk loc Types.String (Tast.Local ps);
|
||
mk loc (Types.Slice (Types.Int Types.U8)) (Tast.Local ds) ]) ]
|
||
in
|
||
(* Both operands are bound before the loop so that a retry re-attempts
|
||
the write and not the expressions that produced it — the same rule
|
||
alloc_guard states for push. *)
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit
|
||
(Tast.Let ([ (ps, path); (ds, data) ],
|
||
[ file_guard ctx loc ~path_slot:ps ~op:1 steps ])))
|
||
| _ -> assert false)
|
||
|
||
(* ── the three that change the filesystem ──────────────────────────
|
||
[delete-file], [rename-file] and [make-directory] are [barf]'s shape with
|
||
a different runtime call, and they are here rather than as prelude
|
||
[declare]s for the one thing a declare cannot do: signal [FileError] with
|
||
the two restarts the compiler emits. A declare could only answer a bool,
|
||
and "the delete failed, here is a boolean" is the shape decision 5 exists
|
||
to keep out of this language — a handler that made the parent directory
|
||
and wants [retry], or that has another path and wants [use-value], has
|
||
nothing to hold onto.
|
||
|
||
Each answers [()] and not a bool for the same reason [barf] does: the
|
||
failure is the condition, so a return value would only ever be true. The
|
||
questions that are *not* failures — does this exist, how big is it —
|
||
answer a value instead, and those two are prelude functions over one
|
||
[declare] because nothing about them needs a restart.
|
||
|
||
[op] continues the FileError numbering the prelude names: 0 read, 1 write,
|
||
and 2, 3, 4 here. A handler matching on it is matching on the prelude's
|
||
[file-op-delete] and friends, not on a literal. *)
|
||
| "delete-file" | "make-directory" ->
|
||
arity ctx loc name 1 args;
|
||
let sym, op =
|
||
if String.equal name "delete-file" then "flan_file_delete", 2
|
||
else "flan_file_mkdir", 4
|
||
in
|
||
let path = check ctx ~want:Types.String (List.hd args) in
|
||
let ps = fresh_slot ctx Types.String in
|
||
let steps try_ =
|
||
[ try_ (rt loc (Types.Int Types.I8) sym
|
||
[ mk loc Types.String (Tast.Local ps) ]) ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit
|
||
(Tast.Let ([ (ps, path) ],
|
||
[ file_guard ctx loc ~path_slot:ps ~op steps ])))
|
||
|
||
(* Two paths and one restart slot, so the guard holds the *source*: a
|
||
[use-value] renames a different file to the same destination. That is the
|
||
direction a handler can act on — the destination it asked for is the one
|
||
thing it already knows — and it is written down here because the other
|
||
reading is equally plausible until somebody says which it is.
|
||
|
||
The destination is bound before the loop, exactly as [barf] binds its
|
||
data, so a retry re-attempts the rename and not the expression that
|
||
computed where to. *)
|
||
| "rename-file" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ from_; to_ ] ->
|
||
let from_ = check ctx ~want:Types.String from_ in
|
||
let to_ = check ctx ~want:Types.String to_ in
|
||
let ps = fresh_slot ctx Types.String in
|
||
let ds = fresh_slot ctx Types.String in
|
||
let steps try_ =
|
||
[ try_ (rt loc (Types.Int Types.I8) "flan_file_rename"
|
||
[ mk loc Types.String (Tast.Local ps);
|
||
mk loc Types.String (Tast.Local ds) ]) ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc Types.Unit
|
||
(Tast.Let ([ (ps, from_); (ds, to_) ],
|
||
[ file_guard ctx loc ~path_slot:ps ~op:3 steps ])))
|
||
| _ -> assert false)
|
||
|
||
(* ── containers ────────────────────────────────────────────────── *)
|
||
(* [at] and [len] were already the names for a fixed array and a slice, so a
|
||
Vec extends them rather than adding a parallel pair — which is the
|
||
asymmetry [nth] was removed for. A Vec's length is i32 like every other
|
||
length here (index_ty): widening indices is one change across all of them
|
||
and not a Vec question. *)
|
||
| "len" ->
|
||
arity ctx loc name 1 args;
|
||
let target = List.hd args in
|
||
let a = check ctx target in
|
||
(match a.Tast.ty with
|
||
| Types.Array _ | Types.Slice _ | Types.String ->
|
||
prim Tast.Len index_ty [ a ]
|
||
| Types.Vec _ ->
|
||
let n = rt loc (Types.Int Types.I64) "flan_vec_len" [ a; here loc ] in
|
||
expect ctx loc ~want (mk loc index_ty (Tast.Prim (Tast.Cast index_ty, [ n ])))
|
||
(* Extended rather than given a name of its own, for the reason [at] and
|
||
[len] were extended over Vec: one question, one word. *)
|
||
| Types.Map _ ->
|
||
let n = rt loc (Types.Int Types.I64) "flan_map_len" [ a; here loc ] in
|
||
expect ctx loc ~want (mk loc index_ty (Tast.Prim (Tast.Cast index_ty, [ n ])))
|
||
(* A dyn length is an i32 like every other length here, not a dyn holding
|
||
one. [len] is what an index loop compares against, and handing back a
|
||
boxed number would make [(< i (len xs))] a dyn comparison and a pair of
|
||
allocations per iteration. The runtime answers a dyn; it is unboxed at
|
||
once and narrowed the way the Vec's i64 above is. *)
|
||
| Types.Dyn ->
|
||
let n = unbox loc (Types.Int Types.I64) (rt loc Types.Dyn "flan_dyn_len" [ a ]) in
|
||
expect ctx loc ~want (mk loc index_ty (Tast.Prim (Tast.Cast index_ty, [ n ])))
|
||
| other ->
|
||
fail loc
|
||
"len takes an array, a slice, a string, a Vec or a Map, found %s"
|
||
(Types.to_string other))
|
||
| "at" ->
|
||
(match args with
|
||
| target :: idx when idx <> [] ->
|
||
let target = check ctx target in
|
||
(match target.Tast.ty with
|
||
| Types.Vec _ ->
|
||
let p, elem = vec_at ctx loc target idx in
|
||
expect ctx loc ~want (mk loc elem (Tast.Deref p))
|
||
(* One index, because a dyn container is one dimension: the nested
|
||
[(at grid r c)] spelling walks a type the compiler can see through,
|
||
and here it cannot. [(at (at g r) c)] is the spelling that works and
|
||
is what the refusal names. *)
|
||
| Types.Dyn ->
|
||
(match idx with
|
||
| [ i ] ->
|
||
expect ctx loc ~want
|
||
(rt loc Types.Dyn "flan_dyn_at" [ target; check ctx ~want:Types.Dyn i ])
|
||
| _ ->
|
||
fail loc
|
||
"(at ...) over a dyn takes one index — the compiler cannot see \
|
||
the shape of a dyn container, so write (at (at x i) j)")
|
||
| _ ->
|
||
let idx, ty = indexed ctx target idx in
|
||
prim Tast.At ty (target :: idx))
|
||
| _ -> fail loc "%s is (%s collection index ...)" name name)
|
||
(* (slice a), (slice a lo) and (slice a lo hi). The two short forms are
|
||
written out here into the three-argument one and are that form after
|
||
this line: same node, same checks, same code. Nothing is added at run
|
||
time, because neither missing argument needs anything computed —
|
||
[lo] is 0, and [hi] is the length, which on a fixed array is the
|
||
constant [len] already folds to and on a slice or a string is the
|
||
length word the value is carrying anyway.
|
||
|
||
The target is read twice when [hi] is the implicit length, so a target
|
||
that is not already a name goes into a slot first: [(slice (f x))] must
|
||
call [f] once. An array target never needs the slot, because its length
|
||
is a constant and the target appears exactly once. *)
|
||
| "slice" ->
|
||
(match args with
|
||
| [] | _ :: _ :: _ :: _ :: _ ->
|
||
fail loc
|
||
"slice is (slice a), (slice a lo) or (slice a lo hi) — given %d \
|
||
arguments" (List.length args)
|
||
| target :: bounds ->
|
||
let target = check ctx target in
|
||
let ty = target.Tast.ty in
|
||
(* A string slices to a string, not to a [u8]: the result views the
|
||
same bytes and is read-only for the same reason the source is, and
|
||
calling it a byte slice would hand out a writable-looking view of
|
||
storage the program does not own. *)
|
||
let result = match ty with
|
||
| Types.Array (_, t) | Types.Slice t -> Types.Slice t
|
||
| Types.String -> Types.String
|
||
| other ->
|
||
fail loc "slice takes an array, a slice or a string, found %s"
|
||
(Types.to_string other)
|
||
in
|
||
(* An array that came back from a call is a value in a temporary this
|
||
expression does not own: the slice would outlive it and view
|
||
whatever the frame reused those bytes for, with nothing to trap on.
|
||
A [let] gives it a name and a lifetime, so that is what the refusal
|
||
names. An array *literal* is not this case — it is written here and
|
||
the frame holds it for as long as the form it is written in. *)
|
||
(match ty, target.Tast.e with
|
||
| Types.Array _, (Tast.Call _ | Tast.CallPtr _) ->
|
||
fail loc
|
||
"this slices an array a call returned, and a returned array is a \
|
||
temporary — the slice would outlive it and view storage the \
|
||
frame has reused. Bind it first: (let [a (…)] (slice a …))"
|
||
| _ -> ());
|
||
let int k = mk loc index_ty (Tast.Int (k, Types.I32)) in
|
||
(* [hi] is wanted twice only when it is the implicit length of
|
||
something whose length is not static. *)
|
||
let needs_slot =
|
||
List.length bounds < 2
|
||
&& (match ty with Types.Array _ -> false | _ -> true)
|
||
&& (match target.Tast.e with
|
||
| Tast.Local _ | Tast.Global _ -> false
|
||
| _ -> true)
|
||
in
|
||
let slot = if needs_slot then Some (fresh_slot ctx ty) else None in
|
||
let src () = match slot with
|
||
| Some s -> mk loc ty (Tast.Local s)
|
||
| None -> target
|
||
in
|
||
let whole_len () = match ty with
|
||
| Types.Array (n, _) -> int n
|
||
| _ -> mk loc index_ty (Tast.Prim (Tast.Len, [ src () ]))
|
||
in
|
||
let lo_loc, lo, hi_loc, hi =
|
||
match bounds with
|
||
| [] -> loc, int 0L, loc, whole_len ()
|
||
| [ lo ] ->
|
||
lo.Ast.loc, check ctx ~want:index_ty lo, loc, whole_len ()
|
||
| [ lo; hi ] ->
|
||
lo.Ast.loc, check ctx ~want:index_ty lo,
|
||
hi.Ast.loc, check ctx ~want:index_ty hi
|
||
| _ -> assert false
|
||
in
|
||
(* A bound may sit one past the end, so the length is checked against
|
||
lo and hi both, not against the last valid index. *)
|
||
(match literal lo with
|
||
| Some k -> static_index lo_loc ty ~past_end:true "slice bound" k
|
||
| None -> ());
|
||
(match literal hi with
|
||
| Some k -> static_index hi_loc ty ~past_end:true "slice bound" k
|
||
| None -> ());
|
||
(match literal lo, literal hi with
|
||
| Some a, Some b when a > b ->
|
||
fail loc "slice [%Ld %Ld) runs backwards — lo must not exceed hi" a b
|
||
| _ -> ());
|
||
let body = mk loc result (Tast.Prim (Tast.Slice, [ src (); lo; hi ])) in
|
||
(match slot with
|
||
| None -> expect ctx loc ~want body
|
||
| Some s ->
|
||
expect ctx loc ~want
|
||
(mk loc result (Tast.Let ([ (s, target) ], [ body ])))))
|
||
|
||
(* (slice-from-ptr p n) — NEXT.md, "a pointer from C needs a length before
|
||
it can be indexed". A (Ptr T) that came back from C is readable at
|
||
element 0 through [deref] and nowhere else, because [indexed] takes an
|
||
Array or a Slice and a pointer is neither. C hands back an address and no
|
||
length, so the length has to come from the caller, and this is the form
|
||
that says so out loud.
|
||
|
||
**What the caller is promising**, and the compiler checks none of it: that
|
||
[p] really addresses [n] consecutive [T], that they are initialised, and
|
||
that they outlive every use of the result. Get it wrong and this reads
|
||
memory that is not there — the bounds check the result carries will agree
|
||
with a length that was a lie, because the length *is* the lie. It is the
|
||
same trust [declare-c] already extends, written at the one site where
|
||
somebody had to know the answer anyway.
|
||
|
||
No marker on the name. A [?] in this language means *asks*
|
||
([font-valid?]), and this does not ask; [zeroed], the nearest
|
||
neighbour — a value conjured rather than
|
||
derived — carries no marker either. [ptr] is the marker: a (Ptr T) only
|
||
ever arrives from a [declare-c], so the word already names the C boundary,
|
||
and a reader who sees it has already been told where the promise comes
|
||
from.
|
||
|
||
**It owns nothing.** The result is a [Types.Slice], which carries no
|
||
allocator and is the same non-owning view (as-slice v) answers — so
|
||
[free] refuses it by the rule it already had ("free takes an owning
|
||
container"). *)
|
||
| "slice-from-ptr" ->
|
||
arity ctx loc name 2 args;
|
||
(match args with
|
||
| [ target; n ] ->
|
||
let target = check ctx target in
|
||
let elem =
|
||
match target.Tast.ty with
|
||
| Types.Ptr t -> t
|
||
| other ->
|
||
fail loc
|
||
"slice-from-ptr takes a (Ptr T) and the number of elements behind \
|
||
it, found %s. It is for a pointer that came back from C, whose \
|
||
length only the caller knows"
|
||
(Types.to_string other)
|
||
in
|
||
let n_loc = n.Ast.loc in
|
||
let n = check ctx ~want:index_ty n in
|
||
(* A negative literal is a lie the checker can see, so it does not wait
|
||
for the run-time test emit.ml plants beside it. *)
|
||
(match literal n with
|
||
| Some k when k < 0L ->
|
||
fail n_loc
|
||
"slice-from-ptr length %Ld is negative — the length is what the \
|
||
caller promises the pointer addresses, and no pointer addresses \
|
||
fewer than zero elements" k
|
||
| _ -> ());
|
||
prim Tast.SliceFromPtr (Types.Slice elem) [ target; n ]
|
||
| _ -> assert false)
|
||
|
||
(* ── pointers ──────────────────────────────────────────────────── *)
|
||
| "addr" ->
|
||
arity ctx loc name 1 args;
|
||
let a = List.hd args in
|
||
(match place_of_expr a with
|
||
| None ->
|
||
fail a.Ast.loc
|
||
"addr takes the address of a place — a name, (.field x), (at a i) \
|
||
or (deref p)"
|
||
| Some p ->
|
||
let p, ty = check_place ctx a.Ast.loc p in
|
||
expect ctx loc ~want (mk loc (Types.Ptr ty) (Tast.Addr p)))
|
||
| "deref" ->
|
||
arity ctx loc name 1 args;
|
||
let a = check ctx (List.hd args) in
|
||
(match a.Tast.ty with
|
||
| Types.Ptr t -> expect ctx loc ~want (mk loc t (Tast.Deref a))
|
||
| other -> fail loc "deref takes a (Ptr T), found %s"
|
||
(Types.to_string other))
|
||
|
||
(* ── Option ────────────────────────────────────────────────────── *)
|
||
(* (Some nil) cannot be built. Some marks a value present; nil is dyn's own
|
||
way of saying absent; a present absence is what would make nil and None
|
||
the same case of an (Option dyn) and break nil <-> None at the boundary
|
||
in both directions. Refused here at the literal, which the checker can
|
||
see the same way it sees any other [nil]; a dyn value that only turns
|
||
out to be nil once the program runs is caught by the runtime guard on
|
||
the value instead, named for what it refuses rather than just that it
|
||
does. *)
|
||
| "Some" ->
|
||
arity ctx loc name 1 args;
|
||
let arg = List.hd args in
|
||
let inner = match want with Some (Types.Option t) -> Some t | _ -> None in
|
||
(* A literal [nil] is refused by this form's own message below, not by
|
||
[expect]'s bare-T refusal — checking it against [inner] here would
|
||
let a bare T's "wrap the type in Option" reach the reader even though
|
||
the type here is already wrapped in one. Checked with no want instead,
|
||
which is exactly what a bare [nil] resolves against on its own (see
|
||
[var]'s "nil" arm), so it arrives below still dyn and still nil. *)
|
||
let ast_nil = match arg.Ast.e with Ast.Var "nil" -> true | _ -> false in
|
||
let a = check ctx ?want:(if ast_nil then None else inner) arg in
|
||
let a =
|
||
if not (Types.equal a.Tast.ty Types.Dyn) then a
|
||
else if is_nil_lit a then
|
||
Loc.failk "check/some-nil" loc
|
||
"(Some nil) cannot be built — Some marks a value present, and nil \
|
||
is dyn's own absence, so a present nil would make nil and None \
|
||
the same case of an (Option dyn), which nil <-> None at the \
|
||
boundary depends on staying apart. Use None instead"
|
||
else rt loc Types.Dyn "flan_dyn_need_not_nil" [ a ]
|
||
in
|
||
expect ctx loc ~want (mk loc (Types.Option a.Tast.ty) (Tast.Some_ a))
|
||
|
||
(* ── the milestone-2 host primitives (plan.org) ────────────────── *)
|
||
(* (bytes-view s): the string's own storage seen as a [u8], costing nothing.
|
||
This is what (bytes s) used to be, renamed for what it is: a *view*. The
|
||
slice aliases the string — a literal's view points into .rodata and a
|
||
store through it traps at -O0 on either backend — so it is read-only by
|
||
convention until the type system can say so (FIX.org, read-only slices).
|
||
Reading through it is the whole use: bytes=?, split, index-of-bytes and
|
||
every other comparison walks a string's bytes without copying them. *)
|
||
| "bytes-view" ->
|
||
arity ctx loc name 1 args;
|
||
prim Tast.Bytes (Types.Slice (Types.Int Types.U8))
|
||
[ check ctx ~want:Types.String (List.hd args) ]
|
||
|
||
(* (bytes s) / (bytes s a): a *writable copy* of the string's bytes, from
|
||
the context allocator or one named — never a hidden malloc, which is
|
||
spec-memory.md's frozen rule over every allocating operation. It used to
|
||
be the zero-cost reinterpret above, and the author's in-place sort over
|
||
(bytes "INSERTIONSORT") wrote into the string constant; "I would expect
|
||
bytes to copy" is the ruling this implements.
|
||
|
||
The lowering mirrors [vec-new]: a hidden (Vec u8) temp holds the block so
|
||
the allocation registry can read its extent, the attempt sits under
|
||
[alloc_guard] so a failure signals StorageExhausted with retry, and the
|
||
answer is the [as-slice] of the whole of it. The slice carries no
|
||
allocator, so nothing can [free] this block through it — it lives until
|
||
its allocator's free-all or destroy, which is the story every borrowed
|
||
view already has and is written down in BUILT.md's surface table.
|
||
|
||
No [region_check]: that guard compares a Vec header being *stored* against
|
||
the region it lands in, and the header here is a temp nothing stores. *)
|
||
| "bytes" ->
|
||
(match args with
|
||
| s :: rest when List.length rest <= 1 ->
|
||
let u8 = Types.Int Types.U8 in
|
||
let s = check ctx ~want:Types.String s in
|
||
let a = allocator_arg ctx loc rest in
|
||
(* The string is bound before the guard's loop, so a retry re-attempts
|
||
the same copy rather than re-evaluating the expression that produced
|
||
the string. Same rule as [push]'s element. *)
|
||
let sv = fresh_slot ctx Types.String in
|
||
let v = fresh_slot ctx (Types.Vec u8) in
|
||
let out = fresh_slot ctx (Types.Slice u8) in
|
||
let attempt =
|
||
rt loc (Types.Int Types.I8) "flan_bytes_dup"
|
||
[ mk loc (Types.Vec u8) (Tast.Local v); a;
|
||
mk loc Types.String (Tast.Local sv); here loc ]
|
||
in
|
||
let fill =
|
||
rt loc Types.Unit "flan_vec_as_slice"
|
||
[ mk loc (Types.Vec u8) (Tast.Local v);
|
||
addr_of loc (mk loc (Types.Slice u8) (Tast.Local out));
|
||
mk loc index_ty (Tast.Int (0L, Types.I32));
|
||
mk loc index_ty (Tast.Int (-1L, Types.I32));
|
||
size_of loc u8; here loc ]
|
||
in
|
||
expect ctx loc ~want
|
||
(mk loc (Types.Slice u8)
|
||
(Tast.Let
|
||
([ (sv, s);
|
||
(v, mk loc (Types.Vec u8) (Tast.Zero (Types.Vec u8)));
|
||
(out, mk loc (Types.Slice u8) (Tast.Zero (Types.Slice u8))) ],
|
||
[ with_note loc (alloc_guard ctx loc attempt)
|
||
(reg_note loc "flan_dev_reg_note_vec"
|
||
(mk loc (Types.Vec u8) (Tast.Local v))
|
||
[ size_of loc u8 ] u8);
|
||
fill;
|
||
mk loc (Types.Slice u8) (Tast.Local out) ])))
|
||
| _ -> fail loc "bytes is (bytes s) or (bytes s allocator)")
|
||
|
||
(* (string b): a [u8] seen as a string. The mirror of (bytes-view s),
|
||
spelled the same way — a type name in head position, like (bytes s) and
|
||
unlike the numeric casts, which go through [is_cast] and really do
|
||
convert.
|
||
|
||
It costs nothing. emit.ml lowers Types.String and Types.Slice _ to the
|
||
same %slice, 16 bytes at align 8, so a string and a [u8] are already the
|
||
identical value at run time; both this and [Bytes] emit as the argument
|
||
itself. What changes is only what the checker will let the value be
|
||
passed to — which is the whole gap: i64->bytes answers a [u8] and every
|
||
declare-c text parameter wants a string, and nothing joined them.
|
||
|
||
Two decisions are baked in here.
|
||
|
||
1. It does NOT check UTF-8, because `string` does not claim UTF-8. The
|
||
prelude settles this: valid-utf8? is an ordinary function you call when
|
||
you care, decode-rune/rune-at/rune-count all take [u8] rather than
|
||
string, and decode-rune answers {.ok false .width 1} on a malformed
|
||
byte rather than assuming its input is well-formed. The one place the
|
||
runtime treats a string differently from a byte slice is
|
||
flan_escape_bytes, for a string nested in a printed structure, and that
|
||
is a byte-wise escape table with no decoding in it. So there is no code
|
||
that would be wrong about a string of arbitrary bytes, and a check here
|
||
would be the only enforcement point in the language — a claim the rest
|
||
of it does not make.
|
||
|
||
2. It does not widen the literal-write hole (NEXT.md, "Writing through a
|
||
string literal"). That hole is the other direction: (bytes-view "Hi")
|
||
hands you a writable-looking slice over constant data — narrowed since
|
||
(bytes s) became a copy, and closable only by read-only slice types
|
||
(FIX.org). This direction only loses the ability to write — a string
|
||
is read-only everywhere — so the result of (string b) can reach
|
||
strictly fewer stores than b could.
|
||
|
||
The sharp edge left here is one of lifetime and no longer one of sharing:
|
||
the slice that i64->bytes / f64->bytes / u64->bytes answer is a view into
|
||
a frame slot belonging to *that call site* (see [to_bytes]), so two of
|
||
them can be held at once and the text of one survives the making of the
|
||
next. What it does not survive is its frame — calling it a string does not
|
||
copy it, so storing one in a container or returning it hands back a view
|
||
of storage that has been reused. Copy the bytes for that. *)
|
||
| "string" ->
|
||
arity ctx loc name 1 args;
|
||
prim Tast.StrOfBytes Types.String [ byte_slice ctx (List.hd args) ]
|
||
| "bytes->f64" ->
|
||
arity ctx loc name 1 args;
|
||
prim Tast.BytesToF64 (Types.Float Types.F64) [ byte_slice ctx (List.hd args) ]
|
||
| "bytes->i64" ->
|
||
arity ctx loc name 1 args;
|
||
prim Tast.BytesToI64 (Types.Int Types.I64) [ byte_slice ctx (List.hd args) ]
|
||
| "f64->bytes" ->
|
||
arity ctx loc name 1 args;
|
||
expect ctx loc ~want
|
||
(to_bytes ctx loc Tast.F64ToBytes
|
||
(check ctx ~want:(Types.Float Types.F64) (List.hd args)))
|
||
| "i64->bytes" ->
|
||
arity ctx loc name 1 args;
|
||
expect ctx loc ~want
|
||
(to_bytes ctx loc Tast.I64ToBytes
|
||
(check ctx ~want:(Types.Int Types.I64) (List.hd args)))
|
||
| "write-stdout" ->
|
||
arity ctx loc name 1 args;
|
||
prim Tast.WriteStdout Types.Unit [ byte_slice ctx (List.hd args) ]
|
||
|
||
(* (println x) and (print x): the structural printer, selected on the type
|
||
the argument checked to. plan.org, Milestone 5 — "compiler-provided,
|
||
per concrete type". That is not overloading and needs no type variables:
|
||
there is no dispatch at run time and no user-supplied printer to pick
|
||
between. The walk itself is render.ml, shared with the REPL, which is what
|
||
stops the two from drifting apart.
|
||
|
||
[min]/[max]/[zeroed] above dispatch on the resolved argument type the same
|
||
way. The slots the slice arm needs come out of the frame of whatever
|
||
function this call is written in, via [fresh_slot] — allocated once per
|
||
call site, at check time, not once per iteration of a loop around it.
|
||
|
||
A string prints raw here and quoted inside a structure. Those are not in
|
||
conflict: (println "hello") has to print hello or it is useless, and
|
||
(println b) where b has a string field has to quote it or the field
|
||
cannot be told from the punctuation. The split is exactly top level vs
|
||
nested, which is why it lives here and not in the walk. *)
|
||
| "print" | "println" ->
|
||
(* Variadic, with Clojure's spacing: every argument prints in order with a
|
||
single space between each pair, and [println] ends the line. No arity
|
||
check — (println) is the newline alone and (print) is nothing, which is
|
||
also Clojure's answer. Each argument gets the same walk it would get
|
||
alone, so one call mixes typed and dyn values freely, and a bad argument
|
||
is refused at its own location: the arguments are checked one by one
|
||
below, each carrying its own loc, and render.ml fails on the
|
||
expression's loc rather than the form's. *)
|
||
(* Printing is a read, not a move: the walk goes over the value and keeps
|
||
nothing. Without this, (println v) would consume a Vec and every
|
||
printing of one would be its last. *)
|
||
let checked = List.map (fun target -> check ctx target) args in
|
||
(* ── The allow-list, and what it takes to get on it ───────────────
|
||
plan.org names [println] as the one compiler-provided exception — it
|
||
"selects a structural printer at each concrete instantiation" — and
|
||
that cannot be reconciled with an abstract pass as written: a pass that
|
||
decides an operator's legality *without* substituting cannot make an
|
||
exception for the one operator whose legality is only decidable after
|
||
substituting. So the exception is made explicit: these two forms are
|
||
*deferred* to instantiation, and every other operator is answered where
|
||
it is written.
|
||
|
||
Every member of this list is a place where a refusal moves from the
|
||
definition to a call site, which is the thing the abstract pass exists
|
||
to prevent. **That cost is not the same for every member, and the list
|
||
is not closed.** What makes it bearable is whether the call site has a
|
||
*stated requirement* to be refused against.
|
||
|
||
[print] and [println] have none and need none: every type prints, so
|
||
there is no [where] predicate for printability — one would always hold
|
||
and would be noise on a signature — and there is correspondingly no
|
||
call site these can be refused at. They are deferred and then always
|
||
succeed. That is the cheapest possible membership.
|
||
|
||
The map operations — [put], [get], [has-key?], [map-remove], [reserve], [clone],
|
||
through [deferred_key] beside [key_fns] — are the other kind, and they
|
||
are here on a different argument. They *can* fail at a concrete type,
|
||
so deferring them does move a refusal. But [{:where (hashable? $t)}] is
|
||
in the signature, and it is the author's own written requirement: an
|
||
instantiation at a non-hashable type is refused against that clause, by
|
||
name, at the call that asked for the type. That is a refusal the caller
|
||
can act on and one the generic's author chose to be responsible for —
|
||
categorically different from an unconstrained [(+ a b)] failing deep in
|
||
a body with no signature to blame, which is the case the abstract pass
|
||
exists to prevent and which stays refused at the definition. A generic
|
||
that does *not* declare the predicate gets no deferral: [deferred_key]
|
||
checks first, and [map_type] has usually refused the signature already.
|
||
|
||
So the rule for adding to this list is not a headcount. It is: either
|
||
the operation cannot fail after substituting, or a declared predicate
|
||
gives its failure a place to land. Anything else is answered here.
|
||
|
||
The node produced here is a unit no-op, thrown away with the rest of
|
||
the abstract pass. The real printer is selected when the copy is
|
||
checked with [t] concrete. One generic argument defers the whole call:
|
||
the printers for its neighbours would be re-selected at instantiation
|
||
anyway, so building them here would be work thrown away twice. *)
|
||
if List.exists (fun a -> generic_ty a.Tast.ty) checked then
|
||
mk loc Types.Unit Tast.Unit
|
||
else
|
||
let bslice = Types.Slice (Types.Int Types.U8) in
|
||
let write x = mk loc Types.Unit (Tast.Prim (Tast.WriteStdout, [ x ])) in
|
||
(* One frame slot per conversion the printer emits, which is what
|
||
[to_bytes] is for. The printer writes each number out before making the
|
||
next, so a shared buffer would in fact have served it — but the slot is
|
||
what the node now carries, and a printer that assembled its own buffer
|
||
would be a second answer to the same question. [escape] is the one that
|
||
still renders into a static: it is reachable from nowhere but here, and
|
||
its 1KB buffer per printed string field is a frame cost with no bug
|
||
behind it. Said here so the asymmetry is a decision and not an
|
||
oversight. *)
|
||
let conv pr x = to_bytes ctx loc pr x in
|
||
let emitter : Render.emitter =
|
||
{ Render.ebytes = write;
|
||
estr = (fun x -> write (mk loc bslice
|
||
(Tast.Prim (Tast.EscapeBytes, [ x ]))));
|
||
ei64 = (fun x -> write (conv Tast.I64ToBytes x));
|
||
eu64 = (fun x -> write (conv Tast.U64ToBytes x));
|
||
ef64 = (fun x -> write (conv Tast.F64ToBytes x)) }
|
||
in
|
||
let rc =
|
||
{ Render.structs =
|
||
Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.structs [];
|
||
datas = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.datas [];
|
||
unions = Hashtbl.fold (fun _ v acc -> v :: acc) ctx.env.unions [];
|
||
enums = Hashtbl.fold (fun k v acc -> (k, v) :: acc) ctx.env.enums [];
|
||
emit = emitter;
|
||
(* [println] never follows a pointer, and the allocation registry does
|
||
not change that. spec-memory.md fixes what it prints — "Ptr and
|
||
Handle print their address or identity rather than recursively
|
||
dereferencing" — and a printed line belongs to the program, so it
|
||
must read the same in a release build, where there is no registry to
|
||
ask. Following one is the *inspector's* move, and session.ml is
|
||
where that context is built. *)
|
||
ptrs = None;
|
||
alloc = (fun ty -> fresh_slot ctx ty) }
|
||
in
|
||
let render_one a =
|
||
match a.Tast.ty with
|
||
| Types.String | Types.Slice (Types.Int Types.U8) ->
|
||
[ write (mk loc bslice (Tast.Prim (Tast.Bytes, [ a ]))) ]
|
||
| _ -> Render.render rc 0 a
|
||
in
|
||
(* Built fresh per use rather than shared: nothing else in this file puts
|
||
one node in two places of a tree, and a pass that hangs state off a
|
||
node would be entitled to assume it appears once. *)
|
||
let space () =
|
||
write (mk loc bslice
|
||
(Tast.Prim (Tast.Bytes, [ mk loc Types.String (Tast.Str " ") ])))
|
||
in
|
||
let parts =
|
||
match checked with
|
||
| [] -> []
|
||
| first :: rest ->
|
||
render_one first
|
||
@ List.concat_map (fun a -> space () :: render_one a) rest
|
||
in
|
||
let nl =
|
||
if String.equal name "println" then
|
||
[ write
|
||
(mk loc bslice
|
||
(Tast.Prim (Tast.Bytes, [ mk loc Types.String (Tast.Str "\n") ])))
|
||
]
|
||
else []
|
||
in
|
||
expect ctx loc ~want (mk loc Types.Unit (Tast.Do (parts @ nl)))
|
||
| "exit" ->
|
||
arity ctx loc name 1 args;
|
||
prim Tast.Exit Types.Never [ check ctx ~want:index_ty (List.hd args) ]
|
||
| "argv" ->
|
||
arity ctx loc name 0 args;
|
||
prim Tast.Argv (Types.Slice Types.String) []
|
||
|
||
(* ── casts: (i32 x), (f64 x), and an enum both ways ────────────────
|
||
|
||
(i32 e) and (GamepadAxis n) are written here rather than in an arm of
|
||
their own because they are the same operation: an enum is an i32 at run
|
||
time — Types.Enum says so — and emit.ml's [cast] already reduces one to
|
||
its i32 before choosing an instruction. So both directions cost nothing:
|
||
src and target are equal after that reduction and [cast] answers the
|
||
value unchanged.
|
||
|
||
Why this does not give the typo back. The property worth keeping is that
|
||
:spcae at a call site is an error at that site, and it still is: a
|
||
keyword resolves against the parameter's enum and a bare integer does not
|
||
fit one. What changes is only that a program can *say* it means the
|
||
conversion, by name, at the site. The rule was never "an integer is
|
||
dangerous", it was "an integer must not arrive silently", and a written
|
||
(GamepadAxis i) is not silent.
|
||
|
||
Three sub-decisions:
|
||
|
||
1. enum → any numeric is always allowed and never checked. It is lossless
|
||
to i32 by construction, and a narrower target truncates by the same
|
||
rule every int→int cast already follows — no special case, and (f32 e)
|
||
means (f32 (i32 e)) rather than an arbitrary refusal.
|
||
|
||
2. integer → enum accepts a value that is not a declared member. raylib's
|
||
gesture is a bitfield and an OR of flags is a legal Gesture that is no
|
||
single member, so refusing it would refuse correct programs; and
|
||
session.ml's printer already falls through to the number for an
|
||
out-of-range enum, on purpose, so refusing to *construct* one while
|
||
blessing its display would be incoherent. An Option would make every
|
||
site unwrap for no safety bought, and a literal-only refusal would
|
||
catch nothing — the bitfield case is a run-time value.
|
||
|
||
3. Only an integer converts *to* an enum. Not a float, which has no
|
||
meaning here, and not another enum: an enum-to-enum hop goes through
|
||
(i32 x) so that both ends are written down. *)
|
||
| _ when Hashtbl.mem ctx.env.enums name ->
|
||
arity ctx loc name 1 args;
|
||
let target = resolve_name ctx.env ~seen:[] loc name in
|
||
let a = check ctx (List.hd args) in
|
||
(match a.Tast.ty with
|
||
| Types.Int _ -> ()
|
||
| other ->
|
||
fail loc "%s converts an integer to an enum, found %s — an enum or a \
|
||
float goes through (i32 x) first" name
|
||
(Types.to_string other));
|
||
prim (Tast.Cast target) target [ a ]
|
||
(* A cast to a *type variable*: [(t x)] inside a generic body. The name is
|
||
not one [is_cast] knows, because [is_cast] asks whether the name is a
|
||
machine type and [t] is not — so this is its own arm, above the ordinary
|
||
one and below the enums, and it reaches the same [Cast] prim.
|
||
|
||
Inside an instantiation [resolve_name] has already answered with the
|
||
concrete target, so the copy casts to a real type and the emitter sees
|
||
nothing unusual. During the abstract pass the target is [Var t] and the
|
||
[where] clause is what says the cast means anything at all: a cast
|
||
produces a number, so [numeric?] is what admits it. *)
|
||
| _ when (List.mem name ctx.env.tyvars || List.mem_assoc name ctx.env.subst)
|
||
&& List.length args = 1 ->
|
||
let target = resolve_name ctx.env ~seen:[] loc name in
|
||
unconstrained ctx.env loc ("a cast to " ^ name) ~needs:"numeric?" target;
|
||
let a = check ctx (List.hd args) in
|
||
(match a.Tast.ty with
|
||
| Types.Enum _ -> ()
|
||
| t when Types.is_numeric t || generic_ty t -> ()
|
||
| t -> fail loc "%s converts a number, found %s" name (Types.to_string t));
|
||
prim (Tast.Cast target) target [ a ]
|
||
| _ when is_cast name && List.length args = 1 ->
|
||
let target = resolve_name ctx.env ~seen:[] loc name in
|
||
let a = check ctx (List.hd args) in
|
||
(match a.Tast.ty with
|
||
| Types.Enum _ -> ()
|
||
(* A dyn opens here — [cast_dyn], FIX.org 2026-09-20. Only on this arm:
|
||
the generic one above casts to a type *variable*, whose [where] clause
|
||
says the operand is numeric, and a dyn is not what a [numeric?] bound
|
||
admits. *)
|
||
| Types.Dyn -> ()
|
||
| t when Types.is_numeric t -> ()
|
||
| t -> fail loc "%s converts a number, found %s" name (Types.to_string t));
|
||
(match a.Tast.ty with
|
||
| Types.Dyn -> cast_dyn ctx loc target a
|
||
| _ -> prim (Tast.Cast target) target [ a ])
|
||
|
||
(* ── ordinary calls ────────────────────────────────────────────── *)
|
||
| _ -> ordinary_call ctx ~want loc name args
|
||
|
||
(* Everything that is not a builtin arm: a local of function type, a generic
|
||
signature, the function table, and the refusals for a name that is none of
|
||
them. Reached two ways — by falling past every arm above, and by the
|
||
shadowing guard at the very top of [named_call], which sends a call whose
|
||
name the program has defined straight here. One function so that both
|
||
routes resolve a name by exactly the same rules. *)
|
||
and ordinary_call ctx ~want loc name args =
|
||
match () with
|
||
(* A local or a parameter holding a function value, called by the name it is
|
||
bound to — which is what the body of [map] looks like. It is checked
|
||
before the global function table: a binding shadows a defn of the same
|
||
name (one namespace, ordinary lexical scoping). A local of any *other*
|
||
type falls through to the table, so a program that shadows a function
|
||
name with an i32 and then calls the function still means the function. *)
|
||
| _ when (match lookup ctx name with
|
||
| Some b -> (match b.bty with Types.Fn _ -> true | _ -> false)
|
||
| None -> false) ->
|
||
(* The binding the guard already found, read directly. Going back through
|
||
[check] would repeat the lookup and walk the capture path for a type
|
||
that is not capturable. *)
|
||
(match lookup ctx name with
|
||
| Some b -> call_value ctx ~want loc (mk loc b.bty (Tast.Local b.slot)) args
|
||
| None -> assert false)
|
||
| _ when Hashtbl.mem ctx.env.gsigs name ->
|
||
let vars, params, ret = Hashtbl.find ctx.env.gsigs name in
|
||
generic_call ctx ~want loc name vars params ret args
|
||
| _ ->
|
||
match Hashtbl.find_opt ctx.env.fns name with
|
||
| Some (params, ret) ->
|
||
if List.length args <> List.length params then
|
||
fail loc "%s takes %d argument%s, given %d" name
|
||
(List.length params)
|
||
(if List.length params = 1 then "" else "s")
|
||
(List.length args);
|
||
let i = ref (-1) in
|
||
let args =
|
||
map2_lr (fun p a -> incr i; check_arg ctx name !i p a) params args
|
||
in
|
||
expect ctx loc ~want (mk loc ret (Tast.Call (name, args)))
|
||
| None ->
|
||
if Hashtbl.mem ctx.env.datas name then
|
||
fail loc
|
||
"%s is a data type — a data type value names the case too, as \
|
||
(%s.%s {.field value ...})"
|
||
name name (first_case_name ctx.env name)
|
||
else if Hashtbl.mem ctx.env.cases name then
|
||
(* [(U.C)] and [(C)]: a case written as a call. Both are how someone
|
||
reaches for a constructor, and neither is one. *)
|
||
let dname, c = Hashtbl.find ctx.env.cases name in
|
||
fail loc
|
||
"%s is a case of the data type %s — write (%s.%s {.field value ...}), \
|
||
or %s.%s on its own when it has no fields"
|
||
name dname dname c.Tast.vname dname c.Tast.vname
|
||
else if Hashtbl.mem ctx.env.structs name then
|
||
positional_struct ctx ~want loc name args
|
||
else if String.contains name '/' then
|
||
unimplemented loc
|
||
(Printf.sprintf "the call %s into an imported package" name) 4
|
||
else
|
||
(* The did-you-mean comes first, and for a capitalised head it is asked
|
||
of the *type* tables as well: [(Piont 1 2)] with [Point] declared is
|
||
a typo, and the generics sentence below would be a confident answer
|
||
about a feature nobody was reaching for. Only a capitalised head
|
||
consults the types — a lowercase name written where a value goes
|
||
was not a mistyped struct, which is [value_candidates]' whole
|
||
point. *)
|
||
let capitalised =
|
||
name <> "" && name.[0] = Char.uppercase_ascii name.[0]
|
||
&& name.[0] <> Char.lowercase_ascii name.[0]
|
||
in
|
||
let guess =
|
||
match nearest (!builtin_names @ value_candidates ctx) name with
|
||
| Some _ as m -> m
|
||
| None -> if capitalised then near_miss ctx.env name else None
|
||
in
|
||
match guess with
|
||
| Some m ->
|
||
Loc.failk "check/unknown-function" loc
|
||
"unknown function %s — did you mean %s?" name m
|
||
| None ->
|
||
if args <> [] && capitalised then
|
||
(* [(defonce p (Pair i32))]. A capitalised head with arguments and
|
||
no near miss anywhere is somebody reaching for a parameterised
|
||
type, which is what the type resolver says about [(Pair i32)]
|
||
when the same text lands in a type position. Before defonce took
|
||
either reading, that is the message this text got; it says the
|
||
same thing here so the answer does not depend on which side of
|
||
the fork the form fell down. *)
|
||
Loc.failk "check/unknown-function" loc
|
||
"unknown function %s. A capitalised name is a type, and a type \
|
||
given type arguments — (%s ...) — is a generic type, which is \
|
||
not there yet. A generic *function* is: it is written with \
|
||
[$t] in its parameter vector and copied per call site"
|
||
name name
|
||
else Loc.failk "check/unknown-function" loc "unknown function %s" name
|
||
|
||
(* Does the program's own definition of this name take this call over?
|
||
Two questions, in this order, and the order is what makes the guard cheap
|
||
enough to be the first arm of the dispatch.
|
||
|
||
Is the name a builtin's at all. One lookup in [builtin_set], false for
|
||
every call to an ordinary function — which is most calls in most programs
|
||
— and the question that stops the second from being asked at all. Asking
|
||
it first also keeps the arms that are not calls — an enum cast, a cast to
|
||
a type variable, a machine-type cast — exactly where they were, since a
|
||
name that reaches one of those is not a builtin's either.
|
||
|
||
Is there a definition of it that reaches this call: a local of function
|
||
type, or a defn — ordinary or generic — written in this same file.
|
||
|
||
And is the definition visible here, which is asked of the two files: the
|
||
one the definition was written in and the one this call is written in. A
|
||
definition shadows the builtin through its own file and no further, which
|
||
is the same visibility a defn has everywhere else — the prelude is the
|
||
language's own source and means the builtin wherever it writes one, and an
|
||
imported package keeps the builtin it was written against no matter what
|
||
the program importing it decides to call [get].
|
||
|
||
The file and not the enclosing function's name. A package's functions are
|
||
qualified at the import ([rl/get]), so asking whether the owner's name
|
||
carries a slash answers correctly everywhere a call sits inside a
|
||
function — and wrongly in the one place a call does not: a package's
|
||
global initialiser, which is checked with no owner at all. An importer
|
||
defining [len] reached inside an imported [(defonce sz i32 (len "abcd"))]
|
||
and changed what it computed. The files were never wrong about it.
|
||
|
||
What it costs is the REPL: an expression evaluated with no file behind it
|
||
is not the file the defn was written in, so it reaches the builtin. That
|
||
is the conservative direction, and C-c C-c — which sends the buffer's own
|
||
path — is not affected. *)
|
||
and shadows_builtin ctx loc name =
|
||
(* Where the definition was written, if this name has one. A generic is in
|
||
[generics] and nowhere near [fn_locs], so both tables are asked. *)
|
||
let declared_in () =
|
||
match Hashtbl.find_opt ctx.env.fn_locs name with
|
||
| Some at -> Some at.Loc.file
|
||
| None ->
|
||
(match Hashtbl.find_opt ctx.env.generics name with
|
||
| Some fn -> Some fn.Ast.nloc.Loc.file
|
||
| None -> None)
|
||
in
|
||
(* A local of function type is lexical: it cannot be in scope anywhere but
|
||
the file that bound it, so there is no file to compare. *)
|
||
let local_fn () =
|
||
match lookup ctx name with
|
||
| Some b -> (match b.bty with Types.Fn _ -> true | _ -> false)
|
||
| None -> false
|
||
in
|
||
Hashtbl.mem builtin_set name
|
||
&& (local_fn ()
|
||
|| (match declared_in () with
|
||
| Some file -> String.equal file loc.Loc.file
|
||
| None -> false))
|
||
|
||
(* ── A call to a generic function ───────────────────────────────────────
|
||
The whole of instantiation, and it is at the call site because the call
|
||
site is the only place the concrete types exist. Odin does the same thing
|
||
in the same place: [check_expr.cpp]'s
|
||
[find_or_generate_polymorphic_procedure] runs from call checking, builds
|
||
the concrete proc type from the operands, scans the base entity's
|
||
[gen_procs] for an [are_types_identical] match, and generates a new
|
||
[Entity] only on a miss. *)
|
||
and generic_call ctx ~want loc name vars pats pret args =
|
||
if List.length args <> List.length pats then
|
||
fail loc "%s takes %d argument%s, given %d" name (List.length pats)
|
||
(if List.length pats = 1 then "" else "s") (List.length args);
|
||
(* Arguments first, and with no expectation where the parameter's type still
|
||
mentions a variable — there is nothing to expect until the argument has
|
||
said what it is. So an untyped literal falls to its own default and
|
||
[(id 3)] instantiates at i32, which is the one place inference at a
|
||
generic call site is weaker than at a monomorphic one.
|
||
|
||
A variable already bound by an earlier argument is substituted back into
|
||
the parameters still to come, so [(sort-by (slice ns 0 4) (fn [a b] (< a
|
||
b)))] works: by the time the [fn] is reached, [(Fn [$t $t] bool)] has
|
||
become [(Fn [i32 i32] bool)] and the literal has the position it needs to
|
||
take its types from. Left to right, which is the order Odin's operands
|
||
are gathered in and the order [map2_lr] already guarantees. *)
|
||
let subst = ref [] in
|
||
(* Does the signature bind [v] anywhere *inside* a type — [[$t]],
|
||
[(Fn [$t $t] bool)], [(Vec $t)]? A bare [$t] parameter is a scalar the
|
||
join below may move; a variable reached through a constructor is bound
|
||
exactly, because a container's elements cannot be rewritten and a
|
||
function value's type is its own. One scan, answered per variable. *)
|
||
let rec mentions v (t : Types.t) =
|
||
match t with
|
||
| Types.Var u -> String.equal u v
|
||
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
|
||
| Types.Option e -> mentions v e
|
||
| Types.Map (k, w) -> mentions v k || mentions v w
|
||
| Types.Fn (ps, r) -> List.exists (mentions v) ps || mentions v r
|
||
| _ -> false
|
||
in
|
||
let bound_exactly v =
|
||
List.exists
|
||
(fun (p : Types.t) ->
|
||
match p with Types.Var _ -> false | t -> mentions v t)
|
||
pats
|
||
in
|
||
(* Pairs that met no join while the arguments were walked. They are not
|
||
refused on the spot because a *later* argument can still settle them:
|
||
(f u32-x i32-y i64-z) has no join at the second argument and a perfectly
|
||
good one — i64, which both widen into — at the third. Each entry is
|
||
re-asked against the final binding below, so acceptance cannot depend on
|
||
the order the arguments were written in. *)
|
||
let pending = ref [] in
|
||
let targs =
|
||
map2_lr
|
||
(fun pat a ->
|
||
let p = subst_ty !subst pat in
|
||
(* Which variable, if any, this parameter *is* — written as a bare
|
||
[$t] and already bound by an argument to the left. That is the one
|
||
shape implicit widening can reach, because [Types.widens_to] admits
|
||
only numeric scalars: a variable bound inside [[$t]] or
|
||
[(Fn [$t $t] bool)] leaves a parameter no widening applies to, so
|
||
the [sort-by] path below is untouched by construction. *)
|
||
let bound_scalar =
|
||
match pat with
|
||
| Types.Var v when (not (generic_ty p)) && Types.is_numeric p ->
|
||
Some v
|
||
| _ -> None
|
||
in
|
||
(* An untyped constant has no type of its own to keep, so it still
|
||
takes the variable's — [(clamp-to y 0 10)] with [y] an i64 means
|
||
three i64s and there is no conversion anywhere in it. Everything
|
||
else is checked on its own terms. *)
|
||
let untyped_literal =
|
||
match a.Ast.e with
|
||
| Ast.Int _ | Ast.Float _ | Ast.Byte _ -> true
|
||
| _ -> false
|
||
in
|
||
let a =
|
||
if generic_ty p then check ctx a
|
||
else if bound_scalar <> None && not untyped_literal then
|
||
(* On its own terms first. A form that has no type without a want
|
||
— [(zeroed)] is the one that matters — refuses here and is
|
||
checked against the parameter as it always was; the trial
|
||
leaves no trace of the attempt. *)
|
||
(match trial ctx (fun () -> check ctx a) with
|
||
| Ok r -> r
|
||
| Error _ -> check ctx ~want:p a)
|
||
else check ctx ~want:p a
|
||
in
|
||
(* **Mixed widths at one variable join at the wider type.** The rule
|
||
used to refuse the pair both ways — FIX.org, "Generics and
|
||
implicit widening", recorded the join as the coherent alternative
|
||
and refusing as the direction that could be walked back. It was
|
||
walked back on 2026-09-20, by the author: a numeric argument at a
|
||
variable an earlier argument already bound resolves the variable
|
||
to whichever of the pair the other widens into, value-preserving
|
||
widening only, so [(eq2? (i8 3) (i64 3))] and its reverse are one
|
||
copy at i64. A pair with no join — u64 against i64 — is still
|
||
refused: there is no type that holds every value of both, and
|
||
inventing one would be picking a type neither argument was
|
||
written at.
|
||
|
||
Only where the variable is bound by bare scalars. A variable the
|
||
signature also reaches through a container is bound exactly —
|
||
a slice's elements cannot be rewritten to a wider width — so
|
||
those keep the refusal, in their own words. And only where the
|
||
pair is one widening has an opinion about: a string where $t was
|
||
bound to i64 is an ordinary mismatch and gets the ordinary
|
||
refusal below. *)
|
||
let handled =
|
||
match bound_scalar with
|
||
| Some v
|
||
when (not (Types.equal p a.Tast.ty))
|
||
&& Types.is_numeric a.Tast.ty ->
|
||
(match Types.join p a.Tast.ty with
|
||
| Some j when Types.equal j p ->
|
||
(* This argument widens into the binding; the wrap happens
|
||
with the others, once the binding is final. *)
|
||
true
|
||
| Some j when not (bound_exactly v) ->
|
||
subst := (v, j) :: List.remove_assoc v !subst;
|
||
true
|
||
| Some _ ->
|
||
Loc.failk "check/tyvar-no-widening" a.Tast.loc
|
||
"%s's $%s was bound to %s by an earlier argument, and this \
|
||
one is %s. The signature also binds $%s inside a \
|
||
container or function type, which binds its element \
|
||
exactly — the pair cannot join at the wider type there. \
|
||
Write the conversion — (%s x) — or pass the arguments at \
|
||
one type"
|
||
name v (Types.to_string p) (Types.to_string a.Tast.ty) v
|
||
(Types.to_string p)
|
||
| None ->
|
||
pending := (v, p, a.Tast.ty, a.Tast.loc) :: !pending;
|
||
true)
|
||
| _ -> false
|
||
in
|
||
if (not handled) && not (bind_ty subst p a.Tast.ty) then
|
||
fail a.Tast.loc "%s expects %s here, found %s" name
|
||
(Types.to_string p) (Types.to_string a.Tast.ty);
|
||
a)
|
||
pats args
|
||
in
|
||
(* Every variable has to be determined by an argument. A return-only
|
||
variable has nothing to bind it — there is no explicit instantiation
|
||
syntax by design (plan.org) — so it is refused here, where the signature
|
||
can be named, rather than producing a copy with a hole in it. *)
|
||
List.iter
|
||
(fun v ->
|
||
if not (List.mem_assoc v !subst) then
|
||
fail loc
|
||
"%s's type variable $%s is not determined by any argument — a \
|
||
generic function is instantiated from its call site, and there is \
|
||
no syntax for naming the type" name v)
|
||
vars;
|
||
(* The pairs that met no join, re-asked now that every argument has spoken.
|
||
A later, wider argument dissolves one — u32 and i32 both widen into an
|
||
i64 that arrived third — and one still standing is the real refusal:
|
||
these two widths meet at no type. *)
|
||
List.iter
|
||
(fun (v, t1, t2, ploc) ->
|
||
let final = List.assoc v !subst in
|
||
let fits t =
|
||
Types.equal t final || Types.widens_to ~from:t ~into:final
|
||
in
|
||
if not (fits t1 && fits t2) then
|
||
Loc.failk "check/tyvar-no-join" ploc
|
||
"this call binds %s's $%s to both %s and %s, and the two meet at \
|
||
no type: implicit widening only ever widens — every value kept, \
|
||
no sign lost — and neither of these holds every value of the \
|
||
other. Write the conversion you mean at one of the arguments, or \
|
||
pass them at one type"
|
||
name v (Types.to_string t1) (Types.to_string t2))
|
||
!pending;
|
||
(* The binding is final; the arguments it out-widened catch up. Only a bare
|
||
[$t] parameter can be here — [bound_exactly] kept every container-bound
|
||
variable at one exact type — and the cast is the same node the written
|
||
conversion would have built. *)
|
||
let targs =
|
||
map2_lr
|
||
(fun (pat : Types.t) a ->
|
||
match pat with
|
||
| Types.Var v ->
|
||
(match List.assoc_opt v !subst with
|
||
| Some f
|
||
when (not (Types.equal f a.Tast.ty))
|
||
&& Types.is_numeric a.Tast.ty
|
||
&& Types.widens_to ~from:a.Tast.ty ~into:f ->
|
||
widen a.Tast.loc f a
|
||
| _ -> a)
|
||
| _ -> a)
|
||
pats targs
|
||
in
|
||
(* **A type variable is not instantiated at dyn.** Nothing stopped it before:
|
||
[dyn] is an ordinary case of [Types.t], so it substituted like any other
|
||
type and a copy was generated at it. The copy then reached whatever the
|
||
body did with the value, and the dyn answers are not all there — [(Option
|
||
dyn)] has no descriptor the collector can find, [as-slice] over a
|
||
[(Vec dyn)] refuses. So the refusal existed, it just arrived from inside
|
||
the generic's own source: [(or-else (Some d) e)] over two dyns is reported
|
||
against [<prelude>:385], a line the caller did not write and cannot act
|
||
on. Every one of those is this refusal arriving late and in the wrong
|
||
place.
|
||
|
||
Refusing at the binding is also the honest statement of the split. Two
|
||
models answer "one body, many types" here and they are not rivals: this
|
||
one instantiates at compile time and keeps the types, and [defgeneric] /
|
||
[defmulti] dispatch at run time on a value that carries its own. A dyn
|
||
argument is asking the second question of the first machinery. The
|
||
message says so and names the other spelling.
|
||
|
||
Bounded variables were already refused — [pred_holds] says no to dyn for
|
||
all four predicates — so this closes the unbounded half, which is exactly
|
||
the half that reached the prelude-source diagnostics. A variable that
|
||
*does* carry a clause is left to that refusal deliberately: it names the
|
||
predicate the signature actually wrote down, which is the more specific
|
||
answer of the two, and the generic cast's pin depends on it. *)
|
||
let clause_on v =
|
||
match Hashtbl.find_opt ctx.env.generics name with
|
||
| None -> false
|
||
| Some gfn ->
|
||
List.exists
|
||
(fun (p : Ast.pred) -> String.equal p.Ast.pvar v) gfn.Ast.fwhere
|
||
in
|
||
List.iter
|
||
(fun (v, t) ->
|
||
if reaches_dyn t && not (clause_on v) then
|
||
Loc.failk "check/tyvar-at-dyn" loc
|
||
"this call would instantiate %s at $%s = %s, and a type variable \
|
||
is not instantiated at dyn: a copy is made per *written* type, \
|
||
and dyn is the one type whose own type is not known until it \
|
||
runs. One value, two models — a defgeneric with a defmethod per \
|
||
class dispatches on what the value turns out to be, which is the \
|
||
question a dyn argument is asking. Write the type the value has, \
|
||
or reach for the dyn side"
|
||
name v (Types.to_string t))
|
||
!subst;
|
||
let cparams = List.map (subst_ty !subst) pats in
|
||
let cret = subst_ty !subst pret in
|
||
if List.exists generic_ty cparams || generic_ty cret then begin
|
||
(* One generic function calling another at its *own* variable, seen from
|
||
the abstract pass over the caller's body — [sort-by] calling [swap]
|
||
at [t]. There is no copy to make yet: [t] is not a type. The node is
|
||
built so the call still type-checks and is thrown away with the rest of
|
||
the abstract pass; the real copy is generated when the caller is
|
||
instantiated and the same call site resolves [t] to a concrete type.
|
||
|
||
But the callee's [where] clause is answerable here, and has to be. The
|
||
whole promise of the abstract pass is that a generic's refusals arrive
|
||
at its definition; if the predicate were left to the instantiation,
|
||
[(defn f [x $t] () (sort [x]))] would be accepted at its definition
|
||
and refused at whichever call site first instantiated it — a refusal in
|
||
code the caller did not write, which is the thing the pass exists to
|
||
avoid. So the caller has to declare at least what the callee asks for,
|
||
and [pred_entails] means [ordered?] covers a callee wanting
|
||
[equal?] without anyone writing both. *)
|
||
(match Hashtbl.find_opt ctx.env.generics name with
|
||
| None -> ()
|
||
| Some gfn ->
|
||
List.iter
|
||
(fun (p : Ast.pred) ->
|
||
match List.assoc_opt p.Ast.pvar !subst with
|
||
| Some (Types.Var v) when not (declares ctx.env.tvpreds v p.Ast.pname) ->
|
||
Loc.failk "check/predicate-not-carried" loc
|
||
"%s is written {:where (%s $%s)}, and this call passes the \
|
||
type variable %s, which nothing here declares %s. Add \
|
||
{:where (%s $%s)} to this function's own clause — a \
|
||
predicate a body relies on has to be carried by every \
|
||
signature between it and the call site"
|
||
name p.Ast.pname p.Ast.pvar v p.Ast.pname p.Ast.pname v
|
||
| Some t when not (generic_ty t) && not (pred_holds p.Ast.pname t) ->
|
||
Loc.failk "check/predicate-unsatisfied" loc
|
||
"%s is written {:where (%s $%s)}, and this call passes %s, \
|
||
which is not %s"
|
||
name p.Ast.pname p.Ast.pvar (Types.to_string t) p.Ast.pname
|
||
| _ -> ())
|
||
gfn.Ast.fwhere);
|
||
expect ctx loc ~want (mk loc cret (Tast.Call (name, targs)))
|
||
end
|
||
else
|
||
let sym = instantiate ctx.env loc name vars !subst cparams cret in
|
||
expect ctx loc ~want (mk loc cret (Tast.Call (sym, targs)))
|
||
|
||
(* Cache or generate, Odin's loop. The key is the whole concrete signature
|
||
compared pairwise with [Types.equal] — [are_types_identical] — so calling
|
||
at the same type twice makes one copy. *)
|
||
and instantiate env loc gname vars subst cparams cret =
|
||
let cache =
|
||
match Hashtbl.find_opt env.insts gname with
|
||
| Some r -> r
|
||
| None -> let r = ref [] in Hashtbl.replace env.insts gname r; r
|
||
in
|
||
let same (ps, r, _) =
|
||
List.length ps = List.length cparams
|
||
&& List.for_all2 Types.equal ps cparams && Types.equal r cret
|
||
in
|
||
match List.find_opt same !cache with
|
||
| Some (_, _, sym) -> sym
|
||
| None ->
|
||
let sym =
|
||
gname ^ "-"
|
||
^ String.concat "-" (List.map (fun v -> mangle_ty (List.assoc v subst)) vars)
|
||
in
|
||
(* Each instantiation checks the concrete types answer the [where] clause.
|
||
This is the half of the feature that only exists per copy: the abstract
|
||
pass took the predicates on trust, and here is where the trust is
|
||
settled, at the call site that asked, naming it.
|
||
|
||
Before the name-collision check, on purpose. The prelude keeps a
|
||
per-width family beside a generic where the generic's bound refuses
|
||
some widths — [abs] under [integer?] beside the declared [abs-f32] and
|
||
[abs-f64] — so a float caller of [abs] computes the sym [abs-f64], and
|
||
"abs-f64 is already defined, rename one of them" is the wrong sentence
|
||
for what went wrong: the bound refused the type, and that is the
|
||
message with the fix in it. *)
|
||
let fn = Hashtbl.find env.generics gname in
|
||
List.iter
|
||
(fun (p : Ast.pred) ->
|
||
match List.assoc_opt p.Ast.pvar subst with
|
||
| None -> ()
|
||
| Some t ->
|
||
if not (pred_holds p.Ast.pname t) then
|
||
Loc.failk "check/predicate-unsatisfied" loc
|
||
"this call instantiates %s at $%s = %s, and %s does not \
|
||
answer %s — which %s requires, being written {:where (%s \
|
||
$%s)}. The requirement is the signature's, so the refusal is \
|
||
here, at the call that asked for the type: pass one the \
|
||
predicate admits"
|
||
gname p.Ast.pvar (Types.to_string t) (Types.to_string t)
|
||
p.Ast.pname gname p.Ast.pname p.Ast.pvar)
|
||
fn.Ast.fwhere;
|
||
if Hashtbl.mem env.fns sym then
|
||
fail loc
|
||
"%s at these types is called %s, and %s is already defined — rename \
|
||
one of them" gname sym sym;
|
||
runaway env loc gname cparams;
|
||
(* The entry goes in *before* the body is checked, which is what makes a
|
||
recursive generic function terminate: the call to itself at the same
|
||
types finds this and does not generate a second copy. *)
|
||
cache := (cparams, cret, sym) :: !cache;
|
||
Hashtbl.replace env.fns sym (cparams, cret);
|
||
let saved_subst = env.subst and saved_vars = env.tyvars
|
||
and saved_preds = env.tvpreds and saved_chain = env.chain in
|
||
(* Inside the copy there are no variables left: [resolve_name] answers
|
||
[t] with the concrete type, so every node the body produces is as
|
||
concrete as one written out by hand. The [where] clause goes out of
|
||
scope with them — there is nothing abstract left for it to permit, and
|
||
every operator is answered by the concrete type it now has. *)
|
||
env.subst <- List.map (fun v -> (v, List.assoc v subst)) vars;
|
||
env.tyvars <- [];
|
||
env.tvpreds <- [];
|
||
env.chain <- env.chain @ [ (gname, cparams, loc) ];
|
||
let restore () =
|
||
env.subst <- saved_subst; env.tyvars <- saved_vars;
|
||
env.tvpreds <- saved_preds; env.chain <- saved_chain
|
||
in
|
||
let tfn =
|
||
match !check_fn_ref env { fn with Ast.name = sym } with
|
||
| tfn -> restore (); tfn
|
||
| exception e ->
|
||
restore ();
|
||
(* A copy whose body did not check is not a copy. Both entries go back
|
||
out, so a second call at the same types is the same refusal again
|
||
rather than a cache hit on a function that does not exist. *)
|
||
cache := List.filter (fun (_, _, s) -> s <> sym) !cache;
|
||
Hashtbl.remove env.fns sym;
|
||
raise e
|
||
in
|
||
env.instances <- tfn :: env.instances;
|
||
sym
|
||
|
||
and is_cast name =
|
||
Types.ikind_of_name name <> None || Types.fkind_of_name name <> None
|
||
|
||
and byte_slice ctx (a : Ast.expr) =
|
||
check ctx ~want:(Types.Slice (Types.Int Types.U8)) a
|
||
|
||
and numeric_want want =
|
||
match want with Some (Types.Int _ | Types.Float _) -> want | _ -> None
|
||
|
||
(* Both operands of a binary operator have one type, so one side has to decide
|
||
it. Check the side that carries the most information first: a non-literal
|
||
over a literal, and a float literal over an integer one, since an integer
|
||
constant converts to a float and not back.
|
||
|
||
Widening (FIX.org 2026-09-20) does not retire that rule, it finishes it.
|
||
Three things decide, in this order:
|
||
|
||
1. An expectation, if the site has one, and it reaches *both* operands. So
|
||
(defn f [] i64 (+ a b)) over two i32s widens each operand and adds at
|
||
i64, rather than adding at i32 and widening the sum. That is the better
|
||
of the two readings and it costs nothing to prefer it, because no program
|
||
that compiled before can reach it — the pair used to be a refusal.
|
||
2. A literal, exactly as before: it takes its width from the other operand,
|
||
so (+ x 1) over a u64 x is still u64 arithmetic and (let [h fnv-offset])
|
||
over a u64 defconst still means what it meant. [needs_want] is what marks
|
||
the forms this applies to and it is untouched.
|
||
3. Otherwise the *wider* side decides — [Types.join], whichever operand the
|
||
other widens into, with a [Cast] put on the narrower one. (+ i32-var
|
||
i64-var) is an i64 add. Equal width across signedness has no join, by
|
||
construction: neither i32 nor u32 widens into the other, and the refusal
|
||
says which cast to write.
|
||
|
||
The mechanism for 3 is a *trial*: ask y for [a]'s type, and if that refusal
|
||
is the one widening was invented for, look again the other way round. Two
|
||
things have to be true for a trial to be honest, and both are below.
|
||
|
||
[trial] is the first. Checking is not a function of its argument — it
|
||
allocates frame slots and it opens scopes — so a check that is abandoned
|
||
has to leave no trace, and [scoped] cannot help: it restores the scope on
|
||
the way *out*, which an exception does not take. Without this a binding
|
||
from the abandoned pass outlives it, which is visible as a name that should
|
||
be unknown resolving anyway, and worse, as a shadow: the inner binding of
|
||
(let [t ...] ... (let [t ...] t) ... t) survives into the outer t's slot
|
||
with nothing ever stored in it. That is an uninitialised read, produced by
|
||
a program the compiler accepted.
|
||
|
||
[literal_at_want] is the second. A trial that refused because a *literal*
|
||
could not be built at the wanted type is not a pair of types that failed to
|
||
meet — the literal had no type of its own to bring — so looking again would
|
||
answer with the literal's default and quietly move (+ u8-thing 300) to i32.
|
||
Rule 2 above is not a description of the old language kept for continuity;
|
||
it is what the author decided, and the kind is how the trial obeys it. *)
|
||
and trial ctx f =
|
||
(* Everything a check writes into the context, put back if the check is
|
||
abandoned — and it is *everything* on purpose, not a chosen subset.
|
||
|
||
Picking the fields that looked like they mattered was tried twice and was
|
||
wrong twice. [scope] and the slot fields were the first round, found as an
|
||
uninitialised read. The second round was worse, because the symptom was
|
||
the other way up: a form that opens a window and closes it on the way out
|
||
— [check_frames] setting [in_frames], [loop] pushing onto [loops] — leaves
|
||
that window *open* when a trial inside it is abandoned, and then refuses
|
||
a perfectly good program.
|
||
|
||
(println (+ i32-x (handler-bind [] i64-y)))
|
||
(return 0)
|
||
|
||
compiled before this lane and was refused after it, with "return is not
|
||
allowed inside handler-bind yet" pointing at a line with no handler-bind
|
||
anywhere near it. A false refusal is not a lesser bug than a false accept;
|
||
it is just quieter about being one.
|
||
|
||
So the rule here is not judgement, it is the whole record. Three fields
|
||
would have self-healed anyway — [defer_ok] and [tail] are read and cleared
|
||
on entry to [check], [outer_what] is never written after the context is
|
||
built — and they are restored regardless, because "this one cannot
|
||
currently leak" is exactly the reasoning that produced two rounds of
|
||
leaks. The destructuring below is closed and warning 9 is turned on for
|
||
it, so a new field on [ctx] stops this function compiling until somebody
|
||
decides about it, rather than joining the list of things nobody noticed.
|
||
|
||
What is *not* restored, once, deliberately: [env.lifted] keeps whatever
|
||
function an abandoned trial lifted out of an [fn] literal. It is dead —
|
||
the names are [fn/<owner>/N] handed out by count, so the live pass gets
|
||
fresh ones and nothing refers to the orphan — and it rides along into the
|
||
module as a function nobody calls. Left alone because [env] is the
|
||
program's table and not this form's, and rewinding it would mean deciding
|
||
what else on [env] a trial may have touched.
|
||
|
||
The generic instantiation cache is the other table a trial reaches, and
|
||
it does not rewind either. [instantiate] rewinds a copy whose *body*
|
||
refused, which is a different event from a copy the caller abandoned —
|
||
and the abandoned one does not need rewinding. A generic call's
|
||
instantiation is read off its arguments and never off the ambient want:
|
||
an unbound variable is checked with no expectation at all, and a bound
|
||
one does not widen. So the trial and the live pass ask [instantiate] for
|
||
the same types, the second ask is a cache hit on the first, and exactly
|
||
one copy exists either way. Pinned in test_flan, "a generic inside an
|
||
abandoned trial".
|
||
|
||
Only [Loc.Error] is caught. A timeout or a stack overflow is not a
|
||
refusal to reconsider, and silently continuing past one would turn a
|
||
resource failure into a wrong answer. *)
|
||
let[@warning "+9"] { env = _; ret = _; slots; slot_tys; slot_names; scope;
|
||
defers; defer_slot; defer_ok; defer_block; outer = _;
|
||
outer_what; in_frames; loops; tail; in_defer;
|
||
owner = _ } = ctx in
|
||
match f () with
|
||
| r -> Ok r
|
||
| exception Loc.Error d ->
|
||
ctx.slots <- slots; ctx.slot_tys <- slot_tys;
|
||
ctx.slot_names <- slot_names; ctx.scope <- scope;
|
||
ctx.defers <- defers; ctx.defer_slot <- defer_slot;
|
||
ctx.defer_ok <- defer_ok; ctx.defer_block <- defer_block;
|
||
ctx.outer_what <- outer_what; ctx.in_frames <- in_frames;
|
||
ctx.loops <- loops; ctx.tail <- tail; ctx.in_defer <- in_defer;
|
||
Error d
|
||
|
||
(* Whether the trial's refusal is one worth reconsidering. A literal that did
|
||
not fit is not, and neither is a refusal a program cannot make any use of
|
||
having a second opinion on. *)
|
||
and reconsiderable (d : Loc.diag) = not (String.equal d.Loc.kind literal_at_want)
|
||
|
||
(* [a] was checked, y refused [a]'s type, and [b] is y on its own terms — held
|
||
by the caller when it has one, taken here when it does not. If the pair has
|
||
a join it can only be [b]'s type (had it been [a]'s, the trial would have
|
||
passed), so [a] is the operand that moves. *)
|
||
and join_widen (a : Tast.expr) (b : Tast.expr) =
|
||
match Types.join a.Tast.ty b.Tast.ty with
|
||
| Some t when not (Types.equal t a.Tast.ty) ->
|
||
Some (widen a.Tast.loc t a, widen b.Tast.loc t b)
|
||
| _ -> None
|
||
|
||
and join_pair ctx (a : Tast.expr) (y : Ast.expr) (d : Loc.diag) =
|
||
(* Check y on its own terms to find out whether it was simply the wider
|
||
operand. This trial is guarded too: if y cannot check without an
|
||
expectation at all — [None], [(zeroed)] — the original refusal is the one
|
||
reported, so no form loses the expectation it used to get. *)
|
||
match trial ctx (fun () -> check ctx y) with
|
||
| Error _ -> raise (Loc.Error d)
|
||
| Ok b ->
|
||
(match join_widen a b with
|
||
| Some pair -> pair
|
||
| None -> raise (Loc.Error d))
|
||
|
||
and binary ctx ?(dyn_ok = false) ?(join = true) name loc ~want args =
|
||
match args with
|
||
| [ x; y ] ->
|
||
let y_decides =
|
||
(is_literal x && not (is_literal y))
|
||
|| (match x.Ast.e, y.Ast.e with
|
||
| (Ast.Int _ | Ast.Byte _), Ast.Float _ -> true
|
||
| _ -> false)
|
||
in
|
||
(* A form that cannot be checked without being told what is wanted. A
|
||
literal takes its width from the expectation, and a keyword has no
|
||
meaning at all without one — [:lo] resolves against the enum the site
|
||
expects and there is no keyword type to fall back on. Everything else
|
||
checks on its own terms. *)
|
||
let needs_want (f : Ast.expr) =
|
||
is_literal f || (match f.Ast.e with Ast.Kw _ -> true | _ -> false)
|
||
in
|
||
if y_decides then begin
|
||
let b = check ctx ?want y in
|
||
let a = check ctx ~want:b.Tast.ty x in
|
||
a, b
|
||
end
|
||
(* [dyn_ok] is set by the operators that have a dyn lowering, and it exists
|
||
to stop the second operand being coerced to the first's type before
|
||
anybody has asked whether the pair is a dyn one.
|
||
|
||
Without it [(+ n x)] over an [i64] n and a dyn x threads [i64] into the
|
||
second check, [expect] does what an annotation site asked for and
|
||
unboxes, and the result is a *machine* add of a value the runtime was
|
||
never asked about: the program traps on a float instead of promoting,
|
||
and nothing in the source says why. The mirror image [(+ x n)] boxed
|
||
correctly, so the bug was visible only in one operand order.
|
||
|
||
Both sides are checked on their own terms here and the caller decides.
|
||
That is safe exactly when neither operand needs an expectation, which is
|
||
what [needs_want] settles — a literal still gets the first operand's
|
||
type, so [(+ x 1)] over a dyn x goes on building an i64 one. *)
|
||
else if dyn_ok && not (needs_want y) then begin
|
||
let a = check ctx ?want x in
|
||
let b = check ctx y in
|
||
(* Nothing dyn about this pair after all, so it is put back the way the
|
||
typed path built it. Re-checking only when the types actually differ
|
||
keeps the common case to one check of each operand. *)
|
||
if a.Tast.ty = Types.Dyn || b.Tast.ty = Types.Dyn
|
||
|| Types.equal a.Tast.ty b.Tast.ty
|
||
then a, b
|
||
(* Asking y for [a]'s type stays the first thing tried, and not only for
|
||
continuity: y was checked above with no expectation at all, and an
|
||
expectation is information. A sum of two products handed to an f32
|
||
function is the case — test/programs/math.flan does it — because every
|
||
literal inside those products defaults to f64 on its own terms, so
|
||
reading the join off the two unexpected halves would answer f64 for a
|
||
form the site asked to be f32. The re-check builds them at f32 as it
|
||
always did.
|
||
|
||
[b] is only used when that re-check refuses, which is the direction
|
||
[expect] cannot serve: a is the narrower operand and it is the one
|
||
that has to move. Nothing is checked a third time — the own-terms [b]
|
||
already in hand is the answer. *)
|
||
else
|
||
(match trial ctx (fun () -> check ctx ~want:a.Tast.ty y) with
|
||
| Ok b' -> a, b'
|
||
| Error d ->
|
||
(match
|
||
if join && reconsiderable d then join_widen a b else None
|
||
with
|
||
| Some pair -> pair
|
||
| None -> raise (Loc.Error d)))
|
||
end
|
||
else begin
|
||
let a = check ctx ?want x in
|
||
match trial ctx (fun () -> check ctx ~want:a.Tast.ty y) with
|
||
| Ok b -> a, b
|
||
| Error d ->
|
||
if join && reconsiderable d then join_pair ctx a y d
|
||
else raise (Loc.Error d)
|
||
end
|
||
| _ -> fail loc "%s takes two arguments" name
|
||
|
||
(* ── The builtins, said out loud ───────────────────────────────────────
|
||
A name, a signature and one line, for every name [named_call] and [var]
|
||
answer without the program having written it. The editor's C-c C-v used to
|
||
say "the running program defines no arena-new", which was true and useless:
|
||
a builtin is in the compiler, so it is in no program's symbol table and
|
||
[Dev.defs] had nothing to hand over. This is what it hands over instead.
|
||
|
||
It lives here rather than in dev.ml because it describes the arms above it,
|
||
and a table in another file drifts from them silently. [test_flan]'s
|
||
[builtin_table] reads this file and the arms and fails on either one having
|
||
a name the other does not, so the drift is a failing build rather than a
|
||
name that answers nothing.
|
||
|
||
The signature follows [Dev.signature_of_fn]'s shape — [name [params] ret] —
|
||
so eldoc reads a builtin the way it reads a defn. Where an arm does not
|
||
have one shape the signature says what is true rather than inventing one:
|
||
[?] marks an argument that may be left out, [|] separates the types an arm
|
||
really does accept, and the predicate names are the compiler's own
|
||
([numeric?], [ordered?], [equal?] — check.ml's [where] evaluator), because
|
||
a generic's author already writes them. Three names have no shape at all
|
||
and carry a bare name instead of a bracket list; their line says why.
|
||
|
||
One line each, because this is read in an echo area and a help buffer. The
|
||
lines are the arms' own reasons, cut down — where an arm argues for a
|
||
decision above itself, the sentence a person needs at the call site is the
|
||
conclusion, not the argument. *)
|
||
let builtins : (string * string * string) list =
|
||
[ (* arithmetic and comparison *)
|
||
("+", "+ [numeric? ...] numeric?",
|
||
"Sum, folded left over two or more operands. Two operands of different \
|
||
numeric types meet at the wider one when that cannot lose — i32 and i64 \
|
||
add at i64 — and i32 with u32 has no such type and is refused.");
|
||
("-", "- [numeric? ...] numeric?",
|
||
"Difference, folded left: (- a b c) is ((a - b) - c).");
|
||
("*", "* [numeric? ...] numeric?",
|
||
"Product, folded left over two or more operands of one numeric type.");
|
||
("/", "/ [numeric? ...] numeric?",
|
||
"Quotient, folded left. Integer division truncates toward zero.");
|
||
("%", "% [numeric? numeric?] numeric?",
|
||
"Remainder, and it stays at two operands: (% a b c) would mean \
|
||
(% (% a b) c), which is a thing nobody writes on purpose.");
|
||
("=", "= [equal? equal?] bool",
|
||
"Equality. It admits two types < does not: a handle, where \"the same \
|
||
entity\" is the question the type exists to answer, and a string, \
|
||
compared bytewise by content rather than ordered.");
|
||
("!=", "!= [equal? equal?] bool",
|
||
"Inequality, over everything = accepts.");
|
||
("<", "< [ordered? ordered?] bool",
|
||
"Less than. Machine numbers only: ordering a handle would order a \
|
||
free-list slot index, which means nothing.");
|
||
("<=", "<= [ordered? ordered?] bool", "Less than or equal.");
|
||
(">", "> [ordered? ordered?] bool", "Greater than.");
|
||
(">=", ">= [ordered? ordered?] bool", "Greater than or equal.");
|
||
("not", "not [bool] bool",
|
||
"Negates a bool. Nothing else in this language is a truth value.");
|
||
("bit-and", "bit-and [int ...] int",
|
||
"Bitwise and, folded left. Integers only; operands of different widths \
|
||
meet at the wider one, the way + does.");
|
||
("bit-or", "bit-or [int ...] int", "Bitwise or, folded left over integers.");
|
||
("bit-xor", "bit-xor [int ...] int",
|
||
"Bitwise exclusive or, folded left over integers.");
|
||
("<<", "<< [int int] int",
|
||
"Left shift. The value's type decides — a narrower count widens to it, a \
|
||
wider one is refused — and a literal count at or past the value's width \
|
||
is refused too, because LLVM calls that poison.");
|
||
(">>", ">> [int int] int",
|
||
"Right shift. The value's type decides and the count widens to it, never \
|
||
the reverse; a literal count at or past the width is refused, as it is \
|
||
for <<.");
|
||
("min", "min [ordered? ...] ordered?",
|
||
"The smallest of two or more operands, each of them evaluated exactly \
|
||
once however many there are. Two widths meet at the wider: (min i8-x \
|
||
i16-y) is an i16.");
|
||
("max", "max [ordered? ...] ordered?",
|
||
"The largest of two or more operands, each evaluated exactly once.");
|
||
("zeroed", "zeroed [] T",
|
||
"The all-bytes-zero value of whatever it is being stored into, so it \
|
||
only means anything where a type is expected of it.");
|
||
("filled", "filled [u8] T",
|
||
"Every byte of whatever it is being stored into set to one byte, as in \
|
||
(set grid (filled 0xFF)). Numbers only, and structs and fixed arrays \
|
||
built out of them.");
|
||
("dead-beef", "dead-beef [u32?] T",
|
||
"A four-byte pattern repeating over whatever it is being stored into, \
|
||
written so a hex dump reads it left to right: 0xDEADBEEF with no \
|
||
argument, the u32 given otherwise. A size that is not a multiple of \
|
||
four ends on a prefix of the pattern. Same types [filled] takes.");
|
||
("destructure~nth", "destructure~nth [[n T] i32 i32 i32] T",
|
||
"Written by the compiler for a destructuring let, and unspellable: the \
|
||
reader makes ~ a delimiter, so no source symbol can name this.");
|
||
|
||
(* allocators, spec-memory.md *)
|
||
("make-allocator", "make-allocator",
|
||
"A user-written allocator, not implemented and refused wherever it is \
|
||
written. Use (arena-new n), which is the parameterised allocator that \
|
||
does exist.");
|
||
("allocator-from", "allocator-from",
|
||
"A user-written allocator, not implemented — see make-allocator.");
|
||
("allocator", "allocator",
|
||
"A user-written allocator, not implemented — see make-allocator.");
|
||
("heap-allocator", "heap-allocator [] Allocator",
|
||
"The process heap as an Allocator: it releases one block at a time, so \
|
||
can-free? is true of it.");
|
||
("arena-new", "arena-new [i64] Allocator",
|
||
"A new arena of exactly this many bytes. The capacity is explicit and \
|
||
the backing store never grows, which is what makes \"exhausted\" a \
|
||
state a test can reach on purpose.");
|
||
("arena-destroy", "arena-destroy [Allocator] ()",
|
||
"Hands the arena's pages back to the system, which free-all \
|
||
deliberately does not.");
|
||
("free-all", "free-all [Allocator] ()",
|
||
"Releases everything the allocator holds and bumps its epoch, keeping \
|
||
the capacity. It traps rather than quietly doing nothing when there is \
|
||
no region to release.");
|
||
("can-free?", "can-free? [Allocator] bool",
|
||
"Whether this allocator can release a single block, read off its \
|
||
capability set rather than asked of a query procedure.");
|
||
("can-free-all?", "can-free-all? [Allocator] bool",
|
||
"Whether this allocator can release everything it holds at once.");
|
||
("alloc-epoch", "alloc-epoch [Allocator] i64",
|
||
"The counter free-all bumps. A container records it and traps if it \
|
||
moved; this is the same number, readable, so a program can say what it \
|
||
saw.");
|
||
("alloc-id", "alloc-id [Allocator] i64",
|
||
"The allocator's identity — its address — which is what a condition's \
|
||
:allocator field carries, so a handler holding several regions can \
|
||
tell which one ran out.");
|
||
("alloc-budget", "alloc-budget [Allocator] i64",
|
||
"The ceiling on live bytes, 0 for none. A handler that answers \
|
||
StorageExhausted with retry is the one that raises it.");
|
||
("set-alloc-budget", "set-alloc-budget [Allocator i64] ()",
|
||
"Sets the ceiling on live bytes; 0 for none. It is also how a program \
|
||
exhausts an allocator on purpose.");
|
||
("alloc-live-blocks", "alloc-live-blocks [Allocator] i64",
|
||
"How many blocks are still live — \"did you forget to free\", answered \
|
||
at the tier that can answer it.");
|
||
("with-allocator", "with-allocator [Allocator body ...] T",
|
||
"Runs the body with this allocator in the context, and answers the \
|
||
body's last expression. It releases nothing: not at the end of the \
|
||
body, not anywhere.");
|
||
|
||
(* (Vec T) *)
|
||
("vec-new", "vec-new [T? Allocator?] (Vec T)",
|
||
"An empty Vec. A let has no type annotation, so the element type is \
|
||
written at the call — (vec-new i32) — wherever the context does not \
|
||
say it; an allocator may be named the same way.");
|
||
("push", "push [(Vec T) T] ()",
|
||
"Appends one element, growing the Vec through its allocator. Unit and \
|
||
not an error code: a failed allocation signals StorageExhausted.");
|
||
("reserve", "reserve [(Vec T)|(Map K V) i32] ()",
|
||
"Makes room for n more. For a map the number is entries rather than \
|
||
slots — the block is sized so that n still sits under the load \
|
||
factor.");
|
||
("as-slice", "as-slice [(Vec T) i32? i32?] [T]",
|
||
"A non-owning view of the whole Vec, or of the half-open range \
|
||
[lo hi). It carries no allocator, and a push, a put or a reserve may \
|
||
invalidate it.");
|
||
("free", "free [(Vec T)|(Map K V)] ()",
|
||
"Releases the container's block. It does not recurse into elements that \
|
||
own storage — such a container is refused here, and releasing its \
|
||
region with free-all is the answer.");
|
||
("clone", "clone [(Vec T)|(Map K V) Allocator?] (Vec T)|(Map K V)",
|
||
"A deep, independent copy, from the current allocator or one named. \
|
||
Refused for a container whose elements own storage: a bytewise copy \
|
||
would alias the original's blocks under a name promising otherwise.");
|
||
|
||
(* (Map K V) *)
|
||
("map-new", "map-new [K? V? Allocator?] (Map K V)",
|
||
"An empty map. The key and value types are written at the call — \
|
||
(map-new string i32) — wherever the context does not say them.");
|
||
("put", "put [(Map K V) K V] ()",
|
||
"Inserts or replaces. Unit rather than an error code, and \
|
||
(set (get m k) v) is not map syntax.");
|
||
("get", "get [(Map K V) K] (Option V)",
|
||
"The value at the key, or None. Nothing signals here — a lookup that \
|
||
finds nothing is an answer — and the value comes back as a copy of \
|
||
its bytes.");
|
||
("map-remove", "map-remove [(Map K V) K] (Option V)",
|
||
"Removes the entry and answers the value it held, or None if there was \
|
||
none.");
|
||
("map-next", "map-next [(Map K V) (Ptr i64) (Ptr K) (Ptr V)] bool",
|
||
"Walks the map one entry per call through a cursor the caller owns, and \
|
||
is the whole of map iteration: (while (map-next m (addr cur) (addr k) \
|
||
(addr v)) ...).");
|
||
("has-key?", "has-key? [(Map K V) K] bool",
|
||
"Whether the key is present, copying no value — the form a condition \
|
||
wants, where get would hand back an Option to match on. Over a dyn map \
|
||
it is the question that stays askable when nil might also be stored \
|
||
under the key.");
|
||
("class-of", "class-of [dyn] dyn",
|
||
"The class's name as a keyword for a value built by a defclass \
|
||
constructor, and nil for everything else — an ordinary map included. \
|
||
It is what a defgeneric dispatches on, so a class dispatcher is this \
|
||
call over the first argument and a defmulti whose body is (class-of x) \
|
||
is the same generic function written the other way.");
|
||
("keyword", "keyword [string|[u8]] dyn",
|
||
"The interned dyn keyword named by the bytes, for a name that only \
|
||
exists at run time — a reader building :texture-path out of a token's \
|
||
text. A literal :foo is already one.");
|
||
|
||
(* assets, embedded at compile time *)
|
||
("embed", "embed [\"path\" string?] [u8]",
|
||
"The file's bytes, read at compile time and baked in as a constant; \
|
||
(embed \"p\" string) reads it as a string instead. The path is \
|
||
relative to the file the form is written in, and the slice points into \
|
||
read-only data.");
|
||
("embed-dir", "embed-dir [\"path\"] [n EmbedFile]",
|
||
"Every file in the directory, read at compile time, as a fixed array of \
|
||
EmbedFile. It does not descend.");
|
||
("compile-error", "compile-error [\"message\"] ()",
|
||
"Refuses the compile with that message, at the form it is written in. \
|
||
What a macro expands to when it has to say why: a name nothing defines \
|
||
carries a name, and this carries a sentence.");
|
||
|
||
(* files *)
|
||
("slurp", "slurp [string Allocator?] (Vec u8)",
|
||
"Reads a whole file. No Result and no out-parameter: a failure to read \
|
||
signals FileError under retry and use-value, and a failure to allocate \
|
||
signals StorageExhausted.");
|
||
("barf", "barf [string [u8]] ()",
|
||
"Writes a whole file. On the web target it signals FileError every \
|
||
time, with the path — there is no conditional compilation, so the \
|
||
program decides rather than the build.");
|
||
("delete-file", "delete-file [string] ()",
|
||
"Removes the file, or signals FileError with retry and use-value. It \
|
||
answers () and not a bool, because the failure is the condition.");
|
||
("make-directory", "make-directory [string] ()",
|
||
"Creates the directory, or signals FileError. () for the reason \
|
||
delete-file answers one.");
|
||
("rename-file", "rename-file [string string] ()",
|
||
"Renames the first path to the second, or signals FileError. A \
|
||
use-value names a different source for the same destination, which is \
|
||
the direction a handler can act on.");
|
||
|
||
(* containers *)
|
||
("len", "len [[n T]|[T]|string|(Vec T)|(Map K V)] i32",
|
||
"How many elements. One question and one word across an array, a slice, \
|
||
a string, a Vec and a Map.");
|
||
("at", "at [collection i32 ...] T",
|
||
"The element at an index, bounds-checked — and for a Vec with the \
|
||
allocator's epoch checked first. On a string it is the byte, a u8. It \
|
||
is also a place, so (set (at v i) x) goes through the same check; a \
|
||
string is the exception, being a view it does not own.");
|
||
("slice", "slice [[n T]|[T]|string i32? i32?] [T]|string",
|
||
"The half-open range [lo hi) as a non-owning view. lo defaults to 0 and \
|
||
hi to the length, so (slice a) is the whole of it and (slice a n) is \
|
||
the tail from n. A bound may sit one past the end; a literal pair that \
|
||
runs backwards is refused here. A string slices to a string.");
|
||
("slice-from-ptr", "slice-from-ptr [(Ptr T) i32] [T]",
|
||
"Puts a length on a pointer that came back from C. The caller promises \
|
||
it addresses that many initialised T and that they outlive the result; \
|
||
the compiler checks none of it.");
|
||
("addr", "addr [place] (Ptr T)",
|
||
"The address of a place — a name, (.field x), (at a i) or (deref p) — \
|
||
and not of an arbitrary expression.");
|
||
("deref", "deref [(Ptr T)] T",
|
||
"The value behind a pointer, and a place, so (set (deref p) x) writes \
|
||
through it.");
|
||
|
||
(* Option *)
|
||
("Some", "Some [T] (Option T)",
|
||
"Wraps a value as a present Option. None is the other half, and is \
|
||
written as a name rather than as a call.");
|
||
|
||
(* the host primitives *)
|
||
("bytes", "bytes [string Allocator?] [u8]",
|
||
"A writable copy of the string's bytes, from the current allocator or \
|
||
one named. It allocates like vec-new does — a failure signals \
|
||
StorageExhausted with retry — and the block lives until its \
|
||
allocator's free-all or destroy. For reading without a copy, \
|
||
bytes-view.");
|
||
("bytes-view", "bytes-view [string] [u8]",
|
||
"The string's own storage seen as a byte slice. It costs nothing — both \
|
||
are a ptr and a length at run time — and it decodes nothing. \
|
||
Read-only by convention: a literal's bytes are constant data, so the \
|
||
slice looks writable and a store through it traps.");
|
||
("string", "string [[u8]] string",
|
||
"A byte slice seen as a string, and free at run time. It does not check \
|
||
UTF-8, because `string` does not claim UTF-8 — valid-utf8? is an \
|
||
ordinary function you call when you care.");
|
||
("bytes->f64", "bytes->f64 [[u8]] f64", "Parses a float out of the bytes.");
|
||
("bytes->i64", "bytes->i64 [[u8]] i64",
|
||
"Parses an integer out of the bytes.");
|
||
("f64->bytes", "f64->bytes [f64] [u8]",
|
||
"The number's text, in a frame slot belonging to this call site — so \
|
||
two of them can be held at once, and neither survives its frame. Copy \
|
||
the bytes to keep one.");
|
||
("i64->bytes", "i64->bytes [i64] [u8]",
|
||
"The number's text, in a frame slot belonging to this call site; it \
|
||
does not survive the frame.");
|
||
("write-stdout", "write-stdout [[u8]] ()",
|
||
"Writes the bytes to standard output exactly as given: no newline and \
|
||
no formatting.");
|
||
("print", "print [T ...] ()",
|
||
"The structural printer, selected for each argument's concrete type; \
|
||
arguments print in order with a single space between them. A string \
|
||
prints raw at the top level and quoted inside a structure, and a Ptr \
|
||
or a Handle prints its address rather than being followed. Printing \
|
||
is a read, so it does not consume the value.");
|
||
("println", "println [T ...] ()",
|
||
"print, with a newline after it — (println) alone is the newline.");
|
||
("exit", "exit [i32] never",
|
||
"Ends the process with this status. It has no value, so nothing written \
|
||
after it runs.");
|
||
("argv", "argv [] [string]", "The command line, as a slice of strings.");
|
||
|
||
(* the names rather than calls — [var]'s arms. Their
|
||
signature is the [name type] shape [Dev.defs] gives a global, because
|
||
that is what they are at the site: a value, not a call. *)
|
||
("true", "true bool", "The true boolean literal.");
|
||
("false", "false bool", "The false boolean literal.");
|
||
("nil", "nil dyn",
|
||
"The absent dyn value: what (get m k) answers for a key a dyn map does \
|
||
not hold, and always dyn — what a nil does at an (Option T) boundary \
|
||
is a later milestone's question.");
|
||
("None", "None (Option T)",
|
||
"The absent Option. It takes its type from its context — a return type \
|
||
or an annotated binding — because nothing about the word says what it \
|
||
is an Option of.");
|
||
("context/allocator", "context/allocator Allocator",
|
||
"The allocator in effect here: what with-allocator rebinds, and what an \
|
||
allocating operation uses when none is named at the site.");
|
||
("context/temp", "context/temp Allocator",
|
||
"The scratch allocator the calling convention carries beside \
|
||
context/allocator.")
|
||
]
|
||
|
||
(* The forward reference declared beside [nearest], filled the moment the table
|
||
it names exists. Nothing reads it before a call is checked, and no call is
|
||
checked before this module is loaded. *)
|
||
let () =
|
||
builtin_names := List.map (fun (n, _, _) -> n) builtins;
|
||
List.iter (fun (n, _, _) -> Hashtbl.replace builtin_set n ()) builtins
|
||
|
||
(* ── The one thing shadowing owes the reader ────────────────────────────
|
||
A defn named after a builtin is legal and it wins ([shadows_builtin]), and
|
||
that is a large thing to have happened in silence: every [(get m k)] in
|
||
the file now means something the reader has to go and look at. So it is
|
||
said once, where the decision was made, and never again at the call sites
|
||
— a footgun notice, not a lint.
|
||
|
||
It is a warning and it says so in the one way that matters: nothing raises
|
||
and the exit status does not move. Unlike [memory_sites] it is behind no
|
||
flag, because there is nothing to tune — a program either renamed a
|
||
builtin or it did not, and the line is one line and rare.
|
||
|
||
The second half of the sentence names the way out. Until [builtin/] there
|
||
was none: a file that defined [len] had given the builtin [len] up for the
|
||
whole file, and a defn that meant to *wrap* it was unbounded recursion
|
||
instead. Saying so here is the cheapest place it can be said — the reader
|
||
is being told the name was taken over, and the next thing they want to
|
||
know is what is left.
|
||
|
||
The prelude is skipped: its defns are the language's own and a collision
|
||
there is a compiler bug rather than news for whoever is compiling. So are
|
||
qualified names, for the reason [shadows_builtin]'s [visible] gives —
|
||
[rl/get] is not [get] and shadows nothing. *)
|
||
let shadowed_builtins (decls : Ast.decl list) : Loc.diag list =
|
||
List.filter_map
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defn fn
|
||
when Hashtbl.mem builtin_set fn.Ast.name
|
||
&& not (String.contains fn.Ast.name '/')
|
||
&& not (String.equal fn.Ast.nloc.Loc.file Prelude.file) ->
|
||
Some
|
||
(Loc.diag ~kind:"check/shadows-builtin" fn.Ast.nloc
|
||
(Printf.sprintf
|
||
"%s shadows the builtin %s — every call in this program now \
|
||
reaches your definition — the builtin stays reachable as %s%s"
|
||
fn.Ast.name fn.Ast.name builtin_prefix fn.Ast.name))
|
||
| _ -> None)
|
||
decls
|
||
|
||
(* ── Declarations: pass 1, collect ─────────────────────────────────── *)
|
||
|
||
(* Constant folding, only over integers and only for defconst — enough for an
|
||
array length like (/ screen-height cell-size). *)
|
||
let rec const_int env (e : Ast.expr) : int64 option =
|
||
match e.Ast.e with
|
||
| Ast.Int n -> Some n
|
||
| Ast.Byte b -> Some (Int64.of_int b)
|
||
| Ast.Var n -> Hashtbl.find_opt env.consts n
|
||
(* Left to right over any number of operands, because that is how the
|
||
checker reads the same form: an array length that type-checks as a
|
||
product of three literals and is then not a constant would be a
|
||
distinction with nothing behind it. [%] is still two, as it is there. *)
|
||
| Ast.Call ({ Ast.e = Ast.Var op; _ }, x :: y :: rest) ->
|
||
let step a b =
|
||
match op with
|
||
| "+" -> Some (Int64.add a b)
|
||
| "-" -> Some (Int64.sub a b)
|
||
| "*" -> Some (Int64.mul a b)
|
||
| "/" when b <> 0L -> Some (Int64.div a b)
|
||
| "%" when b <> 0L && rest = [] -> Some (Int64.rem a b)
|
||
| _ -> None
|
||
in
|
||
List.fold_left
|
||
(fun acc e ->
|
||
match acc, const_int env e with
|
||
| Some a, Some b -> step a b
|
||
| _ -> None)
|
||
(const_int env x) (y :: rest)
|
||
| _ -> None
|
||
|
||
(* [(defconst grid [rows [cols u8]])]. A two-element defconst has no type slot
|
||
— the second form is always a value — so the brackets were read as an array
|
||
*literal* and [u8] as a name in it, and the refusal that came out was
|
||
"unknown name u8", which sends the reader to look for a missing definition
|
||
of something the language has had all along.
|
||
|
||
A type name inside an array literal is unambiguous evidence, because a type
|
||
and a value cannot share a name: [collect]'s claimed table is over every
|
||
declaration kind there is. So finding one means the whole form was meant as
|
||
a type, and the form that takes one is [defonce]. *)
|
||
let rec defconst_type_shaped env gname (v : Ast.expr) =
|
||
match v.Ast.e with
|
||
| Ast.Arr items ->
|
||
List.iter
|
||
(fun (i : Ast.expr) ->
|
||
match i.Ast.e with
|
||
| Ast.Var n when is_type_name env n ->
|
||
Loc.failk "check/defconst-is-a-type" i.Ast.loc
|
||
"%s is a type, and this is a value: a two-element defconst has no \
|
||
type slot, so the brackets around it were read as an array \
|
||
literal and %s as a name in it. A global declared by its type is \
|
||
a defonce — write (defonce %s ...) with the same brackets"
|
||
n n gname
|
||
| _ -> defconst_type_shaped env gname i)
|
||
items
|
||
| _ -> ()
|
||
|
||
let collect env (decls : Ast.decl list) =
|
||
(* One pass over every declaration kind before any of the others, because
|
||
the tables below are per-kind — structs, data types, aliases, enums, functions
|
||
and globals each have their own — and a collision between two of them
|
||
would otherwise be found by LLVM, as [redefinition of function
|
||
'@flan.item'], or not at all. A [defn item] and a [defonce item] are two
|
||
declarations of one name and are rejected here. *)
|
||
(* The qualifier is reserved on this side too. [(defn builtin/len ...)]
|
||
reads — the reader treats [/] as an ordinary symbol character — and would
|
||
otherwise land in [env.fns] under a name nothing can ever call, because
|
||
[named_call] strips the prefix before any table is consulted. A
|
||
declaration that can only ever be dead is refused where it is written
|
||
rather than left to be discovered. [Load] refuses the same qualifier from
|
||
the other direction, at an import's alias. *)
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
match Ast.declared_name d with
|
||
| Some n when qualified_builtin n <> None ->
|
||
fail d.Ast.dloc
|
||
"%s cannot be declared: %s is a reserved qualifier, so a name \
|
||
spelled with it reaches the compiler's builtins and never a \
|
||
declaration — nothing could call this one"
|
||
n builtin_prefix
|
||
| _ -> ())
|
||
decls;
|
||
let claimed = Hashtbl.create 64 in
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
match Ast.declared_name d with
|
||
| None -> ()
|
||
| Some n ->
|
||
(match Hashtbl.find_opt claimed n with
|
||
| Some first ->
|
||
(* The second one is the error, because it is the one to delete;
|
||
the first is the note, because without it the message is a
|
||
claim the reader has to go and verify. *)
|
||
Loc.failk "check/defined-twice" d.Ast.dloc
|
||
~notes:[ Loc.note first (n ^ " is already defined here") ]
|
||
"%s is defined twice" n
|
||
| None -> ());
|
||
Hashtbl.add claimed n d.Ast.dloc)
|
||
decls;
|
||
(* Names first, so a struct may mention one declared below it. *)
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defstruct (n, _) ->
|
||
Hashtbl.replace env.locs n d.Ast.dloc;
|
||
Hashtbl.replace env.structs n { Tast.sname = n; fields = [] }
|
||
| Ast.Defdata (n, _) ->
|
||
Hashtbl.replace env.locs n d.Ast.dloc;
|
||
Hashtbl.replace env.datas n { Tast.dname = n; cases = [] }
|
||
| Ast.Defunion (n, _) ->
|
||
Hashtbl.replace env.locs n d.Ast.dloc;
|
||
Hashtbl.replace env.unions n { Tast.sname = n; fields = [] }
|
||
(* [int] and [float] are builtin aliases (Types), and a program that
|
||
declared them itself — which this one's author did, before they were
|
||
builtin — must not quietly stop meaning what it says. The alias
|
||
table is never consulted for either name: [resolve_name] answers
|
||
from [ikind_of_name] first, so a [(defalias int i64)] left to
|
||
register would be read as [i32] at every use and nothing would ever
|
||
say so.
|
||
|
||
So the target decides. Spelled as the builtin's own type, the
|
||
declaration is true and is accepted as the no-op it is — the old
|
||
program still compiles, and deleting the line is a cleanup rather
|
||
than a fix. Spelled as anything else it is refused, because the only
|
||
alternative is to silently mean something else. Nothing is written
|
||
to the table either way: the name resolves without it. *)
|
||
| Ast.Defalias (n, { Ast.t = Ast.Tname target; _ })
|
||
when n = "int" || n = "float" ->
|
||
let builtin = if n = "int" then "i32" else "f32" in
|
||
if target <> builtin then
|
||
Loc.failk "check/builtin-alias" d.Ast.dloc
|
||
"%s is a builtin alias for %s and cannot be redefined as %s — \
|
||
delete this defalias, or give the type another name"
|
||
n builtin target
|
||
| Ast.Defalias (n, _) when n = "int" || n = "float" ->
|
||
let builtin = if n = "int" then "i32" else "f32" in
|
||
Loc.failk "check/builtin-alias" d.Ast.dloc
|
||
"%s is a builtin alias for %s and cannot be redefined — delete \
|
||
this defalias, or give the type another name"
|
||
n builtin
|
||
| Ast.Defalias (n, t) -> Hashtbl.replace env.aliases n t
|
||
| _ -> ())
|
||
decls;
|
||
(* Compile-time integer constants next, to a fixpoint, because an array
|
||
length may name a constant declared below it — top-level names in a
|
||
package are order-independent (plan.org, Modules). *)
|
||
let fold_consts () =
|
||
let progress = ref false in
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defconst (n, _, v) when not (Hashtbl.mem env.consts n) ->
|
||
(match const_int env v with
|
||
| Some i -> Hashtbl.replace env.consts n i; progress := true
|
||
| None -> ())
|
||
| _ -> ())
|
||
decls;
|
||
!progress
|
||
in
|
||
while fold_consts () do () done;
|
||
let field (f : Ast.field) : Tast.field =
|
||
(* The flag is reset through [Fun.protect] because a refusal here does not
|
||
end the run: [program_all] carries on collecting diagnostics, and a
|
||
flag left set would misword every later unknown-type message. *)
|
||
env.in_field <- true;
|
||
let fty =
|
||
Fun.protect ~finally:(fun () -> env.in_field <- false)
|
||
(fun () -> resolve env f.Ast.fty)
|
||
in
|
||
no_zeroed_fn f.Ast.fty.Ast.tloc
|
||
(Printf.sprintf "the field %s" f.Ast.fname) fty;
|
||
{ Tast.fname = f.Ast.fname; fty }
|
||
in
|
||
(* Constants with no declared type are inferred from their value, which needs
|
||
every other signature in hand — so they are deferred to a pass of their
|
||
own below. *)
|
||
let untyped = ref [] in
|
||
(* Enums come first, in a pass of their own: a signature below may name one,
|
||
and [resolve] has to find it before it resolves that signature. *)
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defenum (n, members) ->
|
||
let names = List.map fst members in
|
||
if List.length (List.sort_uniq compare names) <> List.length names then
|
||
fail d.Ast.dloc "%s declares the same member twice" n;
|
||
Hashtbl.replace env.enums n members;
|
||
Hashtbl.replace env.locs n d.Ast.dloc
|
||
| _ -> ())
|
||
decls;
|
||
(* Every type name is registered by here — structs, data types and unions by
|
||
the names-first pass, aliases with them, enums by the pass just above — so
|
||
this is the first point at which a [defn]'s parameter vector can be paired.
|
||
It is done before the signature loop below rather than inside it, because a
|
||
signature may name a type declared further down and pairing must not depend
|
||
on the order the file was written in. *)
|
||
let decls = pair_decls env decls in
|
||
(* And for the same reason, at the same point: a three-element defonce is a
|
||
type or a value by name, and every type name is registered by here. *)
|
||
let decls = settle_defvars env decls in
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
let loc = d.Ast.dloc in
|
||
match d.Ast.d with
|
||
| Ast.Package _ -> ()
|
||
| Ast.Defenum _ -> ()
|
||
(* Imports are gone by now: [Load] resolved them into these very decls,
|
||
so one reaching the checker is a driver that skipped that step. *)
|
||
| Ast.Import (alias, _) ->
|
||
fail loc "internal: the import of %s was not resolved before checking"
|
||
alias
|
||
(* [Shim.expand] rewrote every one of these into a [Declare] and a
|
||
[Defn] before [collect] ran, so one arriving here is a driver that
|
||
skipped that step. *)
|
||
| Ast.DeclareC (fn, _) ->
|
||
fail loc "internal: the declare-c of %s was not expanded before checking"
|
||
fn.Ast.name
|
||
| Ast.Declare (fn, csym) ->
|
||
if Hashtbl.mem env.fns fn.Ast.name then
|
||
fail loc "%s is declared twice" fn.Ast.name;
|
||
let params =
|
||
List.map (fun (p : Ast.field) -> resolve env p.Ast.fty) fn.Ast.params
|
||
in
|
||
let ret =
|
||
match fn.Ast.ret with None -> Types.Unit | Some t -> resolve env t
|
||
in
|
||
(* What may cross the boundary. A slice or a string goes as ptr+len,
|
||
a scalar as itself; an aggregate does not go at all, because how
|
||
one is passed differs per target and reproducing that here would
|
||
be three calling conventions to maintain. Pass (Ptr T) instead and
|
||
let the C shim dereference it — that is what the shim is for. *)
|
||
let crossable what (t : Types.t) =
|
||
match t with
|
||
| Types.Int _ | Types.Float _ | Types.Bool | Types.Ptr _
|
||
| Types.Enum _ | Types.Unit -> ()
|
||
| Types.String | Types.Slice _ when what = "a parameter" -> ()
|
||
(* Its own arm, because the general advice below is wrong for it and
|
||
dangerously so. A dyn is one machine word and would cross without
|
||
complaint — [(Ptr dyn)] is not the fix and there is nothing for a
|
||
shim to read: what the C side would receive is a word whose
|
||
meaning only the dyn runtime knows, and C has no way to ask.
|
||
Refused by name rather than let through as an integer. *)
|
||
| Types.Dyn ->
|
||
fail loc
|
||
"%s of %s is dyn, which does not cross to C. A dyn is one word \
|
||
and would pass as an integer, but what the word means is the \
|
||
dyn runtime's and there is nothing on the C side that can ask \
|
||
— take the value at a written type and pass that"
|
||
what fn.Ast.name
|
||
| _ ->
|
||
fail loc
|
||
"%s of %s is %s, which cannot cross to C directly — pass \
|
||
(Ptr %s) and let the shim read it" what fn.Ast.name
|
||
(Types.to_string t) (Types.to_string t)
|
||
in
|
||
List.iter (crossable "a parameter") params;
|
||
crossable "the return type" ret;
|
||
Hashtbl.replace env.fns fn.Ast.name (params, ret);
|
||
Hashtbl.replace env.externs fn.Ast.name csym;
|
||
Hashtbl.replace env.extern_locs fn.Ast.name loc
|
||
| Ast.Defalias _ -> ()
|
||
| Ast.Defstruct (n, fs) ->
|
||
let names = List.map (fun (f : Ast.field) -> f.Ast.fname) fs in
|
||
if List.length (List.sort_uniq compare names) <> List.length names then
|
||
fail loc "%s declares the same field twice" n;
|
||
let fields = List.map field fs in
|
||
(* Recorded before the refusal below rather than after it, because the
|
||
refusal asks [region_only], which walks this very declaration: a
|
||
recursive value's field is a [(Vec Value)] and answering for it
|
||
means reading [Value]'s own cases back out of the table. A [fail]
|
||
aborts the whole compilation, so an entry left behind by a
|
||
declaration that is about to be refused is never read. *)
|
||
(* A dyn field used to be refused here, for the reason a condition's
|
||
was: the collector's roots are the frames, and a struct outlives
|
||
the frame that built it, so its dyn field was a live value
|
||
reachable only through memory the marker never walked. The
|
||
per-type descriptors lifted that. A struct that holds a dyn now
|
||
gets a descriptor saying at which byte offsets its dyn words sit,
|
||
and every slot, global and temporary that holds one goes on the
|
||
collector's root stack with that descriptor beside it — see
|
||
runtime/flan_dyn.h's [flan_dyn_root_push_desc], which is also where
|
||
the reason no instance carries a header word is written down.
|
||
|
||
What is still refused is a dyn the descriptor cannot reach: one
|
||
inside a typed container, behind a pointer, or in a data type's
|
||
payload, where the offset is not a static property of the type.
|
||
That refusal is [hidden_dyn] below, and it is made over the whole
|
||
program rather than here, because the type that hides it may be
|
||
declared after the one that names it. *)
|
||
Hashtbl.replace env.structs n { Tast.sname = n; fields };
|
||
(* A struct field may own storage. Since the repeal a struct
|
||
holding a [(Vec i32)] is an ordinary value: assignment copies the
|
||
header bytes, the copies alias one buffer, and freeing through
|
||
two copies is the program's bug — Odin's contract, kept whole.
|
||
|
||
The region rule is separate and survives on its own ground: a
|
||
field whose container holds *owning* elements can only have been
|
||
built against a region allocator — the guard at its construction
|
||
is what makes sure of it (see [vec-new]) — so that graph is
|
||
released by one [free-all] and no teardown recurses anywhere. *)
|
||
| Ast.Defdata (n, vs) ->
|
||
(* A data type with no cases has no value, so nothing could ever be given
|
||
one, and a parameter of that type would be a function nothing can
|
||
call. It parses; it is refused here rather than surviving to a
|
||
layout with a tag and no case for the tag to name. *)
|
||
if vs = [] then
|
||
fail loc
|
||
"%s declares no cases, so no value of it can exist — a data type is \
|
||
(defdata %s [(Case [field Type ...]) ...])" n n;
|
||
let cnames = List.map (fun (v : Ast.variant) -> v.Ast.vname) vs in
|
||
if List.length (List.sort_uniq compare cnames) <> List.length cnames
|
||
then fail loc "%s declares the same case twice" n;
|
||
let cases_with_loc =
|
||
List.map
|
||
(fun (v : Ast.variant) ->
|
||
let fnames =
|
||
List.map (fun (f : Ast.field) -> f.Ast.fname) v.Ast.vfields
|
||
in
|
||
if List.length (List.sort_uniq compare fnames)
|
||
<> List.length fnames then
|
||
fail v.Ast.vloc "%s.%s declares the same field twice"
|
||
n v.Ast.vname;
|
||
let vfields = List.map field v.Ast.vfields in
|
||
v.Ast.vloc, { Tast.vname = v.Ast.vname; vfields })
|
||
vs
|
||
in
|
||
let cases = List.map snd cases_with_loc in
|
||
(* Registered before the field refusal below, not after, for the
|
||
reason the struct's copy of this gives: [region_only] has to read
|
||
this data type's own cases back out to answer for a [(Vec Value)]
|
||
that names [Value]. The two declarations do this identically
|
||
because a data type that could hold a container where a struct
|
||
could not would be a hole in the same rule. *)
|
||
Hashtbl.replace env.datas n { Tast.dname = n; cases };
|
||
(* A case's fields are a struct and may own storage, on the struct's
|
||
terms since the repeal: copies alias, and the free is the
|
||
program's to write. The region rule still applies on its own
|
||
ground — a [(Vec Value)] case field can only have been built
|
||
against a region, so the recursive dynamic value parses into an
|
||
arena and one [free-all] releases the graph, no teardown
|
||
anywhere. *)
|
||
List.iter
|
||
(fun (c : Tast.variant) ->
|
||
Hashtbl.replace env.cases (n ^ "." ^ c.Tast.vname) (n, c);
|
||
Hashtbl.replace env.cases c.Tast.vname (n, c))
|
||
cases
|
||
(* Nothing records which member of a union is live, so nothing — the
|
||
program included — can free the right one through the union itself.
|
||
Since the repeal that is a fact about the value and not a refusal:
|
||
a member may own storage, and freeing it is done through whatever
|
||
tag the program keeps beside the union, as C does. *) | Ast.Defunion (n, ms) ->
|
||
if ms = [] then
|
||
fail loc
|
||
"%s declares no members, so it has no size and nothing could be \
|
||
read out of it — a union is (defunion %s [member Type ...])" n n;
|
||
let names = List.map (fun (f : Ast.field) -> f.Ast.fname) ms in
|
||
if List.length (List.sort_uniq compare names) <> List.length names then
|
||
fail loc "%s declares the same member twice" n;
|
||
let fields = List.map field ms in
|
||
Hashtbl.replace env.unions n { Tast.sname = n; fields }
|
||
| Ast.Defn fn ->
|
||
(* A signature that introduces a type variable is a *pattern*, not a
|
||
signature: it goes in [gsigs] and the function goes nowhere near
|
||
[fns], because nothing can be called at [t]. Every call site turns
|
||
it into an ordinary entry. *)
|
||
let vars = signature_tyvars fn in
|
||
(* The [where] clause is checked against the signature here, once,
|
||
rather than at every use of it: a predicate nobody has heard of,
|
||
or one about a variable the signature never bound, is a mistake
|
||
about this definition and is refused at this definition. *)
|
||
List.iter
|
||
(fun (p : Ast.pred) ->
|
||
if not (List.mem p.Ast.pname predicate_names) then
|
||
Loc.failk "check/unknown-predicate" p.Ast.ploc
|
||
"%s is not a type predicate. The ones there are: %s"
|
||
p.Ast.pname (String.concat ", " predicate_names);
|
||
if not (List.mem p.Ast.pvar vars) then
|
||
Loc.failk "check/unbound-predicate-variable" p.Ast.ploc
|
||
"$%s is not a type variable of %s — a where clause \
|
||
constrains the variables the signature binds%s"
|
||
p.Ast.pvar fn.Ast.name
|
||
(if vars = [] then ", and this signature binds none"
|
||
else
|
||
", which here are "
|
||
^ String.concat ", " (List.map (fun v -> "$" ^ v) vars)))
|
||
fn.Ast.fwhere;
|
||
env.tyvars <- vars;
|
||
env.tvpreds <- fn.Ast.fwhere;
|
||
let params =
|
||
List.map (fun (p : Ast.field) -> resolve env p.Ast.fty) fn.Ast.params
|
||
in
|
||
let ret =
|
||
match fn.Ast.ret with None -> Types.Unit | Some t -> resolve env t
|
||
in
|
||
env.tyvars <- [];
|
||
env.tvpreds <- [];
|
||
if vars = [] then begin
|
||
Hashtbl.replace env.fns fn.Ast.name (params, ret);
|
||
Hashtbl.replace env.fparams fn.Ast.name fn.Ast.params;
|
||
Hashtbl.replace env.fn_locs fn.Ast.name fn.Ast.nloc
|
||
end
|
||
else begin
|
||
Hashtbl.replace env.generics fn.Ast.name fn;
|
||
Hashtbl.replace env.gsigs fn.Ast.name (vars, params, ret)
|
||
end
|
||
| Ast.Defvar (n, t, _, k) ->
|
||
let ty = match t with
|
||
| Some t -> resolve env t
|
||
| None ->
|
||
fail loc "%s %s needs a type"
|
||
(match k with Ast.Once -> "defonce" | Ast.Every -> "def") n
|
||
in
|
||
Hashtbl.replace env.globals n (ty, false);
|
||
Hashtbl.replace env.global_locs n loc
|
||
| Ast.Defconst (n, Some t, _) ->
|
||
Hashtbl.replace env.globals n (resolve env t, true);
|
||
Hashtbl.replace env.global_locs n loc
|
||
| Ast.Defconst (n, None, v) ->
|
||
defconst_type_shaped env n v;
|
||
Hashtbl.replace env.global_locs n loc;
|
||
untyped := (n, v) :: !untyped
|
||
(* [Classes.expand] ran at the top of [build_program] and left none of
|
||
these behind, the way [Shim.expand] leaves no [declare-c] behind. A
|
||
driver that assembled a declaration list and skipped that pass would
|
||
otherwise get a missing name from wherever the constructor was
|
||
called, with nothing pointing here. *)
|
||
| Ast.Defclass (n, _) | Ast.Defgeneric { Ast.name = n; _ }
|
||
| Ast.Defmulti { Ast.name = n; _ } ->
|
||
fail loc
|
||
"internal: %s reached the checker unexpanded — Classes.expand did \
|
||
not run over this declaration list" n
|
||
| Ast.Defmethod m ->
|
||
fail loc
|
||
"internal: a method of %s reached the checker unexpanded — \
|
||
Classes.expand did not run over this declaration list" m.Ast.mgen)
|
||
decls;
|
||
(* Also to a fixpoint, and for the same reason: one untyped constant may be
|
||
defined in terms of another declared after it. A constant that still does
|
||
not check once no progress is left has a real error, so the last round is
|
||
run without swallowing it. *)
|
||
let infer (_, v) = (check (invented_ctx env Types.Unit) v).Tast.ty in
|
||
let pending = ref (List.rev !untyped) in
|
||
let rec settle () =
|
||
let left =
|
||
List.filter
|
||
(fun ((n, _) as c) ->
|
||
match infer c with
|
||
| ty -> Hashtbl.replace env.globals n (ty, true); false
|
||
| exception Loc.Error _ -> true)
|
||
!pending
|
||
in
|
||
let progressed = List.length left < List.length !pending in
|
||
pending := left;
|
||
if progressed && left <> [] then settle ()
|
||
in
|
||
settle ();
|
||
List.iter (fun c -> ignore (infer c)) !pending;
|
||
(* The paired declarations, handed back so that pass two checks the bodies of
|
||
the same functions whose signatures this pass registered. Pairing needs the
|
||
type names, which only this pass has; every pass after it needs the result,
|
||
and a [defn] still carrying an unpaired vector would check as a function of
|
||
no parameters at all. *)
|
||
decls
|
||
|
||
(* A type that contains itself by value has no finite size. [(Ptr T)] and a
|
||
slice are indirections and break the cycle; a fixed array does not, because
|
||
it is inline. Caught here rather than when a backend tries to lay the type
|
||
out or a zero value is built for it — which would not fail, it would hang. *)
|
||
let check_finite env =
|
||
let rec walk seen name =
|
||
if List.mem name seen then
|
||
fail (Option.value (Hashtbl.find_opt env.locs name) ~default:Loc.unknown)
|
||
"%s contains itself by value, so it has no size — go through (Ptr %s)"
|
||
name name;
|
||
let seen = name :: seen in
|
||
match Hashtbl.find_opt env.structs name with
|
||
| Some s -> List.iter (fun (f : Tast.field) -> ty seen f.Tast.fty) s.Tast.fields
|
||
| None ->
|
||
match Hashtbl.find_opt env.datas name with
|
||
| Some u ->
|
||
List.iter
|
||
(fun (c : Tast.variant) ->
|
||
List.iter (fun (f : Tast.field) -> ty seen f.Tast.fty) c.Tast.vfields)
|
||
u.Tast.cases
|
||
| None ->
|
||
(* A union whose member is itself is the same infinite type a struct's
|
||
is — the size is the largest member and the largest member is the
|
||
whole thing. Nothing about overlaying storage makes the recursion
|
||
finite, so it is on the same walk rather than left to hang the
|
||
layout calculator. *)
|
||
match Hashtbl.find_opt env.unions name with
|
||
| None -> ()
|
||
| Some u ->
|
||
List.iter (fun (f : Tast.field) -> ty seen f.Tast.fty) u.Tast.fields
|
||
and ty seen = function
|
||
| Types.Named n -> walk seen n
|
||
| Types.Array (_, e) | Types.Option e -> ty seen e
|
||
| _ -> ()
|
||
in
|
||
Hashtbl.iter (fun n _ -> walk [] n) env.structs;
|
||
Hashtbl.iter (fun n _ -> walk [] n) env.datas;
|
||
Hashtbl.iter (fun n _ -> walk [] n) env.unions
|
||
|
||
(* No [bool] and no data type anywhere inside a union, at any depth — see the [Defunion] arm in
|
||
[collect] for why an [i1] read out of a union is the one punning hazard
|
||
Flan refuses rather than defines. It runs here, after [collect], because it
|
||
has to look through a member's *struct* to reach the fields inside it and
|
||
the struct table is only complete once every declaration has been walked. A
|
||
union that contains itself is impossible by [check_finite] above, so the
|
||
recursion terminates without a seen set — except through a [Ptr], which
|
||
this does not follow: a bool behind a pointer is a bool in someone else's
|
||
storage and is loaded from an address, not reinterpreted out of a blob. *)
|
||
let check_union_members env =
|
||
let rec walk uname where (t : Types.t) =
|
||
match t with
|
||
| Types.Bool ->
|
||
fail (Option.value (Hashtbl.find_opt env.locs uname) ~default:Loc.unknown)
|
||
"%s is a bool, and a union may not hold one at any depth: writing a \
|
||
member that overlays it leaves a byte that is neither 0 nor 1, and \
|
||
an i1 with that byte in it is a value the optimiser is entitled to \
|
||
assume cannot exist. Hold a u8 in the union and compare it yourself"
|
||
where
|
||
(* An [Option] is deliberately not on this list, and the difference is
|
||
worth stating because a reader will ask. Its [match] lowers to a test of
|
||
the tag byte and a branch, so a scribbled tag reads as a [Some] with a
|
||
garbage payload — a number nobody stored, which is exactly what this
|
||
language says a union read is. A data type's lowers to a chain of
|
||
comparisons with an [unreachable] after the last one. *)
|
||
| Types.Array (_, e) | Types.Option e -> walk uname where e
|
||
| Types.Named n when Hashtbl.mem env.datas n ->
|
||
fail (Option.value (Hashtbl.find_opt env.locs uname) ~default:Loc.unknown)
|
||
"%s is %s, a data type, and a union may not hold one at any depth: a \
|
||
data type's tag steers every match over it, and overlaying another \
|
||
member leaves that tag arbitrary — a tag no case names falls past \
|
||
every comparison into a block the optimiser may treat as \
|
||
unreachable. This is the same refusal uninit on a data type gets, \
|
||
and it arrives here because a union is the other way to hand one \
|
||
bytes nobody wrote. Hold the %s beside the union"
|
||
where n n
|
||
| Types.Named n ->
|
||
(match Hashtbl.find_opt env.structs n with
|
||
| Some st ->
|
||
List.iter
|
||
(fun (f : Tast.field) ->
|
||
walk uname (where ^ "." ^ f.Tast.fname) f.Tast.fty)
|
||
st.Tast.fields
|
||
| None ->
|
||
match Hashtbl.find_opt env.unions n with
|
||
| None -> ()
|
||
| Some u ->
|
||
List.iter
|
||
(fun (f : Tast.field) ->
|
||
walk uname (where ^ "." ^ f.Tast.fname) f.Tast.fty)
|
||
u.Tast.fields)
|
||
| _ -> ()
|
||
in
|
||
Hashtbl.iter
|
||
(fun n (u : Tast.structure) ->
|
||
List.iter
|
||
(fun (f : Tast.field) -> walk n (n ^ "'s member " ^ f.Tast.fname) f.Tast.fty)
|
||
u.Tast.fields)
|
||
env.unions
|
||
|
||
(* ── Declarations: pass 2, check bodies ────────────────────────────── *)
|
||
|
||
let rec check_fn env (fn : Ast.fn) : Tast.fn =
|
||
let params, ret = Hashtbl.find env.fns fn.Ast.name in
|
||
let ctx = { (invented_ctx env ret) with owner = fn.Ast.name } in
|
||
List.iter2
|
||
(fun (p : Ast.field) ty ->
|
||
if List.mem_assoc p.Ast.fname ctx.scope then begin
|
||
let first =
|
||
List.find_opt
|
||
(fun (q : Ast.field) -> q.Ast.fname = p.Ast.fname)
|
||
fn.Ast.params
|
||
in
|
||
let notes =
|
||
match first with
|
||
| Some q when q != p ->
|
||
[ Loc.note q.Ast.floc ("the first " ^ p.Ast.fname ^ " is here") ]
|
||
| _ -> []
|
||
in
|
||
Loc.failk "check/duplicate-parameter" p.Ast.floc ~notes
|
||
"%s has two parameters named %s" fn.Ast.name p.Ast.fname
|
||
end;
|
||
ignore (bind ctx p.Ast.fname ty ~assignable:false))
|
||
fn.Ast.params params;
|
||
let body =
|
||
match fn.Ast.fbody with
|
||
| [] ->
|
||
if Types.equal ret Types.Unit then []
|
||
else fail fn.Ast.nloc "%s returns %s but has no body" fn.Ast.name
|
||
(Types.to_string ret)
|
||
| body ->
|
||
(* The last form is the return value, unless the function returns Unit,
|
||
in which case whatever it evaluates to is discarded. *)
|
||
let want = if Types.equal ret Types.Unit then None else Some ret in
|
||
(* Every form here is at the top level of the function body, so every one
|
||
of them may carry a [defer] — and so may a form inside a [let] written
|
||
here, which is what [ctx.defer_ok] carries down. [check] registers it
|
||
and yields [unit]; the permission is granted again before each form
|
||
because [check] withdraws it as it starts.
|
||
|
||
A trailing [defer] is still a [defer] and not the return value, so the
|
||
expectation is not put to it: it would only ever report [Unit] against
|
||
the declared return type, which names the wrong problem. *)
|
||
let is_defer (e : Ast.expr) =
|
||
match e.Ast.e with Ast.Defer _ -> true | _ -> false
|
||
in
|
||
let rec go = function
|
||
| [ last ] ->
|
||
ctx.defer_ok <- true;
|
||
[ (if is_defer last then check ctx last else check ctx ?want last) ]
|
||
| x :: rest ->
|
||
ctx.defer_ok <- true;
|
||
let x = check ctx x in
|
||
x :: go rest
|
||
| [] -> assert false
|
||
in
|
||
go body
|
||
in
|
||
(* Function exit runs the defers, innermost first. An explicit [return] ran
|
||
its own (see [check]); this is the fall-off-the-end path. A trap does not
|
||
run them — it is [noreturn] and then [unreachable] — and that is the same
|
||
rule the bounds checks already follow. *)
|
||
let body =
|
||
match ctx.defers with
|
||
| [] -> body
|
||
| ds when Types.equal ret Types.Unit -> body @ ds
|
||
| ds ->
|
||
(* The result is computed before the defers run and returned after, so it
|
||
goes through a slot rather than staying the last form. *)
|
||
let rec split = function
|
||
| [ last ] -> ([], last)
|
||
| x :: rest -> let (init, last) = split rest in (x :: init, last)
|
||
| [] -> assert false
|
||
in
|
||
let init, last = split body in
|
||
let s = fresh_slot ctx ret in
|
||
let loc = last.Tast.loc in
|
||
init @ [ mk loc ret
|
||
(Tast.Let ([ (s, last) ], ds @ [ mk loc ret (Tast.Local s) ])) ]
|
||
in
|
||
(* The counter's zero, at the top of the body and above every store to it.
|
||
Nothing else in the function reads the slot, so this is the whole of its
|
||
cost on the path that never transfers. *)
|
||
let body =
|
||
match ctx.defer_slot with
|
||
| None -> body
|
||
| Some s -> defer_counter_zero s fn.Ast.nloc :: body
|
||
in
|
||
{ Tast.name = fn.Ast.name; params;
|
||
slots = Array.of_list (List.rev ctx.slot_tys);
|
||
snames = Array.of_list (List.rev ctx.slot_names);
|
||
(* The same defers again, for the transfer exit path §5 describes. The
|
||
normal path has them spliced into [body] above; this one is guarded on
|
||
the count, because a transfer can start above a defer that the text has
|
||
not reached and cleanup over an unwritten binding is not cleanup. *)
|
||
ret; body;
|
||
fdefers =
|
||
(match ctx.defer_slot with
|
||
| None -> ctx.defers
|
||
| Some s -> guarded_defers s ctx.defers);
|
||
fparent = None; floc = fn.Ast.nloc }
|
||
|
||
(* The generic body, checked once with its variables abstract. Nothing is kept
|
||
— the [Tast.fn] it produces is thrown away, and so is anything it lifted —
|
||
because a generic function has no code: only its instantiations do. What is
|
||
kept is the *refusal*: an operator an unconstrained variable does not
|
||
support fails here, at the definition, naming the variable, rather than at
|
||
whichever call site happened to instantiate it at a type that worked.
|
||
|
||
The holes in it are real and are the report's business: [println] is
|
||
plan.org's one compiler-provided exception and this pass rejects it. *)
|
||
and check_generic env (fn : Ast.fn) =
|
||
let vars, params, ret = Hashtbl.find env.gsigs fn.Ast.name in
|
||
let saved_lifted = env.lifted and saved_vars = env.tyvars
|
||
and saved_preds = env.tvpreds in
|
||
env.tyvars <- vars;
|
||
(* What the abstract pass may assume. Every operator the body reaches asks
|
||
[env.tvpreds] whether the variable was declared to support it, and every
|
||
instantiation asks the concrete type the same question again. *)
|
||
env.tvpreds <- fn.Ast.fwhere;
|
||
Hashtbl.replace env.fns fn.Ast.name (params, ret);
|
||
let finish () =
|
||
Hashtbl.remove env.fns fn.Ast.name;
|
||
env.lifted <- saved_lifted;
|
||
env.tyvars <- saved_vars;
|
||
env.tvpreds <- saved_preds
|
||
in
|
||
(match check_fn env fn with
|
||
| _ -> finish ()
|
||
| exception e -> finish (); raise e)
|
||
|
||
(* The knot from [instantiate]: a call site makes a copy, and making one is
|
||
checking a function. *)
|
||
let () = check_fn_ref := check_fn
|
||
|
||
(* A container's only compile-time *constant* is the zeroed one: a Vec's or a
|
||
Map's real value exists at run time, behind an allocator. That fact has not
|
||
changed. What changed is what follows from it.
|
||
|
||
It used to follow that a container global could only ever start zeroed, and
|
||
the argument was that there was nowhere for a computed initialiser to run:
|
||
[Emit.const] said "there is no init-at-startup path, by design", and the
|
||
backend that did run initialisers at startup ran them out of .init_array,
|
||
which a reload module deliberately has none of. A rule that held on one
|
||
backend and not the other would not be a rule, so the language refused the
|
||
form on both.
|
||
|
||
There is an init-at-startup path now, and it is the same one on both
|
||
backends: a computed initialiser is lifted into a function of its own and
|
||
the program calls it from [main], after the runtime is up and before any of
|
||
the program's own code runs. So the premise is gone and the refusal goes
|
||
with it. (defonce g (Vec u8) (slurp "level.edn")) is an ordinary program now,
|
||
and it is the shape the author kept writing.
|
||
|
||
What is still refused is [uninit] on one, and that is a different rule with
|
||
a reason of its own: a Vec's garbage pointer is not a garbage number. Every
|
||
operation on it dereferences a block address nobody wrote, where a zeroed
|
||
Vec is a real empty Vec — null block, zero length, zero capacity — and is
|
||
the value a program would have written anyway.
|
||
|
||
What a runtime-loaded global is for has not changed either. The data is
|
||
loaded once and it outlives main: a main that returns and is entered again
|
||
finds the global exactly as it left it, because nothing between the two runs
|
||
touches it — and that now includes a reload, which does not re-run
|
||
initialisers. Assigning a second time overwrites the first block and leaks
|
||
it — there is no [drop] and no cross-function flow analysis that could see
|
||
the second assignment, so that is the manual-memory answer and the
|
||
language's own: free is a thing you write. *)
|
||
let rec zero_only (t : Types.t) =
|
||
match t with
|
||
| Types.Vec _ | Types.Map _ -> true
|
||
| Types.Option e | Types.Array (_, e) -> zero_only e
|
||
| _ -> false
|
||
|
||
let container_global_init loc n (ty : Types.t) (init : Ast.init) =
|
||
if zero_only ty then
|
||
match init with
|
||
| Ast.Uninit ->
|
||
fail loc
|
||
"the global %s is %s, and uninit on one is refused: its block pointer \
|
||
steers every read of it, and garbage there is not a garbage number \
|
||
the way it is for an f64. Write (defonce %s %s) with no initialiser — \
|
||
a zeroed %s is an empty one, and that is a value, not a placeholder"
|
||
n (Types.to_string ty) n (Types.to_string ty) (Types.to_string ty)
|
||
| _ -> ()
|
||
|
||
(* A container global has to be a [defonce]. A [defconst] is not an assignable
|
||
place — [check_place] refuses one by name — and a container's only constant
|
||
is the zeroed one, so a constant Vec could only ever hold the empty value
|
||
it was declared with: nothing could ever put the file's bytes in it.
|
||
Refused here, where the fix is one keyword, rather than at the (set ...)
|
||
that discovers it three forms later. *)
|
||
let no_container_defconst loc n (ty : Types.t) =
|
||
if zero_only ty then
|
||
fail loc
|
||
"the global %s is %s, and a %s global is a defonce and not a defconst: \
|
||
a constant is not an assignable place, so nothing could ever load \
|
||
this one — it would stay the empty %s it was declared as. Write \
|
||
(defonce %s %s) and fill it in a function"
|
||
n (Types.to_string ty) (Types.to_string ty) (Types.to_string ty)
|
||
n (Types.to_string ty)
|
||
|
||
(* A union member written into a *constant* would have to be encoded into the
|
||
blob at link time, which is the byte-level encoder a data type case does not
|
||
have either: a constant is what the linker writes, and a union value is a
|
||
store. Refused here, where the message can name the way through, rather than
|
||
at the emitter as "this one is computed", which is true and says nothing.
|
||
|
||
A defonce is no longer any of this and no longer asks. Its computed
|
||
initialiser is lifted into a function that runs at startup, so the member is
|
||
written by exactly the store that writes one anywhere else — the encoder was
|
||
only ever needed because there was nothing to run.
|
||
|
||
A zeroed union needs none of this either and is the ordinary declaration. *)
|
||
let no_union_const env loc n (v : Tast.expr) =
|
||
match v.Tast.ty, v.Tast.e with
|
||
(* The all-bytes-zero value is a constant and needs none of this, so it is
|
||
the one initialiser that goes through — which is what makes (U {}) and a
|
||
declaration with no value the same thing here as everywhere else. *)
|
||
| _, (Tast.Zero _ | Tast.Uninit _) -> ()
|
||
| Types.Named un, _ when Hashtbl.mem env.unions un ->
|
||
fail loc
|
||
"the constant %s is the union %s, and a union member cannot be written \
|
||
into a constant: a constant is what the linker writes into the image \
|
||
and storing a member is a store. Leave it zeroed, or make it a defonce \
|
||
and let its initialiser run at startup"
|
||
n un
|
||
| _ -> ()
|
||
|
||
(* A defconst's value is what the linker writes into the program's image, so it
|
||
has to be a value the linker can write: a literal, a zero, an aggregate of
|
||
those — [Tast.const_init]'s set, which is [Emit.const]'s accepted set asked
|
||
as a question. The integer arithmetic a defconst is allowed to be written as
|
||
is already gone by here: [collect]'s folding pass turned [(/ screen-height
|
||
cell-size)] into its answer before any type resolved, so what arrives here
|
||
is an [Int] and passes.
|
||
|
||
The author's rule, 2026-09-20: "defconst should not be computed, it's the
|
||
equivalent to a compiler const." Refused here rather than in a backend
|
||
because a backend can only refuse the program it is asked to emit, and the
|
||
two were not asking the same question — [Emit.const] refused a computed
|
||
defconst by name while the x86 backend ran it through the startup function
|
||
behind an [.init~once.] flag, like a defonce. One refusal in the checker is
|
||
the same program refused the same way on both, and it is the only place
|
||
that can say what to do instead.
|
||
|
||
A data type case says it with its own message, which is the one thing a
|
||
reader could not work out from "this is computed": the case would have to be
|
||
serialised into the payload blob, and that is an encoder rather than an
|
||
order of operations. It is searched for the way [Emit.const] used to find it
|
||
— down through the aggregates, because a case inside a struct literal is the
|
||
same unwritable value as a case on its own, and [(defconst g S (S {.u (U.B
|
||
{.x 1})}))] told about "computed" would be advice nobody could follow.
|
||
|
||
Left to right and stopping at the first value the image cannot hold, which
|
||
is [Emit.const]'s own order: it spelled the fields in order and failed at
|
||
the one it could not spell. So a computed field written before a case field
|
||
is still the general message, because that is the field a reader meets
|
||
first. *)
|
||
(* The first subexpression a constant image has no value for, descending
|
||
through the aggregates whose parts are themselves constants. [None] is a
|
||
value the linker can write, which is [Tast.const_init] arrived at from the
|
||
other side — the two walk the same nodes, and this one keeps the offender
|
||
rather than the verdict. *)
|
||
let rec unwritable (e : Tast.expr) =
|
||
match e.Tast.e with
|
||
| Tast.Int _ | Tast.Float _ | Tast.Bool _ | Tast.Str _ | Tast.Unit
|
||
| Tast.Zero _ | Tast.Uninit _ | Tast.None_ -> None
|
||
| Tast.Make (_, es) | Tast.Arr es -> List.find_map unwritable es
|
||
| Tast.Some_ v -> unwritable v
|
||
| _ -> Some e
|
||
|
||
let const_defconst_init env loc n (v : Tast.expr) =
|
||
match unwritable v with
|
||
| None -> ()
|
||
| Some { Tast.e = Tast.MakeCase (dname, case, _); _ } ->
|
||
fail loc
|
||
"a constant cannot be %s.%s — a data type's payload is a blob, and \
|
||
writing a case into one at link time needs a byte-level encoder that \
|
||
does not exist (a string field could not be encoded at all). Make it a \
|
||
defonce, whose initialiser runs at startup and stores the case, or \
|
||
declare it zeroed, which is %s.%s"
|
||
dname case dname
|
||
(match Hashtbl.find_opt env.datas dname with
|
||
| Some { Tast.cases = c :: _; _ } -> c.Tast.vname
|
||
| _ -> "its first case")
|
||
| Some _ ->
|
||
fail loc
|
||
"a constant's value must be a compile-time constant — the constant %s \
|
||
is computed. A defonce may have a computed initialiser, because it runs \
|
||
at startup and stores the result; a defconst is what the linker writes \
|
||
into the image and has nowhere to run. Write (defonce %s ...), or give \
|
||
the constant a literal — integer constants may also be written as \
|
||
arithmetic over literals and other constants, which is folded here"
|
||
n n
|
||
|
||
(* A global's initialiser runs at startup: from [main], after the runtime is
|
||
up, before a line of the program's own code. Nothing has established a
|
||
handler or a restart by then, and nothing outside the initialiser can — the
|
||
program has not started.
|
||
|
||
That used to be the reason all four of the condition forms were refused
|
||
inside one, and the refusal lived in [x86.ml] because that was the only
|
||
backend with an init-at-startup path. Its argument was that a transfer out
|
||
of an initialiser would return into the loader, which was true of a
|
||
.init_array constructor and is not true of a call from [main]. So the rule
|
||
is narrower now, and what is left of it is what is still true.
|
||
|
||
A [handler-bind] or a [restart-case] *inside* an initialiser is ordinary
|
||
code: it pushes its frames, runs, and pops them, all before the initialiser
|
||
returns, and nothing it does is visible outside. Both backends run it
|
||
exactly as they run it in any other function — which is what makes (defonce
|
||
data (Vec u8) (slurp "level.edn")) an ordinary program, since [slurp] is a
|
||
restart-case with its own signal inside it, and that is the shape the author
|
||
kept reaching for.
|
||
|
||
What is refused is a [signal] or an [invoke-restart] with no condition
|
||
machinery around it at all. Everywhere else in the language those two are
|
||
answered by a frame some *caller* established; in an initialiser there is no
|
||
caller, so one with nothing around it is inert by construction — a signal
|
||
nothing can hear, or an invoke-restart that can only fail at the invoke
|
||
site. Either form counts as enclosure, including a restart-case around a
|
||
signal: that pair is [slurp], and a restart-case says what the initialiser
|
||
wants to happen when nobody answers, which is the thing an unenclosed one
|
||
cannot say.
|
||
|
||
A clause's body is not in this walk at all — a handler-bind clause is lifted
|
||
into a function of its own — so the [invoke-restart] a handler writes is
|
||
never the one refused here. *)
|
||
let no_transfer_in_init n (v : Tast.expr) =
|
||
(* The nodes that are under a handler or a restart within this initialiser,
|
||
by identity: [Tast.walk] visits nodes rather than paths, so the enclosure
|
||
is recorded in one pass and asked in the next. *)
|
||
let covered = ref [] in
|
||
Tast.walk
|
||
(fun (e : Tast.expr) ->
|
||
let cover body = List.iter (Tast.walk (fun x -> covered := x :: !covered)) body in
|
||
match e.Tast.e with
|
||
| Tast.Handled (_, body) -> cover body
|
||
| Tast.RestartCase (cs, body) ->
|
||
cover [ body ];
|
||
List.iter (fun (c : Tast.rclause) -> cover c.Tast.rbody) cs
|
||
| _ -> ())
|
||
v;
|
||
Tast.walk
|
||
(fun (e : Tast.expr) ->
|
||
let bad what can =
|
||
fail e.Tast.loc
|
||
"%s in the initialiser of the global %s, with no handler-bind or \
|
||
restart-case around it: an initialiser runs at startup, before the \
|
||
program has a caller that could have established one, so this can \
|
||
only %s. Write one inside the initialiser — they run there like \
|
||
anywhere else — or move the whole thing into a function the \
|
||
program calls"
|
||
what n can
|
||
in
|
||
if List.memq e !covered then ()
|
||
else
|
||
match e.Tast.e with
|
||
| Tast.Signal _ -> bad "signal" "go unheard"
|
||
| Tast.InvokeRestart _ -> bad "invoke-restart" "fail at the invoke site"
|
||
| _ -> ())
|
||
v
|
||
|
||
(* A computed initialiser, lifted into a function of its own that returns the
|
||
value. The global's initialiser becomes the call, which is the whole of what
|
||
the backends had to learn: one of them already lowers an initialiser as
|
||
ordinary code and now lowers a call, and the other emits the global zeroed
|
||
and stores the call's result at startup.
|
||
|
||
A function rather than the expression left in place, for a reason that is
|
||
not tidiness: an initialiser can contain a [let], and a [let] needs a frame.
|
||
The slots were allocated on a context this function discarded, so what the
|
||
backend got was a slot index into a frame of size zero — [(defonce c i64 (let
|
||
[x (i64 5)] (+ x 1)))] crashed the x86 backend with an out-of-bounds index,
|
||
and there was no frame to give it without inventing one. This is that frame,
|
||
and it is the one every other body already has.
|
||
|
||
[fparent] is the global rather than a function, which is a small widening of
|
||
what the field means: nobody wrote this name, so completing it or jumping to
|
||
it is meaningless, and the one reader that asks — [Dev]'s [defs] — wants
|
||
exactly that answer. The others are unaffected: a whole-program build emits
|
||
a cell for every function it emits, and a redefinition module reaches this
|
||
one through neither, because a reload does not run initialisers. *)
|
||
let lift_ginit ctx loc n ty (v : Tast.expr) =
|
||
no_transfer_in_init n v;
|
||
let fname = "global/" ^ n in
|
||
ctx.env.lifted <-
|
||
{ Tast.name = fname; params = [];
|
||
slots = Array.of_list (List.rev ctx.slot_tys);
|
||
snames = Array.of_list (List.rev ctx.slot_names);
|
||
(* An initialiser is a nested form as far as [defer_ok] is concerned, so
|
||
nothing can register one here and both of these are empty. Written the
|
||
same way [check_fn] writes them anyway, so that the day the rule
|
||
widens this does not quietly become the one exit path that runs a
|
||
defer nobody reached. *)
|
||
ret = ty;
|
||
body =
|
||
(match ctx.defer_slot with
|
||
| None -> [ v ]
|
||
| Some s -> [ defer_counter_zero s loc; v ]);
|
||
fdefers =
|
||
(match ctx.defer_slot with
|
||
| None -> ctx.defers
|
||
| Some s -> guarded_defers s ctx.defers);
|
||
fparent = Some n; floc = loc }
|
||
:: ctx.env.lifted;
|
||
{ Tast.e = Tast.Call (fname, []); ty; loc }
|
||
|
||
let check_global env (d : Ast.decl) : Tast.global option =
|
||
let ctx () = invented_ctx env Types.Unit in
|
||
match d.Ast.d with
|
||
| Ast.Defvar (n, _, init, kind) ->
|
||
let ty, _ = Hashtbl.find env.globals n in
|
||
no_zeroed_fn d.Ast.dloc (Printf.sprintf "the global %s" n) ty;
|
||
container_global_init d.Ast.dloc n ty init;
|
||
(* A [def]'s initialiser is lifted into [global/<n>] whatever it is — a
|
||
zero, a literal, a computed expression — where a [defonce]'s is lifted
|
||
only when it is computed. The lifting is what makes the form's promise
|
||
reachable: the host's startup function calls the initialiser through
|
||
its function cell, so a re-evaluated [def] swaps the cell and the next
|
||
re-run stores the *edited* value. A constant left inline would be
|
||
baked into the host's startup body, and every re-run would paint the
|
||
stale value back. [uninit] is the one exception on both forms: there
|
||
is nothing to run, so there is nothing to lift. *)
|
||
let lift_always = (match kind with Ast.Once -> false | Ast.Every -> true) in
|
||
let ginit =
|
||
match init with
|
||
| Ast.Zeroed when lift_always ->
|
||
let c = ctx () in
|
||
lift_ginit c d.Ast.dloc n ty { Tast.e = Tast.Zero ty; ty; loc = d.Ast.dloc }
|
||
| Ast.Zeroed -> { Tast.e = Tast.Zero ty; ty; loc = d.Ast.dloc }
|
||
| Ast.Uninit ->
|
||
(* Everywhere else [uninit] is an opt-out from ZII and the bytes are
|
||
whatever they were: a garbage f64 is a garbage number. A data type is
|
||
the one type where that is qualitatively worse — the tag steers
|
||
control flow, a tag no case names falls past every comparison in a
|
||
[match], and the block after them is [unreachable], which LLVM is
|
||
entitled to assume cannot happen. So the one place where garbage
|
||
becomes "the optimiser may do anything" is refused by name, and the
|
||
zeroed form, which is the first declared case, is named beside it. *)
|
||
(match ty with
|
||
| Types.Named un when Hashtbl.mem env.datas un ->
|
||
fail d.Ast.dloc
|
||
"%s is a data type, and uninit on one is refused: its tag steers \
|
||
every match, and a tag no case names has no arm to reach. Drop \
|
||
the uninit — a zeroed %s is %s, which is a real case"
|
||
(Types.to_string ty) un
|
||
(match Hashtbl.find_opt env.datas un with
|
||
| Some { Tast.cases = c :: _; _ } -> un ^ "." ^ c.Tast.vname
|
||
| _ -> "its first case")
|
||
| _ -> ());
|
||
{ Tast.e = Tast.Uninit ty; ty; loc = d.Ast.dloc }
|
||
| Ast.Init v ->
|
||
let c = ctx () in
|
||
let v = check c ~want:ty v in
|
||
if Tast.const_init v && not lift_always then v
|
||
else lift_ginit c d.Ast.dloc n ty v
|
||
(* [settle_defvars] turned every one of these into a [Zeroed] or an
|
||
[Init] during [collect], and this pass runs over the list that pass
|
||
handed back. One arriving here is a driver that checked a global
|
||
without collecting first. *)
|
||
| Ast.Ambiguous _ ->
|
||
fail d.Ast.dloc
|
||
"internal: the third element of (%s %s ...) was never decided"
|
||
(match kind with Ast.Once -> "defonce" | Ast.Every -> "def") n
|
||
in
|
||
Some { Tast.gname = n; gty = ty; ginit; gconst = false; gfolded = false;
|
||
grerun = (match kind with Ast.Once -> false | Ast.Every -> true) }
|
||
| Ast.Defconst (n, _, v) ->
|
||
let ty, _ = Hashtbl.find env.globals n in
|
||
no_zeroed_fn d.Ast.dloc (Printf.sprintf "the global %s" n) ty;
|
||
no_container_defconst d.Ast.dloc n ty;
|
||
(* [collect] already folded the integer constants, because an array length
|
||
has to be known before any type resolves. Use that value here rather
|
||
than the expression it came from: a global's initialiser has to be a
|
||
compile-time constant, and [(/ screen-height cell-size)] is one — the
|
||
folding pass is the only thing that knows it. *)
|
||
let ginit =
|
||
match Hashtbl.find_opt env.consts n, ty with
|
||
| Some k, Types.Int kind ->
|
||
(* Still range-checked: this path skips [check], and [in_range] is the
|
||
only thing that rejects 300 as a u8. *)
|
||
{ Tast.e = Tast.Int (in_range d.Ast.dloc kind k, kind); ty;
|
||
loc = d.Ast.dloc }
|
||
| _ -> check (ctx ()) ~want:ty v
|
||
in
|
||
no_union_const env d.Ast.dloc n ginit;
|
||
(* After the union's own refusal, so a computed union member keeps the
|
||
message that names its way through rather than the general one. *)
|
||
const_defconst_init env d.Ast.dloc n ginit;
|
||
(* [env.consts] holds exactly the constants the folding pass consumed, so
|
||
membership is the question "is this value in the program's shape?" *)
|
||
Some { Tast.gname = n; gty = ty; ginit; gconst = true;
|
||
gfolded = Hashtbl.mem env.consts n; grerun = false }
|
||
| _ -> None
|
||
|
||
(* The entry point, plan.org: (defn main [args [string]] i32), with both the
|
||
parameter and the return type optional. *)
|
||
(* Where [main] was written. [env.locs] is the table of where each *type* was
|
||
declared — [collect] fills it for structs, data types, unions and enums and
|
||
for nothing else — so a function's own location is not in it and the
|
||
[find_opt] idiom the rest of this file uses does not apply here. The
|
||
declaration list does have it, and the caller is holding the list anyway.
|
||
|
||
Without this both refusals below opened with <unknown>:0:0, which tells a
|
||
reader that a rule exists and not where they broke it, and gives
|
||
[next-error] nothing to jump to. A [main] that arrived some other way — a
|
||
[declare], say — still has no [defn] to point at, so that case keeps the
|
||
unknown span rather than inventing one. *)
|
||
let main_loc (decls : Ast.decl list) =
|
||
let is_main (d : Ast.decl) =
|
||
match d.Ast.d with Ast.Defn fn -> fn.Ast.name = "main" | _ -> false
|
||
in
|
||
match List.find_opt is_main decls with
|
||
| Some { Ast.d = Ast.Defn fn; _ } -> fn.Ast.nloc
|
||
| _ -> Loc.unknown
|
||
|
||
let check_main env decls =
|
||
let at = main_loc decls in
|
||
match Hashtbl.find_opt env.fns "main" with
|
||
| None -> () (* a library, or a file being checked on its own *)
|
||
| Some (params, ret) ->
|
||
let ok_params =
|
||
match params with
|
||
| [] -> true
|
||
| [ Types.Slice Types.String ] -> true
|
||
| _ -> false
|
||
in
|
||
if not ok_params then
|
||
fail at
|
||
"main takes no parameters or one [string], not (%s)"
|
||
(String.concat " " (List.map Types.to_string params));
|
||
if not (Types.equal ret Types.Unit || Types.equal ret (Types.Int Types.I32))
|
||
then
|
||
fail at "main returns i32 or nothing, not %s"
|
||
(Types.to_string ret)
|
||
|
||
(* The environment as well as the program. A session needs it to check an
|
||
expression typed at a REPL against the program the process is running — and
|
||
it has to be this one rather than anything rebuilt from declarations,
|
||
because [program] prepends the prelude and no accumulated AST contains it. *)
|
||
(* Separate entry points below rather than a flag on the one the session calls,
|
||
for the reason [Parse] gives at the same fork: [Loc.Errors] is a second
|
||
exception that the session and the daemon do not catch, so the guarantee
|
||
that they never see one should be structural and not a default argument. *)
|
||
(* ── The order the initialisers run in ─────────────────────────────── *)
|
||
|
||
(* Declaration order is the order a program's globals are started in, and it is
|
||
the wrong one as soon as one of them is computed from another: [(defonce b
|
||
i64 (+ a 10))] written above [(defonce a i64 (+ 1 2))] read a zero and
|
||
answered 10 without saying anything. So the computed ones are sorted
|
||
by what they need, which is what Odin does (src/checker.cpp,
|
||
[calculate_global_init_order]) and for the same reason — the alternative is
|
||
a rule about where in the file a global has to be written, which is a rule
|
||
about text rather than about meaning.
|
||
|
||
Only the computed globals are sorted, and only against each other. A
|
||
constant initialiser cannot read a global at all — [Tast.const_init]'s
|
||
accepted set has no [Global] in it — so a constant is already there before
|
||
anything runs: it is in the object image on one backend and written from
|
||
.init_array before main on the other. That makes the partition total and the
|
||
sort small, and it is why a global initialised from a [defconst] needs no
|
||
edge.
|
||
|
||
The dependency is transitive through calls, not just through what the
|
||
initialiser names: [(defonce a i64 (f))] where [f] reads [b] needs [b]
|
||
started first, and an analysis that only looked at the initialiser's own
|
||
text would order that pair by luck. Odin's graph is transitive for the same
|
||
reason.
|
||
|
||
A cycle is refused rather than broken. Some global in it would have to be
|
||
started from another's zero, and which one that is cannot be read off the
|
||
program — the two spellings of the same cycle would differ only in which
|
||
line the compiler happened to reach first. *)
|
||
let init_order (globals : Tast.global list) (fns : Tast.fn list) =
|
||
let computed =
|
||
List.filter
|
||
(fun (g : Tast.global) -> not (Tast.const_init g.Tast.ginit))
|
||
globals
|
||
in
|
||
if computed = [] then globals
|
||
else begin
|
||
let is_computed = Hashtbl.create 8 in
|
||
List.iter
|
||
(fun (g : Tast.global) -> Hashtbl.replace is_computed g.Tast.gname ())
|
||
computed;
|
||
let ftbl = Hashtbl.create 64 in
|
||
List.iter (fun (f : Tast.fn) -> Hashtbl.replace ftbl f.Tast.name f) fns;
|
||
(* What each function reads, to a fixpoint over the call graph: its own
|
||
references, plus everything its callees read. A round that changes
|
||
nothing is the answer, which needs no special case for a recursive
|
||
function and no visited set to get wrong. *)
|
||
let reads = Hashtbl.create 64 in
|
||
let calls = Hashtbl.create 64 in
|
||
let add tbl k v =
|
||
let cur = try Hashtbl.find tbl k with Not_found -> [] in
|
||
if not (List.mem v cur) then Hashtbl.replace tbl k (v :: cur)
|
||
in
|
||
List.iter
|
||
(fun (f : Tast.fn) ->
|
||
let note n =
|
||
if Hashtbl.mem is_computed n then add reads f.Tast.name n
|
||
else if Hashtbl.mem ftbl n then add calls f.Tast.name n
|
||
in
|
||
List.iter (Reach.expr_refs note) f.Tast.body;
|
||
List.iter (Reach.expr_refs note) f.Tast.fdefers)
|
||
fns;
|
||
let changed = ref true in
|
||
while !changed do
|
||
changed := false;
|
||
Hashtbl.iter
|
||
(fun caller callees ->
|
||
List.iter
|
||
(fun callee ->
|
||
List.iter
|
||
(fun g ->
|
||
let cur = try Hashtbl.find reads caller with Not_found -> [] in
|
||
if not (List.mem g cur) then begin
|
||
Hashtbl.replace reads caller (g :: cur);
|
||
changed := true
|
||
end)
|
||
(try Hashtbl.find reads callee with Not_found -> []))
|
||
callees)
|
||
(Hashtbl.copy calls)
|
||
done;
|
||
(* And what each computed global needs, which is the same walk over its
|
||
initialiser — whose one node is a call to the function the initialiser
|
||
was lifted into, so the answer is that function's reads. *)
|
||
let needs (g : Tast.global) =
|
||
let acc = ref [] in
|
||
let note n =
|
||
let put r = if not (List.mem r !acc) then acc := r :: !acc in
|
||
if Hashtbl.mem is_computed n then put n
|
||
else List.iter put (try Hashtbl.find reads n with Not_found -> [])
|
||
in
|
||
Reach.expr_refs note g.Tast.ginit;
|
||
if List.mem g.Tast.gname !acc then
|
||
fail g.Tast.ginit.Tast.loc
|
||
"the global %s is initialised from itself: its own value is what the \
|
||
initialiser is producing, so there is nothing there to read but the \
|
||
zero it starts as. Leave it zeroed and load it in a function"
|
||
g.Tast.gname;
|
||
!acc
|
||
in
|
||
let deps = List.map (fun (g : Tast.global) -> (g.Tast.gname, needs g)) computed in
|
||
let deps_of n = try List.assoc n deps with Not_found -> [] in
|
||
(* Kahn's, in declaration order: of the globals that are ready, the one
|
||
written first goes first, so the emitted order is the source's wherever
|
||
the source's order was possible at all. *)
|
||
let done_ = Hashtbl.create 8 in
|
||
let order = ref [] in
|
||
let progress = ref true in
|
||
while !progress do
|
||
progress := false;
|
||
List.iter
|
||
(fun (g : Tast.global) ->
|
||
if not (Hashtbl.mem done_ g.Tast.gname)
|
||
&& List.for_all (fun d -> Hashtbl.mem done_ d) (deps_of g.Tast.gname)
|
||
then begin
|
||
Hashtbl.replace done_ g.Tast.gname ();
|
||
order := g :: !order;
|
||
progress := true
|
||
end)
|
||
computed
|
||
done;
|
||
(match
|
||
List.filter
|
||
(fun (g : Tast.global) -> not (Hashtbl.mem done_ g.Tast.gname))
|
||
computed
|
||
with
|
||
| [] -> ()
|
||
| (g : Tast.global) :: _ ->
|
||
(* The whole ring, not one name out of it. A cycle is a fact about a set
|
||
of globals and a message naming one of them leaves the reader to find
|
||
the rest; naming each edge says which read to break. *)
|
||
let stuck n = not (Hashtbl.mem done_ n) && List.mem_assoc n deps in
|
||
let rec ring path n =
|
||
if List.mem n path then
|
||
let rec cut = function
|
||
| [] -> []
|
||
| x :: r -> if String.equal x n then x :: r else cut r
|
||
in
|
||
cut path
|
||
else
|
||
match List.find_opt stuck (deps_of n) with
|
||
| None -> path @ [ n ]
|
||
| Some d -> ring (path @ [ n ]) d
|
||
in
|
||
let r = ring [] g.Tast.gname in
|
||
let edges =
|
||
List.mapi
|
||
(fun i n ->
|
||
Printf.sprintf "%s needs %s's value" n
|
||
(List.nth r ((i + 1) mod List.length r)))
|
||
r
|
||
in
|
||
fail g.Tast.ginit.Tast.loc
|
||
"the globals %s initialise each other: %s. One of them has to start \
|
||
without the other — leave it zeroed and load it in a function that \
|
||
runs once, where the order is yours to write"
|
||
(String.concat " and " r) (String.concat ", " edges));
|
||
(* The sorted sequence, dropped back into the slots the computed globals
|
||
already occupied. Everything else — a constant, a zeroed container —
|
||
stays exactly where it was declared, so a diff of the emitted image
|
||
shows the reordering and nothing else. *)
|
||
let seq = ref (List.rev !order) in
|
||
List.map
|
||
(fun (g : Tast.global) ->
|
||
if Hashtbl.mem is_computed g.Tast.gname then
|
||
match !seq with
|
||
| x :: rest -> seq := rest; x
|
||
| [] -> g
|
||
else g)
|
||
globals
|
||
end
|
||
|
||
(* ── What a per-type descriptor can reach ───────────────────────────────
|
||
*
|
||
* The struct dyn field is no longer refused: a type that holds dyn words at
|
||
* static offsets gets a descriptor naming those offsets, and every place a
|
||
* value of it can live — a frame slot, a global, a temporary a call answered
|
||
* with — goes on the collector's root stack with that descriptor beside it.
|
||
* runtime/flan_dyn.h's [flan_dyn_root_push_desc] is where the whole of that
|
||
* argument is written, including why no instance carries a header word.
|
||
*
|
||
* What a static offset cannot express is what is left, and it is refused here
|
||
* rather than emitted as a descriptor that quietly omits a field:
|
||
*
|
||
* - a dyn inside a typed container. A [(Vec S)] holds its elements in
|
||
* allocator memory of a length nothing static knows, so the dyn words of
|
||
* one are not a list of offsets. The M2 queue's item 3 is the
|
||
* descriptor that can say it — pointer, length and element type — and it is
|
||
* a different shape from this one, deliberately. A [(Map K V)] is the same
|
||
* fact twice over.
|
||
* - a dyn in a data type's payload. The cases overlay one another, so which
|
||
* words are dyn depends on the tag, which is a run-time question. The same
|
||
* goes for a C union's members.
|
||
* - a dyn inside an [(Option T)], whose payload exists only under the tag: the
|
||
* words of a [None] are zero and marking them is harmless, but that is a
|
||
* fact about how this compiler happens to build one and not something the
|
||
* type says, and a descriptor that relied on it would be relying on it
|
||
* silently.
|
||
*
|
||
* A [(Ptr S)] and a [[S]] are deliberately *not* on that list, and the reason
|
||
* is worth stating because it looks like an omission. Neither owns storage.
|
||
* The only storage this compiler hands out for a type that holds dyn is a
|
||
* frame slot, a global, or a fixed array inside one of those — and all three
|
||
* are rooted with their descriptor already, so a pointer or a slice into one
|
||
* addresses bytes the collector is marking. That is what makes a condition's
|
||
* payload work at all: a handler clause is lifted to a function taking a
|
||
* [(Ptr Cond)], and the value it points at is in the signalling frame with a
|
||
* descriptor beside it. Storage that came from C is the program's business the
|
||
* way every other pointer from C is.
|
||
*
|
||
* And one cap, which is not a representation question but an arithmetic one:
|
||
* the offsets of a fixed array are flattened one element at a time, so an
|
||
* array of a million structs would be a million-word table in .rodata. The
|
||
* repeat form that avoids it is item 3's machinery, so this says so instead of
|
||
* building half of it. *)
|
||
|
||
let desc_offsets_max = 4096
|
||
|
||
(* Is there a dyn in the storage a value of this type *is*, which is not the
|
||
same as whether its printed form mentions dyn anywhere. A pointer and a
|
||
slice are stopped at, because what they address is somebody else's storage
|
||
and is rooted where it was declared. That is the same line [hidden_dyn]
|
||
takes for a bare [(Ptr S)], and taking it here as well is what lets
|
||
[(Vec (Ptr Cond))] be written — a vector of pointers to condition structs
|
||
holds no dyn words of its own, and refusing it with a sentence about the dyn
|
||
inside it was wrong twice over. *)
|
||
let rec dyn_reach ~through p seen (t : Types.t) =
|
||
let go = dyn_reach ~through p seen in
|
||
match t with
|
||
| Types.Dyn -> true
|
||
| Types.Array (_, e) | Types.Vec e | Types.Option e -> go e
|
||
| Types.Map (k, v) -> go k || go v
|
||
| Types.Ptr e | Types.Slice e -> through && go e
|
||
| Types.Fn _ -> false
|
||
| Types.Named n when not (List.mem n seen) ->
|
||
let seen = n :: seen in
|
||
let field (fl : Tast.field) = dyn_reach ~through p seen fl.Tast.fty in
|
||
(match List.find_opt (fun (s : Tast.structure) -> s.Tast.sname = n)
|
||
p.Tast.structs with
|
||
| Some s -> List.exists field s.Tast.fields
|
||
| None ->
|
||
match List.find_opt (fun (u : Tast.data) -> u.Tast.dname = n)
|
||
p.Tast.datas with
|
||
| Some u ->
|
||
List.exists
|
||
(fun (c : Tast.variant) -> List.exists field c.Tast.vfields)
|
||
u.Tast.cases
|
||
| None ->
|
||
match List.find_opt (fun (u : Tast.structure) -> u.Tast.sname = n)
|
||
p.Tast.unions with
|
||
| Some u -> List.exists field u.Tast.fields
|
||
| None -> false)
|
||
| _ -> false
|
||
|
||
let dyn_anywhere p seen t = dyn_reach ~through:false p seen t
|
||
|
||
(* The other question, and it is a different one: is there a dyn reachable from
|
||
here *at all*, pointers and slices followed. Only the foreign boundary asks
|
||
it, and it has to — the storage on the far side of a pointer is rooted where
|
||
it was declared when the declaration was Flan's, and is rooted nowhere at
|
||
all when it was C's. Keeping the two apart is the whole of the fix: the
|
||
narrowing above is right for a Flan type, and reusing it at the boundary
|
||
made [(Ptr (Ptr S))] and [(Ptr [S])] answer no. *)
|
||
let dyn_through p seen t = dyn_reach ~through:true p seen t
|
||
|
||
(* Is a dyn reachable from here only by going through a pointer or a slice.
|
||
This is the parameter question. Flan supplies the storage one level down
|
||
from a foreign parameter — it passes the address of a place, and a place is
|
||
a frame slot, a global or an array inside one, all of them rooted with their
|
||
descriptor — so a [(Ptr S)] parameter is an ordinary borrow and stays
|
||
writable. What is *below* that level is C's, and a pointer or a slice found
|
||
there is a hop into storage nothing rooted. *)
|
||
let rec dyn_behind_pointer p seen (t : Types.t) =
|
||
let go = dyn_behind_pointer p seen in
|
||
match t with
|
||
(* The crossing, and the [seen] set does *not* travel across it. The two
|
||
walks ask different questions — this one does not count a direct dyn, the
|
||
one below it does — so a name already visited on the way here would be
|
||
pruned from a question it was never asked. That is not a nicety: a struct
|
||
with a pointer to itself and a dyn field is exactly the shape that hits
|
||
it, and [(defstruct Node [next (Ptr Node) x dyn])] at [(Ptr Node)] was
|
||
accepted while the same thing unrolled into two types was refused. C
|
||
hung a node off [next], put a dyn in it, and the collector freed it —
|
||
which is the one failure this whole boundary exists to prevent.
|
||
|
||
It still terminates. This walk's own [seen] guards its own [Named]
|
||
recursion, and each crossing starts a separate finite walk of its own. *)
|
||
| Types.Ptr e | Types.Slice e -> dyn_through p [] e
|
||
| Types.Array (_, e) | Types.Vec e | Types.Option e -> go e
|
||
| Types.Map (k, v) -> go k || go v
|
||
| Types.Dyn | Types.Fn _ -> false
|
||
| Types.Named n when not (List.mem n seen) ->
|
||
let seen = n :: seen in
|
||
let field (fl : Tast.field) = dyn_behind_pointer p seen fl.Tast.fty in
|
||
(match List.find_opt (fun (s : Tast.structure) -> s.Tast.sname = n)
|
||
p.Tast.structs with
|
||
| Some s -> List.exists field s.Tast.fields
|
||
| None ->
|
||
match List.find_opt (fun (u : Tast.data) -> u.Tast.dname = n)
|
||
p.Tast.datas with
|
||
| Some u ->
|
||
List.exists
|
||
(fun (c : Tast.variant) -> List.exists field c.Tast.vfields)
|
||
u.Tast.cases
|
||
| None ->
|
||
match List.find_opt (fun (u : Tast.structure) -> u.Tast.sname = n)
|
||
p.Tast.unions with
|
||
| Some u -> List.exists field u.Tast.fields
|
||
| None -> false)
|
||
| _ -> false
|
||
|
||
(* How many dyn words a descriptor for this type would name, which is what the
|
||
cap above is about. Only the by-value shapes contribute; the rest are
|
||
refused by [hidden_dyn] before this number matters. *)
|
||
(* Saturated at one past the cap, because the number only ever has to be
|
||
compared with it. That is not tidiness: [(defonce big [4611686018427387904
|
||
S])] is a length an [Int64.to_int] multiplication wraps *negative* on, so an
|
||
honest product made the test [n > desc_offsets_max] false, the declaration
|
||
was accepted, and the emitter then sat building the offset list until
|
||
something killed it. A refusal that overflows into an acceptance is worse
|
||
than no refusal. Every arm below stays at or under [desc_offsets_max + 1],
|
||
so nothing here can multiply two numbers large enough to wrap. *)
|
||
let sat n = if n > desc_offsets_max then desc_offsets_max + 1 else n
|
||
|
||
let rec dyn_words p seen (t : Types.t) =
|
||
match t with
|
||
| Types.Dyn -> 1
|
||
| Types.Array (n, e) ->
|
||
let w = dyn_words p seen e in
|
||
(* Out of range in either direction saturates. A negative length is
|
||
nonsense and the layout refuses it further down, but this arm has to
|
||
answer *something*, and the one thing it must not answer is a small
|
||
number: [Int64.to_int (-1L) * w] is negative, which reads as under the
|
||
cap and is how the overflow above got through in the first place. *)
|
||
if w = 0 then 0
|
||
else if Int64.compare n 0L < 0
|
||
|| Int64.compare n (Int64.of_int (desc_offsets_max + 1)) > 0 then
|
||
desc_offsets_max + 1
|
||
else sat (Int64.to_int n * w)
|
||
| Types.Named nm when not (List.mem nm seen) ->
|
||
(match List.find_opt (fun (s : Tast.structure) -> s.Tast.sname = nm)
|
||
p.Tast.structs with
|
||
| Some s ->
|
||
List.fold_left
|
||
(fun acc (fl : Tast.field) ->
|
||
sat (acc + dyn_words p (nm :: seen) fl.Tast.fty))
|
||
0 s.Tast.fields
|
||
| None -> 0)
|
||
| _ -> 0
|
||
|
||
(* The first place under this type where a dyn sits that no descriptor reaches,
|
||
as the type to name in the refusal. *)
|
||
let rec hidden_dyn p seen (t : Types.t) : Types.t option =
|
||
let under e = if dyn_anywhere p seen e then Some t else None in
|
||
match t with
|
||
| Types.Dyn -> None
|
||
| Types.Array (_, e) -> hidden_dyn p seen e
|
||
| Types.Vec e | Types.Option e -> under e
|
||
| Types.Map (k, v) ->
|
||
if dyn_anywhere p seen k || dyn_anywhere p seen v then Some t else None
|
||
(* A pointer and a slice are views of storage something else roots; see the
|
||
note above. What they point at is checked where it is declared. *)
|
||
| Types.Ptr e | Types.Slice e -> hidden_dyn p seen e
|
||
| Types.Fn _ -> None
|
||
| Types.Named n when not (List.mem n seen) ->
|
||
let seen = n :: seen in
|
||
(match List.find_opt (fun (s : Tast.structure) -> s.Tast.sname = n)
|
||
p.Tast.structs with
|
||
| Some s ->
|
||
List.fold_left
|
||
(fun acc (fl : Tast.field) ->
|
||
match acc with
|
||
| Some _ -> acc
|
||
| None -> hidden_dyn p seen fl.Tast.fty)
|
||
None s.Tast.fields
|
||
| None ->
|
||
(* A data type's payload and a union's members both overlay, so any dyn
|
||
in one is hidden by the type itself and not by a member of it. *)
|
||
match List.find_opt (fun (u : Tast.data) -> u.Tast.dname = n)
|
||
p.Tast.datas with
|
||
| Some u ->
|
||
if List.exists
|
||
(fun (c : Tast.variant) ->
|
||
List.exists
|
||
(fun (fl : Tast.field) -> dyn_anywhere p seen fl.Tast.fty)
|
||
c.Tast.vfields)
|
||
u.Tast.cases
|
||
then Some t else None
|
||
| None ->
|
||
match List.find_opt (fun (u : Tast.structure) -> u.Tast.sname = n)
|
||
p.Tast.unions with
|
||
| Some u ->
|
||
if List.exists
|
||
(fun (fl : Tast.field) -> dyn_anywhere p seen fl.Tast.fty)
|
||
u.Tast.fields
|
||
then Some t else None
|
||
| None -> None)
|
||
| _ -> None
|
||
|
||
(* Every place a value can live: a global, a parameter, a return, a frame slot.
|
||
Two passes want exactly this list — the descriptor refusal below and the
|
||
[--no-gc] site collection at the bottom of the file — and each walked it
|
||
itself until the phrase naming an unnamed slot drifted between the two. One
|
||
walk now; the callers keep their own filters, which is where they really
|
||
differ.
|
||
|
||
[visit] is handed the location, the phrase naming the place, and the type.
|
||
[~slot] says whether this is a frame slot: that is the one distinction a
|
||
caller filters on, and a caller cannot recover it from the type. [?after_fn]
|
||
runs at the end of each function, before the next is begun, so that a caller
|
||
which also walks the body emits its diagnostics in the order a single loop
|
||
over [p.Tast.fns] gave them. *)
|
||
let value_sites (p : Tast.program) ?(after_fn = fun (_ : Tast.fn) -> ())
|
||
(visit : slot:bool -> _) =
|
||
List.iter
|
||
(fun (g : Tast.global) ->
|
||
visit ~slot:false g.Tast.ginit.Tast.loc
|
||
(Printf.sprintf "the global %s" g.Tast.gname) g.Tast.gty)
|
||
p.Tast.globals;
|
||
List.iter
|
||
(fun (fn : Tast.fn) ->
|
||
List.iteri
|
||
(fun i t ->
|
||
visit ~slot:false fn.Tast.floc
|
||
(Printf.sprintf "parameter %d of %s" (i + 1) fn.Tast.name) t)
|
||
fn.Tast.params;
|
||
visit ~slot:false fn.Tast.floc
|
||
(Printf.sprintf "the return type of %s" fn.Tast.name) fn.Tast.ret;
|
||
Array.iteri
|
||
(fun i t ->
|
||
(* A slot the program named is called by that name; one the checker
|
||
synthesised has none to give, and "a local" is the phrase both
|
||
passes now use for it. The preposition is [in] either way,
|
||
because a named slot has always read "%s in %s". *)
|
||
let named =
|
||
if i < Array.length fn.Tast.snames then fn.Tast.snames.(i)
|
||
else None
|
||
in
|
||
visit ~slot:true fn.Tast.floc
|
||
(Printf.sprintf "%s in %s"
|
||
(match named with Some n -> n | None -> "a local")
|
||
fn.Tast.name)
|
||
t)
|
||
fn.Tast.slots;
|
||
after_fn fn)
|
||
p.Tast.fns
|
||
|
||
(* Over the whole program rather than at each declaration, because the type
|
||
that hides a dyn may be declared after the one that names it — and because
|
||
a struct nobody ever holds a value of costs nothing either way. *)
|
||
let dyn_descriptors (p : Tast.program) =
|
||
let check loc what (t : Types.t) =
|
||
(match hidden_dyn p [] t with
|
||
| Some at ->
|
||
Loc.failk "check/dyn-descriptor" loc
|
||
"%s is %s, and the dyn inside %s is one no descriptor can find. The \
|
||
collector marks a struct's dyn fields by their byte offsets, which \
|
||
%s does not have — its storage is not part of the value. Hold the \
|
||
dyn in a struct field, or wait for the typed container view"
|
||
what (Types.to_string t) (Types.to_string at) (Types.to_string at)
|
||
| None -> ());
|
||
(* The count is saturated, so the message says more-than rather than a
|
||
figure the reader could check — which is the honest thing to print,
|
||
since the figure it would otherwise print is the one that wrapped. *)
|
||
if dyn_words p [] t > desc_offsets_max then
|
||
Loc.failk "check/dyn-descriptor" loc
|
||
"%s is %s, whose descriptor would name more than %d dyn words. The \
|
||
offsets of an array are flattened one element at a time, and %d is \
|
||
the most this compiler will write out — the repeat form that would \
|
||
avoid it arrives with the typed container view"
|
||
what (Types.to_string t) desc_offsets_max desc_offsets_max
|
||
in
|
||
(* The foreign boundary, which is the one place the note above admits an
|
||
honest hole. Every slot, global and array this compiler hands out for a
|
||
type that holds dyn is rooted with its descriptor. Storage that came from
|
||
C is not: nothing pushed a root for it, nothing ever will, and a dyn word
|
||
sitting in it is a live value the collector cannot see and will free. A
|
||
bare dyn is already refused by name at this boundary for a different
|
||
reason — C has no way to ask what the word means — and this is the same
|
||
sentence at one remove.
|
||
|
||
The rule is about *ownership* and not about shape, so the two directions
|
||
are asked different questions and the answer to "is a (Ptr S) allowed" is
|
||
"which way is it going":
|
||
|
||
- A return, and anything reachable from it however many pointers deep, is
|
||
C's storage. [(Ptr S)], [(Ptr (Ptr S))] and [(Ptr [S])] are all refused,
|
||
and the last two are the ones a one-level check missed — following a
|
||
pointer is exactly what [dyn_anywhere] stops doing, which is right for
|
||
a Flan type and wrong here.
|
||
- A parameter's outermost level is Flan's. The compiler passes the address
|
||
of a *place*, and a place is a frame slot, a global or an array inside
|
||
one — rooted with its descriptor and marked for the whole call. So
|
||
[(Ptr S)] and [[S]] as parameters are ordinary borrows and stay
|
||
writable, which is what a read-only C inspector wants and what
|
||
shim.ml's own advice tells people to write. Below that level the
|
||
storage is C's again: a [(Ptr (Ptr S))] parameter is an out-parameter,
|
||
and what C writes into it is a pointer of C's own. *)
|
||
List.iter
|
||
(fun (e : Tast.extern) ->
|
||
let refuse what (t : Types.t) why =
|
||
Loc.failk "check/dyn-descriptor" e.Tast.eloc
|
||
"%s of %s (the C symbol %s) is %s, and a dyn is reachable through \
|
||
it. %s, so the collector cannot mark that word and will free what \
|
||
it names — pass the fields across at written types instead"
|
||
what e.Tast.ename e.Tast.esym (Types.to_string t) why
|
||
in
|
||
(* One level in, because that level is the compiler's own: what a
|
||
foreign parameter of pointer or slice type receives is the address
|
||
of a place. Below it the question is [dyn_behind_pointer]'s again. *)
|
||
let below (t : Types.t) =
|
||
match t with Types.Ptr e | Types.Slice e -> e | t -> t
|
||
in
|
||
List.iteri
|
||
(fun i t ->
|
||
if dyn_behind_pointer p [] (below t) then
|
||
refuse (Printf.sprintf "parameter %d" (i + 1)) t
|
||
"The outermost level is this compiler's own storage and is \
|
||
rooted, but what lies below it is C's and nothing rooted that")
|
||
e.Tast.eparams;
|
||
if dyn_through p [] e.Tast.eret then
|
||
refuse "the return type" e.Tast.eret
|
||
"What C hands back points at storage this compiler never rooted")
|
||
p.Tast.externs;
|
||
value_sites p (fun ~slot:_ loc what t -> check loc what t)
|
||
|
||
let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env =
|
||
let env = new_env () in
|
||
let decls = Parse.program (Prelude.forms ()) @ decls in
|
||
(* Before anything is collected: every (declare-c ...) becomes an ordinary
|
||
flattened [declare] with a Flan [defn] over it, and the C that does the
|
||
flattening comes back to be compiled into the build. Nothing below this
|
||
line knows the form exists. *)
|
||
let decls, cshim = Shim.expand decls in
|
||
(* And on the same line: every class and generic function becomes the
|
||
[defn]s it stands for. It runs over the whole list because a method may
|
||
be written anywhere in it, which is also what makes a reload rebuild
|
||
every dispatch from the session's declarations — see lib/classes.ml. *)
|
||
let decls = Classes.expand decls in
|
||
(* And with the declaration list in its final shape — the classes expanded,
|
||
the shims flattened, the imports already qualified by [Load] — the one
|
||
warning this compiler prints unasked. Here rather than in [bin/main.ml]
|
||
beside [print_memory_warnings] because every route into the compiler
|
||
passes through this function: build, check, run, and the dev daemon's
|
||
reload, which is where a defn is most likely to be written. Printed in
|
||
the shape [Loc] gives an error, so a checker in an editor parses it the
|
||
same way. *)
|
||
List.iter
|
||
(fun (d : Loc.diag) ->
|
||
prerr_endline
|
||
(Loc.entry ~mark:'~' ~label:"warning: " d.Loc.dloc d.Loc.dmsg))
|
||
(shadowed_builtins decls);
|
||
(* Pass one, and it stops at the first thing it refuses. That is not
|
||
laziness: every name, type and signature in the file comes from here, so a
|
||
declaration this pass could not make sense of leaves a hole that pass two
|
||
would report once per mention. A wrong signature is one error; the thirty
|
||
"unknown name" lines under it are not errors, they are the same one.
|
||
|
||
Pass two is where the volume is, and it is where collecting pays. By the
|
||
time it runs every signature is sound, so a body that fails to check
|
||
cannot make the next body fail — which is what makes a declaration a
|
||
resync point that needs no resynchronising. *)
|
||
let decls = collect env decls in
|
||
check_finite env;
|
||
check_union_members env;
|
||
let s = Loc.sink ~on:keep_going in
|
||
ignore (Loc.caught s (fun () -> check_main env decls));
|
||
(* Every generic body, checked once with its variables left abstract, and
|
||
the result thrown away. This is the pass plan.org's rule needs and Odin
|
||
has no equivalent of: Odin checks a polymorphic body only per
|
||
instantiation, so [a + b] over a [$T] compiles there and fails only if
|
||
nobody ever calls it at a numeric type. plan.org says the opposite — an
|
||
unconstrained variable supports only what every type supports, and [=],
|
||
[<], [+] and [hash] over one are *rejected, not silently instantiated*.
|
||
Rejecting them means type-checking the body with nothing substituted,
|
||
which is this, and it is a second pass over the same source. *)
|
||
List.iter
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
| Ast.Defn fn when Hashtbl.mem env.gsigs fn.Ast.name ->
|
||
ignore (Loc.caught s (fun () -> check_generic env fn))
|
||
| _ -> ())
|
||
decls;
|
||
let globals =
|
||
List.filter_map
|
||
(fun d -> Option.join (Loc.caught s (fun () -> check_global env d)))
|
||
decls
|
||
in
|
||
let fns =
|
||
List.filter_map
|
||
(fun (d : Ast.decl) ->
|
||
match d.Ast.d with
|
||
(* A generic [defn] does not reach the typed IR at all. Only its
|
||
instantiations do, and they are collected below. *)
|
||
| Ast.Defn fn when Hashtbl.mem env.gsigs fn.Ast.name -> None
|
||
| Ast.Defn fn -> Loc.caught s (fun () -> check_fn env fn)
|
||
| _ -> None)
|
||
decls
|
||
in
|
||
Loc.finish s;
|
||
(* The handler clauses lifted out along the way. They are ordinary functions
|
||
from here down; nothing in the backend knows they were written inside
|
||
something else. *)
|
||
let fns = fns @ List.rev env.lifted in
|
||
(* The copies generics turned into, in the order they were generated. Like a
|
||
lifted clause they are ordinary functions from here down — but unlike one
|
||
they are reached *by name* from arbitrary call sites, so they carry no
|
||
[fparent] and a dev build gives each its own cell. *)
|
||
let fns = fns @ List.rev env.instances in
|
||
(* And the order the computed initialisers run in, which needs the whole
|
||
function list: what a global reads is transitive through what it calls. *)
|
||
let globals = init_order globals fns in
|
||
(* Sorted, so the emitted IR is reproducible build to build: a Hashtbl's
|
||
fold order is not. *)
|
||
let values name tbl =
|
||
Hashtbl.fold (fun _ v acc -> v :: acc) tbl []
|
||
|> List.sort (fun a b -> String.compare (name a) (name b))
|
||
in
|
||
let externs =
|
||
Hashtbl.fold
|
||
(fun name esym acc ->
|
||
let eparams, eret = Hashtbl.find env.fns name in
|
||
let eloc =
|
||
match Hashtbl.find_opt env.extern_locs name with
|
||
| Some l -> l
|
||
| None -> Loc.unknown
|
||
in
|
||
{ Tast.ename = name; esym; eparams; eret; eloc } :: acc)
|
||
env.externs []
|
||
|> List.sort (fun (a : Tast.extern) b -> String.compare a.Tast.esym b.Tast.esym)
|
||
in
|
||
let p =
|
||
{ Tast.structs = values (fun (s : Tast.structure) -> s.Tast.sname) env.structs;
|
||
datas = values (fun (u : Tast.data) -> u.Tast.dname) env.datas;
|
||
unions = values (fun (u : Tast.structure) -> u.Tast.sname) env.unions;
|
||
globals; externs; fns; cshim }
|
||
in
|
||
(* Last, over the finished program: which dyn words a per-type descriptor can
|
||
reach and which it cannot. It needs every declaration in hand, which is
|
||
what makes it a pass here rather than a check at each one. *)
|
||
dyn_descriptors p;
|
||
(p, env)
|
||
|
||
(** The program and the environment, stopping at the first refusal. What a
|
||
session needs, and it raises [Loc.Error] and never [Loc.Errors]. *)
|
||
let program_with_env (decls : Ast.decl list) : Tast.program * env =
|
||
build_program ~keep_going:false decls
|
||
|
||
let program (decls : Ast.decl list) : Tast.program =
|
||
fst (build_program ~keep_going:false decls)
|
||
|
||
(** The same, reporting every declaration whose body it refuses rather than the
|
||
first. Raises [Loc.Errors], so only a caller prepared for a list should be
|
||
calling it. *)
|
||
let program_all (decls : Ast.decl list) : Tast.program =
|
||
fst (build_program ~keep_going:true decls)
|
||
|
||
(* ── What a session needs to know about instantiations ──────────────────
|
||
A generic [defn] never reaches [Tast.fns] — only its copies do — so the
|
||
editor's [C-c C-c], which installs the bodies named by the form it was
|
||
sent, would install nothing at all for a generic. These are what
|
||
[Session.eval] expands the name with. They are here rather than there
|
||
because [env]'s tables are the only record that a symbol was ever generic:
|
||
past this module an instantiation is an ordinary function and nothing knows
|
||
it was written once. *)
|
||
|
||
(* Is this name a generic definition rather than an ordinary one? *)
|
||
let is_generic env n = Hashtbl.mem env.gsigs n
|
||
|
||
(* Every copy of [gname] this check produced, by symbol. Transitivity needs no
|
||
walk: a whole-program check has already generated every copy every call
|
||
site asked for, including the ones a generic pulled in by calling another
|
||
generic at its own variable. *)
|
||
let instantiations env gname =
|
||
match Hashtbl.find_opt env.insts gname with
|
||
| None -> []
|
||
| Some l -> List.rev_map (fun (_, _, sym) -> sym) !l
|
||
|
||
(* The generic a symbol came from, and the types it was asked for — [None] for
|
||
an ordinary function. What a refusal about [sort-i32] needs in order to
|
||
say which line the programmer should look at, since [sort-i32] appears
|
||
nowhere in the source. *)
|
||
let instantiation_origin env sym =
|
||
Hashtbl.fold
|
||
(fun gname l acc ->
|
||
match acc with
|
||
| Some _ -> acc
|
||
| None ->
|
||
(match List.find_opt (fun (_, _, s) -> String.equal s sym) !l with
|
||
| Some (ps, _, _) -> Some (gname, ps)
|
||
| None -> None))
|
||
env.insts None
|
||
|
||
(* Checking one expression against a live session can *generate* a copy: the
|
||
first [C-x C-e] of [(id 3)] instantiates [id] at [i32] and the copy is in
|
||
[env.instances] and in no program anywhere. Without these two the module
|
||
that gets built calls a symbol it never defined. A mark before and the
|
||
difference after is the whole protocol. *)
|
||
let instance_mark env = List.length env.instances
|
||
|
||
let instances_since env mark =
|
||
let fresh = List.length env.instances - mark in
|
||
List.rev
|
||
(List.filteri (fun i _ -> i < fresh) env.instances)
|
||
|
||
(* Expressions checked against a program that is already running, all of them
|
||
into *one* frame. It is empty to start with — a REPL expression has no
|
||
parameters and no enclosing function — so the slots it ends up with are
|
||
whatever their own [let]s allocate.
|
||
|
||
One frame and not one each, which is what the inspector's write verb needs
|
||
and what it must not assemble by hand. Two expressions checked separately
|
||
both number their slots from zero, so splicing them into one thunk would
|
||
have the second one's [let] reading and writing the first one's storage — a
|
||
frame that is two frames wearing one frame's clothes. Sharing the [ctx] is
|
||
the whole of the fix, and it is a fix because there is exactly one allocator
|
||
of slot indices in this compiler and it is this record's counter.
|
||
|
||
They are otherwise independent: none of them binds a name for the next,
|
||
because the list is a list of values being stored and not a sequence.
|
||
|
||
[want] is the write verb too, and [C-x C-e] passes none: a store into a
|
||
slot of type [f32] has an expectation to offer and a typed expression does
|
||
not. The whole value of passing it is that [3] arrives as an [f32] rather
|
||
than as an [i32] the store would then have to be refused for. It flows
|
||
through [check] the way an expectation flows anywhere — that is what
|
||
bidirectional means — and [expect] at the end catches the arms that ignore
|
||
it, so the refusal is the checker's own "expected f32, found string" rather
|
||
than a second sentence written here that would drift from it. *)
|
||
let expressions env (es : (Types.t option * Ast.expr) list) :
|
||
Tast.expr list * Types.t array * string option array =
|
||
let ctx = invented_ctx env Types.Unit in
|
||
(* Folded rather than mapped, because [List.map]'s order is unspecified and
|
||
every one of these calls has a side effect on [ctx] — the slot counter it
|
||
shares. An order nobody chose is one that can differ between builds, and
|
||
two frames laid out differently for the same edit is the kind of thing
|
||
that is found by somebody else, much later. *)
|
||
let ts =
|
||
List.rev
|
||
(List.fold_left
|
||
(fun acc (want, (e : Ast.expr)) ->
|
||
expect ctx e.Ast.loc ~want (check ctx ?want e) :: acc)
|
||
[] es)
|
||
in
|
||
(ts, Array.of_list (List.rev ctx.slot_tys),
|
||
Array.of_list (List.rev ctx.slot_names))
|
||
|
||
(* The one-expression case, which is every caller but the write verb. *)
|
||
let expression env ?want (e : Ast.expr) :
|
||
Tast.expr * Types.t array * string option array =
|
||
match expressions env [ (want, e) ] with
|
||
| [ t ], tys, names -> (t, tys, names)
|
||
| _ -> assert false
|
||
|
||
(* ── --no-gc ────────────────────────────────────────────────────────────
|
||
|
||
The flag that says this program is to be compiled with no collector in it,
|
||
and the way to keep that promise is to refuse every dyn rather than to emit
|
||
a different program. A dyn value is a value the runtime allocates and the
|
||
collector owns; there is no smaller version of it to fall back to, and
|
||
quietly leaking instead would be a memory model nobody asked for.
|
||
|
||
So this is a pass and not a flag. It runs between [Check] and [Emit], it
|
||
answers unit or it refuses, and nothing downstream of it is told the flag
|
||
exists — which is what makes a fully annotated program's output byte for
|
||
byte identical with the flag and without it. Emit has no [no_gc] field to
|
||
branch on, and that is deliberate: a field would be one more thing that
|
||
could change a comment, a name or an ordering, and the identity is worth
|
||
more than the branch would ever buy.
|
||
|
||
Every site is named, the way the global cycle refusal names the whole ring
|
||
rather than one member of it. A reader who has to annotate their program
|
||
wants the list, not the first one and then another compile. *)
|
||
|
||
let dyn_sites (p : Tast.program) : Loc.diag list =
|
||
let found = ref [] in
|
||
let add loc what = found := (loc, what) :: !found in
|
||
(* A type that *holds* a dyn and not only the type [dyn] itself. A struct
|
||
with a dyn field is a collected value as much as a bare one is, and since
|
||
the per-type descriptors it is a value a program can have without any
|
||
expression in it ever having the type [dyn] — a zeroed one, never filled,
|
||
whose dyn word the collector is still asked to mark. *)
|
||
let holds t = dyn_anywhere p [] t in
|
||
value_sites p
|
||
(* The filter this pass has and the descriptor pass does not: a frame slot
|
||
whose type is bare [dyn] is passed over here. A parameter or a global of
|
||
that type is still named. *)
|
||
(fun ~slot loc what t ->
|
||
if holds t && not (slot && t = Types.Dyn) then add loc what)
|
||
~after_fn:(fun (fn : Tast.fn) ->
|
||
(* The body's own dyn values, which are the ones a signature does not
|
||
show: a let bound to a boxed literal, a (vec-new dyn) deep inside an
|
||
expression. Reported at the node, because that is the character to
|
||
change. *)
|
||
List.iter
|
||
(Tast.walk
|
||
(fun (e : Tast.expr) ->
|
||
match e.Tast.e with
|
||
| Tast.Prim (Tast.Rt sym, _)
|
||
when e.Tast.ty = Types.Dyn
|
||
&& String.length sym > 8
|
||
&& String.sub sym 0 8 = "flan_dyn" ->
|
||
add e.Tast.loc (Printf.sprintf "this value in %s" fn.Tast.name)
|
||
| _ -> ()))
|
||
fn.Tast.body);
|
||
List.rev_map
|
||
(fun (loc, what) ->
|
||
Loc.diag ~kind:"check/no-gc" loc
|
||
(Printf.sprintf
|
||
"%s holds a dyn, and --no-gc says this program carries no \
|
||
collector. A \
|
||
dyn value is one the runtime allocates and the collector owns, so \
|
||
there is nothing smaller to compile it to — write the type"
|
||
what))
|
||
!found
|
||
|
||
let no_gc (p : Tast.program) =
|
||
match dyn_sites p with [] -> () | ds -> raise (Loc.Errors ds)
|
||
|
||
(* ── Memory diagnostics ─────────────────────────────────────────────────
|
||
|
||
"Which of these lines allocates?", answered on demand. Clojure's
|
||
[*warn-on-boxed*] crossed with Rider's heap-allocation squiggles, and the
|
||
same shape [dyn_sites] above has: a pass over the finished program, off
|
||
unless somebody asks, and nothing downstream is told it exists. Asking for
|
||
it cannot change what compiles.
|
||
|
||
Two classes, because the two heaps are not the same heap and a reader wants
|
||
to know which one a line is spending. [kind] carries it — ["memory/gc"] is
|
||
the dyn runtime's collected heap, ["memory/native"] is an allocator the
|
||
program named — so the CLI and the daemon dispatch on one field and neither
|
||
has to parse a message.
|
||
|
||
**Precision over completeness.** A site named here allocates, and a site
|
||
that only *might* says so in the first two words. That rule is what decides
|
||
the table below, and it decided it against the obvious guesses more than
|
||
once — every claim here was read out of runtime/flan_rt.c and
|
||
runtime/flan_dyn.c rather than assumed:
|
||
|
||
- [(vec-new T)] does not allocate. The lowering passes a capacity of zero
|
||
(see the [flan_vec_init] call in the ["vec-new"] arm) and
|
||
[flan_vec_init]'s body returns before [flan_vec_grow] when [cap <= 0].
|
||
The block arrives at the first push. [(map-new K V)] is the same: its
|
||
[flan_map_init] leaves [data] NULL and says so on its own line.
|
||
[(vec-new dyn)] and [(map-new dyn)] are the *other* answer — those are
|
||
the dyn runtime's own objects and [gc_alloc] runs at the call.
|
||
- A dyn immediate does not allocate: nil, a bool, an f64, a keyword, and
|
||
an int inside the payload. The payload is 48 bits
|
||
([DYN_PAYMASK]/[DYN_INT_MAX] in flan_dyn.c), so only an i64 that can
|
||
leave ±2^47 is a "may allocate", and a value widened from a narrower
|
||
integer type provably cannot.
|
||
- A keyword is interned and immortal — [flan_dyn_kw]'s entry is not a GC
|
||
object and the collector never traces one — so it is not named here.
|
||
- A dyn container growing itself is not named, and this one is a judgement
|
||
rather than a fact about the runtime: [flan_dyn_push] and
|
||
[flan_dyn_map_set] provably may [gc_alloc], and they are still left out.
|
||
The unit this pass reports is a line the programmer can act on — crossing
|
||
into dyn is a choice, pushing onto an allocator's Vec is a choice — and a
|
||
dyn vector taking a block to hold what was just put in it is the only
|
||
thing it could do. Marking it would squiggle every [(push dv x)] in a
|
||
program that chose dyn. Written down in FIX.org, because it is the one
|
||
row here that the precision rule alone does not decide.
|
||
- Dyn arithmetic is not named. [flan_dyn_add] and its siblings end in
|
||
[flan_dyn_from_i64], so a wide enough result spills, but nothing static
|
||
knows the operands and a squiggle on every [(+ a b)] over dyn is the
|
||
false positive this pass exists not to have. *)
|
||
|
||
(* The payload's range, restated from [DYN_INT_MAX]/[DYN_INT_MIN] in
|
||
runtime/flan_dyn.c: 2^47-1 and its negation less one. Restated rather than
|
||
read, the way every other number this compiler shares with the runtime is,
|
||
and wrong only in the direction of a missing warning if the runtime ever
|
||
widens it. *)
|
||
let dyn_payload_max = 140737488355327L
|
||
let dyn_payload_min = -140737488355328L
|
||
|
||
(* An integer type that cannot reach the payload's edge whatever its value. *)
|
||
let narrower_than_payload (t : Types.t) =
|
||
match t with
|
||
| Types.Int (Types.I8 | Types.I16 | Types.I32
|
||
| Types.U8 | Types.U16 | Types.U32) -> true
|
||
| _ -> false
|
||
|
||
(* Can this argument to [flan_dyn_from_i64] spill onto the heap?
|
||
|
||
One level of unwrapping and no more: [box] widens with a single
|
||
[Cast i64], and peeling further would walk through a *narrowing* cast the
|
||
programmer wrote and report a range the value cannot have. *)
|
||
let int_may_spill (e : Tast.expr) =
|
||
let e =
|
||
match e.Tast.e with
|
||
| Tast.Prim (Tast.Cast (Types.Int Types.I64), [ inner ])
|
||
when narrower_than_payload inner.Tast.ty -> inner
|
||
| _ -> e
|
||
in
|
||
match e.Tast.e with
|
||
| Tast.Int (n, _) -> n > dyn_payload_max || n < dyn_payload_min
|
||
| _ -> not (narrower_than_payload e.Tast.ty)
|
||
|
||
(* A [flan_vec_init] whose capacity is a literal zero takes no block. That is
|
||
every [(vec-new T)]; [slurp] passes the file's size and is the caller that
|
||
makes this a test rather than a constant. *)
|
||
let vec_init_allocates (args : Tast.expr list) =
|
||
match args with
|
||
| _ :: _ :: cap :: _ ->
|
||
(match cap.Tast.e with Tast.Int (n, _) -> n > 0L | _ -> true)
|
||
| _ -> true
|
||
|
||
(* The classifier. [Some (kind, message)] for a site that allocates or may,
|
||
[None] for everything else — and [None] is the answer for every symbol not
|
||
named here, which is what keeps a new runtime entry point silent rather
|
||
than guessed at. *)
|
||
let memory_class (sym : string) (args : Tast.expr list) =
|
||
let gc m = Some ("memory/gc", m) and native m = Some ("memory/native", m) in
|
||
match sym with
|
||
(* ── The collected heap ── *)
|
||
| "flan_dyn_from_bytes" ->
|
||
gc "allocates: a string crossing into dyn is copied onto the \
|
||
collector's heap"
|
||
| "flan_dyn_vec_new" ->
|
||
gc "allocates: a dyn vector is an object on the collector's heap"
|
||
| "flan_dyn_map_new" ->
|
||
gc "allocates: a dyn map is an object on the collector's heap"
|
||
| "flan_dyn_map_new_class" ->
|
||
gc "allocates: a class instance is a dyn map on the collector's heap, \
|
||
with the class's name in its header"
|
||
| "flan_dyn_view_vec" | "flan_dyn_view_flat" ->
|
||
gc "allocates: a typed container crossing into dyn takes a view record \
|
||
on the collector's heap — the elements are not copied, the record is"
|
||
| "flan_dyn_from_i64" when (match args with [ x ] -> int_may_spill x | _ -> true) ->
|
||
gc "may allocate: an i64 outside ±2^47 does not fit a dyn's payload and \
|
||
spills onto the collector's heap"
|
||
(* ── An allocator the program named ── *)
|
||
| "flan_arena_new" ->
|
||
native "allocates: an arena takes its whole region from the host here"
|
||
| "flan_vec_init" when vec_init_allocates args ->
|
||
native "allocates: the Vec is sized up front and takes its block from \
|
||
its allocator here"
|
||
| "flan_vec_push" ->
|
||
native "may allocate: a push past the Vec's capacity grows it through \
|
||
its allocator"
|
||
| "flan_vec_reserve" ->
|
||
native "may allocate: a reserve past the Vec's capacity grows it through \
|
||
its allocator"
|
||
| "flan_map_put" ->
|
||
native "may allocate: a put past the map's load factor grows its block \
|
||
through its allocator"
|
||
| "flan_map_reserve" ->
|
||
native "may allocate: a reserve past the map's load factor grows its \
|
||
block through its allocator"
|
||
| "flan_vec_clone" ->
|
||
native "may allocate: cloning a non-empty Vec takes a new block from its \
|
||
allocator"
|
||
| "flan_map_clone" ->
|
||
native "may allocate: cloning a non-empty map takes a new block from its \
|
||
allocator"
|
||
| _ -> None
|
||
|
||
(** Every site in the program that allocates, or may. Ordered by source
|
||
position, one diagnostic per location and class — a lowering emits several
|
||
runtime calls at one location and a reader wants the line named once.
|
||
|
||
[?file] narrows it to one source file, which is what a command that was
|
||
handed a path wants: the prelude pushes onto Vecs on a dozen lines and an
|
||
import has its own, and neither is a line the person who asked can do
|
||
anything about. Left out, everything the program holds is reported — which
|
||
is what a client that does its own filtering, the editor among them,
|
||
should ask for. *)
|
||
let memory_sites ?file (p : Tast.program) : Loc.diag list =
|
||
let found = ref [] in
|
||
let seen = Hashtbl.create 64 in
|
||
let look (e : Tast.expr) =
|
||
match e.Tast.e with
|
||
| Tast.Prim (Tast.Rt sym, args) ->
|
||
(match memory_class sym args with
|
||
| None -> ()
|
||
| Some (kind, msg) ->
|
||
let loc = e.Tast.loc in
|
||
let key = (loc.Loc.file, loc.Loc.line, loc.Loc.col, kind) in
|
||
if (match file with None -> true | Some f -> String.equal f loc.Loc.file)
|
||
&& not (Hashtbl.mem seen key) then begin
|
||
Hashtbl.replace seen key ();
|
||
found := Loc.diag ~kind loc msg :: !found
|
||
end)
|
||
| _ -> ()
|
||
in
|
||
(* A global's initialiser runs at startup and allocates there as much as a
|
||
body does — [(defonce names (vec-new dyn))] is a heap object before main
|
||
has a line of its own — so the globals are walked and not only the
|
||
functions. *)
|
||
List.iter (fun (g : Tast.global) -> Tast.walk look g.Tast.ginit) p.Tast.globals;
|
||
List.iter
|
||
(fun (fn : Tast.fn) -> List.iter (Tast.walk look) fn.Tast.body)
|
||
p.Tast.fns;
|
||
let placed (d : Loc.diag) = d.Loc.dloc.Loc.line > 0 in
|
||
List.stable_sort
|
||
(fun a b ->
|
||
match (placed a, placed b) with
|
||
| true, false -> -1
|
||
| false, true -> 1
|
||
| _ -> Loc.before a.Loc.dloc b.Loc.dloc)
|
||
(List.rev !found)
|