flan/test/test_flan.ml
Joseph Ferano 7794f06f00 The same mistake was wearing two faces, and neither named the fix
($u x) was an unknown function in the same body where (vec-new $u) was an
unbound type variable, because the cast arm did not take the sigil clause
type_named took. It takes it now, so one mistake has one story.

And the story was a rule rather than an answer: "only a defn signature can"
is what to say when nothing is in scope to name — a struct field, a global —
but inside a signature that introduces $t, the name that was meant is almost
always t. It names them. Which names those are comes from tyvars abstractly
and from subst inside an instantiation, because a body is checked under both
and reading one would answer the same mistake two ways in a single run.
2026-09-21 10:27:54 +07:00

5903 lines
313 KiB
OCaml

(* Reader tests. Plain assertions, no test framework — another dependency that
would have to be reimplemented if the compiler is ever self-hosted. *)
open Flan
(* The watchdog first: a hang is the one failure mode that reports
nothing at all. See watchdog.ml. *)
let () = Watchdog.arm ~seconds:600 "test_flan"
let failures = Test_support.failures
let check name cond =
if not cond then begin
incr failures;
Printf.printf "FAIL %s\n" name
end
let contains = Test_support.contains
(* Every read in this table runs under a five-second alarm. The reader is the
one part of the compiler whose mistakes loop rather than raise — a branch
that forgets to advance reads the same character for ever — and a hanging
case reports nothing at all. Five seconds is thousands of times what any
row here needs; what it buys is that a loop becomes a named failing row and
the rest of the table still runs. *)
(* Once one read has not returned, the reader is looping and every row after
it would spend the same five seconds proving the same thing — a hundred
rows is eight minutes of that. So the first timeout wedges the rest: they
fail immediately and the binary still reports, which is the whole point of
the alarm. *)
let wedged = ref false
let guarded seconds f =
if !wedged then raise Watchdog.Timeout
else
match Watchdog.within seconds f with
| x -> x
| exception Watchdog.Timeout -> wedged := true; raise Watchdog.Timeout
let read ?(file = "<test>") src =
guarded 5 (fun () -> Reader.read_all ~file src)
(* The corpus files, which are larger and are read from disk. *)
let read_file path = guarded 30 (fun () -> Reader.read_file path)
let reads name src expected =
match read src with
| forms ->
let got = String.concat " " (List.map Form.to_string forms) in
if got <> expected then begin
incr failures;
Printf.printf "FAIL %s\n src: %s\n got: %s\n wanted: %s\n"
name src got expected
end
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
name src (Loc.to_string loc) msg
| exception Watchdog.Timeout ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n the reader did not return\n"
name src
(* [needle] is the point: a read error that fires for the wrong reason is not
the test passing. Without it "backtick at end of input" would be green even
if the backtick were still an ordinary symbol character. *)
let rejects ?needle name src =
match read src with
| _ -> incr failures; Printf.printf "FAIL %s: expected a read error\n" name
| exception Watchdog.Timeout ->
incr failures;
Printf.printf "FAIL %s: the reader did not return\n" name
| exception Loc.Error { Loc.dmsg = msg; _ } ->
(match needle with
| Some n when not (contains msg n) ->
incr failures;
Printf.printf "FAIL %s\n error: %s\n wanted: ...%s...\n" name msg n
| _ -> ())
let () =
(* ── Atoms ─────────────────────────────────────────────────────── *)
reads "integer" "42" "42";
reads "negative" "-1" "-1";
(* A sign is part of the number, both ways. [+5] is the one that reads as a
symbol the moment the '+' case is dropped from the dispatch, and a symbol
named "+5" is an unknown name much later and somewhere else. *)
reads "leading plus" "+5" "5";
reads "plus float" "+0.5" "0.5";
reads "plus in a call" "(f +5 -5)" "(f 5 -5)";
(* And the operator is still itself: [+] alone is the addition symbol, and
[(+ 1 2)] must not read its head as a number. *)
reads "bare plus" "+" "+";
reads "float" "0.05" "0.05";
reads "hex" "0xE6B800FF" "3870818559";
reads "string" "\"SAND\"" "\"SAND\"";
reads "symbol" "empty-at?" "empty-at?";
reads "qualified" "rl/draw-fps" "rl/draw-fps";
reads "field access" ".pos" ".pos";
reads "operator" "->>" "->>";
reads "bare minus" "-" "-";
reads "keyword" ":space" ":space";
reads "else keyword" ":else" ":else";
(* Byte literals, as used by calc-me's tokenizer. *)
reads "byte named" "\\space" "\\space";
reads "byte digit" "\\0" "\\0";
reads "byte paren" "\\(" "\\(";
reads "byte dot" "\\." "\\.";
(* ── Sequences ─────────────────────────────────────────────────── *)
reads "list" "(+ 1 2)" "(+ 1 2)";
reads "vector" "[1 2 3]" "[1 2 3]";
reads "map literal" "{.src src .pos 0}" "{.src src .pos 0}";
reads "type notation" "[4 f32]" "[4 f32]";
reads "nested type" "[rows [cols u32]]" "[rows [cols u32]]";
reads "commas as space" "[1, 2, 3]" "[1 2 3]";
reads "nested" "(a (b [c {.d e}]))" "(a (b [c {.d e}]))";
(* ── Trivia ────────────────────────────────────────────────────── *)
reads "line comment" "; nope\n42" "42";
reads "trailing comment" "42 ; nope" "42";
reads "banner comment" ";;;; header\n(f)" "(f)";
reads "multiple forms" "(a) (b)" "(a) (b)";
reads "empty source" "" "";
reads "only comments" "; nothing here" "";
(* ── Quote ─────────────────────────────────────────────────────── *)
(* Restart names are quoted symbols. Before this existed, 'skip-form read as
a symbol *named* "'skip-form", which is silently a different symbol from
skip-form and nothing would ever have reported it. *)
reads "quote symbol" "'skip-form" "(quote skip-form)";
reads "quote in call" "(invoke-restart 'use-placeholder)"
"(invoke-restart (quote use-placeholder))";
reads "quote list" "'(a b)" "(quote (a b))";
(* ── Quasiquote ────────────────────────────────────────────────── *)
(* The bug this closes: a backtick was an ordinary symbol character, so
`(a b) came back as the unknown name "`" — the apostrophe's old failure
mode, still open one sigil over. Clojure's ` ~ ~@ rather than Common
Lisp's ` , ,@ because a comma is whitespace here and every binding vector
depends on that. *)
reads "quasiquote list" "`(a b)" "(quasiquote (a b))";
reads "unquote" "`(a ~b)" "(quasiquote (a (unquote b)))";
reads "unquote-splicing" "`(a ~@bs)" "(quasiquote (a (unquote-splicing bs)))";
reads "unquote a call" "`(+ ~(f x) 1)"
"(quasiquote (+ (unquote (f x)) 1))";
(* Nesting: the reader does not count levels, it just wraps again. Which
level an unquote belongs to is the expander's problem, not the reader's. *)
reads "nested quasiquote" "`(a `(b ~c))"
"(quasiquote (a (quasiquote (b (unquote c)))))";
(* An unquote outside any quasiquote still reads. It has to: the reader is
dumb and has no idea where it is. Parse refuses it — see the parse tests. *)
reads "unquote alone" "~x" "(unquote x)";
reads "splice alone" "~@x" "(unquote-splicing x)";
(* A quote inside a quasiquote stays a quote; the two sigils do not merge. *)
reads "quote in quasi" "`(a 'b)" "(quasiquote (a (quote b)))";
(* The delimiter half of the fix: without it ~x is one symbol named "~x". *)
reads "tilde ends a name" "(f a~b)" "(f a (unquote b))";
reads "backtick ends a name" "(f a`b)" "(f a (quasiquote b))";
reads "backtick in vec" "[`a ~b]" "[(quasiquote a) (unquote b)]";
(* ── Discard ───────────────────────────────────────────────────── *)
(* [#_] reads the next form and throws it away, so commenting out a form does
not mean counting its closing parens. Clojure's spelling and Clojure's
semantics, including the repeated form. *)
reads "discard in a call" "(f #_a b)" "(f b)";
reads "discard the last" "(f a #_b)" "(f a)";
reads "discard the first" "(#_f g a)" "(g a)";
reads "discard a list" "(f #_(g x) b)" "(f b)";
reads "discard in a vector" "[a #_b c]" "[a c]";
reads "discard in a map" "{:a 1 #_:b #_2 :c 3}" "{:a 1 :c 3}";
(* Two discards drop two forms, and that is the recursion rather than a
count: the outer discard reads one form, and the form it reads is itself a
discard that returns the one after. *)
reads "two discards" "(f #_#_a b c)" "(f c)";
reads "three discards" "(f #_#_#_a b c d)" "(f d)";
(* Every position a form can appear in. *)
reads "discard at top level" "#_(defn a [] () 1) (defn b [] () 2)" "(defn b [] () 2)";
reads "discard a whole file" "#_(defn a [] () 1)" "";
reads "discard before quote" "(f #_a 'b)" "(f (quote b))";
reads "discard of a quote" "(f #_'a b)" "(f b)";
(* Nested, which the recursive read gives for free. *)
reads "discard inside a discarded form" "(f #_(g #_h i) j)" "(f j)";
(* A name may still contain '#' — it is only a discard at the start of a
form, after trivia. *)
reads "hash inside a name" "(f a#_b)" "(f a#_b)";
(* Nothing to discard is an error, not a silent nothing. *)
rejects ~needle:"end of input" "discard at end of input" "(f a #_";
rejects ~needle:"unbalanced" "discard of a closing paren" "(f a #_)";
(* The whole class: no reader-significant character may end up inside a name. *)
let rec bad_names f =
let open Form in
match f.v with
| Sym s | Kw s ->
if String.exists (fun c -> c = '\'' || c = '^' || c = '`' || c = '~') s
then [ s ] else []
| List l | Vec l | Map l -> List.concat_map bad_names l
| _ -> []
in
let corpus =
"(invoke-restart 'skip-form) (a 'b [c 'd] {.e 'f}) '(g 'h) \
`(i ~j ~@k) `(l `(m ~n)) [`o ~p] {.q `r} (f a~b x`y)"
in
check "no sigils leak into names"
(bad_names (Form.make (Form.List (read corpus))
Loc.unknown) = []);
(* ── Errors ────────────────────────────────────────────────────── *)
rejects "unclosed list" "(f x";
rejects "unbalanced close" ")";
rejects "mismatched" "(f x]";
rejects "unterminated str" "\"abc";
rejects "empty keyword" ":";
rejects "unknown char" "\\bogus";
(* An escape the reader does not know is a typo, not a character: accepting
\q as 'q' silently reads a different string than the one that was
written, and nothing downstream can tell. *)
rejects "unknown string escape" "\"a\\qb\"" ~needle:"unknown string escape";
(* The escapes it does know still decode, which is what says the rejection
above rejects the unknown one and not escaping itself. Asserted on the
string's bytes rather than through [Form.to_string], which escapes them
again and would compare the source with itself. *)
(match read "\"a\\nb\\tc\\\\d\\\"e\\0f\"" with
| [ { Form.v = Form.Str s; _ } ] ->
check "known escapes" (s = "a\nb\tc\\d\"e\000f")
| _ -> check "known escapes: one string" false);
rejects "metadata" "^:async";
rejects "dangling quote" "'";
(* Each of these asserts the reason, not merely that something failed. *)
rejects "backtick at end" "`" ~needle:"unexpected end of input";
rejects "tilde at end" "~" ~needle:"unexpected end of input";
rejects "splice at end" "~@" ~needle:"unexpected end of input";
rejects "quasiquote unclosed" "`(a b" ~needle:"unclosed";
(* ── Locations ─────────────────────────────────────────────────── *)
(match read ~file:"f.flan" "(a)\n (b)" with
| [ a; b ] ->
check "loc line 1" (a.loc.line = 1 && a.loc.col = 1);
check "loc line 2" (b.loc.line = 2 && b.loc.col = 3);
check "loc file" (a.loc.file = "f.flan")
| _ -> check "loc: two forms" false);
(match read ~file:"f.flan" "(f\n bad" with
| _ -> check "unclosed reports opening loc" false
| exception Loc.Error { Loc.dloc = loc; _ } ->
check "unclosed reports opening loc" (loc.line = 1 && loc.col = 1));
(* ── Spans ─────────────────────────────────────────────────────
A location ends where the form ends, which is what an underline needs
and what a column number cannot give. Asserted on the width rather than
on the end column alone: a span that never got widened is zero wide, and
that is the failure mode worth catching — the field would exist, nothing
would fill it, and every squiggle would be one character long. *)
(match read ~file:"f.flan" "(foo bar)" with
| [ l ] ->
check "span covers the list" (Loc.width l.loc = Some 9);
(match l.Form.v with
| Form.List [ head; arg ] ->
check "span covers the head symbol" (Loc.width head.loc = Some 3);
check "span covers the argument" (Loc.width arg.loc = Some 3);
check "span starts at the symbol" (arg.loc.col = 6)
| _ -> check "span: two elements" false)
| _ -> check "span: one form" false);
(match read ~file:"f.flan" "\"hi\" 42 :kw" with
| [ s; n; k ] ->
check "span covers a string with its quotes" (Loc.width s.loc = Some 4);
check "span covers a number" (Loc.width n.loc = Some 2);
check "span covers a keyword with its colon" (Loc.width k.loc = Some 3)
| _ -> check "span: three atoms" false);
(* A form that runs over a line end has no width on its first line, and says
so rather than reporting a negative one. *)
(match read ~file:"f.flan" "(a\n b)" with
| [ l ] ->
check "multi-line span is flagged" (Loc.multiline l.loc);
check "multi-line span has no single-line width" (Loc.width l.loc = None)
| _ -> check "span: one multi-line form" false);
Test_support.report ~label:"reader" ()
(* ═══ Parse: forms → AST ═══════════════════════════════════════════ *)
let parse1 src =
match read src with
| [ f ] -> Parse.expr f
| _ -> failwith "test source must be exactly one form"
let parse_decl src =
match read src with
| [ f ] -> Parse.decl f
| _ -> failwith "test source must be exactly one form"
(* [needle] again: the house rule is that an unimplemented form is refused by
name with the reason, so a test that only proves *something* failed does not
observe the rule it is there for. *)
let parse_rejects ?needle name src =
match read src |> Parse.program with
| _ -> incr failures; Printf.printf "FAIL %s: expected a parse error\n" name
| exception Loc.Error { Loc.dmsg = msg; _ } ->
(match needle with
| Some n when not (contains msg n) ->
incr failures;
Printf.printf "FAIL %s: wrong reason\n wanted: %s\n got: %s\n"
name n msg
| _ -> ())
let () =
let open Ast in
(* ── Sugar is desugared, not preserved ─────────────────────────── *)
(match (parse1 "(when c a b)").e with
| If (_, { e = Do [ _; _ ]; _ }, None) -> ()
| _ -> check "when -> if+do" false);
(* An empty body is the same [Do []] that [(do)] already is, and not a
refusal. (when test) is a guard whose consequent has not been written yet
-- a state a program passes through while it is being written -- and
refusing it bought nothing that the empty [do] does not already allow.
[unless] in the prelude took the same change; it is a macro now, so it is
asserted in programs/prelude-macros.flan instead of here. *)
(match (parse1 "(when c)").e with
| If (_, { e = Do []; _ }, None) -> ()
| _ -> check "(when test) with no body -> if+(do)" false);
(* The test is still required, because there is nothing to branch on
without one. *)
parse_rejects "when with no test at all" "(defn f [] () (when))"
~needle:"when is (when test body ...)";
(* The same rule for a function with nothing in it. A [defn] whose declared
return type is () and whose body is empty has always been legal -- there
is a unit to answer and no forms needed to reach it -- and [Check] refuses
the case where the declaration disagrees, "returns i32 but has no body".
An [fn] now parses the same way; it declares no return type, so the
position it sits in is what decides, and the two rows below check.ml's
arms are in the checker section further down. *)
(match (parse1 "(fn [])").e with
| Fn ([], []) -> ()
| _ -> check "(fn []) parses with an empty body" false);
parse_rejects "fn with no parameter vector" "(defn f [] () (fn))"
~needle:"fn is (fn [param ...] body ...)";
(* unless was here, and is not any more: it is a defmacro in the prelude,
and the parser has nothing to say about it. What it expands to is the
same if-over-(not) this used to assert, and it is asserted where it can
be now -- test/programs/macro-unless.flan, through a compiler that has to
run the macro to get there. *)
(* The label is peeled off the head, and [break] carries the name it was
given rather than anything resolved — resolving it is the checker's job,
which is what makes it not a goto. *)
(match (parse1 "(while :outer c a)").e with
| While (Some "outer", _, [ _ ]) -> ()
| _ -> check "while takes a label" false);
(match (parse1 "(break :outer)").e with
| Break (Some "outer") -> ()
| _ -> check "break takes a label" false);
(match (parse1 "(continue)").e with
| Continue None -> ()
| _ -> check "bare continue" false);
(match (parse1 "(until c a)").e with
| While (None, { e = Call ({ e = Var "not"; _ }, [ _ ]); _ }, [ _ ]) -> ()
| _ -> check "until -> while(not)" false);
(match (parse1 "(cond a 1 b 2 :else 3)").e with
| If (_, _, Some { e = If (_, _, Some { e = Int 3L; _ }); _ }) -> ()
| _ -> check "cond -> nested if with :else last" false);
(* and/or short-circuit, so they must not become calls. Both bind the test
to a temp and answer the temp on the deciding path -- Clojure's own
expansion, (let [t a] (if t b t)) for and and (let [t a] (if t t b))
for or -- which is what hands back the actual deciding operand rather
than a bare bool, and what evaluates the test exactly once (M2 queue
item 7 and its review pass).
The bound name, the bound value and the arm are all pinned, not just
the shape: a desugaring that dropped the temp and wrote the operand
into the arm twice, (if a b a), would still match a pattern that left
the binding as [_]. *)
(match (parse1 "(and a b)").e with
| Let ([ { bname; bval = { e = Var "a"; _ }; _ } ],
[ { e = If ({ e = Var t1; _ },
{ e = Var "b"; _ },
Some { e = Var t2; _ }); _ } ])
when bname = t1 && t1 = t2 -> ()
| _ -> check "and short-circuits" false);
(match (parse1 "(or a b)").e with
| Let ([ { bname; bval = { e = Var "a"; _ }; _ } ],
[ { e = If ({ e = Var t1; _ },
{ e = Var t2; _ },
Some { e = Var "b"; _ }); _ } ])
when bname = t1 && t1 = t2 -> ()
| _ -> check "or short-circuits" false);
(* ── Forms that bind or alter control are never calls ──────────── *)
(* This is the class that silently misparses: it reads fine as a call and
means something entirely different. *)
(match (parse1 "(dotimes [i 10] (f i))").e with
| Dotimes (None, "i",
{ dstart = None; dstop = { e = Int 10L; _ }; dstep = None },
[ _ ]) -> ()
| _ -> check "dotimes binds" false);
(* The written bound is always the *stop*, so the shorter forms are the
longer one with its defaults left off. *)
(match (parse1 "(dotimes [i 9 -1 -1] (f i))").e with
| Dotimes (None, "i",
{ dstart = Some { e = Int 9L; _ };
dstop = { e = Int (-1L); _ };
dstep = Some { e = Int (-1L); _ } },
[ _ ]) -> ()
| _ -> check "dotimes takes start, stop and step" false);
(match (parse1 "(fn [x y] x)").e with
| Fn ([ "x"; "y" ], [ _ ]) -> ()
| _ -> check "fn binds" false);
(match (parse1 "(defer (close f))").e with
| Defer [ _ ] -> ()
| _ -> check "defer is not a call" false);
(match (parse1 "(some (find x))").e with
| Unwrap (Usome, _) -> ()
| _ -> check "some is not a call" false);
(match (parse1 "(try (read x))").e with
| Unwrap (Utry, _) -> ()
| _ -> check "try is not a call" false);
(* ── Places: the fixed assignable list, not setf ───────────────── *)
(match (parse1 "(set x 1)").e with
| Set (Pvar "x", _) -> () | _ -> check "set local" false);
(match (parse1 "(set (.hp e) 1)").e with
| Set (Pfield (_, "hp"), _) -> () | _ -> check "set field" false);
(match (parse1 "(set (at g r c) 1)").e with
| Set (Pindex (_, [ _; _ ]), _) -> () | _ -> check "set index" false);
(match (parse1 "(set (deref p) 1)").e with
| Set (Pderef _, _) -> () | _ -> check "set deref" false);
parse_rejects "set on a call" "(set (foo x) 1)";
(* ── Field access and struct literals ──────────────────────────── *)
(match (parse1 "(.pos c)").e with
| Field ({ e = Var "c"; _ }, "pos") -> ()
| _ -> check "field access" false);
(match (parse1 "(Cursor {.src s .pos 0})").e with
| Struct ("Cursor", [ ("src", _); ("pos", _) ]) -> ()
| _ -> check "struct literal" false);
(match (parse1 "[1 2 3]").e with
| Arr [ _; _; _ ] -> () | _ -> check "array literal" false);
(* ── Types: brackets mean different things by position ─────────── *)
(* Read out of [praw], not [params]: a defn's parameter vector is carried
undecided until Check pairs it, so the parser no longer fills [params] at
all. Every type spelled here is one the parser still resolves on sight —
brackets and lists are types by their shape whatever the environment says
— so [Ptype] is the shape under test and a [Pname] here would mean the
spelling stopped being recognised as a type. *)
let ty src =
match parse_decl (Printf.sprintf "(defn f [x %s] ())" src) with
| { d = Defn { praw = Some [ Pname ("x", _); Ptype t ]; _ }; _ } -> t.t
| _ -> failwith "bad type test"
in
(match ty "[u8]" with Tslice _ -> () | _ -> check "[T] is a slice" false);
(match ty "[4 f32]" with
| Tarray (Lint 4L, _) -> () | _ -> check "[n T] is an array" false);
(match ty "[rows [cols u32]]" with
| Tarray (Lname "rows", { t = Tarray (Lname "cols", _); _ }) -> ()
| _ -> check "nested array with named lengths" false);
(match ty "(Ptr Cursor)" with
| Tapp ("Ptr", [ _ ]) -> () | _ -> check "(Ptr T)" false);
(* A map type is an application like (Ptr T) and (Vec T) now that the brace
spelling is gone: [Ast.Tmap] survives only as what [Cimport] builds. *)
(match ty "(Map string i32)" with
| Tapp ("Map", [ _; _ ]) -> () | _ -> check "(Map K V) is a map type" false);
(match ty "(Fn [a a] bool)" with
| Tfn ([ _; _ ], _) -> () | _ -> check "(Fn [T] R)" false);
(* ── Declarations ──────────────────────────────────────────────── *)
(match (parse_decl "(defn f [x i32] bool x)").d with
| Defn { ret = Some _; praw = Some [ _; _ ]; fbody = [ _ ]; _ } -> ()
| _ -> check "defn with return type" false);
(* () is the unit return type, and the body is what follows it. *)
(match (parse_decl "(defn f [x i32] () (g x))").d with
| Defn { ret = Some { t = Tname "Unit"; _ }; fbody = [ _ ]; _ } -> ()
| _ -> check "defn returning ()" false);
(* A lone () is the return type and an empty body, not a body of one form. *)
(match (parse_decl "(defn f [x i32] ())").d with
| Defn { ret = Some { t = Tname "Unit"; _ }; fbody = []; _ } -> ()
| _ -> check "defn returning () with no body" false);
(match (parse_decl "(defonce grid [4 u32])").d with
| Defvar ("grid", Some _, Zeroed, Once) -> ()
| _ -> check "defonce is ZII" false);
(match (parse_decl "(defonce buf [4 u8] uninit)").d with
| Defvar (_, _, Uninit, Once) -> ()
| _ -> check "defonce uninit opts out" false);
(* A three-element defonce whose third element cannot be a type is settled
here, by its shape, and comes out as the dyn global it means. *)
(match (parse_decl "(defonce score 0)").d with
| Defvar ("score", Some { t = Tname "dyn"; _ }, Init _, Once) -> ()
| _ -> check "a literal third element parses as a dyn initialiser" false);
(* A bare symbol could be either and parse does not know any names, so both
readings are carried out of here for [Check] to pick between. *)
(match (parse_decl "(defonce total foo)").d with
| Defvar ("total", Some { t = Tname "foo"; _ }, Ambiguous _, Once) -> ()
| _ -> check "a symbol third element parses undecided" false);
(match (parse_decl "(defonce v (Vec i32))").d with
| Defvar ("v", Some { t = Tapp ("Vec", _); _ }, Ambiguous _, Once) -> ()
| _ -> check "a parenthesised third element parses undecided" false);
(* [def] takes exactly the spellings [defonce] takes — the same parse arm
reads both — and differs in the one field that says what a re-run does.
One row per spelling, each asserting the [Every]. *)
(match (parse_decl "(def grid [4 u32])").d with
| Defvar ("grid", Some _, Zeroed, Every) -> ()
| _ -> check "def is ZII" false);
(match (parse_decl "(def buf [4 u8] uninit)").d with
| Defvar (_, _, Uninit, Every) -> ()
| _ -> check "def uninit opts out" false);
(match (parse_decl "(def score 0)").d with
| Defvar ("score", Some { t = Tname "dyn"; _ }, Init _, Every) -> ()
| _ -> check "a literal third element of def is a dyn initialiser" false);
(match (parse_decl "(def total foo)").d with
| Defvar ("total", Some { t = Tname "foo"; _ }, Ambiguous _, Every) -> ()
| _ -> check "a symbol third element of def parses undecided" false);
(match (parse_decl "(def counter i64 (start))").d with
| Defvar ("counter", Some { t = Tname "i64"; _ }, Init _, Every) -> ()
| _ -> check "a typed def with an initialiser" false);
(* The old name, refused with the migration in the message: what it is
called now, why the name, and both new spellings — each of which
compiles as written. *)
parse_rejects "the old defvar spelling names defonce"
"(defvar counter i64 7)"
~needle:"defvar is now called defonce — the name says what it does: it \
initialises once and keeps its value across re-runs. Write \
(defonce counter i64 7), or (def counter i64 7) if the value \
should follow the source on every re-run";
(match read "(defvar counter i64 7)" |> Parse.program with
| _ -> check "the old defvar spelling has a kind" false
| exception Loc.Error { Loc.kind; _ } ->
check "the old defvar spelling has a kind" (kind = "parse/defvar-renamed"));
(* A form with nothing after the keyword has nothing to echo, and the
answer must not be "(defonce )" — a malformed old form getting a
malformed new one as its fix. *)
parse_rejects "the old spelling with no arguments names the shapes"
"(defvar)"
~needle:"It is (defonce name Type value?), or (def name Type value?) if \
the value should follow the source on every re-run";
(match (parse_decl "(import rl \"vendor:raylib\")").d with
| Import ("rl", "vendor:raylib") -> () | _ -> check "import" false);
(* ── Unimplemented forms are rejected, not silently called ─────── *)
parse_rejects "handler-bind" "(handler-bind [E h] body)";
parse_rejects "restart-case" "(restart-case body (r [] 1))";
parse_rejects "loop/recur" "(loop [x 1] (recur x))";
(* ── Macros ─────────────────────────────────────────────────────── *)
(* A defmacro is a defn. There is no Ast.Defmacro and there is not going to
be one: a macro is [Form] -> Form, compiled by the same backend as
everything else, and what makes it a macro is that the expander calls it
at compile time rather than the program calling it at run time. *)
(match (parse_decl "(defmacro m [& args] (at args 0))").d with
| Defn { name = "m"; params = [ p ]; ret = Some r; _ } ->
(match p.fty.t, r.t with
| Tslice { t = Tname "Form"; _ }, Tname "Form" -> ()
| _ -> check "defmacro is [Form] -> Form" false)
| _ -> check "defmacro parses as a defn" false);
(* The declared type is the same whatever the author wrote as a parameter
list: the list is bindings over the one slice, opened by [macro_body], and
nothing below the parser learns there was a list at all. *)
(match (parse_decl "(defmacro m [[a b] c & rest] (at rest 0))").d with
| Defn { name = "m"; params = [ p ]; ret = Some r; _ } ->
(match p.fty.t, r.t with
| Tslice { t = Tname "Form"; _ }, Tname "Form" -> ()
| _ -> check "a parameter list is still [Form] -> Form" false)
| _ -> check "a macro with a parameter list parses as a defn" false);
(* Several parameters is the feature now. What is still refused is a list
that cannot be read: [&] with nothing or too much after it, a pattern that
binds nothing, a name bound twice, and a map pattern — which is deferred
rather than unimplemented by accident, see FIX.org. *)
parse_rejects "defmacro with a dangling &" "(defmacro m [a &] a)"
~needle:"& needs a name after it";
parse_rejects "defmacro with two names after &" "(defmacro m [& a b] a)"
~needle:"& takes one name and it is the last thing";
parse_rejects "defmacro with a pattern after &" "(defmacro m [& [a b]] a)"
~needle:"& binds one name for the rest of the arguments";
parse_rejects "defmacro with an empty pattern" "(defmacro m [a []] a)"
~needle:"binds nothing";
parse_rejects "defmacro binding a name twice" "(defmacro m [a [b a]] a)"
~needle:"a is bound twice in this parameter list";
parse_rejects "defmacro with a map pattern" "(defmacro m [{:keys [a]}] a)"
~needle:"map destructuring is not implemented in a macro's parameter list";
(* Shape and feature were separate mistakes and stay separate reasons. *)
parse_rejects "defmacro with no body" "(defmacro m [x])"
~needle:"defmacro is (defmacro name [param ...] body ...)";
parse_rejects "defmacro with no params" "(defmacro m x)"
~needle:"defmacro is (defmacro name [param ...] body ...)";
parse_rejects "defmacro with a non-name param" "(defmacro m [1] x)"
~needle:"a macro's parameter is a name or a [ ] pattern";
parse_rejects "defmacro in expression position" "(defn f [] () (defmacro m [] 1))"
~needle:"top-level declaration";
(* The tagged sum is [defdata] now. The old spelling is refused by name
rather than aliased, because the name is reserved for a type with
different semantics — a file that kept [defunion] must be made to say
which of the two it means instead of being quietly given one of them. *)
parse_rejects "the old defunion spelling"
"(defunion Shape [(Circle [r f32]) (Square [s f32])])"
~needle:"the tagged sum is defdata now";
(* The shape that would otherwise parse: two bare case names read as one
member of a type. Same refusal, and this is the one that matters — it
would have compiled. *)
parse_rejects "the old defunion spelling with payload-less cases"
"(defunion U [A B])"
~needle:"the tagged sum is defdata now";
(match read "(defunion U [A B])" |> Parse.program with
| _ -> check "the old spelling has a kind" false
| exception Loc.Error { Loc.kind; _ } ->
check "the old spelling has a kind" (kind = "parse/defunion-renamed"));
(* The return type is not optional. A void function writes (), and the
refusal says so rather than leaving someone to find it in a grammar. *)
parse_rejects "defn with no return type" "(defn f [] (g))"
~needle:"a function that returns nothing writes ()";
parse_rejects "defn with nothing after the parameters" "(defn f [])"
~needle:"The return type is not optional";
(* One spelling for unit, and the old one names the new one. *)
parse_rejects "the old Unit spelling in return position" "(defn f [] Unit (g))"
~needle:"unit is written (), not Unit";
parse_rejects "the old Unit spelling anywhere else"
"(defn f [g (Fn [i32] Unit)] () (g 1))"
~needle:"unit is written (), not Unit";
(* Quasiquote is a desugaring over Form, and it has already run by the time
the parser sees anything, so what is written here is what a macro body
actually compiles to: the prelude's three form-building functions and
nothing else. Spelled out rather than described, because the desugaring
*is* the contract with the prelude. *)
let desugars name src want =
match read src with
| [ f ] ->
let got = Form.to_string (Expand.quasiquote f) in
if got <> want then begin
incr failures;
Printf.printf "FAIL %s\n got: %s\n wanted: %s\n"
name got want
end
| _ -> check (name ^ ": one form") false
in
desugars "a quasiquoted list is form-cons over Form nodes" "`(a ~b)"
"(Form.List {.xs (form-cons (Form.Sym {.s \"a\"}) (form-cons b (form-nil)))})";
desugars "a splice is form-append" "`(a ~@bs)"
"(Form.List {.xs (form-cons (Form.Sym {.s \"a\"}) (form-append bs (form-nil)))})";
(* A vector keeps its bracket through the desugaring: a binding vector is the
commonest thing a macro builds and Form.Vec is not Form.List. *)
desugars "a quasiquoted vector stays a vector" "`[~x 1]"
"(Form.Vec {.xs (form-cons x (form-cons (Form.Int {.i 1}) (form-nil)))})";
(* Levels are not counted -- not by the reader, deliberately, and not here,
which is why the inner one is refused by name rather than given a meaning
nobody chose. *)
parse_rejects "a quasiquote inside a quasiquote" "(defn f [] Form `(a `(b)))"
~needle:"quasiquote inside a quasiquote";
(* Not a missing feature — an unquote outside a quasiquote is a mistake, and
the reader cannot catch it because it does not track where it is. *)
parse_rejects "unquote outside a quasiquote" "(defn f [] () ~x)"
~needle:"means nothing outside a quasiquote";
parse_rejects "splice where a splice makes no sense" "(defn f [] () (+ 1 ~@xs))"
~needle:"splices only into a list or a vector";
(* A splice with no bracket around it. The quasiquote is real here, so this
one is the desugaring's refusal and not the parser's. *)
parse_rejects "splice not inside a bracket" "(defn f [] Form `~@xs)"
~needle:"nothing here for it to splice into";
(* ── defenum: a value is optional, and autoincrements ──────────── *)
(* The numbers are the whole of what the form means, so they are what is
asserted on -- the parser has resolved them by the time a decl exists, and
nothing downstream can tell an implicit value from a written one. *)
let enum_values name src want =
match (parse_decl src).d with
| Defenum (_, ms) ->
let show vs = String.concat " " (List.map Int64.to_string vs) in
let got = List.map snd ms in
if got <> want then begin
incr failures;
Printf.printf "FAIL %s\n wanted: [%s]\n got: [%s]\n"
name (show want) (show got)
end
| _ -> check (name ^ ": parses as a defenum") false
in
enum_values "every member implicit" "(defenum E [A B C])" [ 0L; 1L; 2L ];
enum_values "every member explicit" "(defenum E [A 3 B 9 C -1])"
[ 3L; 9L; -1L ];
(* The mixed case is the point of the feature: an explicit value resets the
count, and the members below it carry on from there. *)
enum_values "an implicit member follows the explicit one above it"
"(defenum E [A B C 10 D])" [ 0L; 1L; 10L; 11L ];
(* The C idiom the explicit-duplicate rule exists for. *)
enum_values "a written duplicate is an alias and is kept"
"(defenum E [First 0 Second 1 Last 1])" [ 0L; 1L; 1L ];
enum_values "an enum with no members at all" "(defenum E [])" [];
(* A value autoincrement walked into is refused, because nothing in the
source chose it -- and the refusal has to name the *other* member, which
is the half a reader cannot see from the line that failed. Three needles
for one source: the message is only doing its job if both names, the
number, and the way out are all in it. *)
parse_rejects "an autoincrement onto a value already taken"
"(defenum E [A 0 B 1 C 0 D])"
~needle:"D has no value of its own, so it autoincrements to 1";
parse_rejects "the refusal names the member already holding the value"
"(defenum E [A 0 B 1 C 0 D])"
~needle:"which is the value B already has";
parse_rejects "the refusal says how to say the alias was meant"
"(defenum E [A 0 B 1 C 0 D])"
~needle:"Give D its value explicitly";
(* The member collided with is as often below as above: here it is [A], the
implicit one, that is refused, and a left-to-right check would pass it. *)
parse_rejects "an autoincrement onto a value written further down"
"(defenum E [A B 0])"
~needle:"A has no value of its own, so it autoincrements to 0";
(* A member is an i32 at run time, so a value outside i32 is refused where it
is resolved -- and the first of these is why the check has to happen
*before* the collision scan above rather than after it. 0 and 2^32 are
different int64s and the same i32, so the scan compares them, finds them
unequal, and passes a program in which both members are 0; the range
refusal is what stops it ever reaching that comparison. *)
parse_rejects "an explicit member too large for i32"
"(defenum E [A 0 B 4294967296])"
~needle:"the member B of E is 4294967296, which does not fit i32";
parse_rejects "the out-of-range refusal says what the range is"
"(defenum E [A 0 B 4294967296])"
~needle:"its members run from -2147483648 to 2147483647";
(* Nothing in the source wrote 2147483648, so the sentence has to say where
it came from before it can say it is wrong. *)
parse_rejects "an autoincrement off the top of i32"
"(defenum E [A 2147483647 B])"
~needle:"B of E has no value of its own, so it autoincrements to 2147483648";
(* The int64 end of the same problem. The member refused is [A], the one
whose value is actually wrong: were the range check to run after the
recursive call rather than before it, [Int64.add] would wrap past max_int
and the refusal would name B and the number min_int, which appears nowhere
in the program. This needle is the pin on that ordering. *)
parse_rejects "an explicit member at the top of i64 does not wrap"
"(defenum E [A 9223372036854775807 B])"
~needle:"the member A of E is 9223372036854775807, which does not fit i32";
(* Genuinely malformed input still says what a member is, in the grammar the
form now has. *)
parse_rejects "an enum member that is not a name" "(defenum E [1 A])"
~needle:"an enum member is a name, optionally followed by an integer";
parse_rejects "a defenum with no member vector" "(defenum E)"
~needle:"defenum is (defenum Name [member value? ...])";
(* ── Malformed syntax is caught with a location ────────────────── *)
parse_rejects "odd let bindings" "(let [a])";
parse_rejects "odd field pairs" "(defstruct S [a])";
parse_rejects "cond without body" "(cond a)";
parse_rejects "unknown top form" "(nope x)";
parse_rejects "break takes only a label" "(defn f [] () (break 1))"
~needle:"break is (break) or (break :label)";
parse_rejects "continue takes only a label" "(defn f [] () (continue x))"
~needle:"continue is (continue) or (continue :label)";
parse_rejects "a labelled while still needs a test" "(defn f [] () (while :o))"
~needle:"(while :label test body ...)";
parse_rejects "array with no type" "(defn f [] () (array 4))"
~needle:"array is (array COUNT TYPE)";
parse_rejects "array given a value, not a type" "(defn f [] () (array 4 5))"
~needle:"expected a type";
parse_rejects "array with a non-constant count" "(defn f [] () (array (+ 1 1) f32))"
~needle:"an array length is an integer or a constant's name";
(* The dimensions are read in [Parse], and this is the whole reason: without
the bracket being read here it would arrive as an ordinary argument, and
an [Arr] of two names is a perfectly good array literal wherever those
names are constants. So the bracket is required and its contents are
[len]s, refused by the same message [4 f32] gets. *)
parse_rejects "array-fill wants its dimensions in brackets"
"(defn f [] () (array-fill 3 0))"
~needle:"array-fill is (array-fill [n ...] value)";
parse_rejects "array-fill wants a fill value"
"(defn f [] () (array-fill [3]))"
~needle:"array-fill is (array-fill [n ...] value)";
parse_rejects "array-fill has no rank zero"
"(defn f [] () (array-fill [] 0))"
~needle:"array-fill is (array-fill [n ...] value)";
parse_rejects "an array-fill dimension is a length, not an expression"
"(defn f [] () (array-fill [(+ 1 1)] 0))"
~needle:"an array length is an integer or a constant's name";
parse_rejects "array-gen says its own name in its usage"
"(defn f [] () (array-gen 3 g))"
~needle:"array-gen is (array-gen [n ...] f)";
(* ── The corpus parses ─────────────────────────────────────────── *)
List.iter
(fun path ->
match read_file path |> Parse.program with
| _ -> ()
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL %s does not parse: %s: %s\n"
path (Loc.to_string loc) msg)
(* dune runs tests in _build/default/test/; the corpus is declared as a
dep in test/dune and lands at the build root. *)
[ "../calc-me.flan"; "../sand.flan" ];
Test_support.report ~label:"parse" ()
(* ═══ The return type is the slot, not a guess ══════════════ *)
(* (Option f64) and (Some 1) are the same s-expression shape, and the parser
used to tell them apart by looking the head up in a set of the file's type
names. The set had to be complete, it twice was not, and the failure was a
body form silently eaten as a return type. The slot is mandatory now, so
there is nothing to look up: whatever is written there is a type, and
whatever follows is the body. These pin that down from both sides. *)
(* Through [read], so the parser and checker tables are under the reader's
alarm too: their sources go through the same reader. *)
let program src = read src |> Parse.program
let () =
let open Ast in
(* Pick the defn out; some sources also declare a struct. *)
let ret_and_body name src =
match
List.find_map
(fun (d : decl) ->
match d.d with Defn fn -> Some fn | _ -> None)
(program src)
with
| Some { ret; fbody; _ } -> (ret <> None, List.length fbody)
| None -> check (name ^ ": has a defn") false; (false, 0)
in
check "the slot is the return type"
(ret_and_body "option" "(defn f [] (Option f64) (g))" = (true, 1));
(* The two that used to be decided by the table, and are decided by position
now: a constructor call and a prelude struct literal are body forms
because they are not in the slot, not because anything knows what they
are. [(Rune {.code 65})] is the one that was misparsed in every file in
the language. *)
check "a ctor after the slot is a body form"
(ret_and_body "some" "(defn f [] () (Some 1) (bar))" = (true, 2));
check "a prelude struct literal after the slot is a body form"
(ret_and_body "preludelit" "(defn f [] () (Rune {.code 65}) (bar))"
= (true, 2));
(* And what is *in* the slot is a type whether or not the parser could know
it: a struct declared further down the file, a package's type behind an
alias the parser has not resolved, a prelude type. None of these needed a
pre-pass any more. *)
check "a type declared later is still the return type"
(ret_and_body "later"
"(defn f [] Cursor (g)) (defstruct Cursor [pos i32])" = (true, 1));
check "a prelude type is the return type"
(ret_and_body "prelude" "(defn f [] Form (g))" = (true, 1));
check "an unknown name in the slot is still the return type"
(ret_and_body "unknown" "(defn f [] Nope (bar))" = (true, 1));
(* The body may be empty, which is the shape the old optional slot could not
produce: [(defn f [])] had nowhere to put the type. *)
check "a function with a return type and no body"
(ret_and_body "nobody" "(defn f [] ())" = (true, 0));
()
(* ── Checker: AST → typed IR ───────────────────────────────────────── *)
let checked src = program src |> Check.program
(* The environment a bare expression is checked against: the prelude and
nothing else, built once because building it is the expensive half and no
probe below declares anything. *)
let probe_env = lazy (snd (Check.program_with_env []))
(* The type an expression infers to, as the checker prints it. Enough to pin
down literal defaulting and every primitive's result.
Checked as an expression, the way a session checks one sent from the editor.
It used to be the type of [(defconst probe <src>)], which stopped working on
2026-09-20: a defconst's initialiser has to be a compile-time constant now
and most of the probes below are calls — (cast ...), (len ...), a
comparison. A defonce would not do either, since only the defconst form
takes no type. This asks [check] the question the wrapper was only ever a
way of asking. *)
let infers name src expected =
match
let form = List.hd (read src) in
let e = Parse.with_imported [] (fun () -> Parse.expr form) in
let t, _, _ = Check.expression (Lazy.force probe_env) e in
Types.to_string t.Tast.ty
with
| got ->
if got <> expected then begin
incr failures;
Printf.printf "FAIL %s\n src: %s\n got: %s\n wanted: %s\n"
name src got expected
end
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
name src (Loc.to_string loc) msg
| exception Watchdog.Timeout ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n the reader did not return\n"
name src
let accepts name src =
match checked src with
| _ -> ()
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
name src (Loc.to_string loc) msg
| exception Watchdog.Timeout ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n the reader did not return\n"
name src
(* [needle] pins the *reason* down: a rejection for the wrong reason is not a
passing test, and the unimplemented-feature errors are the whole point. *)
let rejects_check name ?needle src =
match checked src with
| _ ->
incr failures;
Printf.printf "FAIL %s: expected a type error\n src: %s\n" name src
| exception Loc.Error { Loc.dmsg = msg; _ } ->
(match needle with
| Some n
when not
(List.exists
(fun i -> String.length msg - i >= String.length n
&& String.sub msg i (String.length n) = n)
(List.init (max 1 (String.length msg)) Fun.id)) ->
incr failures;
Printf.printf "FAIL %s: wrong reason\n wanted: %s\n got: %s\n"
name n msg
| _ -> ())
(* Which reading a three-element [defonce] got, pinned by what the global came
out as rather than by what compiled: the two readings differ in the type and
in whether anything runs at startup, and a test that only asked "does this
check" would pass on either one. [zeroed] is the static reading — the
all-bytes-zero initialiser the linker writes — and its negation is the dyn
one, whose initialiser is an expression [Emit] lifts into the startup
function. *)
let defvar_reading name src gname ~ty ~zeroed =
match checked src with
| p ->
(match List.find_opt (fun (g : Tast.global) -> g.gname = gname) p.globals with
| Some g ->
let got = Types.to_string g.gty in
let got_zeroed =
match g.Tast.ginit.Tast.e with Tast.Zero _ -> true | _ -> false
in
if got <> ty || got_zeroed <> zeroed then begin
incr failures;
Printf.printf
"FAIL %s\n src: %s\n got: %s, %s\n wanted: %s, %s\n"
name src got (if got_zeroed then "zeroed" else "initialised")
ty (if zeroed then "zeroed" else "initialised")
end
| None ->
incr failures;
Printf.printf "FAIL %s: no global named %s\n src: %s\n"
name gname src)
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
name src (Loc.to_string loc) msg
(* What a [def] came out as: the type, the [grerun] that makes the startup
store unguarded, and the lifted initialiser. The last is a property
[defonce] has only when its initialiser is computed and [def] has always —
zero and literal included — because it is what a re-evaluation swaps: the
host's startup calls [global/<n>] through its cell, so the lifted function
is the one place an edited initialiser can land. An [uninit] def is the
documented exception and is not asked this. *)
let def_reading name src gname ~ty =
match checked src with
| p ->
(match List.find_opt (fun (g : Tast.global) -> g.gname = gname) p.globals with
| Some g ->
let got = Types.to_string g.gty in
let lifted =
match g.Tast.ginit.Tast.e with
| Tast.Call (f, []) -> f = "global/" ^ gname
| _ -> false
in
if got <> ty then begin
incr failures;
Printf.printf "FAIL %s: the type is %s, wanted %s\n src: %s\n"
name got ty src
end;
if not g.Tast.grerun then begin
incr failures;
Printf.printf "FAIL %s: not marked for re-run\n src: %s\n"
name src
end;
if not lifted then begin
incr failures;
Printf.printf
"FAIL %s: the initialiser is not the lifted global/%s\n src: %s\n"
name gname src
end
| None ->
incr failures;
Printf.printf "FAIL %s: no global named %s\n src: %s\n"
name gname src)
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
name src (Loc.to_string loc) msg
let () =
(* ── Literal defaulting and inference ──────────────────────────── *)
infers "int defaults to i32" "42" "i32";
infers "float defaults to f64" "0.5" "f64";
infers "byte is u8" "\\space" "u8";
infers "string" "\"hi\"" "string";
infers "bool" "true" "bool";
infers "arithmetic keeps kind" "(+ 1 2)" "i32";
infers "comparison is bool" "(< 1 2)" "bool";
infers "cast" "(f64 3)" "f64";
infers "array literal" "[1 2 3]" "[3 i32]";
infers "nested array" "[[1 2] [3 4]]" "[2 [2 i32]]";
(* (array COUNT TYPE): the constructor a [let] binding needs, because a let
has no type slot and [4 P] there is a two-element literal whose second
element is a name nothing declares. The type spelling is unchanged — the
two [infers] above still hold — and this is the position that had no way
to say it. *)
infers "array constructor" "(array 4 f32)" "[4 f32]";
infers "array of a struct" "(array 2 i32)" "[2 i32]";
infers "array of an array" "(array 2 [3 u8])" "[2 [3 u8]]";
(* (array-fill [r c] v): the same type at any rank, with the element type
taken from the fill value. Unlike [array] above this one is a value and
not a zero, which is what lets it be a defonce's initialiser — see
programs/array-fill.flan for what it puts in the elements. *)
infers "array-fill, rank 1" "(array-fill [5] 7)" "[5 i32]";
infers "array-fill, rank 2" "(array-fill [2 3] 0.5)" "[2 [3 f64]]";
infers "array-fill, rank 3" "(array-fill [2 3 4] true)" "[2 [3 [4 bool]]]";
(* A zero dimension is a legal array with no elements, and the fill loop
runs no passes over it. *)
infers "array-fill of nothing" "(array-fill [0] 1)" "[0 i32]";
infers "bytes of a string" "(bytes \"hi\")" "[u8]";
infers "bytes-view of a string" "(bytes-view \"hi\")" "[u8]";
infers "len is i32" "(len (bytes \"hi\"))" "i32";
infers "slice of a slice" "(slice (bytes \"hi\") 0 1)" "[u8]";
infers "slice of the whole" "(slice (bytes \"hi\"))" "[u8]";
infers "slice from n" "(slice (bytes \"hi\") 1)" "[u8]";
(* A string slices to a string and indexes to a byte. Not to a [u8]: the
result views bytes the program does not own, and a byte slice is
writable-looking. *)
infers "slice of a string" "(slice \"hi\" 0 1)" "string";
infers "whole of a string" "(slice \"hi\")" "string";
infers "string index is a u8" "(at \"hi\" 0)" "u8";
infers "parse text to f64" "(bytes->f64 (bytes \"1.5\"))" "f64";
(* An untyped integer constant is usable where a float is wanted, as in
Odin; the reverse is not. *)
infers "int literal into a float" "(+ 1 0.5)" "f64";
rejects_check "float literal into an int"
"(defn f [] i32 (+ 1 0.5))" ~needle:"expected i32";
(* ── Implicit widening, FIX.org 2026-09-20 ─────────────────────────
The lattice, pinned at its edges rather than row by row: what is in, what
is out, and the two boundaries that were a judgement call and could be
argued the other way — int-into-float admitting only the exact ones, and
equal-width cross-signedness admitting nothing.
[programs/widening.flan] is the other half and asserts the bits; these
assert which programs exist. *)
accepts "same signedness widens"
"(defonce a i32) (defn g [x i64] ()) (defn f [] () (g a))";
accepts "unsigned widens into a wider signed"
"(defonce a u32) (defn g [x i64] ()) (defn f [] () (g a))";
accepts "u8 widens into i16"
"(defonce a u8) (defn g [x i16] ()) (defn f [] () (g a))";
accepts "f32 widens into f64"
"(defonce a f32) (defn g [x f64] ()) (defn f [] () (g a))";
(* Narrowing is the thing that did not change, and the message has to say
narrowing rather than "these are different types" — it also names the
direction that needs nothing, because that is the half a reader coming
from the old rule will not expect. *)
rejects_check "narrowing is still refused, and says so"
"(defonce a i64) (defn g [x i32] ()) (defn f [] () (g a))"
~needle:"i64 into i32 can lose";
rejects_check "and says the other direction is free"
"(defonce a i64) (defn g [x i32] ()) (defn f [] () (g a))"
~needle:"i32 widens into i64 by itself";
rejects_check "float narrowing is refused too"
"(defonce a f64) (defn g [x f32] ()) (defn f [] () (g a))"
~needle:"f64 into f32 can lose";
(* Equal width across signedness: each holds values the other cannot, so
there is no direction at all and the message says that instead. *)
rejects_check "signed does not reach the same-width unsigned"
"(defonce a i32) (defn g [x u32] ()) (defn f [] () (g a))"
~needle:"neither widens into the other";
rejects_check "and a signed value never reaches an unsigned, wider or not"
"(defonce a i32) (defn g [x u64] ()) (defn f [] () (g a))"
~needle:"neither widens into the other";
(* Int into float, exact only. This is where the rule is tighter than
Odin's, which admits any integer into any float; i64 has values no f64
holds, so it is out, and the cast is written. *)
accepts "i32 reaches f64 exactly"
"(defonce a i32) (defn g [x f64] ()) (defn f [] () (g a))";
accepts "u32 reaches f64 exactly"
"(defonce a u32) (defn g [x f64] ()) (defn f [] () (g a))";
accepts "i16 reaches f32 exactly"
"(defonce a i16) (defn g [x f32] ()) (defn f [] () (g a))";
rejects_check "i64 does not reach f64 — above 2^53 it would round"
"(defonce a i64) (defn g [x f64] ()) (defn f [] () (g a))"
~needle:"(f64 x)";
rejects_check "i32 does not reach f32 — above 2^24 it would round"
"(defonce a i32) (defn g [x f32] ()) (defn f [] () (g a))"
~needle:"(f32 x)";
(* Containers are invariant: widening rewrites a value with a cast, and
there is no value to rewrite in a slice that does not own its bytes. *)
rejects_check "a slice of i32 is not a slice of i64"
"(defn g [s [i64]] ()) (defn f [t [i32]] () (g t))"
~needle:"expected [i64]";
(* The binary join. The wider operand decides, in either written order, and
an equal-width cross-signed pair still has nothing to decide on. *)
accepts "the wider operand decides, wider written first"
"(defonce a i64) (defonce b i32) (defn f [] i64 (+ a b))";
accepts "and decides when it is written second"
"(defonce a i64) (defonce b i32) (defn f [] i64 (+ b a))";
accepts "min and max join the same way"
"(defonce a i8) (defonce b i16) (defn f [] i16 (max a b))";
rejects_check "i32 and u32 have no join"
"(defonce a i32) (defonce b u32) (defn f [] i32 (+ a b))"
~needle:"neither widens into the other";
(* The literal rule is untouched, which is what keeps a u64 constant's
arithmetic at u64 rather than defaulting the 1 to an i32. *)
accepts "a literal still takes the other operand's type"
"(defconst fnv u64 0xcbf29ce484222325) (defn f [] u64 (+ fnv 1))";
(* The form DISCUSS.org's note named: an unannotated let of a u64 constant.
It binds a u64 and nothing about widening reaches it — a let with no type
has no expectation to widen against, and the constant is what it says. *)
accepts "an unannotated let of a u64 constant still binds a u64"
"(defconst fnv u64 0xcbf29ce484222325) \
(defn f [] u64 (let [h fnv] (* h 2)))";
(* A literal that does not fit is the program's mistake, not a pair of types
that failed to meet, so the join must not reconsider it — the operand it
would reconsider against is the one the literal was supposed to take its
width *from*. Both spellings: the literal written as the operand, and the
literal buried in one. *)
rejects_check "a literal that does not fit is still refused"
"(defonce m u8) (defn f [] u8 (+ m 300))" ~needle:"300 does not fit in u8";
rejects_check "and is refused inside an operand too"
"(defonce m u8) (defn f [] u8 (+ m (+ 300 1)))"
~needle:"300 does not fit in u8";
rejects_check "a float literal still cannot stand where an int is wanted"
"(defonce n i32) (defn f [] i32 (+ n 1.5))"
~needle:"found the float literal 1.5";
accepts "a literal that does fit still takes the operand's type"
"(defonce m u8) (defn f [] u8 (+ m 200))";
(* The join reconsiders a refused operand, and a reconsidered pass must leave
nothing behind. [scoped] cannot see to that — it puts the scope back on
the way out, which an exception does not take — so [binary] snapshots and
restores around each trial. Both symptoms of not doing it: a binding that
outlives the pass that made it, and the same binding *shadowing* a live
one, which is an uninitialised read in a program the compiler accepted. *)
(* The other half, and the one that bites harder: a form that opens a window
and closes it on the way out leaves it *open* when a trial inside it is
abandoned, and then refuses a program that is fine. Both windows — the
frames [handler-bind] establishes, and the loop [loop] pushes — with the
refusal each would wrongly produce written as the second half of the
test, so a regression shows up as the message coming back rather than as
a silent accept. *)
accepts "an abandoned trial inside handler-bind does not leave its frames up"
"(defonce n i32) (defonce w i64) \
(defn f [] i32 (println (+ n (handler-bind [] w))) (return 0))";
accepts "nor does one inside a loop leave the loop up"
"(defonce n i32) (defonce w i64) \
(defn f [] i32 (println (+ n (loop [i 0] w))) (defer (println 1)) 0)";
rejects_check "and a break outside every loop still says so plainly"
"(defonce n i32) (defonce w i64) \
(defn f [] i32 (println (+ n (loop [i 0] w))) (break) 0)"
~needle:"break is only allowed inside a loop";
rejects_check "an abandoned trial leaves no binding behind"
"(defonce n i32) (defonce w i64) \
(defn f [] i32 (println (+ n (let [q w] q))) (println q) 0)"
~needle:"unknown name q";
accepts "and does not shadow the binding it was nested in"
"(defonce n i32) (defonce w i64) \
(defn f [] i32 (let [t n] (println (+ n (let [t w] t))) (println t)) 0)";
(* Shifts are the carve-out: the value's type decides and the count widens
to it, never the reverse, because the result's width and the poison check
both belong to the value. *)
accepts "a narrower count widens to the value"
"(defonce v i64) (defonce n u8) (defn f [] i64 (<< v n))";
rejects_check "a wider count does not drag the value up with it"
"(defonce v u8) (defonce n i32) (defn f [] u8 (<< v n))"
~needle:"expected u8";
(* ── Bidirectional flow ────────────────────────────────────────── *)
accepts "return type types the literal" "(defn f [] u8 0)";
accepts "return type types None" "(defn f [] (Option f64) None)";
rejects_check "bare None has no type" "(defconst x None)"
~needle:"what None is an Option of";
accepts "param types the literal"
"(defn g [x u8] ()) (defn f [] () (g 3))";
rejects_check "wrong argument type"
"(defn g [x u8] ()) (defn f [] () (g 0.5))" ~needle:"expected u8";
rejects_check "wrong arity"
"(defn g [x u8] ()) (defn f [] () (g 1 2))" ~needle:"takes 1 argument";
rejects_check "wrong return type"
"(defn f [] bool 1)" ~needle:"expected bool";
(* What the mandatory slot bought: a mistyped type in return position is a
mistyped type. It used to be parsed as the first form of the body and
reported as an unknown *name*, which points at the wrong mistake. *)
rejects_check "a mistyped return type says which type was meant"
"(defn f [] f65 0.0)" ~needle:"did you mean f64";
rejects_check "if branches disagree"
"(defn f [] i32 (if true 1 true))" ~needle:"expected i32";
(* M2 queue item 7: a dyn if's scrutinee is truthiness-tested (Clojure's
rule -- nil and false are the only falsey values); a typed if keeps
needing a strict bool, exactly as before. The runtime survey is
programs/dyn-if-truthy.flan; these three pin the checker's own half. *)
accepts "typed if still takes a bare bool" "(defn f [] i32 (if true 1 2))";
rejects_check "typed if still refuses a non-bool scrutinee"
"(defn f [] i32 (if 1 1 2))"
~needle:"expected bool, found the integer literal 1";
(* Not just refused -- refused with the exact sentence a typed if has
always given here. check_truthy's dyn-or-bool check runs first, but a
value that is neither is re-checked with the old want:Bool so the
message a literal gets is the one it names itself with, not the type
it silently defaulted to along the way. *)
rejects_check "typed if still refuses None with its own message"
"(defn f [] i32 (if None 1 2))" ~needle:"expected bool, found None";
(* None is the other shape of "asked the wrong question first": checked
with no expectation at all it has no answer -- "nothing here says what
None is an Option of" -- rather than a wrong one, so check_truthy's
first attempt *raises* here instead of returning some non-bool,
non-dyn type. The re-check has to run on that path too. *)
accepts "dyn if accepts a non-bool dyn scrutinee"
"(defn f [x] dyn (if x 1 2))";
(* not, while: not [if] under the hood, so check_truthy is reached at
their own call sites (check.ml) rather than for free through
desugaring -- both take a non-bool dyn condition too. *)
accepts "dyn not accepts a non-bool dyn argument"
"(defn f [x] dyn (not x))";
accepts "dyn while accepts a non-bool dyn condition"
"(defn f [x] () (let [y x] (while y (set y false))))";
(* when, cond, and, or: sugar built out of [Ast.If] in parse.ml, so a
non-bool dyn condition reaches them with no separate check.ml case --
confirmed here rather than assumed. *)
accepts "dyn when accepts a non-bool dyn condition"
"(defn f [x] () (when x 0))";
accepts "dyn cond accepts a non-bool dyn condition"
"(defn f [x] dyn (cond x 1 :else 2))";
accepts "dyn and accepts a non-bool dyn operand"
"(defn f [x] i32 (let [y x] (if (and y true) 0 1)))";
accepts "dyn or accepts a non-bool dyn operand"
"(defn f [x] i32 (let [y x] (if (or y true) 0 1)))";
(* A bare keyword condition used to be checked with want:Bool from the
start, landing on the keyword arm's enum-or-refuse case and refusing
by name -- ":kw is an enum member where an enum is expected ... but
bool is expected here", since there was no enum in play. Checked with
no expectation first, as every scrutinee now is, it resolves as the
dyn keyword instead, and a dyn keyword is unconditionally truthy: a
typed if with a bare keyword condition now compiles, and always takes
the then branch. The author's call, recorded at check_truthy: lispy
truthiness wins here, the lost diagnostic is not brought back, and
this pins the new answer down so it does not regress by accident. *)
accepts "a typed if with a bare keyword condition now compiles"
"(defn f [] i32 (if :kw 1 2))";
(* ── Unknown types ─────────────────────────────────────────────── *)
(* A lowercase name is a type variable (plan.org, Types), so a mistyped
primitive would otherwise be reported as unimplemented generics and send
you to plan.org instead of to the character you mistyped. *)
rejects_check "a mistyped primitive" "(defn f [x f65] ())"
~needle:"did you mean f64?";
rejects_check "a transposed primitive" "(defn f [x stirng] ())"
~needle:"did you mean string?";
rejects_check "a mistyped struct"
"(defstruct Cursor [x i32]) (defn f [c Curser] ())"
~needle:"did you mean Cursor?";
(* [(defn f [x t] ())] used to be one parameter of an unimplemented generic
type and is now two parameters of type dyn — a lowercase name resembling
no type is a parameter, which is the whole of dynamic-by-default. The
milestone-5 reading is still reachable, by writing the type variable with
the sigil the signature binds it with. *)
(match checked "(defn f [x t] ())" with
| p ->
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "f") p.Tast.fns with
| Some { Tast.params = [ Types.Dyn; Types.Dyn ]; _ } -> ()
| _ -> check "an unannotated pair is two dyn parameters" false)
| exception _ -> check "an unannotated pair is two dyn parameters" false);
(* A bare lowercase name where a type is the only thing a slot can hold. It
used to be reported as unimplemented generics; generics are implemented,
and a lowercase name is a type variable only where a defn signature
introduced one with the sigil — a struct field is not such a place and
never will be, since only a signature binds. The message used to tell a
field to "write $elem in the parameter vector", and a field has no
parameter vector — the suggestion could not be followed where it was
printed. A field now gets its own sentence, naming the two things that
can actually be written there; the parameter-vector suggestion survives
where it works, which the return-type pin further down exercises. *)
rejects_check "a real type variable at a field" "(defstruct Holder [x elem])"
~needle:"a field is built at one type for every value";
rejects_check "and the field message offers what a field can hold"
"(defstruct Holder [x elem])"
~needle:"Write a concrete type here, or dyn to hold any value";
rejects_check "an unknown concrete type" "(defn f [x Widget] ())"
~needle:"unknown type Widget";
(* ── dyn, and what it does not do yet ──────────────────────────── *)
(* The pairing rule's own refusal. A name that is also a type's has no good
reading — taken as written it is a parameter called [i64] — and the
likelier intent is a pair the wrong way round, which the message names. *)
rejects_check "a parameter named after a type" "(defn f [i64 x] ())"
~needle:"cannot also be this parameter's name";
(* M2 item 3 lifted the container-into-dyn refusal: a [(Vec T)], a slice or
a fixed array with an i64/f64/bool element now crosses as a VIEW rather
than refusing — but only when its storage is permanent, a global's,
which review added after the first landing: a view's descriptor chases
the container's own address on every operation, and a container whose
address dies with a frame is exactly the dangling dyn value the dynamic
side refuses to hand back. Every accepting row below views a global. A
[(Map K V)] still refuses regardless of storage — it rides a
representation this milestone does not give a view — and so does any
container whose element is outside the three the view can hold. *)
accepts "a typed Vec boxed into dyn is a view, not a refusal"
"(defonce v (Vec i64) (vec-new i64))\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take v))";
accepts "a slice boxed into dyn is a view"
"(defonce xs [3 i64])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take (slice xs 0 3)))";
accepts "a fixed array boxed into dyn is a view"
"(defonce a [4 i64])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take a))";
accepts "a bool Vec's view"
"(defonce v (Vec bool) (vec-new bool))\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take v))";
accepts "an f64 Vec's view"
"(defonce v (Vec f64) (vec-new f64))\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take v))";
(* The element restriction is still refused, and by name: a string element
would need a dyn string's own boxing, whose payload is a pointer into
the collector's heap, planted where nothing will ever trace it. *)
rejects_check "a Vec of strings does not view into dyn yet"
"(defonce v (Vec string) (vec-new string))\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take v))"
~needle:"does not cross into dyn yet";
rejects_check "an i32 element is not one of the view's three"
"(defonce v (Vec i32) (vec-new i32))\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take v))"
~needle:"does not cross into dyn yet";
(* A typed (Map K V) is unrelated to item 3 and keeps its own refusal. *)
rejects_check "a typed Map still refuses into dyn"
"(defonce m (Map i64 i64) (map-new i64 i64))\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take m))"
~needle:"does not cross into dyn yet";
(* Which of the two refusals wins when both apply. A LOCAL (Vec string)
fails the lifetime guard and the element check both, and the element
one has to be the one that speaks: the lifetime message names
(defonce g ...) as the spelling that works, and for a string element
the global spelling is refused too, so the other order would hand back
advice that fails when taken. *)
rejects_check "a local Vec of strings gets the element refusal, not the \
lifetime one"
"(defn take [d dyn] i32 1)\n\
(defn main [] i32 (let [v (vec-new string)] (take v)))"
~needle:"does not cross into dyn yet";
(* ── The lifetime guard, added on review ─────────────────────────
A local, a parameter and a temporary all answer false to
[permanent_root], and each gets the same message rather than "cannot be
indexed" or some other accident of which path noticed. *)
rejects_check "a local Vec does not view into dyn — its frame ends"
"(defn take [d dyn] i32 1)\n\
(defn main [] i32 (let [v (vec-new i64)] (take v)))"
~needle:"does not cross into dyn as a view here";
rejects_check "a Vec parameter does not view into dyn"
"(defn take [d dyn] i32 1)\n\
(defn give [v (Vec i64)] i32 (take v))\n\
(defn main [] i32 0)"
~needle:"does not cross into dyn as a view here";
rejects_check "a fixed array local does not view into dyn"
"(defn take [d dyn] i32 1)\n\
(defn main [] i32 (let [a (array 4 i64)] (take a)))"
~needle:"does not cross into dyn as a view here";
(* A slice cut from a global is permanent; the same slice expression
rebound to a local first loses the trace back to it and is refused —
conservative rather than wrong, and the message says what does work. *)
accepts "a slice cut from a global inline is still permanent"
"(defonce xs [3 i64])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take (slice xs 0 3)))";
rejects_check "a slice rebound to a local loses the trace and is refused"
"(defonce xs [3 i64])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (let [s (slice xs 0 3)] (take s)))"
~needle:"does not cross into dyn as a view here";
(* An element of a global is permanent only when the global is an ARRAY.
An array's elements are inside the global's own storage; a slice's are
not — a global [[T]] holds ptr+len and nothing more, and what they
point at may be a frame that has already returned. The refusal row
below is one word different from the acceptance row above it, which is
the point: it is the [At] arm's demand for an array at the level being
indexed and nothing else deciding. Before that guard the refusal row
compiled and segfaulted with no diagnostic at all. *)
accepts "an element of a global array is permanent"
"(defonce rows [2 (Vec i64)])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take (at rows 0)))";
rejects_check "an element of a global slice is not permanent"
"(defonce sv [(Vec i64)])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take (at sv 0)))"
~needle:"does not cross into dyn as a view here";
(* [(at g i j)] is ONE typed node holding both indices, not two nested
ones, so a guard that reads the target's type alone sees level zero and
nothing after it. These two rows pin the multi-index spelling on both
sides: every level an array is permanent, and a slice at ANY level is
not — including the second, which the one-level guard accepted and
which then printed a dead frame's contents with exit 0. *)
accepts "an element of a global array of arrays is permanent"
"(defonce rows [2 [3 (Vec i64)]])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take (at rows 0 1)))";
rejects_check "an element reached through a slice level is not permanent"
"(defonce g [2 [[3 i64]]])\n\
(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take (at g 0 1)))"
~needle:"does not cross into dyn as a view here";
(* A Vec behind a Ptr is refused even though some Ptrs really are
heap-durable — the checker cannot tell this one from a Ptr taken off a
local, and admitting one admits the other. *)
rejects_check "a Vec behind a Ptr does not view into dyn"
"(defn take [d dyn] i32 1)\n\
(defn use [p (Ptr (Vec i64))] i32 (take (deref p)))\n\
(defn main [] i32 0)"
~needle:"does not cross into dyn as a view here";
(* A bracket *literal* is not a typed container yet, and where a dyn is
wanted it builds the runtime's own vec instead — the lowering the map
literal's values ride on, and what makes {:xs [1 2]} mean what it
reads as. *)
accepts "a bracket literal where a dyn is wanted is a dyn vec"
"(defn take [d dyn] i32 1)\n\
(defn main [] i32 (take [1 2 3]))";
(* ── Per-type descriptors — M2 item 2 ──────────────────────────
Both of these were refusals until the descriptors landed, and for one
reason: the collector's roots were frames, so a struct's dyn field was a
live value reachable only through memory the marker never walked. A type
that holds dyn words at static offsets now has a descriptor naming them,
and every slot, global and temporary holding one goes on the root stack
with that descriptor beside it — so the instance never has to carry a
pointer to its own type, which is what would have cost a header word. *)
accepts "a dyn field in a struct"
"(defstruct S [x dyn])\n(defn main [] i32 0)";
accepts "a dyn in a condition's payload"
"(defstruct Boom [what dyn])\n\
(defn main [] () (signal (Boom {.what 1})))";
(* Nested by value, which is the case the flattening is for: the inner
struct's dyn word appears in the outer's table at the sum of the two
offsets, and there is no second descriptor to follow at run time. *)
accepts "a dyn inside a struct inside a struct"
"(defstruct Inner [x dyn])\n\
(defstruct Outer [n i32 in Inner])\n\
(defn main [] i32 (let [o (Outer {.n 1 .in (Inner {.x 2})})] (.n o)))";
(* And a fixed array of them, which is the same flattening once per
element — an array is a value and its storage is the slot's. *)
accepts "a fixed array of structs with dyn fields"
"(defstruct S [x dyn])\n\
(defn main [] i32 (let [a (array 4 S)] (set (.x (at a 0)) 7) 0))";
(* A global, rooted before the startup function runs and never popped. *)
accepts "a global struct with a dyn field"
"(defstruct S [x dyn])\n(defonce s S)\n(defn main [] i32 0)";
(* What the descriptor still cannot reach, each by name. A typed container
owns storage of a length nothing static knows, so the dyn words of one
are not a list of offsets — that is the M2 queue's item 3, and it is a
different shape of descriptor on purpose. *)
rejects_check "a dyn field under a typed container"
"(defstruct S [x dyn])\n\
(defn main [] i32 (let [v (vec-new S)] 0))"
~needle:"no descriptor can find";
(* A data type's cases overlay one another, so which words are dyn depends
on the tag, which is a run-time question a static table cannot answer. *)
rejects_check "a dyn field in a data type's payload"
"(defdata D [(A [x dyn]) (B [n i32])])\n\
(defn main [] i32 (let [d (D.B {.n 1})] 0))"
~needle:"no descriptor can find";
(* An Option's payload exists only under the tag. A None's words are zero
and marking them would be harmless, but that is how this compiler happens
to build one and not something the type says. *)
(* And the one cap, which is arithmetic rather than representation: an
array's offsets are flattened one element at a time, so a big enough
array would be a megabyte of static table. The repeat form that avoids
it is the typed container view's machinery, so this says so. *)
rejects_check "an array too big for a flattened descriptor"
"(defstruct S [x dyn])\n\
(defn main [] i32 (let [a (array 5000 S)] 0))"
~needle:"the most this compiler will write out";
(* And the length that wrapped the multiplication rather than tripping the
cap: an honest product of this by one is negative, so the test read as
under the cap, the declaration was accepted, and the emitter then sat
building the offset list until something killed it. The count is
saturated now. *)
(* And the other end of the same arm: negative rather than enormous. It
reaches the multiplication as a small number, which is exactly what the
cap must not be handed. *)
rejects_check "a negative array length"
"(defstruct S [x dyn])\n\
(defonce neg [-1 S])\n\
(defn main [] i32 0)"
~needle:"the most this compiler will write out";
rejects_check "an array length that overflows the flattened count"
"(defstruct S [x dyn])\n\
(defonce big [4611686018427387904 S])\n\
(defn main [] i32 0)"
~needle:"the most this compiler will write out";
(* A pointer is not storage. A vector of pointers to structs that hold dyn
holds no dyn words of its own, and refusing it said the opposite. *)
accepts "a typed container of pointers to a dyn-bearing struct"
"(defstruct Cond [why dyn])\n\
(defn f [v (Vec (Ptr Cond))] i32 0)\n\
(defn main [] i32 0)";
(* The one honest hole, named at the boundary where it opens — and it is a
question about *ownership*, not about shape, so the two directions get
asked different things.
A return, and everything reachable from it however many pointers deep, is
C's storage. All three shapes, because a check that followed one pointer
and stopped let the other two through: each of them stores a dyn into C
memory and reads it back after a collection, and each is a
heap-use-after-free in flan_dyn_tag under ASan. *)
(* Each on the *type* it names and not on the shared half of the sentence:
three rows against one needle would all still pass if the message named
the wrong shape, which is the one thing these rows exist to tell apart. *)
rejects_check "a pointer to a dyn-bearing struct returned from C"
"(defstruct S [x dyn])\n\
(declare grab [] (Ptr S) \"c_grab\")\n\
(defn main [] i32 0)"
~needle:"the return type of grab (the C symbol c_grab) is (Ptr S)";
rejects_check "a pointer to a pointer to one, returned from C"
"(defstruct S [x dyn])\n\
(declare grab [] (Ptr (Ptr S)) \"c_grab\")\n\
(defn main [] i32 0)"
~needle:"is (Ptr (Ptr S)), and a dyn is reachable through it";
(* The shape shim.ml's own advice tells people to write for an aggregate
result, which is what made this one worth having a test of its own. *)
rejects_check "a pointer to a slice of them, returned from C"
"(defstruct S [x dyn])\n\
(declare grab [] (Ptr [S]) \"c_grab\")\n\
(defn main [] i32 0)"
~needle:"is (Ptr [S]), and a dyn is reachable through it";
(* And an out-parameter, which is a return wearing a parameter's clothes:
the cell is this compiler's, the pointer C writes into it is C's. *)
rejects_check "an out-parameter handing back a pointer to one"
"(defstruct S [x dyn])\n\
(declare out [p (Ptr (Ptr S))] () \"c_out\")\n\
(defn main [] i32 0)"
~needle:"what lies below it is C's";
(* The other direction stays writable, and this is the half a shape-only
rule got wrong. A foreign parameter receives the address of a *place*
a frame slot, a global, an array inside one — and every one of those is
rooted with its descriptor and marked for the whole call. So the
read-only borrow and the slice argument are ordinary and allowed. *)
accepts "a pointer to a dyn-bearing struct passed to C"
"(defstruct S [x dyn])\n\
(declare inspect [p (Ptr S)] () \"c_inspect\")\n\
(defn main [] i32 0)";
accepts "a slice of them passed to C"
"(defstruct S [x dyn])\n\
(declare take [s [S]] () \"c_take\")\n\
(defn main [] i32 0)";
(* A pointer field back to the type's own name, which is the shape that had
no row and needed four passes to surface. The two walks at this boundary
ask different questions — one counts a direct dyn and the other does not
— and they shared a visited set, so [Node] was marked seen on the way
down and then pruned from the question below the pointer. It came out
accepted while the same thing written as two types came out refused,
which is the tell. C hung a malloc'd node off [next], put a dyn in it and
read it back after a hundred thousand allocations: heap-use-after-free in
flan_dyn_tag, freed by gc_sweep. *)
rejects_check "a struct with a pointer to itself and a dyn field"
"(defstruct Node [next (Ptr Node) x dyn])\n\
(declare walk [p (Ptr Node)] () \"c_walk\")\n\
(defn main [] i32 0)"
~needle:"is (Ptr Node), and a dyn is reachable through it";
(* And the same fact through a cycle of two, because a fix that only reset
the set for a self-reference would pass the row above and fail this. *)
rejects_check "two structs pointing at each other, with a dyn in one"
"(defstruct A [b (Ptr B) x dyn])\n\
(defstruct B [a (Ptr A)])\n\
(declare walk [p (Ptr A)] () \"c_walk\")\n\
(defn main [] i32 0)"
~needle:"is (Ptr A), and a dyn is reachable through it";
(* The other side of resetting the set: it must still terminate, and it must
not start refusing a recursive shape with no dyn anywhere under it. Both
of these walk a cycle and both are ordinary. *)
accepts "a self-referential struct with no dyn in it"
"(defstruct L [next (Ptr L) n i64])\n\
(declare walk [p (Ptr L)] () \"c_walk\")\n\
(defn main [] i32 0)";
accepts "a cycle of two with no dyn in either"
"(defstruct M [other (Ptr N) n i64])\n\
(defstruct N [back (Ptr M)])\n\
(declare walk [p (Ptr M)] () \"c_walk\")\n\
(defn main [] i32 0)";
rejects_check "a dyn field under an Option"
"(defstruct S [x dyn])\n\
(defn f [] (Option S) None)\n\
(defn main [] i32 0)"
~needle:"no descriptor can find";
(* And the C boundary, which is the one that would otherwise pass silently:
a dyn is one word and would cross as an integer, and nothing on the other
side can ask what the word means. *)
rejects_check "a dyn crossing to C"
"(declare c-take [d dyn] () \"c_take\")"
~needle:"does not cross to C";
(* ── dyn maps, keywords and nil — M2 item 1 ────────────────────── *)
(* The literal parses and checks: braces whose first form is not a .field
symbol are a dyn map, in binding position and as a call argument alike —
the argument spelling used to be swallowed by the struct-literal rule. *)
accepts "a map literal in a binding"
"(defn main [] i32 (let [m {:a 1 :b \"two\"}] (len m)))";
accepts "a map literal as an argument"
"(defn take [d dyn] i32 1)\n(defn main [] i32 (take {:a 1}))";
accepts "the empty braces are an empty map"
"(defn main [] i32 (let [m {}] (len m)))";
accepts "map literals nest, and brackets inside are dyn vecs"
"(defn main [] i32 (let [m {:xs [1 2] :inner {:c 2.5}}] (len m)))";
rejects_check "a map literal with an odd number of forms"
"(defn main [] i32 (let [m {:a 1 :b}] (len m)))"
~needle:"odd number of forms";
(* The struct spelling is untouched on both of its sides: bare braces
opening on a .field are still a struct field list and not a map, and
(Type {.field v}) still builds one. What changed is *where* the two
rows below are refused — at checking now, not at parsing, because a
.field-keyed brace with a type expected of it is a struct literal and
only the checker can see the expectation. Neither position has one: a
let binding takes its type from its value, and a form that is not a
body's last is not the return value. It is untouched at the head of a
defn body too, single-form or not — [constraints] (parse.ml) leaves a
[.field]-first map alone precisely so this dedicated message, not "a
constraint map is keyword/value pairs", is what a program gets there. *)
rejects_check "bare struct-shaped braces with nothing to infer from"
"(defn main [] i32 (let [m {.x 1}] 0))"
~needle:"does not say which struct it builds";
rejects_check "bare struct-shaped braces at a defn body's head, too"
"(defn main [] i32 {.x 1} 0)"
~needle:"does not say which struct it builds";
accepts "a struct literal still builds"
"(defstruct P [x i32])\n\
(defn main [] i32 (let [p (P {.x 1})] (.x p)))";
(* And the {:where ...} constraint map is still peeled off a defn body —
it opens on :where, which is what now tells it from a map literal
standing as the body's first form. *)
accepts "a where clause is still a constraint map"
"(defn biggest [a $t b $t] $t {:where (ordered? $t)} (if (> a b) a b))\n\
(defn main [] i32 (biggest 1 2))";
(* A where clause with nothing after it is what a moved closing paren
produces -- the body fell outside the defn. It stays a constraint map
even alone, so this is "no body", not the "unknown function ordered?"
it used to print from a stray predicate read as ordinary body code. *)
rejects_check "a where clause with no body after it"
"(defn ordered? [x] bool true)\n\
(defn mx [a $t b $t] $t {:where (ordered? $t)})"
~needle:"has no body";
(* A keyword-keyed map at the head of a multi-form body, other than
:where, is a typo almost every time -- its value is discarded, and a
map literal has no reason to sit somewhere its value goes unused. An
empty map there gets its own message, since it has no key for [keys]
to name. *)
rejects_check "a discarded keyword-keyed map at a defn body's head"
"(defn main [] i32 {:a 1} 0)"
~needle:"is not a key a defn's constraint map takes";
rejects_check "a discarded empty map at a defn body's head"
"(defn main [] i32 {} 0)"
~needle:"an empty map literal here is discarded";
rejects_check "a discarded string-keyed map at a defn body's head"
"(defn main [] i32 {\"a\" 1} 0)"
~needle:"keyword/value pairs — found";
(* Not discarded, because nothing follows it: the map IS the single-form
body, a real dyn value and not a mistake to flag. *)
accepts "a keyword-keyed map as a defn's whole body"
"(defn f [] dyn {:a 1})\n(defn main [] i32 0)";
accepts "an empty map as a defn's whole body"
"(defn f [] dyn {})\n(defn main [] i32 0)";
(* :where does not get the same exception :a and {} get above -- it is
peeled unconditionally, even as a defn's whole single-form body, so
{:where 1} here is read as a constraint map with a malformed predicate
and not as a dyn map with one key named :where. That does narrow the
language: a dyn map genuinely wanting :where as a key has no way to
write one at a defn body's head (it still can anywhere else -- (let
[m {:where 1}] m) is untouched, since [constraints] only ever looks at
a defn's own body). The trade is deliberate: :where is what catches a
moved closing paren leaving a stray predicate as ordinary body code
(see above), and that only works if :where means constraint map with no
exceptions, single-form body included. Pinned so the next edit to this
arm has to notice it is choosing to narrow the language again, rather
than finding out from a bug report. *)
rejects_check ":where is reserved at a defn body's head, even alone"
"(defn f [] dyn {:where 1})\n(defn main [] i32 0)"
~needle:"a where predicate is";
(* Keywords: dyn where nothing else is asked, still an enum member where an
enum is, and refused where a concrete non-dyn type is wanted. *)
accepts "a keyword is a dyn value"
"(defn main [] i32 (let [k :foo] (if (= k :foo) 0 1)))";
accepts "a keyword where an enum is expected still resolves"
"(defenum Axis [x y])\n\
(defn pick [a Axis] i32 1)\n\
(defn main [] i32 (pick :x))";
rejects_check "a keyword where an i32 is expected"
"(defn take [n i32] i32 n)\n(defn main [] i32 (take :foo))"
~needle:"but i32 is expected here";
accepts "keyword takes a string"
"(defn main [] i32 (let [k (keyword \"foo\")] (if (= k :foo) 0 1)))";
rejects_check "keyword takes bytes, not a number"
"(defn main [] i32 (let [k (keyword 3)] 0))"
~needle:"keyword takes a string";
(* nil is a literal now — the dyn absence value, and what (get m k) answers
for a key a map does not hold. It is always dyn. *)
accepts "nil is a dyn literal"
"(defn main [] i32 (let [n nil] (if (= n nil) 0 1)))";
(* Superseded by the M2 item 4 boundary below: a literal [nil] at a bare
typed want is now refused by name, at compile time, rather than by the
generic dyn-boundary message — see "nil at a bare T is refused at
compile time" further down. *)
(* ── nil <-> None at (Option T), M2 queue item 4 ──────────────────
nil and None are the same absence at the one boundary where both are
meaningful. The three sites a dyn can be unboxed at own nil's half of
it too: a parameter, a return type and a global's declared type. *)
accepts "nil becomes None at a return type"
"(defn f [] (Option i64) nil)\n\
(defn main [] i32 (match (f) (Some _) 1 None 0))";
accepts "nil becomes None at a parameter"
"(defn h [o (Option i64)] i32 (match o (Some _) 1 None 0))\n\
(defn main [] i32 (h nil))";
accepts "nil becomes None at a global's declared type"
"(defonce ov (Option i64) nil)\n\
(defn main [] i32 (match ov (Some _) 1 None 0))";
(* The other direction: None crossing into dyn is nil, and a program can
compare the result the same way it compares any other nil. *)
accepts "None becomes nil crossing into dyn"
"(defn g [] dyn None)\n\
(defn main [] i32 (if (= (g) nil) 0 1))";
(* A bare T has no None to become. This nil is the one the checker can see
— the literal, right where the mismatch is — so it is refused here
rather than waiting for the run-time trap the same mismatch reaches for
one call deeper (dyn-not-visibly-nil case, test_acceptance.ml). *)
rejects_check "nil at a bare T is refused at compile time"
"(defn take [n i32] i32 n)\n(defn main [] i32 (take nil))"
~needle:"nil has no None to become";
rejects_check "nil at a bare T is refused at compile time, return position"
"(defn f [] i64 nil)\n(defn main [] i32 0)"
~needle:"nil has no None to become";
(* (Some nil) would make nil and None the same case of an (Option dyn), so
it cannot be built — refused at compile time when the argument is the
literal nil, which is exactly what "the checker can see" means here. *)
rejects_check "(Some nil) is refused at compile time"
"(defn main [] i32 (let [o (Some nil)] 0))"
~needle:"(Some nil) cannot be built";
(* The same refusal where the argument's own want is already (Option T)'s
inner type — a function parameter typed (Option i64), say. [nil] checked
against that inner type directly would hit [expect]'s bare-T refusal
first ("wrap the type in Option"), which is nonsense here: the type
already is one. A regression for the review that found it. *)
rejects_check "(Some nil) at an already-Option want gets Some's message, \
not expect's bare-T one"
"(defn f [o (Option i64)] i64 (match o (Some v) v None -1))\n\
(defn main [] i32 (f (Some nil)))"
~needle:"(Some nil) cannot be built";
(* (Option (Option T)) is legal on the typed side — nothing above refuses
the type — but boxing its Some of an inner None would box that None as
nil, indistinguishable from the outer None, so the crossing into dyn
does not exist for it. *)
rejects_check "(Option (Option T)) does not cross into dyn"
"(defn f [] (Option (Option i64)) None)\n\
(defn g [] dyn (f))\n\
(defn main [] i32 0)"
~needle:"does not cross into dyn";
(* (Option dyn): the payload is already dyn, so [box_option]/[unbox_option]
treat it as the identity — no [box]/[unbox] call, just the tag test —
and the only thing that has to hold is that the payload is never nil,
which is (Some nil)'s refusal above and not this boundary's.
That is what the code does; it is not yet what a program can hold. A
value of type (Option dyn) is refused wherever it would need a GC root
— global, parameter, return or local slot — by the *separate*,
pre-existing per-type-descriptor pass (M2 item 2): the collector marks a
struct's dyn fields by their byte offsets, and (Option dyn)'s payload
has none, the same reason (Vec dyn) and (Map K dyn) are refused today.
Item 4 does not lift that gate; it only makes sure the boundary is
already correct for the day items 2/3 do. The refusal below is that
gate, not a nil-boundary message — proof the two are not tangled. *)
rejects_check "(Option dyn) is a legal type but not yet a storable value"
"(defn k [] (Option dyn) None)\n(defn main [] i32 0)"
~needle:"no descriptor can find";
(* A full round trip through the boundary: a typed i64 boxed into dyn at
one annotated site, then read back as an (Option i64) at another. *)
accepts "a value round-trips through dyn and (Option T)"
"(defn box-it [x i64] dyn x)\n\
(defn unbox-opt [d dyn] (Option i64) d)\n\
(defn main [] i32\n\
\ (match (unbox-opt (box-it 42)) (Some x) (if (= x 42) 0 1) None 1))";
(* A numeric cast opens a dyn box — FIX.org 2026-09-20. The checker's half
is small: every cast name the language has admits a dyn operand now, and
that is what these rows are. What the cast then *does* is a run-time
question and lives in test_acceptance.ml's dyn-cast rows — the same split
as the typed boundary, whose refusal is here and whose trap is there. *)
accepts "every numeric cast takes a dyn"
"(defn d [] dyn 7)\n\
(defn main [] i32\n\
\ (do (i64 (d)) (i32 (d)) (i16 (d)) (i8 (d))\n\
\ (u64 (d)) (u32 (d)) (u16 (d)) (u8 (d))\n\
\ (f64 (d)) (f32 (d))\n\
\ 0))";
(* And a cast the checker could already see was wrong is wrong for the same
reason it was: dyn is admitted by name, not by the numeric test going
soft. A string is not a number and never reaches a runtime tag. *)
rejects_check "a cast still refuses a non-numeric typed operand"
"(defn main [] i32 (i64 \"hi\"))"
~needle:"converts a number";
(* The *generic* cast — [($t x)] in a body whose signature says
[{:where (numeric? $t)}] — is a separate arm in the checker and did not
grow a dyn case. It did not need one: the operand's type there is what
the bound admits, and [numeric?] does not admit dyn, so a dyn cannot
reach that arm to begin with. The refusal is the bound's, at the call
that would have instantiated it. *)
rejects_check "a generic cast's operand is still what its bound admits"
"(defn conv [x $t] $t {:where (numeric? $t)} (t x))\n\
(defn d [] dyn 7)\n\
(defn main [] i32 (i32 (conv (d))))"
~needle:"numeric?";
(* The map operations ride the words the typed map already owns: get, put,
len, has-key? — one question, one word, on both sides. has-key? on a
typed map still checks against its K. *)
accepts "get, put, len and has-key? over a dyn map"
"(defn main [] i32\n\
\ (let [m {:a 1}]\n\
\ (put m :b 2)\n\
\ (if (has-key? m :b) (len m) 0)))";
accepts "has-key? still serves the typed map"
"(defn main [] i32\n\
\ (let [m (map-new string i32 (heap-allocator))]\n\
\ (if (has-key? m \"a\") 1 0)))";
(* The x86 backend used to refuse dyn by name, and what was pinned here was
the sentence it refused with. It compiles it now, which is the thing this
row is for: that backend is the dev daemon's default and dyn is the
iteration feature, so a refusal there was the two of them never meeting.
The assertion is the same shape inverted — it lowers, and it emits the
root discipline while it does. The roots are asserted rather than only
the absence of an exception, because a build that emits the calls and
forgets the roots is exactly the failure that passes every output test:
the collector simply never hears about a value.
Which programs *agree* between the backends is the @x86 sweep's question
and all five dyn programs are in it; this one only has to know that the
lowering exists. *)
let dyn_asm =
X86.program ~checks:true
(Check.program_all
(program "(defn add [x y] dyn (+ x y))\n\
(defn main [] () (print (add 1 2)))"))
in
check "the x86 backend lowers dyn"
(contains dyn_asm "flan_dyn_add");
check "the x86 backend roots its dyn values"
(contains dyn_asm "flan_dyn_root_push"
&& contains dyn_asm "flan_dyn_root_pop");
(* The site travels with the operands, on this backend as on the other: a
dyn arithmetic trap is the type error of a dynamic program, and it used
to print with no file and no line. This backend writes a string constant
as [.byte] hex rather than as text, so the needle is the encoding of the
":1:21" that ends the site of the [(+ x y)] above — the path in front of
it is the test runner's temporary directory and is not pinnable. *)
check "the x86 backend hands the dyn operators their site"
(contains dyn_asm "0x3a,0x31,0x3a,0x32,0x31");
(* And a program with no dyn in it emits not one byte of any of it, which is
what lets the sweep's other MATCHes stand as a regression check on this
lane rather than being re-measured by it. *)
check "a dyn-free program pays nothing for the collector"
(let plain =
X86.program ~checks:true
(Check.program_all
(program "(defn add [x i32 y i32] i32 (+ x y))\n\
(defn main [] () (print (add 1 2)))"))
in
(not (contains plain "flan_dyn_root_push"))
&& not (contains plain "flan_gc_init"));
(* ── Static bounds ─────────────────────────────────────────────── *)
(* A literal index into a fixed array is known now, so it is an error now
rather than a trap later; everything else is the emitted bounds check's
job. A [defconst] is a global in the typed IR, not a folded constant, so
it deliberately stays a runtime trap. *)
let arr = "(defonce a [3 i32]) " in
accepts "last valid index" (arr ^ "(defn f [] i32 (at a 2))");
rejects_check "index past the end" (arr ^ "(defn f [] i32 (at a 3))")
~needle:"out of bounds for length 3";
rejects_check "negative index" (arr ^ "(defn f [] i32 (at a -1))")
~needle:"is negative";
rejects_check "index past the end of an inner dimension"
"(defonce g [2 [4 i32]]) (defn f [] i32 (at g 1 4))"
~needle:"out of bounds for length 4";
accepts "a variable index is checked at runtime, not here"
(arr ^ "(defn f [i i32] i32 (at a i))");
accepts "a defconst index is not folded"
("(defconst k 9) " ^ arr ^ "(defn f [] i32 (at a k))");
(* A slice bound may sit one past the end; an index may not. *)
accepts "slice ending at len" (arr ^ "(defn f [] [i32] (slice a 1 3))");
accepts "empty slice at len" (arr ^ "(defn f [] [i32] (slice a 3 3))");
rejects_check "slice bound past len" (arr ^ "(defn f [] [i32] (slice a 1 4))")
~needle:"out of bounds for length 3";
rejects_check "negative slice bound" (arr ^ "(defn f [] [i32] (slice a -1 2))")
~needle:"is negative";
rejects_check "reversed slice" (arr ^ "(defn f [] [i32] (slice a 2 1))")
~needle:"runs backwards";
(* A slice has no static length, so only the two target-independent rules
apply to one. *)
rejects_check "reversed slice of a slice"
"(defn f [s [u8]] [u8] (slice s 2 1))" ~needle:"runs backwards";
accepts "a slice's length is not known here"
"(defn f [s [u8]] [u8] (slice s 0 99))";
(* The short arities are the three-argument form written out, so they are
held to the same standard: the implicit hi on a fixed array is the same
literal (len a) folds to, and a lo past it is refused here and not later.
A 1-argument slice cannot fail either check — 0 and the length are both
in range by construction — so what is pinned about it is that it is
accepted on each of the three things slice takes. *)
accepts "the whole of an array" (arr ^ "(defn f [] [i32] (slice a))");
accepts "the tail of an array" (arr ^ "(defn f [] [i32] (slice a 3))");
rejects_check "tail past len" (arr ^ "(defn f [] [i32] (slice a 4))")
~needle:"out of bounds for length 3";
rejects_check "negative tail" (arr ^ "(defn f [] [i32] (slice a -1))")
~needle:"is negative";
accepts "the whole of a slice" "(defn f [s [u8]] [u8] (slice s))";
accepts "the tail of a slice" "(defn f [s [u8]] [u8] (slice s 2))";
rejects_check "slice with no target" "(defn f [] i32 (slice))"
~needle:"(slice a lo hi)";
rejects_check "slice of four" (arr ^ "(defn f [] [i32] (slice a 0 1 2))")
~needle:"given 4 arguments";
rejects_check "slice of a Vec"
"(defn f [v (Vec i32)] [i32] (slice v))"
~needle:"slice takes an array, a slice or a string";
(* An array a call returned is a temporary the slice would outlive. It
dangled silently on both backends before, at the three-argument
spelling; (slice (mk)) is the spelling that would have made it
idiomatic, so it is refused rather than written more often. A literal
is not this case — the frame holds one for as long as the form it is
written in, which is what the acceptance program sorts. *)
rejects_check "slice of a returned array"
"(defn mk [] [3 i32] [7 8 9]) (defn f [] [i32] (slice (mk)))"
~needle:"a returned array is a temporary";
rejects_check "slice of a returned array, three arguments"
"(defn mk [] [3 i32] [7 8 9]) (defn f [] [i32] (slice (mk) 0 3))"
~needle:"a returned array is a temporary";
accepts "slice of an array literal"
"(defn f [] [i32] (slice [7 8 9]))";
(* A string indexes and slices, and neither is a place: it is a view of
bytes the program does not own — a literal's are in constant storage —
so there is no store through one to allow. *)
accepts "a string slices" "(defn f [s string] string (slice s 1))";
accepts "a string indexes" "(defn f [s string] u8 (at s 1))";
(* Both routes to a Pindex, because there are two and they do not share a
line of code: one index goes through the [set] arm that checks its own
target, two through [check_place]. The one-index spelling is the one a
person writes, and it is the one that would silently store into
constant data. *)
rejects_check "set through a string"
"(defn f [s string] () (set (at s 0) 65))" ~needle:"not a place";
rejects_check "set through a string, two indices"
"(defn f [s [2 string]] () (set (at s 0 0) 65))" ~needle:"not a place";
(* The mirror, and the one the walk could break by moving the question a
level up: the refusal is about the type being *indexed*, not about the
element that comes out. An array of strings has a string element and
indexes nothing but the array, so assigning a whole one stays legal. *)
accepts "set an array's string element"
"(defn f [s [2 string]] () (set (at s 0) \"world\"))";
rejects_check "set through a string's slice"
"(defn f [s string] () (set (at (slice s 1) 0) 65))"
~needle:"not a place";
(* The address of one is the same question and gets the same answer, so
the message has to fit a reader who asked for a pointer and not a
store. *)
rejects_check "the address of a string's byte"
"(defn f [s string] (Ptr u8) (addr (at s 0)))"
~needle:"take the address of";
(* And a string is still not a [u8]: slicing one does not smuggle a byte
slice out of it. *)
rejects_check "a string slice is not a byte slice"
"(defn g [b [u8]] i32 (len b)) (defn f [s string] i32 (g (slice s)))"
~needle:"expected [u8], found string";
(* (slice-from-ptr p n). The one form in the language whose central claim the
compiler cannot check — whether n is the truth about what p addresses — so
what it does check is worth pinning: the argument really is a pointer, the
length is not absurd on its face, and the result owns nothing. *)
accepts "a pointer plus a length is a slice"
"(defn f [p (Ptr i32) n i32] i32 (at (slice-from-ptr p n) 0))";
accepts "zero is a length"
"(defn f [p (Ptr i32)] i32 (len (slice-from-ptr p 0)))";
rejects_check "slice-from-ptr of something that is not a pointer"
"(defn f [s [i32]] i32 (len (slice-from-ptr s 3)))"
~needle:"takes a (Ptr T)";
rejects_check "slice-from-ptr with a negative literal length"
"(defn f [p (Ptr i32)] i32 (len (slice-from-ptr p -1)))"
~needle:"is negative";
(* The storage stays C's. A slice carries no allocator, so free refuses one
by the rule it already had — this pins that the new form did not become
a thing anybody could hand to free. *)
rejects_check "free of a slice made from a pointer"
"(defn f [p (Ptr i32)] () (free (slice-from-ptr p 3)))"
~needle:"free takes an owning container";
(* ── Structs, fields and auto-deref ────────────────────────────── *)
let cursor = "(defstruct Cursor [src [u8] pos i32]) " in
accepts "struct literal, omitted field zeroed"
(cursor ^ "(defn f [s [u8]] Cursor (Cursor {.src s}))");
rejects_check "unknown field"
(cursor ^ "(defn f [s [u8]] Cursor (Cursor {.nope s}))")
~needle:"has no field nope";
rejects_check "field given twice"
(cursor ^ "(defn f [s [u8]] Cursor (Cursor {.pos 0 .pos 1}))")
~needle:"given twice";
(* {:src s} is a dyn map literal now, not a struct field list with the
wrong punctuation — the colon is wanted for keys, and this is one. What
used to be caught as a mispunctuated struct is caught one level up
instead: (Cursor {:src s}) is a struct type applied to one argument, and
since positional construction landed that is a Cursor built from too few
arguments. The refusal still points at the struct spelling, which is why
the needle below did not have to move. *)
rejects_check "a struct type called with a colon-keyed map"
(cursor ^ "(defn f [s [u8]] Cursor (Cursor {:src s}))")
~needle:"a struct value is written (Cursor {.field value ...})";
(* ── A bare {.field v}, typed by its position ──────────────────── *)
(* The five positions that carry an expectation, and the three that do
not. Everything the named form checks — unknown field, duplicate
field, ZII for the ones left out — is checked here by *being* the
named form: [check_bare] reads the type name off the want and hands
the very same field list to [check_struct]. The two rows below the
accepts are what says so, since they are the named form's own kinds
and messages arriving at a literal with no name on it. *)
let cell = "(defstruct Cell [row i32 col i32]) " in
accepts "bare literal in a defn's return position"
(cell ^ "(defn f [] Cell {.row 1 .col 2})");
accepts "bare literal with a field omitted is ZII, as the named form is"
(cell ^ "(defn f [] Cell {.row 1})");
accepts "bare literal as the only argument of a call"
(cell ^ "(defn g [c Cell] i32 (.row c)) (defn f [] i32 (g {.row 1}))");
accepts "bare literal as a later argument of a call"
(cell ^ "(defn g [n i32 c Cell] i32 (+ n (.row c))) \
(defn f [] i32 (g 1 {.row 2}))");
accepts "bare literal as a field of another literal"
(cell ^ "(defstruct Grid [a Cell b Cell]) \
(defn f [] Grid (Grid {.a {.row 1} .b {.col 2}}))");
accepts "bare literal set into a typed place"
(cell ^ "(defn f [] i32 (let [c (Cell 0 0)] (set c {.row 7}) (.row c)))");
accepts "bare literal at a union want"
"(defunion U [i i32 f f32]) (defn f [] U {.i 5})";
rejects_check "bare literal with an unknown field"
(cell ^ "(defn f [] Cell {.nope 1})") ~needle:"Cell has no field nope";
rejects_check "bare literal with a field given twice"
(cell ^ "(defn f [] Cell {.row 1 .row 2})") ~needle:"given twice";
(* The three no-reading positions. The dyn one is the boundary that
matters most: braces at a dyn want are the dyn map literal and stay
it, so a .field-keyed brace is refused there rather than quietly
given a second meaning — and told which punctuation a map uses. *)
rejects_check "bare literal with no expectation to read"
(cell ^ "(defn f [] i32 (let [c {.row 1}] (.row c)))")
~needle:"does not say which struct it builds";
rejects_check "bare literal at a dyn want is not a dyn map"
(cell ^ "(defn f [] dyn {.row 1})")
~needle:"a dyn map's keys are keywords, as {:row value ...}";
rejects_check "bare literal at a want that is not a struct type"
(cell ^ "(defn f [] i32 {.row 1})")
~needle:"i32 is expected here, which is not a struct type";
(* A data type's name is an expectation, but not a specific enough one:
the want says D and a value of D is one of its cases. The existing
message for that already names them, so the bare path inherits it. *)
rejects_check "bare literal at a data type want names the cases"
"(defdata D [(A [x i32]) (B [y i32])]) (defn f [] D {.x 1})"
~needle:"write (D.A {.field value ...})";
(* Patterns are a separate parser ([dmap], not [expr]), so nothing here
reaches them: the struct pattern is still {name .field} and a bare
{.field v} is still not a pattern of any kind. Both rows answer the
same as they did before this feature — checked against the tip by
hand, not only asserted here. *)
accepts "a struct pattern still destructures in a let"
(cell ^ "(defn f [c Cell] i32 (let [{r .row} c] r))");
parse_rejects "braces are still not a pattern in a match arm"
(cell ^ "(defn f [c Cell] i32 (match c {r .row} r))")
~needle:"expected a pattern, found {r .row}";
(* The dyn map literal is untouched on every side of this. *)
accepts "a keyword-keyed literal is still a dyn map at a dyn want"
"(defn f [] dyn {:a 1 :b [2 3]})";
accepts "the empty braces are still an empty dyn map"
"(defn main [] i32 (let [m {}] (len m)))";
(* ── (Cell 1 2), positional ────────────────────────────────────── *)
accepts "positional struct construction"
(cell ^ "(defn f [] Cell (Cell 1 2))");
accepts "a zero-field struct called with no arguments"
"(defstruct E []) (defn f [] E (E))";
(* Exact arity, and the refusal names the first field it did not reach.
ZII is not withdrawn — it is what the designated form does, and the
message says so — but a positional list cannot say *which* field it
left out, so it is not allowed to leave one out. *)
rejects_check "positional with too few arguments names the missing field"
(cell ^ "(defn f [] Cell (Cell 1))")
~needle:".col has no value";
rejects_check "positional with too few also offers the designated form"
(cell ^ "(defn f [] Cell (Cell 1))")
~needle:"a struct value is written (Cell {.field value ...})";
rejects_check "positional with too many points at the extra argument"
(cell ^ "(defn f [] Cell (Cell 1 2 3))")
~needle:"Cell has 2 fields, and this is argument 3";
(* The mismatch is reported at the argument, in the words a call's
argument already gets; the note is what names the field, because a
positional call site is the one place the source does not show it. *)
rejects_check "positional argument of the wrong type"
"(defstruct Cell [row i32 col f32]) \
(defn f [] Cell (Cell 1 \"x\"))"
~needle:"expected f32, found string";
(* A struct type and a function cannot share a name — [collect]'s
[claimed] table spans every declaration kind — so the head of a call
resolves to exactly one of them and this refusal is what proves it. *)
rejects_check "a struct name and a function name cannot collide"
(cell ^ "(defn Cell [] i32 1)") ~needle:"Cell is defined twice";
(* Mixed spellings are not a thing: the struct-literal arm in [Parse]
takes the braces only as the *whole* argument list. *)
parse_rejects "a struct literal followed by more arguments"
(cell ^ "(defn f [] Cell (Cell {.row 1} 2))")
~needle:"a struct literal is (Cell {.field value ...})";
accepts "field through a pointer auto-derefs"
(cursor ^ "(defn f [c (Ptr Cursor)] i32 (.pos c))");
accepts "set through a pointer"
(cursor ^ "(defn f [c (Ptr Cursor)] () (set (.pos c) 1))");
rejects_check "field of a non-struct"
"(defn f [x i32] i32 (.pos x))" ~needle:"is not a struct";
(* ── Places ────────────────────────────────────────────────────── *)
accepts "a local is assignable"
"(defn f [] i32 (let [x 1] (set x 2) x))";
rejects_check "a parameter is not assignable"
"(defn f [x i32] () (set x 2))" ~needle:"a parameter is not a place you can assign to";
rejects_check "a constant is not assignable"
"(defconst k 1) (defn f [] () (set k 2))"
~needle:"k 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";
accepts "addr of a local gives a pointer"
(cursor ^ "(defn g [c (Ptr Cursor)] i32 (.pos c)) \
(defn f [s [u8]] i32 (let [c (Cursor {.src s})] (g (addr c))))");
rejects_check "addr of a non-place"
"(defn f [] () (addr (+ 1 2)))" ~needle:"addr takes the address of a place";
(* An Option's two fields exist in both backends' layouts — the tag and the
value — and the structural printer reads the tag through them. What has no
spelling in the source language is reaching one: [match] and [some] are
how an Option is opened, and a (.field o) that read the value of a None
would be reading storage the tag says is not there. FIX.org recorded
[Addr (Pfield ...)] on an Option as a hole in both backends; this is the
pair of rows that says the hole has no door — the refusal is the field
access itself, so (addr ...) never gets a place to take the address of. *)
rejects_check "a field of an Option"
"(defstruct Point [x i32 y i32])\n\
(defn f [o (Option Point)] i32 (.x o))"
~needle:"(Option Point) is not a struct, so it has no fields";
rejects_check "the address of a field of an Option"
"(defstruct Point [x i32 y i32])\n\
(defn f [o (Option Point)] (Ptr i32) (addr (.x o)))"
~needle:"(Option Point) is not a struct, so it has no fields";
(* ── Option, some, match ───────────────────────────────────────── *)
accepts "some unwraps in an Option-returning function"
"(defn g [] (Option i32) None) (defn f [] (Option i32) (Some (some (g))))";
rejects_check "some outside an Option-returning function"
"(defn g [] (Option i32) None) (defn f [] i32 (some (g)))"
~needle:"must return an Option";
accepts "match on Option"
"(defn g [] (Option i32) None) \
(defn f [] i32 (match (g) (Some v) v None 0))";
rejects_check "match must be exhaustive"
"(defn g [] (Option i32) None) (defn f [] i32 (match (g) (Some v) v))"
~needle:"not exhaustive";
accepts "a wildcard arm is exhaustive"
"(defn g [] (Option i32) None) (defn f [] i32 (match (g) (Some v) v _ 0))";
rejects_check "match on a non-Option"
"(defn f [x i32] i32 (match x _ 0))" ~needle:"match works on an Option";
(* ── Names, order-independence, entry point ────────────────────── *)
accepts "mutually recursive, no forward declaration"
"(defn even? [n i32] bool (if (= n 0) true (odd? (- n 1)))) \
(defn odd? [n i32] bool (if (= n 0) false (even? (- n 1))))";
rejects_check "unknown name" "(defn f [] i32 nope)" ~needle:"unknown name";
rejects_check "unknown function" "(defn f [] i32 (nope 1))"
~needle:"unknown function";
(* ── Did-you-mean, and the dot habit ───────────────────────────────
[near_miss] was written, tested and wired to the type tables alone, so a
mistyped *value* got the bare refusal. The candidate list at a value
position is the scope, the globals and the functions — and, at a call,
the builtin names, which live in no table the checker keeps. No type
names on either list: a symbol written where a value goes was not a
mistyped struct. *)
rejects_check "a mistyped local is a near miss"
"(defn f [] i32 (let [total 1] totl))" ~needle:"did you mean total?";
rejects_check "a mistyped defn is a near miss"
"(defn helper [x i32] i32 x) (defn f [] i32 (helpr 1))"
~needle:"unknown function helpr — did you mean helper?";
rejects_check "a mistyped builtin is a near miss"
"(defn f [] () (prinltn \"hi\"))"
~needle:"unknown function prinltn — did you mean println?";
(* [p.x] is the habit from C, Go and Odin, and the checker can see exactly
what the head is, so the refusal names the accessor rather than reporting
a name nobody wrote. The declaration comes along as a note, which is
[declared_note]'s shape. *)
rejects_check "dot-infix field access names the accessor"
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] p.x))"
~needle:"a field is read with an accessor, so write (.x p)";
rejects_check "and says so when the field is not there either"
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] p.z))"
~needle:"(.z p), and P has no field z";
rejects_check "and in a set it is the place that is spelled"
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] (set p.x 2) 0))"
~needle:"a field is assigned through an accessor, so write (set (.x p) ...)";
accepts "which is a real form"
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] (set (.x p) 2) (.x p)))";
rejects_check "a dotted head that is not a struct says what it is"
"(defn f [] i32 (let [n 1] n.x))" ~needle:"n is i32, which has no fields";
(* The fourth shape: nothing is bound under the head either, so the message
claims nothing about what q is — only that the dot is not the operator
the writer took it for. *)
rejects_check "and an unbound head claims nothing about it"
"(defn f [] i32 q.x)"
~needle:"unknown name q.x — nothing named q is in scope either. A field \
is reached through an accessor, (.x q), not with a dot";
(* A capitalised head keeps the case spelling it always had: [Shape.Circle]
is real here, so a typo in one is not the dot habit. *)
(* Both sides of the rule, because only the pair says what it is. A
capitalised head is a real spelling here — Shape.Circle — so a typo in
one is a mistyped case and gets none of the accessor advice; the same
text with a lowercase head does. The earlier spelling of this row used
(data ...), which is not a top-level form at all, so it refused as an
unknown top-level form and the needle "unknown" matched that instead of
anything this rule does. *)
(match (try ignore (checked "(defdata Shape [(Circle [r f64])]) \
(defn f [] Shape Shape.Crcle)"); None
with Loc.Error d -> Some d) with
| Some d ->
check "a capitalised dotted name gets no accessor advice"
(contains d.Loc.dmsg "unknown name Shape.Crcle"
&& not (contains d.Loc.dmsg "accessor"))
| None -> check "a mistyped case is refused" false);
(match (try ignore (checked "(defdata Shape [(Circle [r f64])]) \
(defn f [] Shape shape.Crcle)"); None
with Loc.Error d -> Some d) with
| Some d ->
check "and a lowercase one does"
(contains d.Loc.dmsg
"nothing named shape is in scope either. A field is reached through \
an accessor, (.Crcle shape), not with a dot")
| None -> check "a lowercase dotted name is refused" false);
(* [(Pair i32)] in a defonce falls down the value fork now that the third
element takes either reading, and the generics answer the type fork gave
it has to be reachable from here too. *)
(* A capitalised head with arguments is a *type* given type arguments, and
that is the half of generics that is not built — Types.Named is a bare
string with no room for parameters. The sentence says which half, since
generic functions are here and pointing at them is the useful part. *)
rejects_check "a capitalised call with arguments is a generic type"
"(defonce x (Pair i32)) (defn f [] i32 0)"
~needle:"is a generic type, which is not there yet";
accepts "and the generic function it points at is"
"(defn pair-fst [a $t b $u] $t (do b a))\n\
(defn main [] () (println (pair-fst 1 true)))";
rejects_check "defined twice" "(defn f [] ()) (defn f [] ())"
~needle:"defined twice";
accepts "main with no parameters and no return" "(defn main [] ())";
accepts "main with argv and a status" "(defn main [args [string]] i32 0)";
rejects_check "main with a wrong parameter" "(defn main [n i32] ())"
~needle:"main takes no parameters";
rejects_check "main returning the wrong type" "(defn main [] bool true)"
~needle:"main returns i32";
(* And both of them point at the [main] that is wrong. They used to open with
<unknown>:0:0 — the checker's rule about the entry point is the one place
that had a name and no span, because [env.locs] records where a *type* was
declared and a function is not in it. The span is the whole difference
between a message you can act on and a message you have to go looking for,
so it is pinned here rather than left to the reader of a report. *)
let main_at name src =
match checked src with
| _ ->
incr failures;
Printf.printf "FAIL %s: expected a type error\n" name
| exception Loc.Error { Loc.dloc; _ } ->
let got = Loc.to_string dloc in
if got <> "<test>:1:7" then begin
incr failures;
Printf.printf "FAIL %s\n wanted: %s\n got: %s\n"
name "<test>:1:7" got
end
in
main_at "a wrong main parameter points at main" "(defn main [n i32] ())";
main_at "a wrong main return type points at main" "(defn main [] bool true)";
(* ── Classes and generic functions — M2 item 6 ─────────────────── *)
(* A class is a constructor and a shape tag. The constructor is the class's
own name, positional over the slots, and everything that reads or writes
an instance is the dyn map operation that was already there. *)
accepts "a class and its constructor"
"(defclass point [x y])\n\
(defn main [] i32 (let [p (point 1 2)] (if (= (get p :x) 1) 0 1)))";
accepts "a class with no slots"
"(defclass marker [])\n(defn main [] i32 (let [m (marker)] 0))";
accepts "class-of answers nil for anything that is not an instance"
"(defn main [] i32 (if (= (class-of 1) nil) 0 1))";
(* The constructor is an ordinary function, so its arity is the ordinary
arity check and a wrong one names the class. *)
rejects_check "a constructor takes one argument per slot"
"(defclass point [x y])\n(defn main [] i32 (let [p (point 1)] 0))"
~needle:"point";
(* A slot vector holds names and nothing else. [(defclass point [x i64])]
is therefore two slots, one of them unfortunately named — the parser
cannot tell a type's name from a slot's and does not have to, since a
slot has no type to write. What it can tell is a form that is not a name
at all. *)
rejects_check "a slot is a name, not a type expression"
"(defclass point [x (Ptr i64)])\n(defn main [] i32 0)"
~needle:"a class slot is a name";
rejects_check "a class does not name a slot twice"
"(defclass point [x x])\n(defn main [] i32 0)"
~needle:"names the slot x twice";
(* Both halves of the dispatch, and the fact that they are one mechanism:
a defgeneric is a defmulti whose dispatch is (class-of first-argument),
so a method written for a class is a method written for its keyword. *)
accepts "class dispatch"
"(defclass point [x y])\n\
(defgeneric area [self] dyn)\n\
(defmethod area point [p] (* (get p :x) (get p :y)))\n\
(defn main [] i32 (if (= (area (point 2 3)) 6) 0 1))";
accepts "arbitrary dispatch, with a fallback"
"(defmulti describe [x] dyn (get x :kind))\n\
(defmethod describe :square [s] 1)\n\
(defmethod describe :else [s] 2)\n\
(defn main [] i32 (if (= (describe {:kind :round}) 2) 0 1))";
accepts "a method may name its parameters whatever it likes"
"(defclass point [x y])\n\
(defgeneric area [self] dyn)\n\
(defmethod area point [whatever] (get whatever :x))\n\
(defn main [] i32 0)";
(* The rebinding that gives a method its own parameter names is parallel.
A [let] binds in sequence, so the pairwise spelling reads a name it has
just bound: these two type-check either way and the values are what is
wrong, which is why dyn-class.flan is where they are really pinned. What
is pinned here is that both shapes are legal at all. *)
accepts "a method may reverse its generic's parameter names"
"(defmulti g [a b] () (class-of a))\n\
(defmethod g :else [b a] (println b) (println a))\n\
(defn main [] i32 0)";
accepts "a method may shift its generic's parameter names along"
"(defmulti g [a b] () (class-of a))\n\
(defmethod g :else [b c] (println b) (println c))\n\
(defn main [] i32 0)";
accepts "a method may be written above its generic"
"(defclass point [x y])\n\
(defmethod area point [p] (get p :x))\n\
(defgeneric area [self] dyn)\n\
(defn main [] i32 0)";
(* A generic with no methods at all is legal and always misses; that is a
run-time answer (the NoMethod condition), not a compile-time refusal. *)
accepts "a generic with no methods"
"(defgeneric area [self] dyn)\n(defn main [] i32 0)";
rejects_check "a method needs a generic"
"(defclass point [x y])\n(defmethod area point [p] 1)\n\
(defn main [] i32 0)"
~needle:"no defgeneric or defmulti names area";
rejects_check "a method dispatching on an unknown class"
"(defgeneric area [self] dyn)\n(defmethod area square [p] 1)\n\
(defn main [] i32 0)"
~needle:"no defclass names square";
rejects_check "two methods for one dispatch value"
"(defclass point [x y])\n\
(defgeneric area [self] dyn)\n\
(defmethod area point [p] 1)\n\
(defmethod area point [p] 2)\n\
(defn main [] i32 0)"
~needle:"already has a method for point";
(* A class's name and its keyword are one value: a class stands for the
keyword its instances carry, so these two methods are the same method
written twice and the second would be dead code. *)
rejects_check "a class and its keyword are one dispatch value"
"(defclass point [x y])\n\
(defgeneric area [self] dyn)\n\
(defmethod area point [p] 1)\n\
(defmethod area :point [p] 2)\n\
(defn main [] i32 0)"
~needle:"already has a method for :point";
rejects_check "two :else methods for one generic"
"(defmulti d [x] dyn x)\n\
(defmethod d :else [x] 1)\n\
(defmethod d :else [x] 2)\n\
(defn main [] i32 0)"
~needle:"already has a method for :else";
rejects_check "a method's arity is its generic's"
"(defclass point [x y])\n\
(defgeneric area [self] dyn)\n\
(defmethod area point [p q] 1)\n\
(defn main [] i32 0)"
~needle:"and this method of it takes 2";
rejects_check "a generic's parameter is a bare name"
"(defgeneric area [self (Ptr i64)] dyn)\n(defn main [] i32 0)"
~needle:"parameter is a bare name";
rejects_check "a defgeneric has no body"
"(defgeneric area [self] dyn (class-of self))\n(defn main [] i32 0)"
~needle:"It has no body";
rejects_check "a defmulti has one"
"(defmulti describe [x] dyn)\n(defn main [] i32 0)"
~needle:"the body is the dispatch";
rejects_check "a defgeneric needs something to dispatch on"
"(defgeneric area [] dyn)\n(defn main [] i32 0)"
~needle:"dispatches on the class of its first";
rejects_check "a dispatch value is written out, not computed"
"(defmulti d [x] dyn x)\n(defmethod d (f 1) [x] 1)\n\
(defn main [] i32 0)"
~needle:"is written out rather than computed";
(* A method has no return slot: the generic states the type once, for all
of them. What that means for anyone writing the defn spelling by habit
is that the slot they would have written is read as the first form of
the body, and a lone type name there is an unknown name. *)
rejects_check "a method has no return slot"
"(defclass point [x y])\n\
(defgeneric area [self] dyn)\n\
(defmethod area point [p] dyn (get p :x))\n\
(defn main [] i32 0)"
~needle:"dyn";
(* A class and a function are one namespace, as a defn and a defonce are:
the constructor is a defn, so the collision is the ordinary one. *)
rejects_check "a class collides with a function of the same name"
"(defclass point [x y])\n(defn point [] i32 0)\n(defn main [] i32 0)"
~needle:"point";
(* ── Unconstrained operators, and everything past milestone 2 ──── *)
(* M2 queue item 5: typed = and != grow strings, bytewise. Ordering does
not — there is no collation the language has picked, so < stays
refused, on the grounds that a string is equatable but not ordered. *)
accepts "typed = on strings" "(defn f [] bool (= \"a\" \"b\"))";
accepts "typed != on strings" "(defn f [] bool (!= \"a\" \"b\"))";
rejects_check "no built-in < on strings"
"(defn f [] bool (< \"a\" \"b\"))" ~needle:"orders machine numbers and enums";
rejects_check "no built-in <= on strings"
"(defn f [] bool (<= \"a\" \"b\"))" ~needle:"orders machine numbers and enums";
rejects_check "no built-in > on strings"
"(defn f [] bool (> \"a\" \"b\"))" ~needle:"orders machine numbers and enums";
rejects_check "no built-in >= on strings"
"(defn f [] bool (>= \"a\" \"b\"))" ~needle:"orders machine numbers and enums";
(* (Vec T) is built. What is still refused is the arity: one element type,
and a near-miss there would otherwise resolve to a type variable and come
back as generics. *)
rejects_check "Vec takes one type" "(defn f [x (Vec i32 i32)] ())"
~needle:"exactly one type";
(* (Map K V) is the map type spelling, and now the only one: the brace form
is withdrawn from type position, so braces there are refused with the
surviving spelling named. What is refused here is the arity, for the same
reason Vec's is: a near-miss would otherwise resolve to a type variable
and come back as generics. *)
rejects_check "Map takes two types" "(defn f [x (Map i32)] ())"
~needle:"exactly two types";
rejects_check "Result is milestone 6" "(defn f [] (Result i32 i32) None)"
~needle:"milestone 6";
(* ── The region rule, spec-memory.md's arena rule ────────────────────
The compile-time half of it, which is the only half a checker row can
see: which declarations are admitted, and which are still refused. The
run-time half — the branch that decides whether a given construction site
met a region allocator — is programs/arena-region.flan, because it needs
a program that dies to say anything. *)
(* The recursive dynamic value, which is the whole point: a union naming
itself through a container. Admitted because such a container can only
have been built against a region, and one free-all takes the graph. *)
accepts "a data type case holding a Vec of itself"
"(defdata Value [Nil (List [items (Vec Value)])])";
accepts "a data type case holding a Map of itself"
"(defdata Value [Nil (Table [entries (Map string Value)])])";
(* Since the second repeal the plain case is admitted too: a struct or a
case holding a heap-backed Vec copies as bytes, the copies alias one
buffer, and a free through two copies is the program's bug — Odin's
contract exactly. These pin the admission. *)
accepts "a data type case holding a plain Vec"
"(defdata Value [Nil (Bytes [bs (Vec u8)])])";
accepts "a struct field holding a plain Vec"
"(defstruct B [buf (Vec u8)])";
(* free does not recurse and does not quietly release the outer block: it
names free-all, which is the operation that actually releases the graph. *)
rejects_check "free on a container of owning elements"
"(defdata V [Nil (L [xs (Vec V)])])\n\
(defn f [v (Vec V)] () (free v))"
~needle:"(free-all a) takes it";
(* clone is refused for a reason the region does *not* dissolve: it promises
an independent copy and a bytewise one is an alias. *)
rejects_check "clone on a container of owning elements"
"(defdata V [Nil (L [xs (Vec V)])])\n\
(defn f [v (Vec V)] () (let [c (clone v)] (free c)))"
~needle:"cannot be cloned";
(* ── A move-only global ─────────────────────────────────────────────
Legal, started zeroed, and since the repeal of the flow analysis it is
ownable like anything else: passing, binding and freeing one all
type-check, and the process-long lifetime is the program's to keep. The
accepted side is programs/vec-global.flan. What is still refused about
one is declaration-shaped, below. *)
(* And the two declaration shapes. The computed one is the half that changed:
there is an init-at-startup path now, on both backends, so a global Vec
loaded by its own initialiser is an ordinary program — which is what the
commented-out line in sand.flan was reaching for. The defconst is refused
as it always was, and for a reason the startup path does not touch: a
constant is not an assignable place, so nothing could ever load it. *)
accepts "a global Vec with a computed initialiser"
"(defonce g (Vec u8) (slurp \"game-data.edn\")) (defn f [] ())";
rejects_check "a move-only global as a defconst"
"(defconst g (Vec u8) (slurp \"game-data.edn\")) (defn f [] ())"
~needle:"a defonce and not a defconst";
(* uninit is the one initialiser a container still refuses, and it is a
different rule: a garbage block pointer is not a garbage number. *)
rejects_check "a global Vec declared uninit"
"(defonce g (Vec u8) uninit) (defn f [] ())"
~needle:"steers every read of it";
(* ── What may be filled with raw bytes ─────────────────────────────
[(filled b)] and [(dead-beef)] are [zeroed]'s siblings, and the
boundary is the whole of what is new about them: zero is a value every
type can have and 0xDE is not, so the checker says which types survive
arbitrary bytes. The accepting side is programs/fill.flan; what is here
is the catalogue of what it refuses and why each refusal is the runtime's
and not a matter of taste.
The dyn row is the one that would corrupt the collector: a struct holding
a dyn is rooted with a descriptor naming that word's offset, so a filled
one is a root pointing at nothing. *)
accepts "a fixed array of numbers may be filled"
"(defn f [] () (let [a (array 4 u8)] (set a (filled 0xFF))))";
accepts "a struct of numbers may be dead-beefed"
"(defstruct S [a i32 b f64]) \
(defn f [] () (let [s (S {})] (set s (dead-beef))))";
(* Both arities, and a pattern that is not a literal — the operand is an
ordinary u32 expression, which is the byte arm's rule at four times the
width. *)
accepts "dead-beef takes a pattern"
"(defn f [] () (let [a (array 4 u8)] (set a (dead-beef 0xBAADF00D))))";
accepts "dead-beef takes a computed pattern"
"(defn f [p u32] () (let [a (array 4 u8)] (set a (dead-beef p))))";
rejects_check "a struct holding a dyn cannot be filled"
"(defstruct S [a i32 d dyn]) \
(defn f [] () (let [s (S {})] (set s (dead-beef))))"
~needle:"a root pointing at nothing";
rejects_check "a Vec cannot be filled"
"(defn f [] () (let [v (vec-new i32)] (set v (filled 0xFF))))"
~needle:"frees a wild address";
rejects_check "a string cannot be filled"
"(defn f [] () (let [s \"hi\"] (set s (filled 0xFF))))"
~needle:"a length every bounds check believes";
rejects_check "a pointer field cannot be filled"
"(defstruct S [p (Ptr i32)]) \
(defn f [] () (let [s (S {})] (set s (filled 0xFF))))"
~needle:"an address every deref trusts";
(* The one refusal that is about the two backends rather than the runtime:
LLVM reads a bool's low bit and x86 compares the whole byte, so 0xDE is
false on one and true on the other. Byte-identical behaviour across the
backends is what this feature is pinned on, so the divergence is refused
rather than documented. *)
rejects_check "a bool cannot be filled"
"(defn f [] () (let [b false] (set b (filled 0xFF))))"
~needle:"would not even agree with itself";
(* Each of the tagged and address-carrying types names its own reason. They
shared one "it carries a tag that names a case" line until review caught
that it was false for two of them — a union is untagged (env.unions is
"the untagged unions") and a function value is a code pointer, not a
tag. Pinned per type so the reasons cannot quietly re-merge. *)
rejects_check "a union cannot be filled, and not because of a tag"
"(defunion U [a i32 b f64]) \
(defn f [] () (let [u (U {})] (set u (dead-beef))))"
~needle:"a union's members overlay";
rejects_check "a function value cannot be filled"
"(defn g [] ()) (defn f [] () (let [h g] (set h (dead-beef))))"
~needle:"it is a code address";
rejects_check "an enum cannot be filled"
"(defenum K [lo 0 hi 1]) \
(defn f [k K] () (let [e k] (set e (dead-beef))))"
~needle:"the members it declared";
rejects_check "an Option cannot be filled"
"(defn f [] () (let [o (Some 1)] (set o (dead-beef))))"
~needle:"whether the value is there";
(* A data type's tag is a case index, and no byte pattern names a real
case. The type itself is what the message names, because a data type
overlays its cases. *)
rejects_check "a data type cannot be filled"
"(defdata U [(A [x i32]) (B [y i32])]) \
(defn f [] () (let [u (U.A {.x 1})] (set u (filled 0xFF))))"
~needle:"names a case";
(* [zeroed]'s own refusal, worn by both siblings: a fill is the bytes of
whatever type is expected of it, and in a position that expects nothing
there is no type and nothing to fill. This is the shape's cost and it is
paid on purpose — the alternative was a second, place-taking spelling
for an operation [set] already expresses. *)
rejects_check "a fill in a position with no expected type"
"(defn f [] () (print (filled 0xFF)))"
~needle:"needs to know the type it is filling";
rejects_check "a dead-beef in a position with no expected type"
"(defn f [] () (print (dead-beef)))"
~needle:"needs to know the type it is filling";
(* The byte is a u8 and the ordinary literal rule applies to it — there is
no range check of this builtin's own, and there does not need to be. *)
rejects_check "a fill byte out of range"
"(defn f [] () (let [a (array 4 u8)] (set a (filled 300))))"
~needle:"does not fit in u8";
rejects_check "filled takes exactly one byte"
"(defn f [] () (let [a (array 4 u8)] (set a (filled))))"
~needle:"takes 1 argument";
rejects_check "dead-beef takes at most one pattern"
"(defn f [] () (let [a (array 4 u8)] (set a (dead-beef 1 2))))"
~needle:"takes the pattern or nothing at all, given 2";
(* The pattern is four bytes, so a wider literal is a typo rather than
something to truncate. The refusal is [in_range]'s, located at the
literal — this builtin has no range check of its own and does not need
one, exactly as the byte arm does not. *)
rejects_check "a dead-beef pattern out of u32 range"
"(defn f [] () (let [a (array 4 u8)] (set a (dead-beef 0x1DEADBEEF))))"
~needle:"does not fit in u32";
(* A fill is never a value the linker can write into the image, so a
defconst of one is refused by the constant rule rather than by anything
of this feature's own. A defonce is fine: its initialiser runs at
startup, which programs/fill.flan pins. *)
rejects_check "a defconst cannot be filled"
"(defconst g [4 u8] (filled 0xFF)) (defn f [] ())"
~needle:"defconst";
(* ── The third element of a defonce ─────────────────────────────────
The rule, 2026-09-20: a type there is the zeroed static global it has
always been, and anything else is a dyn global initialised from the
expression at startup. The four rows below are the four spellings, each
pinned with its meaning and not merely with the fact that it compiles. *)
defvar_reading "a primitive third element stays a zeroed static"
"(defonce current-color i32) (defn f [] i32 current-color)"
"current-color" ~ty:"i32" ~zeroed:true;
defvar_reading "a bracketed type stays a zeroed static array"
"(defonce grid [2 [3 u32]]) (defn f [] u32 (at grid 0 0))"
"grid" ~ty:"[2 [3 u32]]" ~zeroed:true;
(* The edge the rule turns on: [Point] is a type, so the type reading wins
and this is the zeroed struct it was before the rule existed. A value
named [Point] cannot exist to compete with it — [collect] refuses one
name declared twice, across every declaration kind there is. *)
defvar_reading "a struct's name stays a zeroed static struct"
"(defstruct Point [x i32 y i32]) (defonce p Point) (defn f [] i32 (.x p))"
"p" ~ty:"Point" ~zeroed:true;
rejects_check "a type's name and a value's name cannot collide"
"(defstruct Point [x i32 y i32]) (defonce Point i32 1) (defn f [] ())"
~needle:"defined twice";
(* A parenthesised type is still a type, so this is the zeroed Vec it was —
which is also why a malformed one stays a type error rather than turning
into a call to something named Vec. *)
defvar_reading "a parenthesised type stays a zeroed static"
"(defonce v (Vec i32)) (defn f [] i32 (len v))"
"v" ~ty:"(Vec i32)" ~zeroed:true;
rejects_check "a malformed parenthesised type stays a type error"
"(defonce v (Vec i32 i32)) (defn f [] ())"
~needle:"(Vec T) takes exactly one type";
(* And the new spelling, which is the explicit dyn form with the keyword
left out. *)
defvar_reading "a literal third element is a dyn global holding it"
"(defonce score 0) (defn f [] () (set score (+ score 1)))"
"score" ~ty:"dyn" ~zeroed:false;
defvar_reading "a call as the third element is a dyn global"
"(defn load [] dyn {:n 1}) (defonce game-data (load)) (defn f [] dyn game-data)"
"game-data" ~ty:"dyn" ~zeroed:false;
(* A bare symbol naming a value, which is the shape only a name can settle:
[seed] is not a type, so this is a dyn global initialised from it. *)
defvar_reading "a value's name as the third element is a dyn global"
"(defonce seed i64 3) (defonce score seed) (defn f [] dyn score)"
"score" ~ty:"dyn" ~zeroed:false;
(* The explicit spellings are untouched by all of it. *)
defvar_reading "the explicit dyn form with a value is unchanged"
"(defonce score dyn 0) (defn f [] dyn score)"
"score" ~ty:"dyn" ~zeroed:false;
defvar_reading "the explicit dyn form with no value is unchanged"
"(defonce config dyn) (defn f [] dyn config)"
"config" ~ty:"dyn" ~zeroed:true;
(* [def] through the same third-element rule, one row per spelling. Each
asserts what makes it a def: [grerun], and the initialiser lifted into
[global/<n>] whatever it is — the zero and the literal included, which
is what lets a re-evaluated form swap it through the cell. A defonce
keeps those constants inline; the [defvar_reading] rows above pin that
side (a zeroed one's ginit *is* the zero). *)
def_reading "a def with a type is the zeroed static, repainted"
"(def grid [2 u32]) (defn f [] i32 (i32 (at grid 0)))"
"grid" ~ty:"[2 u32]";
def_reading "a typed def with a constant initialiser still lifts it"
"(def speed i64 3) (defn f [] i64 speed)"
"speed" ~ty:"i64";
def_reading "a computed typed def"
"(defn start [] i64 40) (def counter i64 (start)) (defn f [] i64 counter)"
"counter" ~ty:"i64";
def_reading "a literal third element of a def is a dyn global"
"(def score 0) (defn f [] () (set score (+ score 1)))"
"score" ~ty:"dyn";
(* The defonce beside it stays unmarked: the pair is the whole feature. *)
(match checked "(defonce keep i64 3) (def fresh i64 4) (defn f [] i64 keep)" with
| p ->
let g n = List.find (fun (g : Tast.global) -> g.Tast.gname = n) p.globals in
check "a defonce is not marked for re-run" (not (g "keep").Tast.grerun);
check "and the def beside it is" (g "fresh").Tast.grerun
| exception Loc.Error _ ->
check "a defonce and a def can stand together" false);
(* Lisp-1, same as every other pair of declarations: [collect]'s claimed
table spans def too. *)
rejects_check "a def and a defn cannot share a name"
"(def step 0) (defn step [] i64 1)" ~needle:"step is defined twice";
rejects_check "a def and a defonce cannot share a name"
"(defonce total i64) (def total 0) (defn f [] ())"
~needle:"total is defined twice";
(* The teaching paragraph speaks the form's own name when the form is a
def. *)
rejects_check "a symbol that is neither, under def, says def"
"(def total foo) (defn f [] ())"
~needle:
"foo is neither a type nor a value, and the third element of a def \
has to be one or the other: a type there declares a zeroed global of \
that type — (def total i64) — and a value there declares a dyn \
global holding it — (def total 0)";
(* The symbol that is neither, which is the one position the rule made
ambiguous: before it there was a single reading and "unknown type" was
the whole story, and a message that still said only that would send a
reader looking for the wrong mistake. Both readings, both spellings, and
the near miss over the value names too. *)
rejects_check "a symbol that is neither a type nor a value"
"(defonce total foo) (defn f [] ())"
~needle:
"foo is neither a type nor a value, and the third element of a defonce \
has to be one or the other: a type there declares a zeroed global of \
that type — (defonce total i64) — and a value there declares a dyn \
global holding it — (defonce total 0). Nothing named foo is declared \
as either";
rejects_check "the near miss is over the value names as well as the types"
"(defonce score i64 1) (defonce total scor) (defn f [] ())"
~needle:"Nothing named scor is declared as either — did you mean score?";
(* Three things that know which of the two readings was meant, and get in
ahead of the paragraph rather than being buried under it. A paragraph
about a fork the reader is not standing at is worse than a line. *)
rejects_check "a plain type typo keeps the short answer"
"(defonce total i33) (defn f [] ())"
~needle:"unknown type i33 — did you mean i32?";
(* [int] used to be this row. It resolves now — it is a builtin alias for
[i32] — so the spelling that still teaches has to be one of the ones the
exception did not cover. *)
rejects_check "and another language's spelling is answered by name"
"(defonce total long) (defn f [] ())"
~needle:"unknown type long — Flan spells it i64";
rejects_check "a data case is not a type, and says what is"
"(defdata Shape [(Circle [r f64])]) (defonce g Circle) (defn f [] ())"
~needle:"Circle is a case of the data type Shape, and a case is not a \
type of its own — the global's type is the data type: (defonce g \
Shape). Assign the case you want, as (set g (Shape.Circle \
{.field value ...}))";
(* A bracket form never reaches that fork — the parser gives it the type
reading outright — so a value name inside one used to land in
[resolve_name] and come back as a lecture about generic code. Both
readings at the element that decided it, and the dyn spelling is the one
that works. *)
rejects_check "a bracket type whose element names a value says both readings"
"(defonce a i64 1) (defonce b i64 2) (defonce g [a b]) (defn f [] ())"
~needle:"b names a value, not a type, and the brackets around it were \
read as a type";
rejects_check "and names the dyn spelling that does work"
"(defonce a i64 1) (defonce b i64 2) (defonce g [a b]) (defn f [] ())"
~needle:"put dyn in front of the same brackets — (defonce g dyn ...)";
accepts "which is a real form"
"(defonce a i64 1) (defonce b i64 2) (defonce g dyn [a b]) (defn f [] ())";
(* defconst's two-element form has no type slot, so a type written in one
was read as a name in an array literal and reported as unknown. It is
unambiguous evidence: a type and a value cannot share a name here. *)
rejects_check "a type in a two-element defconst names defonce"
"(defconst rows 4) (defconst cols 4) (defconst grid [rows [cols u8]]) \
(defn f [] ())"
~needle:"u8 is a type, and this is a value: a two-element defconst has no \
type slot";
accepts "and the defonce it names is the form that works"
"(defconst rows 4) (defconst cols 4) (defonce grid [rows [cols u8]]) \
(defn f [] ())";
accepts "an ordinary array constant is untouched" "(defconst xs [1 2 3])";
(* A parameter name is not a mistyped type. This language sizes its machine
types in the name, so a typo in one keeps the digits and a parameter
called [i] or [n] has none — which is the whole of the rule that stopped
[(defn idx [v i] dyn ...)] being refused. *)
accepts "a short parameter name is not a mistyped type"
"(defn idx [v i] dyn v)";
rejects_check "but a mistyped machine type still is"
"(defn g [x f65] f64 x)" ~needle:"unknown type f65 — did you mean f64?";
(* ── (array-fill ...) and (array-gen ...) as initialisers ──────────
DISCUSS.org's "need a value-producing array constructor" wanted
[(defonce grid (array-fill [rows cols] 255))] — the grid filled as part of
its declaration rather than in a mutation step after it. What falls out of
the rules already settled, and it is not a carve-out either way:
The four-element spelling is the one that works. It is a typed global with
a computed initialiser, which is the startup-lifted path a defonce already
had, and the value it stores is an ordinary fixed array.
The three-element spelling does not mean this, and could not. A defonce
whose third element is not a type is a *dyn* global by the 2026-09-20
rule, and a typed fixed array crosses into dyn only as a view of storage
that outlives the view. A freshly built array is a temporary, so the view
lifetime guard refuses it — and where the elements are an array rather
than one of the three scalar widths a view carries, the element refusal
gets there first. Both refusals are the ones any other temporary gets;
neither was written for this form. *)
defvar_reading "a typed array-fill global is computed, not zeroed"
"(defconst rows 2) (defconst cols 3)\n\
(defonce grid [rows [cols u8]] (array-fill [rows cols] 255))\n\
(defn f [] u8 (at grid 0 0))"
"grid" ~ty:"[2 [3 u8]]" ~zeroed:false;
rejects_check "a three-element array-fill defonce is the dyn reading"
"(defonce xs (array-fill [3] (i64 1))) (defn f [] ())"
~needle:"does not cross into dyn as a view here";
rejects_check "and its element type is asked about first"
"(defonce grid (array-fill [2 3] 255)) (defn f [] ())"
~needle:"does not cross into dyn yet";
(* A defconst is not a second path to it: its value is what the linker
writes into the image, and a fill is a loop. *)
rejects_check "array-fill is not a constant's value"
"(defconst g [2 u8] (array-fill [2] (u8 1))) (defn f [] ())"
~needle:"a constant's value must be a compile-time constant";
(* The element type the annotation asks for is the one the fill value is
checked against, so the disagreement is reported at the value. *)
rejects_check "the annotation and the fill value must agree"
"(defonce g [2 [3 u8]] (array-fill [2 3] (f32 1.0))) (defn f [] ())"
~needle:"expected u8, found f32";
rejects_check "the annotation's shape has to be the fill's shape"
"(defonce g [2 u8] (array-fill [3] (u8 1))) (defn f [] ())"
~needle:"expected [2 u8], found [3 u8]";
(* A dimension is the same compile-time length [n T] takes, and a local is
not one. The refusal is [array_len]'s own, which is what "the same rule"
means here. *)
rejects_check "a dimension is a compile-time constant"
"(defn f [] i32 (let [n 3 a (array-fill [n] 0)] 0))"
~needle:"is not a compile-time integer constant";
(* The one condition this form has that the [n T] type spelling does not:
the fill counts in i32 like every other index, so a dimension no i32 can
reach has no loop that could end. Written as a literal, because a
[defconst] that big is refused as an i32 constant before it is ever a
dimension. *)
rejects_check "a dimension has to fit an i32 index"
"(defn f [] i32 (let [a (array-fill [3000000000] 0)] 0))"
~needle:"is not a dimension a fill can count to";
(* The generator. Its type decides the element type, its arity has to be the
rank, and its arguments are indices. *)
accepts "array-gen takes a named function"
"(defn cell [r i32 c i32] i32 (+ (* r 100) c))\n\
(defonce grid [2 [3 i32]] (array-gen [2 3] cell))\n\
(defn f [] i32 (at grid 1 2))";
rejects_check "array-gen's second element is a function"
"(defn f [] i32 (let [a (array-gen [3] 7)] 0))"
~needle:"array-gen's second element is a function value";
rejects_check "the generator takes one argument per dimension"
"(defn g [i i32 j i32] i32 0) (defn f [] i32 (let [a (array-gen [3] g)] 0))"
~needle:"this array-gen has 1 dimension, so its generator is called with \
1 index — and this one takes 2 arguments";
rejects_check "the generator's arguments are i32 indices"
"(defn g [i i64] i32 0) (defn f [] i32 (let [a (array-gen [3] g)] 0))"
~needle:"an index is an i32, and this generator's argument 1 is i64";
(* [resolve] refuses a fixed array of function values — a zeroed one would
be a null pointer — and the type these forms build never goes through
[resolve], so the guard is asked again where the type is built. *)
rejects_check "an array of function values is refused here too"
"(defn h [x i32] i32 x) (defn g [i i32] (Fn [i32] i32) h)\n\
(defn f [] i32 (let [a (array-gen [2] g)] 0))"
~needle:"a fixed array's element cannot be (Fn [i32] i32)";
(* The inline form, the design's canonical one. An fn normally takes its
types from a (Fn ...) want, and this position has none — the *form*
supplies them instead: one i32 index per dimension, and the annotated
element type as the return where there is one. With no annotation the
element type is the body's, the same inference the fill value gets. *)
infers "array-gen takes an inline fn, rank 1"
"(array-gen [5] (fn [i] (* i i)))" "[5 i32]";
infers "array-gen takes an inline fn, rank 2"
"(array-gen [2 3] (fn [i j] (+ (* i 100) j)))" "[2 [3 i32]]";
infers "an inline generator's element type is read off its body"
"(array-gen [3] (fn [i] (i64 i)))" "[3 i64]";
accepts "an annotated defonce takes an inline generator"
"(defonce grid [2 [3 u8]] (array-gen [2 3] (fn [i j] (u8 (+ i j)))))\n\
(defn f [] i32 (i32 (at grid 1 2)))";
(* The annotated element type is the want the body is checked against, so a
disagreement is reported at the generator's answer, in the ordinary
expected/found words — not as a whole-array mismatch a line up. *)
rejects_check "an inline generator's body has to answer the element type"
"(defonce grid [2 [3 u8]] (array-gen [2 3] (fn [i j] 1.5)))\n\
(defn f [] i32 0)"
~needle:"expected u8, found f64";
rejects_check "an inline generator takes one argument per dimension too"
"(defn f [] i32 (let [a (array-gen [2] (fn [i j] i))] 0))"
~needle:"this array-gen has 1 dimension, so its generator is called with \
1 index — and this one takes 2 arguments";
(* ── Computed global initialisers ──────────────────────────────────
The order they run in is the compiler's to choose, so a global written
above the one it reads is fine... *)
accepts "a global initialised from another, written above it"
"(defonce b i64 (+ a 10)) (defonce a i64 (+ 1 2)) (defn f [] i64 b)";
(* ...and a ring is refused with every name in it, because there is no
answer: whichever one started first would read the other's zero. *)
rejects_check "two globals that initialise each other"
"(defn fa [] i64 b) (defn fb [] i64 a)\n\
(defonce a i64 (fa)) (defonce b i64 (fb))\n(defn f [] i64 (+ a b))"
~needle:"initialise each other";
rejects_check "a global initialised from itself"
"(defn fa [] i64 a) (defonce a i64 (fa)) (defn f [] i64 a)"
~needle:"initialised from itself";
(* The dependency is through the call, not only through what the initialiser
names: [fa] reads [b] and nothing in [a]'s text mentions it. *)
accepts "a global that reads another through a function it calls"
"(defn fa [] i64 (+ b 1)) (defonce a i64 (fa)) (defonce b i64 (+ 1 2))\n\
(defn f [] i64 a)";
(* Nothing outside an initialiser can establish a handler or a restart, so an
unanswered signal is a no-op and an unanswered invoke-restart fails at the
invoke site. Both are refused by name. *)
rejects_check "a signal in a global initialiser"
"(defstruct Oops [id i32])\n\
(defonce w i64 (do (signal (Oops {.id 1})) 1))\n(defn f [] i64 w)"
~needle:"with no handler-bind or restart-case around it";
rejects_check "an invoke-restart in a global initialiser"
"(defonce w i64 (do (invoke-restart 'retry) 1))\n(defn f [] i64 w)"
~needle:"an initialiser runs at startup";
(* And what is *inside* one runs like any other code: the frames a
restart-case pushes it also pops, before the initialiser returns. This is
[slurp]'s shape, which is why a global loaded from a file works at all. *)
accepts "a restart-case inside a global initialiser"
"(defstruct Oops [id i32])\n\
(defonce w i64 (restart-case (do (signal (Oops {.id 1})) 7) (use-zero [] 0)))\n\
(defn f [] i64 w)";
(* The borrows, which are what is left once ownership is off the table: a
global Vec is read, mutated in place, viewed and copied, and the copy is
the one thing something else may own. *)
accepts "a global Vec is borrowed, mutated and cloned"
"(defonce g (Vec u8)) \
(defn f [] () (set g (vec-new u8)) (push g 1) (set (at g 0) 2) \
(println (len (as-slice g))) (let [c (clone g)] (free c)))";
rejects_check "try is milestone 6" "(defn f [] i32 (try 1))"
~needle:"milestone 6";
(* dotimes and defer are implemented, and a defer in a [let] is now one of
the places it may be written: a let at the top level of a function body
has exactly the function's extent (see test/programs/defer-let.flan). What
is still rejected is a loop body and a branch, because a defer is copied
into every exit path — so a loop body's would fire once at function exit
rather than once per iteration, and a branch cannot say "maybe
registered". *)
rejects_check "defer is refused in a loop body"
"(defn g [] () 0) (defn f [] () (while true (defer (g))))"
~needle:"a loop body";
rejects_check "defer is refused in a branch"
"(defn g [] () 0) (defn f [] () (if true (defer (g)) 0))"
~needle:"a branch";
(* break and continue. The interesting half is the *relative* rule: a jump
may not cross a construct that has work to do on the way out, and the
refusal names which construct. That is what replaced the blanket refusal
[return] still carries, and the accepting cases below are the ones a
blanket rule would have got wrong. *)
accepts "break leaves the innermost loop"
"(defn f [] () (while true (break)))";
accepts "a labelled break leaves the named loop"
"(defn f [] () (while :o true (while true (break :o))))";
accepts "continue in a dotimes"
"(defn f [] () (dotimes [i 3] (continue)))";
(* And in the other two arities, counting either way. The output side of
this is test/programs/dotimes-range.flan; what is checked here is that
nothing about the longer forms disturbs the loop stack. *)
accepts "break and continue in a start/stop dotimes"
"(defn f [] () (dotimes [i 2 5] (when (= i 3) (continue)) (break)))";
accepts "break and continue in a down-counting dotimes"
"(defn f [] () (dotimes :o [i 9 -1 -1] (when (= i 3) (continue :o)) \
(break :o)))";
(* A step of 0 written as a literal is an infinite loop spelled as an
accident, so it is refused where it is written. A step that is only a
value cannot be refused here and runs no times at all — the sign test
that picks the direction leaves it with neither. *)
rejects_check "a literal step of 0"
"(defn f [] () (dotimes [i 0 10 0] (print i)))"
~needle:"a step of 0 never moves the counter";
accepts "a step whose sign is not known until run time"
"(defn f [] () (let [s 0] (dotimes [i 0 10 s] (print i))))";
(* Three bounds is the most there are. A fourth is refused by the parser,
which names all three arities. *)
rejects_check "a dotimes with four bounds"
"(defn f [] () (dotimes [i 0 10 2 1] (print i)))"
~needle:"(dotimes [name start stop step] body ...)";
rejects_check "a dotimes with no bound at all"
"(defn f [] () (dotimes [i] (print i)))"
~needle:"(dotimes [name stop] body ...)";
(* Every bound is an index, so it is i32 like the one bound always was.
There is no width to join: a wider one is the ordinary type error. *)
rejects_check "a dotimes bound of another width"
"(defn f [] () (let [n (i64 10)] (dotimes [i 0 n] (print i))))"
~needle:"expected i32, found i64";
rejects_check "break outside a loop"
"(defn f [] () (break))" ~needle:"only allowed inside a loop";
rejects_check "continue outside a loop"
"(defn f [] () (continue))" ~needle:"only allowed inside a loop";
rejects_check "a label naming no enclosing loop"
"(defn f [] () (while true (break :nope)))" ~needle:"no loop named :nope";
(* The rule the blanket one could not express, both ways round. A loop
wholly inside a restart-case body keeps its local break; a break that
would *leave* the restart-case is refused, and says so. *)
accepts "a loop inside a restart-case may break out of itself"
"(defn f [] () (restart-case (while true (break)) (go [] (println \"\"))))";
rejects_check "break may not leave a restart-case"
"(defn f [] () (while true (restart-case (break) (go [] (println \"\")))))"
~needle:"a restart-case";
(* A clause is a barrier for the same reason the body is: it runs after a
transfer landed, with the form's frames still to be popped. *)
rejects_check "break may not leave a restart-case from a clause"
"(defn f [] () (while true (restart-case (println \"\") (go [] (break)))))"
~needle:"a restart-case";
accepts "a loop inside a handler-bind may break out of itself"
"(defstruct C [n i32]) (defn f [] () (handler-bind [(C [c] 0)] (while true (break))))";
rejects_check "break may not leave a handler-bind"
"(defstruct C [n i32]) (defn f [] () (while true (handler-bind [(C [c] 0)] (break))))"
~needle:"a handler-bind";
(* loop and recur. The same stack, the same barriers, and one rule of its
own: a recur must be in the loop body's tail. That is what makes this
better than a silent TCO rather than only cheaper — the mistake is a
compile error here and would be a stack overflow there. *)
accepts "recur in the tail of the body"
"(defn f [] i32 (loop [i 0] (if (= i 3) i (recur (+ i 1)))))";
accepts "recur in the tail of a when"
"(defn f [] () (loop [i 0] (when (< i 3) (recur (+ i 1)))))";
accepts "recur in the tail of a nested let"
"(defn f [] i32 (loop [i 0] (let [n (+ i 1)] (if (= i 3) i (recur n)))))";
rejects_check "recur that is not in tail position"
"(defn f [] () (loop [i 0] (recur (+ i 1)) (println \"\")))"
~needle:"tail position";
rejects_check "recur under a call is not in tail position"
"(defn f [] i32 (loop [i 0] (+ 1 (recur (+ i 1)))))"
~needle:"tail position";
rejects_check "recur in a nested loop body is not in tail position"
"(defn f [] () (loop [i 0] (while true (recur (+ i 1)))))"
~needle:"tail position";
(* Where the "refuse mutual recursion by name" answer lives: there are no
tail calls, so a function cannot recur into itself either. *)
rejects_check "recur outside a loop"
"(defn f [] () (recur))" ~needle:"no tail calls";
rejects_check "recur with the wrong number of values"
"(defn f [] i32 (loop [i 0 j 1] (recur 1)))"
~needle:"binds 2 names and this recur passes 1";
(* The barrier, asked the same question break asks and given the same
answer, rather than a second mechanism. *)
rejects_check "recur may not leave a restart-case"
"(defn f [] () (loop [i 0] (restart-case (recur (+ i 1)) (go [] (println \"\")))))"
~needle:"a restart-case";
(* And the restriction this form adds: a loop answers with the value of its
body, so a jump out of one would have no value to give. A while written
inside a loop is untouched, which is the relative rule again. *)
accepts "a while inside a loop keeps its own break"
"(defn f [] () (loop [i 0] (while true (break))))";
rejects_check "break may not leave a loop"
"(defn f [] () (loop [i 0] (break)))" ~needle:"no value to give";
rejects_check "a labelled break may not leave a loop"
"(defn f [] () (while :o true (loop [i 0] (break :o))))"
~needle:"no value to give";
accepts "a while condition is an ordinary expression"
"(defn f [] () (let [v (vec-new i32) n 0] \
(while (and (< n 10) (> (len v) 0)) (set n (+ n 1))) (free v)))";
rejects_check "loop takes no label"
"(defn f [] () (loop :o [i 0] (recur i)))" ~needle:"loop takes no label";
rejects_check "a loop binding is a plain name"
"(defn f [] () (loop [[a b] 0] (recur 0)))" ~needle:"destructuring pattern";
(* into. The expansion is asserted in programs/into.flan, where the values
coming out are the test; what belongs here is the three things it refuses,
each through the one facility a macro has — a name nothing defines. *)
rejects_check "into needs a source and a destination"
"(defn f [] () (free (into [1 2 3])))"
~needle:"into-takes-a-source-a-destination-and-transforms";
rejects_check "a transform is map or filter"
"(defn f [] () (free (into [1 2 3] (vec-new i32) (take 2))))"
~needle:"into-transform-is-map-or-filter";
rejects_check "a transform names one function"
"(defn f [] () (free (into [1 2 3] (vec-new i32) (map))))"
~needle:"into-transform-is-map-or-filter-of-one-function";
(* An import is resolved by [Load] before the checker runs, so one that
reaches [Check] means a driver skipped that step. *)
rejects_check "an unresolved import is a driver bug"
"(import rl \"vendor:raylib\")" ~needle:"not resolved";
(* Keywords resolve against an enum where one is expected, and everywhere
else are the dyn value from M2 — there is no third reading left to
refuse, so a call that wants a concrete non-dyn type still refuses, just
through the ordinary found-dyn sentence of that boundary rather than a
keyword-specific one. *)
rejects_check "a keyword needs an enum"
"(defn g [x i32] ()) (defn f [] () (g :space))" ~needle:"is expected here";
(* This row used to be a rejection, and the sentence it wanted was the cast
arm's "converts a number, found dyn". FIX.org 2026-09-20 took that
refusal away on purpose: a numeric cast opens a dyn box, so [(i64 d)]
compiles for every dyn [d] and the question of what the box holds moved
to run time. A keyword's box holds no number and traps there —
[flan_dyn_cast_kind]'s sentence, the same one a bool's box gets, pinned
in test_acceptance.ml's dyn-cast rows. What is left here is that the
program is now well-typed, which is the change. *)
accepts "a keyword is dyn, and a numeric cast on it is a run-time question"
"(defn f [] () (print (i64 :space)))";
rejects_check "a keyword that is not a member"
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [] () (g :spcae))"
~needle:"has no member :spcae";
(* The near miss. A typo one edit away is suggested, and so is the bare
name of a member that carries a disambiguating prefix — raylib's Key
spells its members key-r, key-space, and :r is the natural mistake. *)
rejects_check "a member one edit away is suggested"
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [] () (g :spcae))"
~needle:"did you mean :space?";
rejects_check "a bare name suggests the prefixed member"
"(defenum Key [key-space 32 key-r 82]) (defn g [k Key] ()) \
(defn f [] () (g :r))"
~needle:"did you mean :key-r?";
accepts "a keyword that is a member"
"(defenum Key [space 32 r 82]) (defn g [k Key] ()) (defn f [] () (g :r))";
(* Converting an enum, explicitly, in both directions. The point of the
conversion is that it is written at the site: a bare integer still does
not fit an enum parameter, so the checked property — a typo is an error
here rather than a wrong number later — is untouched. *)
accepts "an enum converts to an integer"
"(defenum Key [space 32]) (defn f [k Key] i32 (i32 k))";
accepts "an enum converts to a float, through its i32"
"(defenum Key [space 32]) (defn f [k Key] f32 (f32 k))";
accepts "an integer converts to an enum"
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [i i32] () (g (Key i)))";
accepts "a value that is no declared member converts"
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [] () (g (Key 999)))";
rejects_check "an integer still does not fit an enum on its own"
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [i i32] () (g i))"
~needle:"expected Key";
rejects_check "an enum does not convert to another enum"
"(defenum A [x 1]) (defenum B [y 1]) (defn f [a A] B (B a))"
~needle:"converts an integer to an enum";
rejects_check "a float does not convert to an enum"
"(defenum Key [space 32]) (defn f [x f32] Key (Key x))"
~needle:"converts an integer to an enum";
(* An enum is a return type, which needed parse.ml to know enum names. It
knows them under a key of their own: (Key n) is a value now, so putting
Key in [types] would make a body starting with one be eaten as a return
type — the exact trap [is_type_form]'s comment is about. *)
accepts "an enum is a return type"
"(defenum Key [space 32]) (defn f [i i32] Key (Key i))";
accepts "an enum conversion at the head of a body is not a return type"
"(defenum Key [space 32]) (defn g [k Key] ()) \
(defn f [] () (Key 1) (g :space))";
rejects_check "an enum conversion takes one argument"
"(defenum Key [space 32]) (defn f [] Key (Key 1 2))"
~needle:"1 argument";
(* A folded constant skips [check], so its range check has to be its own. *)
rejects_check "a folded constant is still range-checked"
"(defconst c u8 300) (defn f [] u8 c)" ~needle:"does not fit in u8";
(* One top-level namespace, enforced across declaration kinds. Each of these
used to pass the checker — the tables are per-kind — and be caught by LLVM
as a redefinition of an emitted symbol, or not caught at all. *)
rejects_check "a global defined twice"
"(defonce x i32 1) (defonce x i32 2)" ~needle:"defined twice";
rejects_check "a constant shadowing a variable"
"(defconst c 1) (defonce c i32 2)" ~needle:"defined twice";
rejects_check "a function and a global"
"(defn item [] i32 1) (defonce item i32 2)" ~needle:"defined twice";
rejects_check "a struct and an alias"
"(defstruct P [x i32]) (defalias P i32)" ~needle:"defined twice";
rejects_check "an enum and a struct"
"(defenum E [a 1]) (defstruct E [x i32])" ~needle:"defined twice";
rejects_check "an extern and a constant"
"(declare cw [] \"flan_cw\") (defconst cw 1)" ~needle:"defined twice";
(* ── The two builtin aliases ────────────────────────────────────────
[int] is [i32] and [float] is [f32], as of 2026-09-20, and they are the
whole of the exception: every other foreign spelling still teaches. They
are entries in [Types.ikind_of_name] and [Types.fkind_of_name] rather
than prelude [defalias]es, so the claim under test is identity, not
resolution — the rows below are the positions where a merely-resolving
name and the machine type would part company.
The corpus half is test/programs/int-float.flan, which runs on both
backends; what cannot be a program is here, because it does not
compile. *)
accepts "int in a return type and a parameter"
"(defn f [a int] int a)";
accepts "float likewise"
"(defn f [a float] float a)";
(* The position a prelude alias could not have reached: [is_cast] asks the
two [*kind_of_name] functions and never the alias table. *)
accepts "int and float as cast heads"
"(defn f [] int (int (float 1)))";
accepts "int as a generic type argument"
"(defn f [] i64 (let [v (vec-new int) m (map-new int float)] 0))";
accepts "int as a struct field, and float beside it"
"(defstruct P [x int y float]) (defn f [] int (let [p (P {.x 1 .y 2.0})] (.x p)))";
(* The three-element defonce, whose third element is read as a type: a
zeroed static and not a dyn global holding a value called [int]. *)
accepts "a defonce whose type is int" "(defonce g int) (defn f [] int g)";
accepts "and one whose type is float" "(defonce g float) (defn f [] float g)";
accepts "a user alias over int" "(defalias Row (Vec int)) (defn f [] i64 0)";
(* Identity, stated where identity is the only thing that could make it
pass: the two spellings meet as one type with no conversion between
them. *)
accepts "int and i32 are one type"
"(defn g [x i32] i32 x) (defn f [a int] i32 (g a))";
accepts "float and f32 are one type"
"(defn g [x f32] f32 x) (defn f [a float] f32 (g a))";
(* Erasure: the compiler answers in the machine type's name whichever
spelling the source used, which is what the inspector and DWARF show
too — [ikind_name] and [fkind_name] have no [int] to give back. *)
rejects_check "a type error under int names i32"
"(defn f [] int 1.5)" ~needle:"expected i32";
rejects_check "and one under float names f32"
"(defn f [] float (f64 1.0))" ~needle:"expected f32";
(* Widening needs no entry for [int] because [int] *is* [i32], and this pin
says exactly that and nothing about what the widening table holds. It used
to read the other way round — the mixed arithmetic that i32 *refuses*,
int refuses identically — which was true when it was written and stopped
being true when implicit widening landed (FIX.org 2026-09-20): an i32 and
an i64 now meet at i64, so the same form under [int] has to be accepted,
and accepted at i64. Kept pointing at identity by pinning both directions:
the one that widens, and the one that still cannot. *)
accepts "int mixes with i64 exactly as i32 does"
"(defonce a int) (defonce b i64) (defn f [] i64 (+ a b))";
rejects_check "and refuses the narrowing exactly as i32 does"
"(defonce a int) (defonce b i64) (defn f [] int (+ a b))"
~needle:"expected i32, found i64";
(* A program that declared the alias itself — which this one's author did,
before it was builtin. True as written, it is the no-op it says it is;
pointed anywhere else it is refused, because the alias table is never
consulted for the name and the declaration would silently mean i32. *)
accepts "a defalias restating the builtin is a no-op"
"(defalias int i32) (defn f [] int 1)";
accepts "and so is the float one"
"(defalias float f32) (defn f [] float 1.0)";
rejects_check "a defalias redefining int is refused"
"(defalias int i64) (defn f [] int 1)"
~needle:"int is a builtin alias for i32 and cannot be redefined as i64";
rejects_check "and so is one redefining float"
"(defalias float f64) (defn f [] float 1.0)"
~needle:"float is a builtin alias for f32 and cannot be redefined as f64";
rejects_check "a defalias pointing int at a compound type"
"(defalias int (Vec i32)) (defn f [] i32 1)"
~needle:"int is a builtin alias for i32 and cannot be redefined";
(* The exception stops at two names. [integer] is [int]'s own sibling and
still teaches, which is the sharpest statement of where the line is. *)
rejects_check "integer still teaches"
"(defn f [] integer 1)" ~needle:"unknown type integer — Flan spells it i32";
rejects_check "double still teaches"
"(defn f [] double 1.0)" ~needle:"unknown type double — Flan spells it f64";
rejects_check "long still teaches"
"(defn f [] long 1)" ~needle:"unknown type long — Flan spells it i64";
(* A shift by the operand's own width or more is poison in LLVM, and at -O2
a poison return is a function that returns nothing at all. A literal count
is rejected; a computed one is masked in [emit]. *)
rejects_check "a shift past the operand's width"
"(defn f [] i32 (<< 1 32))" ~needle:"out of range";
rejects_check "a right shift past the operand's width"
"(defn f [] u8 (>> (u8 1) 8))" ~needle:"out of range";
accepts "a shift by the widest count in range"
"(defn f [] i32 (<< 1 31))";
(* An index converts from a narrower integer and never from a wider one. *)
accepts "a u32 index" "(defonce a [4 u32]) (defn f [] u32 (let [i 2] (at a (u32 i))))";
rejects_check "an i64 index"
"(defonce a [4 u32]) (defn f [] u32 (let [i 2] (at a (i64 i))))"
~needle:"is wider";
(* An aggregate cannot cross to C — the shim's job, in C, per target. *)
rejects_check "an extern may not take a struct"
"(defstruct V [x f32]) (declare f [v V] \"c_f\")" ~needle:"cannot cross to C";
rejects_check "an extern may not return a struct"
"(defstruct V [x f32]) (declare f [] V \"c_f\")" ~needle:"cannot cross to C";
(* Function values landed; what stayed refused is what they do not include.
An fn takes its parameter types from the position it is written in, and a
defn's body that just answers one says nothing about them. *)
rejects_check "an fn with nothing to say what it takes"
"(defn f [] () (fn [x] x))" ~needle:"nothing here says what this fn";
rejects_check "a lowercase return type no signature introduced"
"(defn f [] a 0)" ~needle:"write $a in the parameter vector";
(* The other half: a name in value position now *works*, and the arity is
checked against the function it names. *)
rejects_check "a function value at the wrong arity"
"(defn g [x i32] i32 x) (defn u [f (Fn [i32] i32)] i32 (f 1 2)) \
(defn f [] i32 (u g))"
~needle:"takes 1 argument, given 2";
rejects_check "a struct cannot contain itself by value"
"(defstruct Node [next Node])" ~needle:"contains itself by value";
rejects_check "nor through a fixed array"
"(defstruct Node [kids [2 Node]])" ~needle:"contains itself by value";
accepts "a pointer breaks the cycle"
"(defstruct Node [next (Ptr Node)])";
rejects_check "an integer literal must fit its type"
"(defn f [] u8 300)" ~needle:"does not fit in u8";
accepts "sequential let bindings"
"(defn f [] i32 (let [a 1 b (+ a 1)] b))";
(* An array literal is [n T] and does not satisfy a slice expectation:
the two are distinct in type and in ownership (spec-memory.md). *)
rejects_check "array literal is not a slice"
"(defn f [] [u8] [1 2 3])" ~needle:"expected [u8]";
rejects_check "array literal is not a struct"
"(defstruct C [pos i32]) (defn f [] C [1 2])" ~needle:"expected C";
rejects_check "wrong element count"
"(defonce xs [2 i32] [1 2 3])" ~needle:"expected 2 elements";
(* Top-level names are order-independent (plan.org, Modules) — including
constants used as array lengths and constants defined in terms of each
other. *)
accepts "a constant declared after its use as a length"
"(defonce grid [rows i32]) (defconst rows 8)";
accepts "constants defined out of order"
"(defconst a (+ b 1)) (defconst b 1)";
(* A typed [defonce] here and not an untyped [defconst], which it was until
2026-09-20: a computed initialiser belongs to a defonce now, and only the
defconst form takes no type. The order-independence being pinned is the
same one either way — [g] is resolved from a declaration further down the
file. *)
accepts "a global initialised from a later function"
"(defonce k u8 (g)) (defn g [] u8 1)";
rejects_check "a genuinely unknown constant still reports itself"
"(defconst a (+ nope 1))" ~needle:"unknown name nope";
(* ── What a defconst's value may be, decided 2026-09-20 ─────────── *)
(* The author's rule: a defconst is the equivalent of a compiler const, so
its value is what the linker writes and never something that runs. The
accepted set is [Tast.const_init]'s, which is [Emit.const]'s — and the
integer arithmetic below is in it because [collect]'s folding pass has
already turned it into its answer by the time the initialiser is looked
at, which is the same pass that makes (/ w cell) usable as an array
length. Both backends refuse the computed one here now, at the checker,
rather than one of them refusing and the other running it at startup. *)
rejects_check "a computed defconst"
"(defn seed [] i64 7) (defconst c i64 (seed))"
~needle:"a constant's value must be a compile-time constant";
rejects_check "a computed defconst names the way through"
"(defn seed [] i64 7) (defconst c i64 (seed))"
~needle:"Write (defonce c ...)";
(* The search for a case goes down through the aggregates, because a case
inside a struct literal is the same value the image cannot hold and the
general message's advice — make it a defonce, or write a literal — is not
followable for one. [Emit.const] recursed for the same reason. *)
rejects_check "a data type case nested in a constant struct"
"(defdata U [A (B [x i32])]) (defstruct S [u U]) \
(defconst g S (S {.u (U.B {.x 1})}))"
~needle:"needs a byte-level encoder that does not exist";
accepts "a constant written as a literal"
"(defconst x u64 0xcbf29ce484222325)";
accepts "a constant written as arithmetic over other constants"
"(defconst w i64 640) (defconst cell i64 16) (defconst cols i64 (/ w cell))";
accepts "a constant aggregate of literals"
"(defstruct P [x i32 y i32]) (defconst origin P (P {.x 1 .y 2}))";
accepts "a constant array of literals"
"(defconst xs [3 i32] [1 2 3])";
accepts "a zeroed constant"
"(defstruct P [x i32 y i32]) (defconst origin P (P {}))";
(* The fold is over integers only, so the same shape in floats is computed
and refused — deliberately, because widening it would be a second folder
and this pins that there is not one. *)
rejects_check "float arithmetic is not folded into a constant"
"(defconst half f64 (/ 1.0 2.0))"
~needle:"a constant's value must be a compile-time constant";
(* ── Conditions, spec-conditions.md §1 and §2 ──────────────────── *)
accepts "handler-bind over a struct condition"
"(defstruct C [id i32]) (defonce n i64)\n\
(defn f [] () (handler-bind [(C [c] (set n 1))] (signal (C {.id 2}))))";
(* Matching is by type and there is no hierarchy, so a condition has to be a
struct — an integer would have nothing to match against. *)
rejects_check "signalling a non-struct"
"(defn f [] () (signal 1))" ~needle:"a condition is a struct";
rejects_check "erroring with a non-struct"
"(defn f [] () (error 1))" ~needle:"a condition is a struct";
(* §2: error is Never, so it unifies with anything — including the position
where a value of some other type was expected. That is what makes it
usable as a restart-case body's fall-through. *)
accepts "error in value position"
"(defstruct C [id i32])\n\
(defn f [] i32 (error (C {.id 1})))";
(* And signal is not: it is Unit, whatever it finds. *)
rejects_check "signal in value position"
"(defstruct C [id i32])\n\
(defn f [] i32 (signal (C {.id 1})))" ~needle:"expected i32";
(* A handler is lifted into a function of its own, so the establishing
function's locals are not there. Capturing them is a closure, which is
milestone 5 — until then it is refused for the reason it is refused for
rather than as an unknown name. *)
rejects_check "a handler capturing a local"
"(defstruct C [id i32])\n\
(defn f [] () (let [n 0] (handler-bind [(C [c] (set n 1))] (signal (C {.id 2})))))"
~needle:"a handler cannot see n";
(* The frames are popped on the way out of the body, so an early exit would
leave them on the stack pointing into a function that has gone. *)
rejects_check "return inside handler-bind"
"(defstruct C [id i32])\n\
(defn f [] i32 (handler-bind [(C [c] (signal c))] (return 1)) 0)"
~needle:"return is not allowed inside handler-bind";
(* spec-memory.md gives a map an upsert of its own, so there is no store
into a lookup and no place form for one. Refused with that reason rather
than as a milestone that will never arrive. *)
rejects_check "a map entry as a place"
"(defn f [] () (set (get m 1) 2))"
~needle:"a map is written with (put m k v)";
(* ── restart-case and invoke-restart, §3 to §6 ─────────────────── *)
accepts "restart-case with a clause that transfers into it"
"(defstruct C [id i32])\n\
(defn g [] i32 (signal (C {.id 1})) 0)\n\
(defn f [] i32 (restart-case (g) (skip [] 7)))\n\
(defn h [] i32 (handler-bind [(C [c] (invoke-restart 'skip))] (f)))";
(* §3: the body and every clause yield the whole form, so they have to agree
— which is also what makes the fall-through path visible in the source.
With a type expected from outside they are each checked against it; with
none, as in a let binding, the first one that produces a value sets it. *)
rejects_check "a clause that disagrees with the body"
"(defn f [] i32 (restart-case 1 (skip [] \"no\")))"
~needle:"expected i32, found string";
rejects_check "two clauses that disagree, with nothing expected"
"(defn f [] i32 (let [x (restart-case (exit 1) (a [] 1) (b [] \"no\"))] 0))"
~needle:"expected i32, found string";
(* §4 finds the first frame offering a name. Two of one name in one frame
would make that a choice nothing in the source shows. *)
rejects_check "one restart-case offering a name twice"
"(defn f [] i32 (restart-case 1 (skip [] 2) (skip [] 3)))"
~needle:"offers skip twice";
(* Same rule as handler-bind: the restart frames are popped on the way out. *)
rejects_check "return inside restart-case"
"(defn f [] i32 (restart-case (return 1) (skip [] 2)))"
~needle:"return is not allowed inside restart-case";
(* §3's parameters. A clause binds them like a function's, so the body sees
them and nothing outside does; what they are is checked against the
invoke at run time, because the two ends meet on a dynamic stack. *)
accepts "a restart with parameters"
"(defn f [] i32 (restart-case 1 (skip [n i32] n)))";
accepts "invoke-restart with arguments"
"(defn f [] () (invoke-restart 'skip 1))";
rejects_check "a restart parameter outside its clause"
"(defn f [] i32 (+ (restart-case 1 (skip [n i32] n)) n))"
~needle:"unknown name n";
rejects_check "a restart argument that is not a value"
"(defn f [] () (invoke-restart 'skip (println \"\")))"
~needle:"a restart argument must be a value";
rejects_check "invoke-restart on an unquoted name"
"(defn f [] () (invoke-restart skip))"
~needle:"a quoted restart name and then its arguments";
(* §5 runs the defers on the way out, so a defer is already the cleanup path
a transfer uses. One that starts its own transfer has no answer. *)
rejects_check "invoke-restart inside a defer"
"(defn f [] i32 (defer (invoke-restart 'skip)) 0)"
~needle:"not allowed inside a defer";
(* Still unimplemented, and still says so by name — which is the point: an
operator the spec names and the compiler lacks must not fall through to a
call and come back as an unknown name. *)
List.iter
(fun (name, src) ->
rejects_check (name ^ " is still unimplemented") src
~needle:"not implemented yet")
[ "find-restart", "(defn f [] () (find-restart 'skip))";
"compute-restarts", "(defn f [] () (compute-restarts))" ];
(* ── handler-case ──────────────────────────────────────────────── *)
(* The unwinding handler. It is built out of a handler-bind and a
restart-case, so most of what could go wrong is already pinned where
those two are; what is asserted here is the surface it puts in front of
them and the one rule that is its own — every clause and the body agree
on a type, which is the type of the whole form. *)
let boom =
"(defstruct Boom [id i32])\n(defstruct Dud [id i32])\n"
in
accepts "handler-case"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] (.id c))]))");
accepts "handler-case with several clauses"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] (.id c)) \
(Dud [c] (+ 1 (.id c)))]))");
(* The whole difference from handler-bind: a clause runs at the form, in the
function that wrote it, so it sees that function's locals. The same body
under a handler-bind is refused by name. *)
accepts "a handler-case clause sees the establishing function's locals"
(boom ^ "(defn f [] i32 (let [n 1] (handler-case 0 [(Boom [c] n)])))");
rejects_check "a handler-bind clause still cannot"
(boom ^ "(defn f [] i32 (let [n 1] (handler-bind [(Boom [c] (set n 2))] 0)))")
~needle:"a handler cannot see n: it is a local of the enclosing function";
(* Nothing static refuses a condition no clause lists: it installs no frame
that matches, so it goes past untouched and the body carries on. *)
accepts "a condition no clause lists"
(boom ^ "(defn f [] i32 (handler-case (do (signal (Dud {.id 1})) 7) \
[(Boom [c] 0)]))");
(* The typing rule, and it fails where an [if] with disagreeing arms fails:
at the form that does not fit, saying what was wanted and what was
found. *)
rejects_check "a handler-case clause that disagrees with the body"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] \"no\")]))")
~needle:"expected i32, found string";
rejects_check "two handler-case clauses that disagree"
(boom ^ "(defn f [] () (println (handler-case 1 [(Boom [c] 2) \
(Dud [c] \"no\")])))")
~needle:"expected i32, found string";
(* Two clauses for one condition type: the first would take every one of
them and the second could never run. *)
rejects_check "one condition type twice"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] 2) (Boom [c] 3)]))")
~needle:"handles Boom twice";
rejects_check "a handler-case clause on something that is not a struct"
"(defn f [] i32 (handler-case 1 [(i32 [c] 2)]))"
~needle:"a handler matches a struct type";
(* The frames are established around the body and taken off after it, so an
early exit out of the middle would leave them on the stack — and the
refusal names the form the reader wrote rather than the handler-bind
underneath it. *)
rejects_check "return inside a handler-case body"
(boom ^ "(defn f [] i32 (handler-case (return 1) [(Boom [c] 2)]))")
~needle:"not allowed inside handler-case";
(* A clause is the other side of that rule and not an exception to it. It
runs at the form, in the function that wrote it, with the frames already
off the stack — so a [return] there is an ordinary return and there is
nothing left for it to strand. *)
accepts "return inside a handler-case clause"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] (return 2))]))");
(* A defer is not, though, and for the reason every nested form is refused
one: it is copied onto every exit path of the *function*, so a defer
written where it looks scoped to the clause would run whether the clause
did or not. The same answer a restart-case clause gets. *)
rejects_check "defer inside a handler-case clause"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] (defer (println \"\")) 2)]))")
~needle:"defer is not allowed inside a nested form";
(* The other way round works. A defer is the cleanup an unwind runs, so
establishing frames inside one is ordinary — what a defer may not do is
start a transfer that leaves it, and a handler-case begins and ends its
own. *)
accepts "handler-case inside a defer"
(boom ^ "(defn f [] i32 (defer (println (handler-case 1 [(Boom [c] 2)]))) 0)");
(* The shape. The clauses go in a vector after the body, which is the
opposite of handler-bind's order, so a form written the other way round
has to say so rather than parse as something else. *)
rejects_check "handler-case with no clauses"
(boom ^ "(defn f [] i32 (handler-case 1 []))")
~needle:"at least one clause";
rejects_check "handler-case written the handler-bind way round"
(boom ^ "(defn f [] i32 (handler-case [(Boom [c] 2)] 1))")
~needle:"handler-case is (handler-case body";
rejects_check "a handler-case clause that binds nothing"
(boom ^ "(defn f [] i32 (handler-case 1 [(Boom [] 2)]))")
~needle:"a handler-case clause is (Type [name] body ...)";
(* ── A function with nothing in it ─────────────────────────────── *)
(* The empty body, the other half of (when test) with no body: a function
that does nothing is a function, and the only question is whether it has
a value to answer. A declared () says it does not and the body may be
empty; a declared anything else says it does, and an empty body cannot
provide one. *)
accepts "a defn returning () with no body"
"(defn nothing [n i32] ())\n(defn f [] i32 (nothing 1) 0)";
rejects_check "a defn returning a value with no body"
"(defn nothing [n i32] i32)"
~needle:"returns i32 but has no body";
(* An fn declares no return type, so the position decides instead. Both arms
are asserted, because the refusal is the one that would otherwise let a
call read a return value nothing wrote. *)
accepts "an fn with no body where a (Fn [] ()) is wanted"
"(defn call [f (Fn [] ())] () (f))\n(defn f [] i32 (call (fn [])) 0)";
rejects_check "an fn with no body where a value is wanted"
"(defn call [f (Fn [] i32)] i32 (f))\n(defn f [] i32 (call (fn [])))"
~needle:"an fn with no body answers ()";
(* ── Destructuring ─────────────────────────────────────────────── *)
(* A pattern is desugared in [Parse] into the bindings and field accesses that
already existed, so what these assert is that the desugaring is *checked*
the same errors an equivalent hand-written let would raise, pointing at the
pattern that stands in for it. *)
let pt = "(defstruct Point [x i32 y i32])\n" in
let line = pt ^ "(defstruct Line [a Point b Point])\n" in
accepts "struct pattern with :keys"
(pt ^ "(defn f [p Point] i32 (let [{:keys [x y]} p] (+ x y)))");
accepts "struct pattern with a name/:field pair"
(pt ^ "(defn f [p Point] i32 (let [{a .x b .y} p] (+ a b)))");
accepts "a nested struct pattern"
(line ^ "(defn f [l Line] i32 (let [{{:keys [x y]} .a} l] (+ x y)))");
(* A later binding sees an earlier pattern's names, as in any let. *)
accepts "a binding after a pattern sees its names"
(pt ^ "(defn f [p Point] i32 (let [{:keys [x]} p y (+ x 1)] y))");
(* Shadowing works because the value goes into a temporary first. *)
accepts "a pattern may shadow the name it destructures"
(line ^ "(defn f [a Line] i32 (let [{a .a} a] (.x a)))");
accepts "a pattern over a call"
(pt ^ "(defn mk [] Point (Point {.x 1 .y 2}))\n\
(defn f [] i32 (let [{:keys [x y]} (mk)] (+ x y)))");
(* The shorthand: a lone .field binds a local of the field's own name. It is
:keys said in the spelling the rest of the language uses for a field, and
it mixes with the pair form in one brace because the two are told apart
one item at a time -- a dot in head position is the shorthand, anything
else is a pattern expecting its .field next. *)
accepts "struct pattern with the .field shorthand"
(pt ^ "(defn f [p Point] i32 (let [{.x .y} p] (+ x y)))");
accepts "the shorthand mixed with a pair in one brace"
(pt ^ "(defn f [p Point] i32 (let [{.x b .y} p] (+ x b)))");
accepts "the shorthand inside a nested pattern"
(line ^ "(defn f [l Line] i32 (let [{{.x .y} .a} l] (+ x y)))");
(* The shorthand names a field, so an unknown one is the same refusal the
named form gets -- it is the same field access underneath. *)
rejects_check "the shorthand naming a field the struct does not have"
(pt ^ "(defn f [p Point] i32 (let [{.z} p] 0))")
~needle:"Point has no field z";
(* This used to be "{.x} has no .field": a lone dotted symbol was read as a
name to bind and the brace then wanted a field after it. An even number
of them was worse than a refusal -- {.x .y} parsed as "bind a local
called .x to field y" and the program failed later with "unknown name x",
several lines from the mistake. Both readings are gone. *)
accepts "a lone .field is the shorthand and not a missing pair"
(pt ^ "(defn f [p Point] i32 (let [{.x} p] x))");
(* And the arm that refusal came from is still there for the case it was
written for: a plain name with nothing after it. *)
rejects_check "a name with no field after it"
(pt ^ "(defn f [p Point] i32 (let [{a} p] 0))")
~needle:"has no .field";
(* And where the shorthand does *not* reach, which is not a limitation it
introduced: a struct pattern has never worked in a match arm, and the two
spellings are refused identically there. Pinned as a pair, because "the
shorthand works wherever {name .field} works" is the claim, and a row on
only one of them would not be saying it. *)
List.iter
(fun pat ->
rejects_check ("a struct pattern in a match arm: " ^ pat)
(pt ^ Printf.sprintf
"(defn f [p Point] i32 (match p %s 0))" pat)
~needle:"expected a pattern, found")
[ "{.x .y}"; "{a .x}" ];
rejects_check "a field the struct does not have"
(pt ^ "(defn f [p Point] i32 (let [{:keys [x z]} p] (+ x z)))")
~needle:"Point has no field z";
rejects_check "a struct pattern over something that is not a struct"
"(defn f [n i32] i32 (let [{:keys [x]} n] x))"
~needle:"i32 is not a struct, so it has no fields";
rejects_check "one pattern binding a name twice"
(pt ^ "(defn f [p Point] i32 (let [{:keys [x x]} p] x))")
~needle:"this pattern binds x twice";
rejects_check "an empty struct pattern"
(pt ^ "(defn f [p Point] i32 (let [{} p] 0))")
~needle:"an empty struct pattern {} binds nothing";
rejects_check "a pattern with no field name after it"
(pt ^ "(defn f [p Point] i32 (let [{a b} p] 0))")
~needle:"expected .field after a";
(* The same refusal on the destructuring side: a field is a field wherever it
is named, so the rule is not half-applied. :keys is the one that keeps its
colon, and the test below it says so. *)
rejects_check "a destructured field written with a colon"
(pt ^ "(defn f [p Point] i32 (let [{a :x} p] a))")
~needle:"a field label is written .x, not :x";
accepts ":keys keeps its colon, naming no field"
(pt ^ "(defn f [p Point] i32 (let [{:keys [x y]} p] (+ x y)))");
(* Clojure's other map-destructuring keys. Each is refused by its own name:
"unexpected form" would leave the author guessing which of the four they
wrote is the one this does not have. *)
List.iter
(fun k ->
rejects_check (k ^ " in a struct pattern")
(pt ^ Printf.sprintf
"(defn f [p Point] i32 (let [{:keys [x] %s q} p] x))" k)
~needle:(k ^ " is not implemented in a destructuring pattern"))
[ ":as"; ":or"; ":strs"; ":syms" ];
(* ── Sequential patterns, and the asymmetry ────────────────────── *)
(* A fixed array's length is in its type, so the arity is a claim the checker
can settle. *)
accepts "an array pattern naming every element"
"(defn f [] i32 (let [xs [1 2 3] [a b c] xs] (+ a (+ b c))))";
accepts "an array pattern with & rest"
"(defn f [] i32 (let [xs [1 2 3] [a & r] xs] (+ a (len r))))";
accepts "& rest taking an empty tail"
"(defn f [] i32 (let [xs [1 2] [a b & r] xs] (+ a (+ b (len r)))))";
accepts "a struct pattern nested in an array pattern"
(pt ^ "(defn f [ps [2 Point]] i32 \
(let [[{:keys [x]} {y .y}] ps] (+ x y)))");
rejects_check "an array pattern that names too few elements"
"(defn f [] i32 (let [xs [1 2 3] [a b] xs] (+ a b)))"
~needle:"this pattern binds 2 names, but [3 i32] has 3 elements";
rejects_check "an array pattern that names too many"
"(defn f [] i32 (let [xs [1 2] [a b c] xs] (+ a (+ b c))))"
~needle:"this pattern binds 3 names, but [2 i32] has 2 elements";
rejects_check "& rest with more names before it than there are elements"
"(defn f [] i32 (let [xs [1 2] [a b c & r] xs] a))"
~needle:"binds 3 names before the &, but [2 i32] has only 2 elements";
(* The asymmetry, and the reason this is refused rather than lowered to a
bounds-checked [at]: over a slice the arity is a claim about a number that
does not exist until the program runs, so a pattern that type checks would
be one that kills the program instead. *)
rejects_check "an array pattern over a slice"
"(defn f [s [i32]] i32 (let [[a b] s] (+ a b)))"
~needle:"a slice's length is a runtime value";
rejects_check "an array pattern over a slice, even with & rest"
"(defn f [s [i32]] i32 (let [[a & r] s] (+ a (len r))))"
~needle:"a slice's length is a runtime value";
rejects_check "an array pattern over something with no elements at all"
"(defn f [n i32] i32 (let [[a b] n] (+ a b)))"
~needle:"i32 is not a fixed array";
rejects_check "an empty array pattern"
"(defn f [] i32 (let [xs [1 2] [] xs] 0))"
~needle:"an empty array pattern [] binds nothing";
rejects_check "& with nothing after it"
"(defn f [] i32 (let [xs [1 2] [a &] xs] a))"
~needle:"& needs a name after it";
rejects_check "& with two names after it"
"(defn f [] i32 (let [xs [1 2] [a & r s] xs] a))"
~needle:"& takes one name";
rejects_check "a pattern that is only & rest"
"(defn f [] i32 (let [xs [1 2] [& r] xs] (len r)))"
~needle:"binds the whole value";
rejects_check "one array pattern binding a name twice"
"(defn f [] i32 (let [xs [1 2] [a a] xs] a))"
~needle:"this pattern binds a twice";
(* ── Where a pattern is not a binding form ─────────────────────── *)
(* Every other binding position takes a plain name. A parameter is the one
worth a reason: it is a name/type pair, and a pattern has no name for the
type to pair with. Refused where it is written, not left to fall out of
"expected a name". *)
List.iter
(fun (what, src) ->
rejects_check ("a pattern in " ^ what) src
~needle:"a pattern binds only in let")
[ "a defn parameter", pt ^ "(defn f [{:keys [x]} Point] i32 x)";
"a defstruct field", "(defstruct S [[a b] i32])";
"an fn parameter", "(defn f [] i32 (let [g (fn [[a b]] a)] 0))";
"a dotimes counter", "(defn f [] () (dotimes [[a b] 3] 0))";
"a declare parameter", pt ^ "(declare g [{:keys [x]} Point] \"G\")" ];
(* ── match over an enum ────────────────────────────────────────── *)
(* Not shipped, and refused twice over because there are two ways to write it
and they fail in different files. Both now say the same thing, which is the
point: the lowering is not what is missing — a keyword has no case in
[Ast.pattern], and [lib/load.ml] matches that type exhaustively. *)
rejects_check "match over an enum, members written as keywords"
"(defenum K [lo 0 hi 1])\n(defn f [k K] i32 (match k :lo 1 :hi 2))"
~needle:"is not implemented as a pattern";
rejects_check "match over an enum, members written as names"
"(defenum K [lo 0 hi 1])\n(defn f [k K] i32 (match k lo 1 hi 2))"
~needle:"match over the enum K is not implemented";
(* The old message blamed milestone 2, which was never the reason, and the
milestone has since arrived: match now works over a declared data type as
well, so the message names both subjects and no milestone. *)
rejects_check "match over something that is neither"
"(defn f [n i32] i32 (match n _ 2))"
~needle:"match works on an Option or a data type, not on i32";
(* A destructuring pattern in an arm's binds is a name position like any
other. *)
rejects_check "a pattern inside a match arm's binds"
"(defstruct P [x i32])\n\
(defn f [o (Option P)] i32 (match o (Some {:keys [x]}) x None 0))"
~needle:"a pattern binds only in let";
(* The desugaring's own machinery is unspellable: the reader makes [~] a
delimiter, so the name never reaches the parser as one symbol. *)
rejects_check "the desugaring's internal name cannot be written by hand"
"(defn f [] i32 (let [xs [1 2]] (destructure~nth xs 0 2 1)))"
~needle:"means nothing outside a quasiquote";
(* ── The untagged union ────────────────────────────────────────── *)
(* Every one of these is a rule the type would be unsound or useless
without, and each says so in its own words rather than falling through to
something generic. The layout itself is pinned where a layout can only be
pinned, against the backend that computes it — see the DWARF/LLVM oracle
in test_acceptance.ml — and what it *does* is pinned by a program that
writes one member and reads another, which is the whole point of the
type. What is here is the catalogue of what it refuses. *)
(match (parse_decl "(defunion U [i i32 f f32])").Ast.d with
| Ast.Defunion ("U", [ a; b ]) ->
check "defunion parses as a member list"
(a.Ast.fname = "i" && b.Ast.fname = "f")
| _ -> check "defunion parses as a member list" false);
(* Nothing to read out of, and no size. It parses; it is refused where the
message can name the shape. *)
rejects_check "a union with no members"
"(defunion U [])\n(defn f [u U] i32 0)"
~needle:"declares no members";
rejects_check "a union that declares a member twice"
"(defunion U [x i32 x f32])\n(defn f [u U] i32 0)"
~needle:"declares the same member twice";
(* The size is the largest member and the largest member is the whole type,
so this is the same infinite type a self-containing struct is. *)
rejects_check "a union that contains itself by value"
"(defunion U [a i32 b U])\n(defn f [u U] i32 0)"
~needle:"contains itself by value";
(* Nothing records which member is live, and since the second repeal that
is the program's fact to keep rather than a refusal: a union member may
own storage, C's way. *)
accepts "a union member that owns storage"
"(defunion U [n i64 v (Vec i32)])\n(defn f [u U] i32 0)";
(* And the one the optimiser would otherwise be handed: a byte that is
neither 0 nor 1 read as an i1. Refused at any depth, which is why the
second row goes through a struct. *)
rejects_check "a bool member"
"(defunion U [b bool n u8])\n(defn f [u U] i32 0)"
~needle:"a union may not hold one at any depth";
rejects_check "a bool inside a struct member"
"(defstruct S [flag bool n i32])\n\
(defunion U [s S n i64])\n(defn f [u U] i32 0)"
~needle:"a union may not hold one at any depth";
(* The same hazard as uninit on a data type, arriving the other way round: a
member written over the tag leaves a tag no case names, and a match on it
falls into a block the optimiser may treat as unreachable. Refused at any
depth for the reason bool is. *)
rejects_check "a data type member"
"(defdata D [A (B [x i32])])\n\
(defunion U [d D n i64])\n(defn f [u U] i32 0)"
~needle:"a data type's tag steers every match";
rejects_check "a data type inside a struct member"
"(defdata D [A B])\n(defstruct S [d D n i32])\n\
(defunion U [s S n i64])\n(defn f [u U] i32 0)"
~needle:"a data type's tag steers every match";
(* An Option is not on that list, and the difference is the lowering: its
match is a test of the tag byte and a branch, so a scribbled tag reads as
a Some with a payload nobody stored — which is what this language says a
union read is. *)
(match checked "(defunion U [o (Option i32) n i64])\n\
(defn f [u U] i32 (match (.o u) (Some x) x None 0))" with
| _ -> check "an Option member is allowed" true
| exception Loc.Error { Loc.dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL an Option member is allowed: %s\n" msg);
(* One member named twice is a different mistake from two members named, and
it gets the refusal the struct path already had. *)
rejects_check "a union literal naming one member twice"
"(defunion U [i i32])\n\
(defn f [] i32 (let [u (U {.i 1 .i 2})] (.i u)))"
~needle:"member i is given twice";
(* Two members is one storage written twice, and which one survived would be
whatever the compiler happened to do last. *)
rejects_check "a union literal giving two members"
"(defunion U [i i32 f f32])\n\
(defn f [] i32 (let [u (U {.i 1 .f 2.0})] (.i u)))"
~needle:"only one of them can be written";
rejects_check "a union literal giving a member it does not have"
"(defunion U [i i32])\n(defn f [] i32 (let [u (U {.z 1})] (.i u)))"
~needle:"U has no member z";
(* There is no tag, so there is nothing for the arms to be alternatives
over. Said by name because the two kinds of union are one keyword apart
and somebody will write it. *)
rejects_check "match on a union"
"(defunion U [i i32 f f32])\n\
(defn f [u U] i32 (match u _ 0))"
~needle:"there is nothing in one to match on";
(* A member narrower than the union leaves the rest indeterminate, so two
values that agree about everything anybody wrote would hash apart. *)
rejects_check "a union as a map key"
"(defunion U [i i32 f f32])\n\
(defn f [m (Map U i32) k U] () (put m k 1))"
~needle:"a union is not a map key";
(* A constant is what the linker writes into the image and a union member is
a store, so a defconst is refused rather than coming back from the emitter
as "this one is computed". A defonce is not refused any more: its computed
initialiser is lifted into a function that runs at startup, and the member
is written by the same store that writes one in a body. *)
accepts "a global initialised with a union member"
"(defunion U [i i32])\n(defonce g U (U {.i 1}))\n(defn f [] i32 0)";
rejects_check "a constant initialised with a union member"
"(defunion U [i i32])\n(defconst c U (U {.i 1}))\n(defn f [] i32 0)"
~needle:"cannot be written into a constant";
(* The all-bytes-zero value is a constant and goes through, which is what
makes (U {}) and a declaration with no value the same thing. *)
(match checked "(defunion U [i i32])\n(defonce g U (U {}))\n\
(defn f [] i32 (.i g))" with
| _ -> check "a global zeroed through a literal is allowed" true
| exception Loc.Error { Loc.dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL a global zeroed through a literal is allowed: %s\n" msg);
(* uninit is refused on a data type because its tag steers a match into a
block LLVM may treat as unreachable. An untagged union steers nothing, so
the argument does not carry over and the answer is different. *)
(match checked "(defunion U [i i32 f f32])\n(defonce g U uninit)\n\
(defn f [] i32 (.i g))" with
| _ -> check "uninit on a union is allowed" true
| exception Loc.Error { Loc.dmsg = msg; _ } ->
incr failures;
Printf.printf "FAIL uninit on a union is allowed: %s\n" msg);
(* The shim writes structs and has no spelling for a union yet — a refusal
about the generator, not about the type, and it says so. *)
rejects_check "a union crossing to C by value"
"(defunion U [i i32])\n(declare-c take [u U] () \"take\")"
~needle:"the shim generator writes structs only";
(* ── Reading a C header (cimport.ml, cjson.ml) ─────────────────── *)
(* Against test/headers/sample.h, which is one function per decision the
importer makes and is committed so that it cannot move. The raylib case
is better evidence and worse coverage: it needs raylib installed, at the
version whose .so is linked, with FLAN_RAYLIB_H set, so as the only test
of this it would skip everywhere.
The assertions are on the *reasons*, not on the counts, for the reason the
acceptance table gives: a refusal that fires for the wrong cause still
refuses, and a count still matches. *)
let imported, dump, env, fixture_ds =
let fixture =
"(defstruct Pair [x f32 y f32])\n\
(defstruct Shade [r u8 g u8 b u8 a u8])\n\
(defenum Mood [calm 0 cross 1])\n\
(defunion Overlay [i i32 f f32])\n\
(defstruct Slot [kind i32 v Overlay])\n"
in
let ds = program fixture in
let taken = Hashtbl.create 16 in
List.iter
(fun d ->
match Ast.declared_name d with
| Some n -> Hashtbl.replace taken n ()
| None -> ())
ds;
let known_structs =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defstruct (n, _) -> Some n | _ -> None)
ds
and known_unions =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defunion (n, _) -> Some n | _ -> None)
ds
and known_enums =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defenum (n, _) -> Some n | _ -> None)
ds
in
let i, d, e =
Cimport.header ~loc:Loc.unknown ~header:"headers/sample.h" ~flags:[]
~known_structs ~known_unions ~known_enums ~taken ~bound_syms:[]
~config:Cimport.no_config
in
(i, d, e, ds)
in
(* What came out, as source, so a wrong type is visible as the line somebody
would otherwise have had to write by hand. *)
let produced = List.map Cimport.decl_source imported.Cimport.decls in
let emits name line =
check ("import-c emits " ^ name) (List.mem line produced)
in
emits "a scalar signature" "(declare-c set-seed [seed u32] \"set_seed\")";
emits "two scalars and a return"
"(declare-c add-ints [a i32 b i32] i32 \"add_ints\")";
(* An aggregate return is the flattening path: Shim turns it into an
out-pointer, and the declaration it starts from has to say the struct. *)
emits "an aggregate return"
"(declare-c make-pair [x f32 y f32] Pair \"make_pair\")";
emits "an aggregate parameter" "(declare-c pair-len [p Pair] f32 \"pair_len\")";
(* const char * is a string going in — the one C spelling that means
something different in a parameter than it does anywhere else. *)
emits "const char * as a string parameter"
"(declare-c name-length [text string] i32 \"name_length\")";
emits "a pointer parameter"
"(declare-c count-at [values (Ptr i32) n i32] i32 \"count_at\")";
(* struct Pair is both Pair and Point in the header and the package
describes it once, so both names have to land on the one defstruct —
raylib does exactly this with Texture2D and TextureCubemap. *)
emits "a second typedef name for a described record"
"(declare-c point-of [p Pair] Pair \"point_of\")";
(* A C enum is an int, and so is a Flan defenum at the boundary; matching by
name is what keeps the nicer face. *)
emits "a C enum against a defenum of the same name"
"(declare-c mood-value [m Mood] i32 \"mood_value\")";
emits "a function of no arguments" "(declare-c take-nothing [] \"take_nothing\")";
(* And the refusals, each by its reason rather than by a count. *)
let refused name needle =
check
("import-c refuses " ^ name ^ ": " ^ needle)
(List.exists
(fun (n, why) -> n = name && contains why needle)
imported.Cimport.hidden)
in
refused "name-of" "returns char *";
refused "fill-buffer" "C may write through";
refused "printf-like" "is variadic";
refused "on-event" "is a function pointer";
refused "file-time" "width that differs";
refused "make-undescribed" "the package does not describe";
(* The order-dependent one. Spin2D and spin2d both kebab to spin-2d, so
neither may have it: whichever won would depend on the order the header
declares them in, and moving two lines in somebody else's header would
rebind a name a program is already calling. *)
refused "spin-2d" "would depend on the order";
check "a colliding name is not imported after all"
(not (List.exists (fun l -> contains l "\"Spin2D\"") produced));
check "nor is the other half of the collision"
(not (List.exists (fun l -> contains l "\"spin2d\"") produced));
(* ── The config beside the header (Cimport.read_config) ────────── *)
(* Why there is a config at all: the generated declarations are committed, so
a hand-edit to them is destroyed by the next regeneration and the edit has
to live somewhere regeneration reads instead. These are the two things it
can say. *)
let with_config config =
let taken = Hashtbl.create 16 in
List.iter
(fun d ->
match Ast.declared_name d with
| Some n -> Hashtbl.replace taken n ()
| None -> ())
fixture_ds;
let known_structs =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defstruct (n, _) -> Some n | _ -> None)
fixture_ds
and known_unions =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defunion (n, _) -> Some n | _ -> None)
fixture_ds
and known_enums =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defenum (n, _) -> Some n | _ -> None)
fixture_ds
in
let i, _, _ =
Cimport.header ~loc:Loc.unknown ~header:"headers/sample.h" ~flags:[]
~known_structs ~known_unions ~known_enums ~taken ~bound_syms:[] ~config
in
(List.map Cimport.decl_source i.Cimport.decls, i.Cimport.hidden)
in
let lines, hidden =
with_config { Cimport.no_config with Cimport.excludes = [ "set_seed" ] }
in
check "an excluded symbol is not generated"
(not (List.exists (fun l -> contains l "\"set_seed\"") lines));
(* Not silently absent. "there is no such binding" and "the package decided
against this binding" are different answers and a reader gets the second,
which is what [hidden] is for. *)
check "and it says it was excluded rather than going quiet"
(List.exists
(fun (n, why) -> n = "set-seed" && contains why "excluded by the package")
hidden);
let lines, _ =
with_config { Cimport.no_config with Cimport.excludes = [ "add_*" ] }
in
check "an exclude pattern matches by prefix"
(not (List.exists (fun l -> contains l "\"add_ints\"") lines));
check "and leaves everything it does not match"
(List.exists (fun l -> contains l "\"set_seed\"") lines);
let lines, _ =
with_config
{ Cimport.no_config with Cimport.renames = [ ("set_seed", "seed") ] }
in
(* The C symbol is kept verbatim, so an override changes the Flan face and
nothing else — which is what makes it safe to spell a predicate the way
Lisp spells one. *)
check "a name override is the Flan name, and the C symbol is untouched"
(List.mem "(declare-c seed [seed u32] \"set_seed\")" lines);
(* The collision above is refused because neither Spin2D nor spin2d may take
[spin-2d] by an accident of header order. Naming one of them is the way
out, and it is the reason the kebab rule is consulted in exactly one
place: the groups are computed on the name a function will really take,
so the rename dissolves the group rather than leaving both refused. *)
let lines, hidden =
with_config
{ Cimport.no_config with Cimport.renames = [ ("Spin2D", "spin-2d-upper") ] }
in
check "a rename resolves a collision for both halves"
(List.exists (fun l -> contains l "\"Spin2D\"") lines
&& List.exists (fun l -> contains l "\"spin2d\"") lines);
check "and the collision is no longer refused"
(not (List.mem_assoc "spin-2d" hidden));
(* The file, since a typo in it would otherwise show up as a binding under
the wrong name. A line that is neither directive is an error rather than a
line quietly skipped. *)
let config_file text =
let f = Filename.temp_file "flan-bindings" "" in
let ch = open_out f in
output_string ch text;
close_out ch;
f
in
let c = Cimport.read_config (config_file "# a comment\n\nexclude Mem*\nname IsWindowReady window-ready?\n") in
check "read_config reads an exclude" (c.Cimport.excludes = [ "Mem*" ]);
check "read_config reads a name override"
(c.Cimport.renames = [ ("IsWindowReady", "window-ready?") ]);
check "a missing config is no exclusions and no overrides"
(Cimport.read_config "no-such-bindings-file" = Cimport.no_config);
check "a line that is neither directive is refused"
(match Cimport.read_config (config_file "rename Foo bar\n") with
| _ -> false
| exception Loc.Error { Loc.dmsg = m; _ } -> contains m "and this is neither");
(* The enum line's fourth column: the prefix the members carry on the Flan
side, so `key-r` is checked as KEY_R and not KEY_KEY_R. *)
let c = Cimport.read_config (config_file "enum Key KEY_ key-\n") in
check "read_config reads an enum line's Flan member prefix"
(c.Cimport.enum_prefixes = [ ("Key", "KEY_") ]
&& c.Cimport.enum_flan_prefixes = [ ("Key", "key-") ]);
check "a Flan member prefix on `enum Foo -` is refused"
(match Cimport.read_config (config_file "enum Foo - key-\n") with
| _ -> false
| exception Loc.Error { Loc.dmsg = m; _ } ->
contains m "nothing to check Foo against");
(* The package's own file, and the round trip the prefix exists for: a Flan
keyword a program writes, and the C name the rule reaches from it. All
eleven of raylib's enums declare a Flan prefix now — the uniformity is
the claim, so one member of each is pinned rather than a sample. The C
side of each row was read off vendor/raylib/raylib-5.5.h; `flan
generate-c vendor/raylib` is what re-checks that against the header, and
this is what keeps the *spellings* from drifting between times somebody
runs it.
The prefix is a reading choice and not a collision fix: a keyword
resolves against the expected type and nothing else, so :point at a
TextureFilter site was never ambiguous. What is pinned here is that the
bindings file still says so uniformly. *)
let rl = Cimport.read_config "../vendor/raylib/bindings" in
let c_name enum member =
match
( List.assoc_opt enum rl.Cimport.enum_prefixes,
List.assoc_opt enum rl.Cimport.enum_flan_prefixes )
with
| Some cp, Some fp ->
(match Cimport.strip_prefix fp member with
| Some stem -> Some (cp ^ Cimport.screaming stem)
| None -> None)
| _ -> None
in
List.iter
(fun (enum, member, cname) ->
check
(Printf.sprintf "%s/%s is %s" enum member cname)
(c_name enum member = Some cname))
[ ("Key", "key-left-shift", "KEY_LEFT_SHIFT");
("MouseButton", "mouse-left", "MOUSE_BUTTON_LEFT");
("TraceLogLevel", "log-warning", "LOG_WARNING");
("CameraProjection", "projection-perspective", "CAMERA_PERSPECTIVE");
("CameraMode", "camera-third-person", "CAMERA_THIRD_PERSON");
("GamepadButton", "button-left-face-up", "GAMEPAD_BUTTON_LEFT_FACE_UP");
("GamepadAxis", "axis-left-trigger", "GAMEPAD_AXIS_LEFT_TRIGGER");
("Gesture", "gesture-pinch-out", "GESTURE_PINCH_OUT");
("MouseCursor", "cursor-resize-nesw", "MOUSE_CURSOR_RESIZE_NESW");
("TextureFilter", "filter-bilinear", "TEXTURE_FILTER_BILINEAR");
("PixelFormat", "pixel-uncompressed-r8g8b8a8",
"PIXELFORMAT_UNCOMPRESSED_R8G8B8A8") ];
(* And the other half of the same claim: every enum the file maps declares
a Flan prefix. A twelfth enum added with two columns would otherwise
reintroduce the split this closed. `enum Foo -` is exempt and has to be
— a line that says the header has nothing to check cannot carry a third
column at all, which is pinned a few rows above. *)
check "every mapped raylib enum declares a Flan-side member prefix"
(List.for_all
(fun (e, cp) ->
String.equal cp "-" || List.mem_assoc e rl.Cimport.enum_flan_prefixes)
rl.Cimport.enum_prefixes);
(* A refused name is a name that exists and cannot be had — Zig's failDecl,
which Load.refuse_hidden already implements for main. Nothing may be in
both lists, or asking for a name that works would report that it does
not. *)
check "nothing is both imported and refused"
(not
(List.exists
(fun (d : Ast.decl) ->
match Ast.declared_name d with
| Some n -> List.mem_assoc n imported.Cimport.hidden
| None -> false)
imported.Cimport.decls));
(* The struct check, which is the point of reading a header the generator
does not otherwise need: the defstruct and the header's record have
different authors, so a disagreement is real information. A
_Static_assert was rejected in docs/BUILT.md as circular for want of exactly
that. *)
let structs_of ds =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defstruct (n, fs) -> Some (n, fs) | _ -> None)
ds
in
check "a defstruct that matches the header is not reported"
(Cimport.check_structs ~env ~structs:(structs_of fixture_ds) dump = []);
(* Permuted: the failure docs/BUILT.md says only a test can catch, because every
field still reads as a plausible number. *)
check "a permuted defstruct is reported"
(match
Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Pair [y f32 x f32])\n")) dump
with
| [ ("Pair", why) ] -> contains why "field order"
| _ -> false);
(* Widened: the other half of the same hazard and the one docs/BUILT.md names —
f64 where the library says float lays out eight bytes where there are
four, and every field after it moves. *)
check "a widened field is reported"
(match
Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Pair [x f32 y f64])\n")) dump
with
| [ ("Pair", why) ] -> contains why "f64" && contains why "f32"
| _ -> false);
(* A struct the header says nothing about is not a disagreement: a package
may describe something the library does not name. *)
check "a struct the header does not describe is left alone"
(Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Nowhere [q i32])\n")) dump
= []);
(* An enum field against the header's [int]. A Flan defenum lowers to
int32_t in a struct field exactly as it does in a parameter, so this is
the same four bytes with a better face on it and not a disagreement —
which is what let raylib's Camera3D.projection stop being an i32 with a
conversion function beside it. *)
check "an enum-typed field against the header's int is not reported"
(Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Mode [kind Mood scale f32])\n"))
dump
= []);
(* Symmetric: the header may be the side that names the enum. *)
check "an i32 field against the header's enum is not reported"
(Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Feel [mood i32 n i32])\n")) dump
= []);
(* And the whole point of the check survives it. The tolerance is for a
32-bit integer and nothing else, so the width hazard docs/BUILT.md names — f64
where the library says float — still fails, in the very struct whose
other field is an enum. *)
check "a widened field beside an enum field is still reported"
(match
Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Mode [kind Mood scale f64])\n"))
dump
with
| [ ("Mode", why) ] -> contains why "f64" && contains why "f32"
| _ -> false);
(* An enum is four bytes, so an enum against something that is not four
bytes is a real disagreement and stays one. *)
check "an enum against a field that is not 32 bits is still reported"
(match
Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Feel [mood i64 n i32])\n")) dump
with
| [ ("Feel", why) ] -> contains why "i64"
| _ -> false);
(* The same claim for a [defunion], and what it unlocked. A record holding a
union member used to be skipped entirely — not recorded, so the
[defstruct] beside it was unchecked too — because there was no Flan type
to compare the member against. There is one now, and [Slot] in the
fixture is checked member by member like any other struct. *)
let unions_of ds =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defunion (n, ms) -> Some (n, ms) | _ -> None)
ds
in
check "a defunion that matches the header is not reported"
(Cimport.check_unions ~env ~unions:(unions_of fixture_ds) dump = []);
check "and the struct holding it is checked rather than skipped"
(Cimport.check_structs ~env ~structs:(structs_of fixture_ds) dump = []);
check "a struct whose union member is given the wrong type is reported"
(match
Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Slot [kind i32 v Pair])\n"))
dump
with
| [ ("Slot", why) ] -> contains why "Overlay"
| _ -> false);
(* Order is the whole hazard for a struct and means nothing for a union:
every member is at offset zero, so a permuted defunion is the same type
and reporting it would be a finding that is not one. *)
check "a permuted defunion is not reported"
(Cimport.check_unions ~env
~unions:(unions_of (program "(defunion Overlay [f f32 i i32])\n")) dump
= []);
(* Missing is the one that changes the size, and a union embedded by value
puts every field after it in the wrong place. *)
check "a defunion missing a member is reported"
(match
Cimport.check_unions ~env
~unions:(unions_of (program "(defunion Overlay [i i32])\n")) dump
with
| [ ("Overlay", why) ] -> contains why "f" && contains why "widest"
| _ -> false);
check "a defunion with a member the header lacks is reported"
(match
Cimport.check_unions ~env
~unions:(unions_of
(program "(defunion Overlay [i i32 f f32 d f64])\n")) dump
with
| [ ("Overlay", why) ] -> contains why "d"
| _ -> false);
check "a defunion whose member is the wrong width is reported"
(match
Cimport.check_unions ~env
~unions:(unions_of (program "(defunion Overlay [i i32 f f64])\n")) dump
with
| [ ("Overlay", why) ] -> contains why "f64" && contains why "f32"
| _ -> false);
(* Two different layouts under one name, which is the same class of finding
a permuted struct is and has a one-keyword fix. *)
check "a defstruct against a union in the header is reported"
(match
Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Overlay [i i32 f f32])\n"))
dump
with
| [ ("Overlay", why) ] -> contains why "union in the header"
| _ -> false);
check "a defunion against a struct in the header is reported"
(match
Cimport.check_unions ~env
~unions:(unions_of (program "(defunion Pair [x f32 y f32])\n")) dump
with
| [ ("Pair", why) ] -> contains why "struct in the header"
| _ -> false);
check "a union the header does not describe is left alone"
(Cimport.check_unions ~env
~unions:(unions_of (program "(defunion Nowhere [q i32])\n")) dump
= []);
(* And the gap that remains, said out loud so it is a decision rather than
an oversight: an anonymous union member has no name and no Flan
spelling, so the record holding one is still not recorded and the
defstruct beside it is still unchecked rather than checked wrongly. *)
check "a record with an anonymous union member is still skipped"
(Cimport.check_structs ~env
~structs:(structs_of (program "(defstruct Anon [kind i32 junk i32])\n"))
dump
= []);
(* ── The constants (Cimport.check_constants) ───────────────────── *)
(* The half of generate-c's claim that used to be missing. A wrong flag bit
and a wrong enum member are the two errors here that are completely
silent — no link error, no type error — which is exactly the class the
header read exists to catch.
Shading is anonymous in sample.h and its typedef carries the name, which
is how raylib writes every one of its enums; SHADE_DARK has no
initialiser, so 6 is counted rather than read. *)
let const_fixture ?(mood = "[calm 0 cross 1]")
?(shading = "[light 4 mid 5 dark 6 half-dark 9]") ?(fancy = "4")
?(extra = "") () =
Printf.sprintf
"(defenum Mood %s)\n\
(defenum Shading %s)\n\
(defconst opt-loud u32 1)\n\
(defconst opt-fast u32 2)\n\
(defconst opt-fancy-mode u32 %s)\n\
(defconst lucky u32 7)\n\
%s"
mood shading fancy extra
in
let enums_of ds =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defenum (n, ms) -> Some (n, ms) | _ -> None)
ds
and pconsts_of ds =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defconst (n, _, e) -> Some (n, e) | _ -> None)
ds
in
let mapping =
{ Cimport.no_config with
Cimport.enum_prefixes = [ ("Mood", "MOOD_"); ("Shading", "SHADE_") ];
const_prefixes = [ ("opt-", "OPT_") ];
(* SHADE_HALFDARK is one word where its siblings are underscored, so the
prefix rule cannot reach it. The narrow exception, said once. *)
constants = [ ("Shading/half-dark", "SHADE_HALFDARK") ] }
in
let raw_constants ?(config = mapping) src =
Cimport.check_constants ~config ~enums:(enums_of (program src))
~consts:(pconsts_of (program src)) dump
in
let constants ?(config = mapping) src =
List.map
(fun (x : Cimport.const_diff) -> (x.Cimport.cname, x.Cimport.cwhy))
(raw_constants ~config src)
in
check "constants that agree with the header are not reported"
(constants (const_fixture ()) = []);
(* The value is compared, which is the whole point: 340 is KEY_LEFT_SHIFT
and 341 is a key that never fires. *)
check "a wrong enum member value is reported, by its C name"
(match constants (const_fixture ~mood:"[calm 0 cross 2]" ()) with
| [ ("Mood/cross", why) ] ->
contains why "MOOD_CROSS" && contains why "is 2 here"
| _ -> false);
(* An enumerator with no [= n] carries no value in clang's dump at all, so
it has to be counted the way C counts it. raylib's TraceLogLevel is eight
members with one initialiser between them. *)
check "an implicitly-numbered enumerator is counted, not skipped"
(match
constants
(const_fixture ~shading:"[light 4 mid 5 dark 7 half-dark 9]" ())
with
| [ ("Shading/dark", why) ] ->
contains why "SHADE_DARK" && contains why "is 6 in the header"
| _ -> false);
(* A name the rule builds and the header does not have is reported and not
skipped. A mapping that quietly matched nothing would read as coverage
and provide none, which would be worse than no check. *)
check "a member the header has no constant for is reported"
(match constants (const_fixture ~mood:"[calm 0 cross 1 murky 2]" ()) with
| [ ("Mood/murky", why) ] -> contains why "no constant named MOOD_MURKY"
| _ -> false);
(* The defconst half. These are raylib's 16 ConfigFlags bits. *)
check "a wrong defconst value is reported"
(match constants (const_fixture ~fancy:"8" ()) with
| [ ("opt-fancy-mode", why) ] ->
contains why "OPT_FANCY_MODE" && contains why "is 4 in the header"
| _ -> false);
(* And a defconst no rule reaches is not reported: a package's constants are
mostly its own, and raylib's 26 colours have no enumerator behind them. *)
check "a defconst no rule reaches is left alone"
(constants (const_fixture ~extra:"(defconst unmapped u32 99)\n" ()) = []);
(* The [constant] line is what reaches a name the prefix rule gets wrong. *)
check "without the constant line the odd name is reported"
(match
constants ~config:{ mapping with Cimport.constants = [] }
(const_fixture ())
with
| [ ("Shading/half-dark", why) ] ->
contains why "no constant named SHADE_HALF_DARK"
| _ -> false);
(* The Flan-side member prefix, declared as the enum line's fourth column.
raylib's Key spells its members key-r, key-space — a bare member name
collides across enums — and the C name is built by stripping that prefix
first, so key-r is KEY_R and not KEY_KEY_R. *)
let prefixed =
{ mapping with Cimport.enum_flan_prefixes = [ ("Mood", "mood-") ] }
in
check "a declared Flan member prefix is stripped before the C name is built"
(constants ~config:prefixed
(const_fixture ~mood:"[mood-calm 0 mood-cross 1]" ())
= []);
check "a wrong value is still caught through the stripped prefix"
(match
constants ~config:prefixed
(const_fixture ~mood:"[mood-calm 0 mood-cross 2]" ())
with
| [ ("Mood/mood-cross", why) ] ->
contains why "MOOD_CROSS" && contains why "is 2 here"
| _ -> false);
(* A member that does not carry the declared prefix is a finding, not a
member checked under a guessed name: `calm` beside a declared `mood-`
would otherwise build MOOD_CALM, which the header has, and the naming
rule the line declares would erode silently. *)
check "a member without the declared Flan prefix is a mapping finding"
(match raw_constants ~config:prefixed (const_fixture ()) with
| [ a; b ] ->
a.Cimport.cmapping && b.Cimport.cmapping
&& contains a.Cimport.cwhy "carry the prefix mood-"
&& contains b.Cimport.cwhy "carry the prefix mood-"
| _ -> false);
(* Coverage itself must not go quiet. A defenum nobody mapped would be
silently unchecked, which is the same hole one level up. *)
check "a defenum with no enum line is itself a finding"
(match constants (const_fixture ~extra:"(defenum Nobody [a 0])\n" ()) with
| [ ("Nobody", why) ] -> contains why "no `enum` line"
| _ -> false);
(* And the way to say so deliberately, for an enum the header cannot check. *)
check "enum - excuses an enum the header says nothing about"
(constants
~config:
{ mapping with
Cimport.enum_prefixes = ("Nobody", "-") :: mapping.Cimport.enum_prefixes }
(const_fixture ~extra:"(defenum Nobody [a 0])\n" ())
= []);
(* A rule that reaches nothing is a typo, and it would otherwise read as
coverage. *)
check "an enum rule naming no defenum is reported"
(match
constants
~config:
{ mapping with
Cimport.enum_prefixes =
("Ghost", "G_") :: mapping.Cimport.enum_prefixes }
(const_fixture ())
with
| [ ("Ghost", why) ] -> contains why "names no defenum"
| _ -> false);
check "a const rule matching no defconst is reported"
(match
constants
~config:
{ Cimport.no_config with Cimport.const_prefixes = [ ("zzz-", "ZZZ_") ] }
"(defconst lucky u32 7)\n"
with
| [ ("zzz-", why) ] -> contains why "matches no defconst"
| _ -> false);
check "a constant line naming nothing is reported"
(match
constants
~config:
{ Cimport.no_config with
Cimport.constants = [ ("Mood/nope", "MOOD_NOPE") ] }
"(defconst lucky u32 7)\n"
with
| [ ("Mood/nope", why) ] -> contains why "names no defconst"
| _ -> false);
(* The two kinds of finding are told apart, because they have different
dispositions: a value that disagrees with the library stops an ordinary
build, and an enum nobody wrote a line for is about the package's own
config and gates `generate-c` instead. *)
check "a value disagreement is not a mapping finding"
(match raw_constants (const_fixture ~fancy:"8" ()) with
| [ x ] -> not x.Cimport.cmapping
| _ -> false);
check "an unmapped defenum is a mapping finding"
(match raw_constants (const_fixture ~extra:"(defenum Nobody [a 0])\n" ()) with
| [ x ] -> x.Cimport.cmapping
| _ -> false);
(* The name rule, which is not an inverse of kebab and does not need to be:
a constant has no declaration to store its C spelling in. *)
List.iter
(fun (flan, c) ->
check
(Printf.sprintf "screaming %s -> %s" flan c)
(String.equal (Cimport.screaming flan) c))
[ ("left-shift", "LEFT_SHIFT"); ("msaa-4x-hint", "MSAA_4X_HINT");
("a", "A"); ("window-mouse-passthrough", "WINDOW_MOUSE_PASSTHROUGH") ];
(* diff_bound: a hand-written declare-c against the header's own signature.
This is the check with no other source — a wrong declare-c is wrong in the
generated prototype too, so the two halves agree with each other and only
the library knows better. *)
let bound_of src =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.DeclareC (fn, sym) -> Some (fn, sym) | _ -> None)
(program src)
in
let differs name src needle =
check ("declare-c against the header: " ^ name)
(match Cimport.diff_bound ~env ~bound:(bound_of src) dump with
| [ d ] -> contains d.Cimport.dwhy needle
| _ -> false)
in
check "a declare-c that matches the header is not reported"
(Cimport.diff_bound ~env
~bound:(bound_of "(declare-c add [a i32 b i32] i32 \"add_ints\")") dump
= []);
differs "a wrong parameter width"
"(declare-c add [a f64 b i32] i32 \"add_ints\")" "parameter a is f64";
differs "a wrong arity" "(declare-c add [a i32] i32 \"add_ints\")"
"the header says 2";
differs "a wrong return type"
"(declare-c add [a i32 b i32] f32 \"add_ints\")" "returns f32";
(* A symbol the header does not have at all is the version-drift case, and
it is how a package pinned to the wrong release announces itself. *)
differs "a symbol the header does not declare"
"(declare-c gone [] \"no_such_function\")" "does not declare";
(* An enum face against a plain int is the expected difference and not a
finding: that is what a defenum is at the boundary. *)
check "an enum face against the header's int is not a difference"
(Cimport.diff_bound ~env
~bound:(bound_of "(declare-c mv [m Mood] i32 \"mood_value\")") dump
= []);
(* The pointer arm. The importer renders a C pointer into whichever Flan type
it thinks is the nicest face — [const char *] becomes [string], [void *]
becomes [(Ptr u8)] — and a hand-written line that wants the raw address
instead is disagreeing with that choice rather than with the header. These
say which of those disagreements are real. *)
let agreed name src =
check ("declare-c against the header: " ^ name)
(Cimport.diff_bound ~env ~bound:(bound_of src) dump = [])
in
(* The case docs/PORTING.md §A.1 could not write: GetCodepointPrevious reads
backwards from its pointer, so a Flan string — which crosses as a
NUL-terminated copy — is the one thing it must not be handed. *)
agreed "a (Ptr u8) where the header says const char *"
"(declare-c name-length [text (Ptr u8)] i32 \"name_length\")";
(* And the string face goes on being right, because this is an addition. *)
agreed "a string where the header says const char *"
"(declare-c name-length [text string] i32 \"name_length\")";
agreed "a pointer that matches the header exactly"
"(declare-c count-at [values (Ptr i32) n i32] i32 \"count_at\")";
(* void * is opaque about what it points at, so there is no element type in
the header to disagree with — §A.2's LoadImageColors → UpdateTexture. *)
agreed "any pointer where the header says void *"
"(declare-c blit [dst (Ptr Pair) src (Ptr Shade) n i32] \"blit\")";
agreed "any pointer where the header returns void *"
"(declare-c scratch [n i32] (Ptr Pair) \"scratch\")";
(* An enum through a pointer is the same four bytes an enum is beside one. *)
agreed "a (Ptr enum) where the header says a pointer to int"
"(declare-c count-at [values (Ptr Mood) n i32] i32 \"count_at\")";
(* And the refusals, which are the whole value of the arm: a binding that
lies about the header still has to be caught, and the message still has to
name the disagreement. *)
differs "a pointer to the wrong named type"
"(declare-c pair-len-p [p (Ptr Shade)] f32 \"pair_len_p\")"
"parameter p is (Ptr Shade) and the header says (Ptr Pair)";
differs "a pointer to the wrong width"
"(declare-c count-at [values (Ptr f64) n i32] i32 \"count_at\")"
"parameter values is (Ptr f64)";
(* void * gives up the element type and nothing else. It is still a pointer,
and a scalar declared against one is still a finding. *)
differs "a scalar where the header says void *"
"(declare-c blit [dst i64 src (Ptr u8) n i32] \"blit\")"
"parameter dst is i64";
differs "a scalar where the header says a pointer"
"(declare-c count-at [values i32 n i32] i32 \"count_at\")"
"parameter values is i32";
(* The byte tolerance is the pointee's and not the world's: inside a pointer
i8 and u8 are two spellings of one byte, and as a scalar they are not. *)
differs "a byte where the header says a wider integer"
"(declare-c add [a u8 b i32] i32 \"add_ints\")" "parameter a is u8";
(* The name rule. Reversibility is by storage — the C symbol is kept verbatim
in the declaration — so what the rule has to be is injective over one
header, which the collision case above asserts. These pin its shape. *)
List.iter
(fun (c, flan) ->
check
(Printf.sprintf "kebab %s -> %s" c flan)
(String.equal (Cimport.kebab c) flan))
[ ("InitWindow", "init-window");
(* An acronym stays one word rather than becoming separate letters. *)
("SetTargetFPS", "set-target-fps");
("ColorToHSV", "color-to-hsv");
("UnloadUTF8", "unload-utf8");
(* A digit run takes the uppercase after it, so 2D is one word. *)
("BeginMode2D", "begin-mode-2d");
("GetScreenToWorld2D", "get-screen-to-world-2d");
("snake_case_already", "snake-case-already") ];
(* cjson.ml, on the shapes clang's dump actually contains. *)
check "json: an escaped string"
(match Cjson.parse "{\"a\":\"x\\ny\"}" with
| Cjson.Obj [ ("a", Cjson.Str "x\ny") ] -> true
| _ -> false);
check "json: nesting, numbers, booleans and null"
(match Cjson.parse "{\"i\":[1,-2,3.5e2],\"b\":true,\"n\":null}" with
| Cjson.Obj
[ ("i", Cjson.Arr [ _; _; _ ]); ("b", Cjson.Bool true);
("n", Cjson.Null) ] -> true
| _ -> false);
check "json: empty containers"
(match Cjson.parse "{\"a\":{},\"b\":[]}" with
| Cjson.Obj [ ("a", Cjson.Obj []); ("b", Cjson.Arr []) ] -> true
| _ -> false);
check "json: trailing bytes are refused"
(match Cjson.parse "{} x" with
| _ -> false
| exception Cjson.Bad _ -> true);
(* ── Lifted function names, and why they are counted per kind ────────
A handler clause and an fn literal are both lifted into functions of their
own, and both are numbered within the function they came out of. One
shared counter would mean that adding a handler-bind above an existing fn
renamed the fn — a rename for a body that did not change, in exactly the
names a dev redefinition module emits and matches on. These check that
each sequence is stable against the other. *)
let lifted_names src =
List.filter_map
(fun (f : Tast.fn) ->
match f.Tast.fparent with Some _ -> Some f.Tast.name | None -> None)
(Check.program (Parse.program (read src))).Tast.fns
in
let with_handler =
"(defstruct Boom [n i32]) (defonce hit i32) \
(defn u [f (Fn [i32] i32)] i32 (f 1)) \
(defn m [] i32 \
(handler-bind [(Boom [c] (set hit (.n c)))] (u (fn [x] x))) 0)"
in
let without_handler =
"(defstruct Boom [n i32]) (defonce hit i32) \
(defn u [f (Fn [i32] i32)] i32 (f 1)) \
(defn m [] i32 (u (fn [x] x)) 0)"
in
check "an fn keeps its number when a handler-bind is added beside it"
(List.mem "fn/m/0" (lifted_names with_handler)
&& List.mem "fn/m/0" (lifted_names without_handler));
check "and the handler clause has a sequence of its own"
(List.exists
(fun n -> contains n "handler/m/0/Boom") (lifted_names with_handler));
(* ── The prelude's own macro calls, and the bootstrap that allows them ──
A macro module is compiled *from* the prelude, so a prelude function that
calls a prelude macro cannot be in the module that would expand it. The
answer is [Macro.reduce]: for that one build the prelude loses every defn
depending on a macro, directly or transitively. These check the reduction
itself, since the thing it prevents is a cycle and a cycle does not show
up as a wrong answer — it shows up as a build that cannot start. *)
let names_of forms =
List.filter_map
(fun (f : Form.t) ->
match f.Form.v with
| Form.List ({ Form.v = Form.Sym ("defn" | "defmacro"); _ }
:: { Form.v = Form.Sym n; _ } :: _) -> Some n
| _ -> None)
forms
in
let reduced = names_of (Macro.reduce (Prelude.forms ())) in
let full = names_of (Prelude.forms ()) in
check "the reduced prelude drops a defn that calls a macro"
(List.mem "format-f64" full && not (List.mem "format-f64" reduced));
(* The macros survive — they are what the module is being built to export —
and so does everything that does not reach one, which is almost all of it. *)
check "the reduced prelude keeps the macros themselves"
(List.mem "clamp" reduced && List.mem "unless" reduced);
check "the reduced prelude keeps a defn that calls no macro"
(List.mem "join" reduced && List.mem "split" reduced);
(* Transitively: a caller of a dropped function is as unbuildable as the
function, so it goes too. Written against a synthetic prelude rather than
the real one, which has no such chain today. *)
let synth src = Reader.read_all ~file:"<synth>" src in
let chain =
synth
"(defmacro m [& args] `(do))\n\
(defn a [] () (m))\n\
(defn b [] () (a))\n\
(defn c [] () (do))\n"
in
check "the reduction is transitive"
(names_of (Macro.reduce chain) = [ "m"; "c" ]);
(* And the one rule that stays: a prelude macro may not call a macro. It used
to fail as an unknown name inside a clang build; it names itself now. *)
let ring = synth "(defmacro m [& args] `(do))\n(defmacro n [& args] (m args))\n" in
check "a prelude macro calling a macro is refused by name"
(match Macro.reduce ring with
| _ -> false
| exception Loc.Error { Loc.dmsg = m; _ } ->
contains m "the prelude macro n calls a macro");
(* ── Diagnostics: kind, notes, and more than one ───────────────
The house rule is that a test asserts the *reason* a thing is refused. A
kind is that assertion made stable: the message may be reworded and the
row still holds, and a row that matches on a kind is saying something a
substring match on prose could only approximate. The messages themselves
are unchanged, so every existing needle still means what it meant. *)
let diag_of src =
match checked src with
| _ -> None
| exception Loc.Error d -> Some d
in
let kind_is name src k =
check name (match diag_of src with Some d -> d.Loc.kind = k | None -> false)
in
kind_is "unknown name has a kind"
"(defn f [] i32 nope)" "check/unknown-name";
kind_is "unknown field has a kind"
"(defstruct S [a i32])\n(defn f [s S] i32 (.b s))" "check/unknown-field";
kind_is "a name defined twice has a kind"
"(defn f [] i32 1)\n(defn f [] i32 2)" "check/defined-twice";
(* The note is the half a location and a string could never carry: the
*other* place, with its own span and its own explanation. *)
(match diag_of "(defn f [] i32 1)\n(defn f [] i32 2)" with
| Some d ->
check "defined twice points at the second" (d.Loc.dloc.Loc.line = 2);
(match d.Loc.notes with
| [ n ] ->
check "and notes the first" (n.Loc.nloc.Loc.line = 1);
check "and says what it is" (contains n.Loc.nmsg "already defined")
| ns -> check "defined twice has one note" (ns = []))
| None -> check "defined twice is refused" false);
(match diag_of "(defstruct S [a i32])\n(defn f [s S] i32 (.b s))" with
| Some d ->
(match d.Loc.notes with
| [ n ] ->
check "an unknown field notes the declaration"
(n.Loc.nloc.Loc.line = 1);
check "and lists the fields there" (contains n.Loc.nmsg "with a")
| _ -> check "an unknown field has one note" false)
| None -> check "an unknown field is refused" false);
(* The call argument, which is the most-hit refusal in the compiler and was
the one that said least: the caret was right and the sentence never named
which argument of which function, nor pointed at the parameter that
wanted the other type. Both halves are asserted here, plus the rule that
keeps the claim honest — a mismatch *inside* an argument is not this
argument's, and is left as it was. *)
(match diag_of "(defn add [a i32 b i32] i32 (+ a b))\n (defn f [] i32 (add 1 \"two\"))" with
| Some d ->
check "a bad call argument has a kind" (d.Loc.kind = "check/argument-type");
check "and says which argument of which function"
(contains d.Loc.dmsg "this is the 2nd argument of add");
(match d.Loc.notes with
| [ n ] ->
check "and notes the parameter's declaration" (n.Loc.nloc.Loc.line = 1);
check "and names the parameter"
(contains n.Loc.nmsg "add's 2nd parameter b is declared i32")
| _ -> check "a bad call argument has one note" false)
| None -> check "a bad call argument is refused" false);
(match diag_of "(defn add [a i32 b i32] i32 (+ a b))\n (defn f [] i32 (add 1 (add 2 \"x\")))" with
| Some d ->
let times needle hay =
let n = String.length needle in
List.length
(List.filter
(fun i -> String.length hay - i >= n && String.sub hay i n = needle)
(List.init (max 1 (String.length hay)) Fun.id))
in
(* Named once, by the call that owns it. The outer call sees a refusal
raised against a span that is not its argument's and passes it on
untouched, which is what stops "the 2nd argument of add" being said
twice about two different forms. *)
check "the inner call owns its own argument, and says so once"
(times "argument of add" d.Loc.dmsg = 1)
| None -> check "a nested bad argument is refused" false);
(* The condition, which used to state a type fact and stop. The rule has two
halves and the dyn half is not the typed half — a dyn condition is
Clojure's, where 0 is true — so the comparison is offered only where it
is right, and with the condition's own name where it has one. *)
rejects_check "a non-bool condition states the rule"
"(defn f [] i32 (let [x 1] (if x 1 0)))"
~needle:"a condition is a bool or a dyn, and this is i32 — test it, as (!= x 0)";
rejects_check "and offers no template for a form it cannot name"
"(defn f [] i32 (if (+ 1 2) 1 0))"
~needle:"this is i32 — test it against 0 with !=";
rejects_check "and offers no comparison at all for a type that has none"
"(defstruct P [x i32]) (defn f [] i32 (let [p (P {.x 1})] (if p 1 0)))"
~needle:"a condition is a bool or a dyn, and this is P";
(* A literal still names itself: that message knows something the rule does
not, so the re-check's answer is kept wherever it is more specific. *)
rejects_check "a literal condition keeps its own message"
"(defn f [] i32 (if 1 1 2))"
~needle:"expected bool, found the integer literal 1";
(* Four words before this: the name and the fact. The declaration is where
the reader's next move is, so it comes along. *)
(match diag_of "(defconst k 1)\n(defn f [] () (set k 2))" with
| Some d ->
check "assigning a constant has a kind" (d.Loc.kind = "check/set-constant");
(match d.Loc.notes with
| [ n ] ->
check "and notes the defconst" (n.Loc.nloc.Loc.line = 1);
check "and says what it is"
(contains n.Loc.nmsg "k is declared a constant here")
| _ -> check "assigning a constant has one note" false)
| None -> check "assigning a constant is refused" false);
(* A type annotation in a let is the first thing anyone arriving from a
typed language writes, and let has no slot for one. The old refusal
landed on the form left over — "binding 5 has no value" — which reads as
if they had miscounted. Only checked on the path that was refusing
anyway, so a binding vector that parses is never examined for it. *)
(match (try ignore (program "(defn f [] i32 (let [x i32 5] x))"); None
with Loc.Error d -> Some d) with
| Some d ->
check "a let annotation has a kind" (d.Loc.kind = "parse/let-type-annotation");
check "and blames the annotation, not the leftover"
(contains d.Loc.dmsg "a let binding takes no type annotation, so i32 \
here is read as the value and 5 is left with no \
name")
| None -> check "a let annotation is refused" false);
(match (try ignore (program "(defn f [] i32 (let [x 1 y] x))"); None
with Loc.Error d -> Some d) with
| Some d ->
check "and an ordinary odd binding vector is unchanged"
(contains d.Loc.dmsg "binding y has no value")
| None -> check "an odd binding vector is refused" false);
(* The operand, not the whole form — the same "whole form vs operand" the
condition work already fixed once. Text gets the extra clause, because
(+ "a" "b") is a reach for concatenation. *)
(match diag_of "(defn f [] () (println (+ \"a\" \"b\")))" with
| Some d ->
check "a non-numeric operand is blamed at the operand"
(d.Loc.dloc.Loc.col = 27);
check "and text is told where concatenation lives"
(contains d.Loc.dmsg
"+ takes numbers, and this is string — there is no + on text. The \
prelude concatenates with concat and join")
| None -> check "a non-numeric operand is refused" false);
(* The return slot, not whatever inside it the type parser gave up on. For
(defn f [x i32] (+ x 1)) that was the 1, three forms deep, where the
mistake is that the whole form is in the slot. What the type parser said
keeps its own span as a note. *)
(match (try ignore (program "(defn f [x i32] (+ x 1))"); None
with Loc.Error d -> Some d) with
| Some d ->
check "a body in the return slot blames the slot" (d.Loc.dloc.Loc.col = 17);
check "and says what is there"
(contains d.Loc.dmsg
"the return type goes here, and this is (+ x 1) — every defn states \
one, and a function that returns nothing writes ()");
check "and keeps the type parser's reason as a note"
(match d.Loc.notes with
| [ n ] -> contains n.Loc.nmsg "expected a type, found 1"
| _ -> false)
| None -> check "a body in the return slot is refused" false);
(* A one-field case binds the payload itself, so the destructuring reach
that follows gets a type fact where it needs to be told the value is
already in hand. Only where the pattern is what bound it: an ordinary
local keeps the sentence it had. *)
rejects_check "a case payload says the field is already in hand"
"(defdata Shape [(Circle [r f64]) (Square [s f64])]) \
(defn f [s Shape] f64 (match s (Circle c) (.r c) (Square q) 0.0))"
~needle:"c is f64 — the pattern bound it to Shape.Circle's field r, so \
the value is already in hand and there is no field left to read";
rejects_check "and an ordinary local keeps the type fact"
"(defn f [] i32 (let [x 1] (.r x)))"
~needle:"i32 is not a struct, so it has no fields";
(* "Allow shadowing but warn": a defn whose name is a builtin's is legal,
it wins at the call sites of the file that wrote it, and the compiler
says so once at the definition.
This used to be the other way round — the dispatch reached every builtin
arm before it looked in the function table, so the defn was silently
unreachable and the arity refusal carried a note saying so. That note
described a resolution order this compiler no longer has, and the source
below, which used to be refused, is the one that proves it: (get p) is
one argument, and the builtin get takes two. *)
let shadow_src =
"(defstruct P [x i32])\n(defn get [p P] i32 (.x p))\n\
(defn f [] i32 (let [p (P {.x 1})] (get p)))"
in
(match Check.shadowed_builtins (program shadow_src) with
| [ d ] ->
check "a defn named after a builtin is warned about, at the definition"
(d.Loc.kind = "check/shadows-builtin"
&& d.Loc.dloc.Loc.line = 2 && d.Loc.dloc.Loc.col = 7);
check "and the warning says what the name now means"
(d.Loc.dmsg
= "get shadows the builtin get — every call in this program now \
reaches your definition — the builtin stays reachable as \
builtin/get");
check "and it carries no notes, being one sentence about one decision"
(d.Loc.notes = [])
| _ -> check "a shadowing defn is warned about exactly once" false);
check "and the call reaches the defn, at the defn's arity"
(match checked shadow_src with
| _ -> true
| exception Loc.Error _ -> false);
check "a program that shadows nothing is warned at not at all"
(Check.shadowed_builtins (program "(defn f [] i32 1)") = []);
(* An operator is a builtin like any other and shadows like any other.
Pinned in both halves because it is the case most likely to be thought
of as special and quietly excepted later: the warning is the same
sentence, and the call is a [Call] to the definition rather than the
[Add] prim it would otherwise have lowered to. *)
let plus_src = "(defn + [a i32 b i32] i32 99)\n(defn f [] i32 (+ 1 2))" in
(match Check.shadowed_builtins (program plus_src) with
| [ d ] ->
check "an operator shadowed by a defn warns like any other builtin"
(d.Loc.kind = "check/shadows-builtin"
&& d.Loc.dmsg
= "+ shadows the builtin + — every call in this program now \
reaches your definition — the builtin stays reachable as \
builtin/+")
| _ -> check "a shadowed operator warns exactly once" false);
(match checked plus_src with
| p ->
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "f") p.Tast.fns with
| Some { Tast.body = [ { Tast.e = Tast.Call ("+", _); _ } ]; _ } -> ()
| _ -> check "a shadowed operator's call reaches the defn" false)
| exception _ -> check "a shadowed operator's call reaches the defn" false);
(* And the file the definition was written in is what the shadow follows.
Same declaration list, a call whose location is another file: the
builtin, whose arity this call does not satisfy. This is the package
global-initialiser case at its smallest — an initialiser is checked with
no enclosing function, so the enclosing name cannot be what decides.
The shadowing defn takes *two* parameters and the builtin takes one, so
the two readings cannot produce the same sentence: reaching the builtin
is a refusal measured at one, and reaching the defn is no refusal at
all. With both at one argument this check passed under either
resolution, which is a check that cannot fail — found in review. *)
(match
Check.program
(program "(defn len [a string b string] i32 999)"
@ Parse.program (read ~file:"<elsewhere>" "(defn g [] i32 (len \"a\" \"b\"))"))
with
| _ -> check "a call in another file does not reach the shadow" false
| exception Loc.Error d ->
check "a call in another file reaches the builtin, at the builtin's arity"
(contains d.Loc.dmsg "len takes 1 argument, given 2"));
(* ── builtin/, the reserved qualifier ──────────────────────────────
The escape from the dead end above: [builtin/len] is the builtin [len]
whatever the file has decided [len] means. Pinned from both ends —
with a shadow in the way and with nothing in the way at all — because a
spelling that only worked while some other declaration existed would be
one nobody could write down in advance. *)
accepts "builtin/ is legal with nothing shadowed"
"(defn f [] i32 (builtin/len \"abcd\"))";
(* The pair that says the two spellings part company. One declaration list,
two calls: the shadow takes two arguments and the builtin takes one, so
each call is refusable only under one of the two readings. The bare name
at two arguments checks, and the qualified one at two arguments is
measured against the builtin's arity. *)
accepts "the bare name reaches the shadowing defn"
"(defn len [a string b string] i32 999)\n\
(defn f [] i32 (len \"a\" \"b\"))";
rejects_check "and builtin/ beside it reaches the builtin"
"(defn len [a string b string] i32 999)\n\
(defn f [] i32 (builtin/len \"a\" \"b\"))"
~needle:"len takes 1 argument, given 2";
(* The program the earlier lane could not write: a shadowing defn that
*wraps* what it shadows. Without the qualifier the inner call reached
the definition being written and the program stack-overflowed at run
time; what is pinned here is that the body holds no call to [len] at
all, which is the difference between a wrapper and a loop. *)
(match checked "(defn len [s string] i32 (builtin/+ 1 (builtin/len s)))" with
| p ->
let recurs = ref false in
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "len") p.Tast.fns with
| Some f ->
List.iter
(Tast.walk (fun (e : Tast.expr) ->
match e.Tast.e with
| Tast.Call ("len", _) -> recurs := true
| _ -> ()))
f.Tast.body;
check "a shadowing defn wraps the builtin instead of recurring"
(not !recurs)
| None -> check "the wrapping defn is checked" false)
| exception Loc.Error d ->
check "a shadowing defn wraps the builtin instead of recurring" false;
print_endline d.Loc.dmsg);
(* An operator through the qualifier, which is the case a reader would
expect to need an exception and does not: the reader takes [builtin/+]
as one symbol, and the call lowers to the [Add] prim even with [+]
shadowed two lines above. *)
(match checked "(defn + [a i32 b i32] i32 99)\n\
(defn f [] i32 (builtin/+ 1 2))" with
| p ->
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "f") p.Tast.fns with
| Some { Tast.body = [ { Tast.e = Tast.Prim (Tast.Add, _); _ } ]; _ } -> ()
| _ -> check "builtin/+ is the operator and not the shadowing defn" false)
| exception _ ->
check "builtin/+ is the operator and not the shadowing defn" false);
(* The value arms reach the same way, and [builtin/context/allocator] falls
out of one strip rather than needing a rule of its own. *)
accepts "a builtin written as a name is qualified too"
"(defn f [] dyn builtin/nil)";
accepts "and so is a builtin whose own name carries a slash"
"(defn f [] Allocator builtin/context/allocator)";
(* A builtin in a value position has nothing to hand back — it is an arm in
the compiler and has no address — and the refusal has to say so rather
than falling through to the function table, which holds the very
definition the qualifier was written to get away from. *)
rejects_check "a call-only builtin is refused as a value, not resolved"
"(defn len [s string] i32 1)\n(defn f [] () (println builtin/len))"
~needle:"builtin/len is the builtin len, which is a call and not a value";
(* The qualifier reaching nothing. Named as not a builtin, and the
did-you-mean is over the builtins alone. *)
(match diag_of "(defn f [] i32 (builtin/nosuch))" with
| Some d ->
check "builtin/ with no builtin behind it has its own kind"
(d.Loc.kind = "check/unknown-builtin");
check "and says what is wrong with it"
(d.Loc.dmsg
= "nosuch is not a builtin, so builtin/nosuch reaches nothing. The \
builtin/ qualifier reaches the compiler's own names and nothing \
else; an ordinary function is called by the name it was defined \
under")
| None -> check "builtin/nosuch is refused" false);
(match diag_of "(defn f [] i32 (builtin/lne \"ab\"))" with
| Some d ->
check "and a near miss is offered in the qualified spelling"
(d.Loc.dmsg
= "lne is not a builtin, so builtin/lne reaches nothing — did you \
mean builtin/len?")
| None -> check "builtin/lne is refused" false);
(* And the did-you-mean everywhere else is untouched: a bare typo is still
answered with a bare name, not with a qualifier nobody reached for. *)
rejects_check "an unqualified typo is not answered with builtin/"
"(defn f [] i32 (lne \"ab\"))" ~needle:"did you mean len?";
(* The reservation from the other side. [(defn builtin/len ...)] reads —
'/' is an ordinary symbol character — and would land in the function
table under a name nothing can ever call, because the prefix is stripped
before any table is consulted. *)
(match diag_of "(defn builtin/len [s string] i32 1)" with
| Some d ->
check "a declaration cannot take the reserved qualifier"
(contains d.Loc.dmsg
"builtin/len cannot be declared: builtin/ is a reserved qualifier")
| None -> check "a declaration under builtin/ is refused" false);
(* and's last operand is the then arm and the sentinel carrying the previous
operand's location is the else arm, so with no expectation in hand the
mismatch was reported one operand early. FIX.org's accepted fix: blame
the arm that is not a compiler temp. *)
(match diag_of "(defn f [] () (println (and true true (vec-new i32))))" with
| Some d ->
check "and blames its last operand, not the one before it"
(d.Loc.kind = "check/shortcircuit-operand" && d.Loc.dloc.Loc.col = 39);
check "and states what the two answers are"
(contains d.Loc.dmsg
"an and answers false when it stops early and its last operand \
otherwise, so the two have to be one type — this operand is (Vec \
i32), and false is a bool")
| None -> check "a mistyped and operand is refused" false);
(* The reader's own two-place error. The bracket that is open is the error
and the end of input is the note, because the fix goes at the first and
the surprise is at the second. *)
(match read "(f\n bad" with
| _ -> check "unclosed is refused" false
| exception Loc.Error d ->
check "unclosed has a kind" (d.Loc.kind = "reader/unclosed");
check "unclosed notes where the input ran out"
(match d.Loc.notes with [ n ] -> n.Loc.nloc.Loc.line = 2 | _ -> false));
(match read "(f x]" with
| _ -> check "a mismatched closer is refused" false
| exception Loc.Error d ->
check "a mismatched closer has a kind"
(d.Loc.kind = "reader/mismatched-closer");
check "and notes the opener"
(match d.Loc.notes with [ n ] -> n.Loc.nloc.Loc.col = 1 | _ -> false));
(* The unterminated string had neither half of that shape: one column on the
opening quote and no note at all, where its two neighbours in this file
both have one. *)
(match read "(println \"oops\n" with
| _ -> check "an unterminated string is refused" false
| exception Loc.Error d ->
check "unterminated string has a kind"
(d.Loc.kind = "reader/unterminated-string");
check "and says what is missing"
(contains d.Loc.dmsg "unterminated string — no closing quote");
check "and notes where the input ran out"
(match d.Loc.notes with
| [ n ] -> contains n.Loc.nmsg "the input ends here, still inside it"
| _ -> false));
(* More than one per run, which is the point of the whole batch. Three bad
bodies, three diagnostics, and the count is exact: a checker that reported
the first and a checker that reported thirty pieces of wreckage would both
fail this row. *)
(match
Check.program_all
(Parse.program_all
(read "(defn a [] i32 nope1)\n\
(defn b [] i32 nope2)\n\
(defn c [] i32 nope3)\n"))
with
| _ -> check "three bad bodies are refused" false
| exception Loc.Errors ds ->
check "three bad bodies give three errors" (List.length ds = 3);
check "and they are in source order"
(List.map (fun (d : Loc.diag) -> d.Loc.dloc.Loc.line) ds = [ 1; 2; 3 ]));
(* The parser resynchronises on a top-level form, so two bad declarations are
two errors rather than one. *)
(match Parse.program_all (read "(defn a)\n(defn b)\n") with
| _ -> check "two bad declarations are refused" false
| exception Loc.Errors ds ->
check "two bad declarations give two errors" (List.length ds = 2));
(* [Check.program] is a different function from [Check.program_all], and
that is the guarantee: the session calls this one, it raises one
diagnostic, and nobody can turn it into a list by passing a label. *)
(match Check.program (Parse.program (read "(defn a [] i32 nope1)\n\
(defn b [] i32 nope2)\n")) with
| _ -> check "Check.program still refuses" false
| exception Loc.Errors _ ->
check "Check.program never answers with a list" false
| exception Loc.Error _ -> ());
(* The first line of a report is exactly the GNU format compilation-mode
parses, and the squiggle is on an indented line under it, which that mode
ignores. Both halves are load-bearing and neither is visible from the
message alone. *)
(match diag_of "(defn f [] i32 nope)" with
| Some d ->
let lines = String.split_on_char '\n' (Loc.report d) in
(match lines with
| head :: rest ->
check "the first line is file:line:col: message"
(head = Loc.to_string d.Loc.dloc ^ ": " ^ d.Loc.dmsg);
check "and the rest is indented"
(List.for_all (fun l -> l = "" || l.[0] = ' ') rest)
| [] -> check "a report has a first line" false)
| None -> check "a report needs a diagnostic" false);
(* ── Generics: the syntax, the predicates, and the two defaults ── *)
(* The one syntax question the feature had, and how it stopped being one.
[{K V}] used to be a legal *return type* spelling for (Map K V), so a
defn with a map return type and a constraint map put two braces in a row
meaning different things. The brace spelling is now withdrawn from type
position entirely, so the slot after the return type can be nothing but
the constraint map, and braces in a type say where the spelling went. *)
accepts "a map return type, written the one way there is"
"(defn f [] (Map string i32) (map-new string i32))";
accepts "a map return type followed by a constraint map"
"(defn f [x $t] (Map string i32) {:where (equal? $t)} \
(do x (map-new string i32)))";
rejects_check "braces in type position say where the spelling went"
~needle:"written (Map K V)"
"(defn f [] {string i32} (map-new string i32))";
(* The predicates, and each one gating the operator it is for. *)
accepts "ordered? admits <"
"(defn less [a $t b $t] bool {:where (ordered? $t)} (< a b))";
accepts "equal? admits ="
"(defn same [a $t b $t] bool {:where (equal? $t)} (= a b))";
accepts "numeric? admits +"
"(defn add [a $t b $t] $t {:where (numeric? $t)} (+ a b))";
rejects_check "equal? does not admit <"
~needle:"nothing here says t is ordered?"
"(defn less [a $t b $t] bool {:where (equal? $t)} (< a b))";
(* The entailments, which are the reason a signature is one predicate long
rather than two. Every type the language orders is a number or an enum,
so it is equatable. *)
accepts "ordered? entails equal?"
"(defn same [a $t b $t] bool {:where (ordered? $t)} (= a b))";
accepts "numeric? entails ordered?"
"(defn less [a $t b $t] bool {:where (numeric? $t)} (< a b))";
(* ── integer? — the bound numeric? was one type too wide for ─────────
It admits every integer kind, signed and unsigned, at every width, and
refuses floats and everything else. It exists so a function that can be
generalized does not need a variant per numeric type: an integer body
under numeric? was instantiated at f32 and f64 too, which is why abs
stayed per-width for a milestone. It entails numeric? — every integer
is a number — so the arithmetic, the written 0 and the untyped integer
literal all come with it; the reverse entailment would let floats into
bit-and and does not exist. *)
accepts "integer? admits +, via the entailment"
"(defn bump [x $t] $t {:where (integer? $t)} (+ x 1))";
accepts "integer? admits <, via the entailment"
"(defn small? [x $t] bool {:where (integer? $t)} (< x 10))";
accepts "integer? admits bit-and"
"(defn low? [x $t] bool {:where (integer? $t)} (= (bit-and x 1) 1))";
accepts "integer? admits the shifts"
"(defn dbl [x $t] $t {:where (integer? $t)} (<< x 1))";
rejects_check "numeric? does not admit bit-and"
~needle:"nothing here says t is integer?"
"(defn low? [x $t] bool {:where (numeric? $t)} (= (bit-and x 1) 1))";
rejects_check "nor the shifts"
~needle:"nothing here says t is integer?"
"(defn dbl [x $t] $t {:where (numeric? $t)} (<< x 1))";
(* An integer?-bounded caller satisfies a numeric?-bounded callee: the
entailment carries across generic calls exactly as ordered?-over-equal?
does. *)
accepts "integer? carries a numeric? callee"
"(defn z? [x $t] bool {:where (numeric? $t)} (= x 0))\n\
(defn odd-z? [x $t] bool {:where (integer? $t)} (z? (bit-and x 1)))";
(* The integer literal is admitted at a bounded variable by the same arm
under both bounds — the bound promises the literal a meaning at every
type the variable can become, and integer?'s types are a subset of
numeric?'s. *)
accepts "an integer literal stands where an integer?-bounded $t is wanted"
"(defn bump [x $t] $t {:where (integer? $t)} (+ x 300))";
(* A float at integer?, refused at the call that asked, naming the bound. *)
rejects_check "a float does not instantiate an integer?-bounded variable"
~needle:"f64 does not answer integer?"
"(defn bump [x $t] $t {:where (integer? $t)} (+ x 1))\n\
(defn main [] () (println (bump 1.5)))";
(* And dyn is refused by the bound too — the clause's own refusal, the more
specific of the two answers, exactly as at numeric?. *)
rejects_check "dyn does not instantiate an integer?-bounded variable"
~needle:"dyn does not answer integer?"
"(defn bump [x $t] $t {:where (integer? $t)} (+ x 1))\n\
(defonce d dyn 5)\n\
(defn main [] () (println (bump d)))";
(* A float literal inside an integer?-bounded body is refused at the
definition, in the bound's own words: there is no instantiation at which
it means anything. *)
rejects_check "a float literal has no meaning under integer?"
~needle:"admits no float type"
"(defn h [x $t] $t {:where (integer? $t)} (+ x 1.5))";
accepts "a variable read twice under one predicate"
"(defn twice [a $t] bool {:where (ordered? $t)} (< a a))";
rejects_check "a predicate nobody has heard of"
~needle:"is not a type predicate"
"(defn f [a $t] $t {:where (sortable? $t)} a)";
rejects_check "a predicate about a variable the signature never bound"
~needle:"is not a type variable of f"
"(defn f [a i32] i32 {:where (ordered? $t)} a)";
(* A generic monomorphises by rechecking its body at the concrete type
(instantiate re-walks the AST, it does not substitute into an
already-built Tast), so [equal? $t] instantiated at string reaches the
same [check.ml] arm the direct = on two string literals above does.
[ordered? $t] never gets that far at string: [instantiate] checks the
{:where} clause itself against the concrete type before the body is
rechecked at all, so the refusal is the predicate one, not [<]'s
no-built-in-comparison message — that one is for a string written
directly in an ordering, where there is no predicate in between to catch
it first. *)
accepts "equal? $t instantiated at string"
"(defn same [a $t b $t] bool {:where (equal? $t)} (= a b)) \
(defn f [] bool (same \"a\" \"b\"))";
rejects_check "ordered? $t instantiated at string"
~needle:"does not answer ordered?"
"(defn less [a $t b $t] bool {:where (ordered? $t)} (< a b)) \
(defn f [] bool (less \"a\" \"b\"))";
(* Everything copies since the second repeal, so a double use of a binding
needs no clause at all — and [copyable?] itself is gone, refused the way
any unknown predicate is, which is this pin's job to remember. *)
accepts "a type variable is usable twice with no clause"
"(defn twice [a $t b (Fn [$t $t] $t)] $t (b a a))";
rejects_check "copyable? is no longer a predicate"
~needle:"is not a type predicate"
"(defn twice [a $t b (Fn [$t $t] $t)] $t {:where (copyable? $t)} (b a a))";
(* The allow-list, and it has two members. println over a type variable is
deferred to the instantiation, because its legality is only decidable
after substituting — which is the one thing the abstract pass otherwise
refuses to do. *)
accepts "println over a type variable is deferred"
"(defn show [x $t] () {:where (equal? $t)} (println x))";
accepts "and so is print"
"(defn show [x $t] () {:where (equal? $t)} (print x))";
(* One generic argument defers the whole call, neighbours included. *)
accepts "a variadic println with a type variable among the arguments"
"(defn show [x $t] () {:where (equal? $t)} (println \"x:\" x 1))";
(* Variadic print/println: any number of arguments, zero included. The
acceptance program pins what comes out; these pin only what the checker
admits, and — the part a program cannot show — where a refusal lands. *)
accepts "println with no arguments" "(defn f [] () (println))";
accepts "print with no arguments" "(defn f [] () (print))";
accepts "println with three arguments of three types"
"(defn f [] () (println \"x:\" 5 true))";
(* An unprintable argument is refused at its own span, not the form's:
each argument is checked carrying its own loc, and render.ml fails on
the expression's loc. Column 43 is the [m], not the [(println]. *)
(let name = "an unprintable argument is refused at the argument" in
match checked "(defn f [m (Map i32 i32)] () (println \"x\" m))" with
| _ ->
incr failures;
Printf.printf "FAIL %s: expected a type error\n" name
| exception Loc.Error { Loc.dloc; dmsg; _ } ->
let got = Loc.to_string dloc in
if got <> "<test>:1:43" || not (contains dmsg "no printer for") then begin
incr failures;
Printf.printf "FAIL %s\n wanted: %s (no printer for)\n got: %s (%s)\n"
name "<test>:1:43" got dmsg
end);
(* A predicate a body relies on has to be carried by every signature between
it and the call site, or the refusal moves into code the caller did not
write. *)
rejects_check "a predicate is not carried through a generic call"
~needle:"has to be carried by every signature"
"(defn outer [s [$t]] () {:where (equal? $t)} (sort s))";
accepts "and is accepted when it is"
"(defn outer [s [$t]] () {:where (ordered? $t)} (sort s))";
(* ── A generic call inside an abandoned widening trial ──────────────
The two features land days apart and meet here. A binary operator whose
operands disagree re-checks the right one at the left one's type inside a
[trial], and on a refusal reconsiders with the join — so a generic call
written on the right is checked twice, once in a pass that is thrown
away. What the discarded pass leaves behind in [env] is the question, and
[env] is the program's table, not the form's: an instantiation made
during it does not go back out, because [instantiate] rewinds only a copy
whose *body* refused.
It does not have to. The answer is that the two passes cannot disagree
about which copy to make, and that is a consequence of the rule above
rather than luck: 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 no longer widens — so the
trial and the live pass ask [instantiate] for the same types, and the
second ask is a cache hit on the first. One copy is emitted, at the type
the arguments chose, and the widening happens around the call.
(twoq 2 3) is i32 both times; the i8 on the left is what moves. *)
(let p =
checked
"(defn twoq [a $t b $t] $t {:where (numeric? $t)} (+ a b))\n\
(defn main [] () (let [small (i8 1)] \
(println (+ small (twoq 2 3)))))"
in
let copies =
List.filter
(fun (f : Tast.fn) ->
String.length f.Tast.name >= 5 && String.sub f.Tast.name 0 5 = "twoq-")
p.Tast.fns
in
match copies with
| [ { Tast.name = "twoq-i32"; _ } ] -> ()
| l ->
check
(Printf.sprintf
"a generic inside an abandoned trial is instantiated once: %s"
(String.concat " " (List.map (fun (f : Tast.fn) -> f.Tast.name) l)))
false);
(* ── 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 the copy was generated. What the copy
then ran into was the dyn answers that are not all there — (Option dyn)
has no descriptor the collector can find — and the refusal arrived from
inside the generic's own source. (or-else (Some d) e) over two dyns used
to be reported against <prelude>:385, a line the caller did not write.
The refusal is at the call site now, and it names the other model rather
than only saying no. *)
rejects_check "a type variable is not instantiated at dyn"
~needle:"is not instantiated at dyn"
"(defn idf [x $t] $t x)\n\
(defonce d dyn 5)\n\
(defn main [] () (println (idf d)))";
rejects_check "and the refusal names the dyn side rather than only saying no"
~needle:"defmethod"
"(defn idf [x $t] $t x)\n\
(defonce d dyn 5)\n\
(defn main [] () (println (idf d)))";
(* Nor at a type that merely *reaches* a dyn, which is the shape that used
to walk furthest before failing: (Option dyn) is the case the collector
has no descriptor for, and the refusal for it arrived from <prelude>:385.
It arrives here now, against the call that asked for the copy. *)
rejects_check "nor at a type that merely reaches a dyn"
~needle:"$t = (Option dyn)"
"(defn maybe [] (Option dyn) None)\n\
(defn idf [x $t] $t x)\n\
(defn main [] () (println (some? (idf (maybe)))))";
(* A variable that carries a clause keeps the clause's refusal, which names
the predicate the signature actually wrote down — the more specific of
the two answers, and the one the generic cast's pin above depends on. *)
rejects_check "a bounded variable is still refused by its bound"
~needle:"numeric?"
"(defn twice [x $t] $t {:where (numeric? $t)} (+ x x))\n\
(defonce d dyn 5)\n\
(defn main [] () (println (twice d)))";
(* ── Mixed widths at one type variable join at the wider type ───────
The rule used to refuse the pair both ways, with the join recorded as
the coherent alternative that could be added without invalidating
anything — the walk-backable direction. The author walked it back on
2026-09-20: a scalar pair at one $t resolves to whichever of the two
the other widens into, value-preserving widening only, and both
argument orders produce the identical copy. A pair with no join — u64
against i64 — keeps a refusal, because there is no type that holds
every value of both. FIX.org, "Generics and implicit widening", and the
2026-09-20 entry that supersedes it. *)
accepts "a scalar pair at one $t joins at the wider type"
"(defn eq2? [a $t b $t] bool {:where (equal? $t)} (= a b))\n\
(defn main [] () (println (eq2? (i64 3) (i8 3))))";
accepts "and the other argument order joins identically"
"(defn eq2? [a $t b $t] bool {:where (equal? $t)} (= a b))\n\
(defn main [] () (println (eq2? (i8 3) (i64 3))))";
(* Order-independence, pinned on the copies and not only on acceptance:
both orders in one program make exactly one instantiation, at i64, and
none at i8. *)
(let syms order_a order_b =
match
checked
("(defn eq2? [a $t b $t] bool {:where (equal? $t)} (= a b))\n\
(defn main [] () (do (println (eq2? " ^ order_a ^ "))\
(println (eq2? " ^ order_b ^ "))))")
with
| p ->
List.filter_map
(fun (f : Tast.fn) ->
if String.length f.Tast.name >= 4
&& String.sub f.Tast.name 0 4 = "eq2?" then Some f.Tast.name
else None)
p.Tast.fns
| exception _ -> [ "did not check" ]
in
check "both orders share one copy, at the wider type"
(syms "(i8 3) (i64 4)" "(i64 5) (i8 6)" = [ "eq2?-i64" ]);
check "and the reversed program instantiates the same one copy"
(syms "(i64 5) (i8 6)" "(i8 3) (i64 4)" = [ "eq2?-i64" ]));
(* The pair that meets at no type is the refusal that stays: neither u64
nor i64 holds every value of the other, and inventing a third type
would be picking one neither argument was written at. *)
rejects_check "u64 and i64 meet at no type"
~needle:"the two meet at no type"
"(defn eq2? [a $t b $t] bool {:where (equal? $t)} (= a b))\n\
(defonce u u64 3)\n(defonce i i64 3)\n\
(defn main [] () (println (eq2? u i)))";
(* And a later, wider argument settles a pair that had no join of its own:
u32 and i32 meet nowhere, but all three meet at the i64 that arrives
third — in either order, which is what the deferred re-ask is for. *)
accepts "a later argument settles a joinless pair"
"(defn tri [a $t b $t c $t] $t {:where (numeric? $t)} (+ a (+ b c)))\n\
(defonce x3 u32 1)\n(defonce y3 i32 2)\n(defonce z3 i64 3)\n\
(defn main [] () (println (tri x3 y3 z3)))";
accepts "and the same trio in the other order"
"(defn tri [a $t b $t c $t] $t {:where (numeric? $t)} (+ a (+ b c)))\n\
(defonce x3 u32 1)\n(defonce y3 i32 2)\n(defonce z3 i64 3)\n\
(defn main [] () (println (tri z3 y3 x3)))";
(* A variable the signature also reaches through a container is bound
exactly — a slice's elements cannot be rewritten to a wider width — so
the join never moves one, in either direction of the mismatch. *)
rejects_check "a container-bound variable does not join wider"
~needle:"binds its element exactly"
"(defn main [] () (let [ns [5 3 9 1]] \
(match (index-of (slice ns 0 4) (i64 9)) \
(Some i) (println i) _ (println -1))))";
(* The one direction a container-fixed binding does admit, and it is new
with the join: a *narrower* scalar widens into the type the container
fixed, through the same cast a monomorphic i32 parameter applies. This
used to refuse with the same both-ways sentence as everything else. *)
accepts "a narrower scalar widens into a container-fixed binding"
"(defn main [] () (let [ns [5 3 9 1]] \
(match (index-of (slice ns 0 4) (i16 9)) \
(Some i) (println i) _ (println -1))))";
(* The written conversion is what the message asks for, and it is accepted:
the refusal is about the *implicit* step, not about reaching i64. *)
accepts "the written conversion is accepted"
"(defn eq2? [a $t b $t] bool {:where (equal? $t)} (= a b))\n\
(defn main [] () (println (eq2? (i64 3) (i64 (i8 3)))))";
(* An untyped constant has no type of its own to keep, so it still takes the
variable's. Nothing is converted here — three i64s were written. *)
accepts "an untyped literal still takes a bound type variable's type"
"(defn clamp3 [x $t lo $t hi $t] $t {:where (ordered? $t)} \
(min (max x lo) hi))\n\
(defn main [] () (println (clamp3 (i64 12) 0 10)))";
(* And the shapes widening cannot reach are untouched, which is the reason
the rule costs so little: [Types.widens_to] admits only numeric scalars,
so a variable bound inside a slice or a function type leaves a parameter
no widening applied to in the first place. *)
accepts "a variable bound inside a constructor is unaffected"
"(defn sort2 [s [$t] before? (Fn [$t $t] bool)] () \
(sort-by s before?))\n\
(defn main [] () (let [ns [5 3 9 1]] \
(sort2 (slice ns 0 4) (fn [a b] (< a b))) (println (at ns 0))))";
(* A form with no type of its own is still checked against the parameter:
the trial that asks for its natural type refuses, and the want it always
had is what it falls back to. *)
accepts "a form that needs a want still gets one at a bound type variable"
"(defn pick [a $t b $t] $t (do b a))\n\
(defn main [] () (println (pick (i64 3) (zeroed))))";
(* ── A numeric literal where a type variable is wanted ──────────────
The author's motivating family — one pos? over every numeric type from
one definition — needs a written 0 to stand where $t stands. The bound
is what makes it sound: every type [numeric?] admits is an integer or a
float, and an untyped integer constant is usable at all of them, so
there is no instantiation of a [numeric?] variable at which the literal
has no meaning. That is the whole rule, and the four pins below are its
two halves and its one asymmetry. *)
accepts "an integer literal stands where a numeric? type variable is wanted"
"(defn above-zero? [x $t] bool {:where (numeric? $t)} (> x 0))";
accepts "and in arithmetic, answering the variable"
"(defn next [x $t] $t {:where (numeric? $t)} (+ x 1))";
(* And the prelude's own three, which are that body under its real name at
every numeric type from one definition. *)
accepts "the prelude's sign family answers at six numeric types"
"(defn main [] () (println (pos? 3) ) (println (neg? (i8 -1))) \
(println (zero? (u8 0))) (println (zero? 0.0)) \
(println (pos? (u64 1))) (println (neg? (f32 -0.5))))";
(* [numeric?] is what admits it and nothing weaker does. [ordered?] admits
an enum, which holds no number, so a literal under it has an
instantiation at which it means nothing — and the refusal below is what
stops that reaching the call site. *)
rejects_check "an unconstrained type variable admits no literal"
~needle:"may be instantiated at a type that holds no number"
"(defn f [x $t] bool (> x 0))";
rejects_check "and ordered? is not the bound that admits one"
~needle:"Declare the bound"
"(defn f [x $t] bool {:where (ordered? $t)} (> x 0))";
(* The asymmetry, and it is the concrete arms' asymmetry rather than a new
one: an untyped integer constant is usable where a float is wanted, and
a float literal is never usable where an integer is wanted. [numeric?]
covers both halves of the numbers, so a body written with a float
literal has no meaning at the integer half of its own bound. Refused at
the definition, which is where the abstract pass promises refusals
arrive — not at whichever call site first asks for i32. *)
rejects_check "a float literal is refused at a type variable even under numeric?"
~needle:"may be instantiated at an integer type"
"(defn half [x $t] $t {:where (numeric? $t)} (* x 0.5))";
(* A map key that is a type variable has no hash and no equality to emit:
they are chosen from the concrete type, which does not exist yet. So the
map operations join print and println on the list of forms the abstract
pass defers to the instantiation — but only under the predicate, which is
what gives the deferred refusal somewhere to land. Without one the type
itself is refused where it is written, at the definition. *)
rejects_check "a map keyed by a type variable that is not hashable?"
~needle:"is not a map key"
"(defn f [m (Map $t i32)] i32 {:where (numeric? $t)} (len m))";
accepts "and hashable? is what says it is"
"(defn f [m (Map $t i32)] i32 {:where (hashable? $t)} (len m))";
accepts "and under it the operations are deferred, not refused"
"(defn f [m (Map $t i32) k $t] () {:where (hashable? $t)} (put m k 1))";
accepts "get over a type-variable key answers an (Option V)"
"(defn f [m (Map $t i32) k $t] i32 {:where (hashable? $t)} \
(match (get m k) (Some v) v _ 0))";
(* And so does the removal, whose placeholder is [get]'s for the same reason:
it answers an (Option V), so the match around it still has to check while
the key is a variable. *)
accepts "map-remove over a type-variable key answers an (Option V)"
"(defn f [m (Map $t i32) k $t] i32 {:where (hashable? $t)} \
(match (map-remove m k) (Some v) v _ 0))";
accepts "and so do has-key?, reserve and clone"
"(defn f [m (Map $t i32) k $t] bool {:where (hashable? $t)} \
(do (reserve m 8) (let [c (clone m)] (free c) (has-key? m k))))";
(* The definition is still where a generic with no clause to point at is
refused: nothing has been written down for an instantiation to be judged
against, so the refusal has nowhere to move to. *)
rejects_check "a map built inside a generic that declares nothing"
~needle:"is not a map key"
"(defn f [k $t] () (let [m (map-new t i32)] (put m k 1) (free m)))";
(* ── A type variable, spelled with the sigil, where a type name goes ──
[$t] is the signature's spelling and [t] is the body's, and they are the
same variable: the tables that record which variables are in scope are
keyed on the bare name, so every membership test has to strip the sigil
before asking. The ones that did not strip were the guards in front of
[vec-new] and [map-new] and the cast arm, which is why a body that wrote
[(vec-new $t)] was told it had not said what the Vec held. *)
accepts "vec-new over a type variable written with the sigil"
"(defn f [x $t] (Vec $t) (let [v (vec-new $t)] (push v x) v))";
accepts "and against a named allocator"
"(defn f [x $t a Allocator] (Vec $t) \
(let [v (vec-new $t a)] (push v x) v))";
accepts "map-new over type variables written with the sigil"
"(defn f [k $t] i32 {:where (hashable? $t)} \
(let [m (map-new $t i32)] (put m k 1) (let [n (len m)] (free m) n)))";
accepts "a zeroed fixed array of a type variable"
"(defn f [x $t] $t (let [a (array 3 $t)] (set (at a 1) x) (at a 1)))";
accepts "a cast to a type variable written with the sigil"
"(defn f [x i32 d $t] $t {:where (numeric? $t)} (do d ($t x)))";
(* The message these were taking is still the message for the case it was
written for: nothing named, and nothing at the site that says. *)
rejects_check "vec-new with no element type and nothing to take one from"
~needle:"nothing here says what (vec-new) is a Vec of"
"(defn f [x $t] i32 (do x (let [v (vec-new)] (free v) 0)))";
(* And a sigil on a name nothing binds is answered as the unbound variable
it is, rather than as a missing element type — with the names that *are*
bound, because inside a signature that introduces one the mistake is
nearly always the second spelling of the first. *)
rejects_check "vec-new over a sigil that names no variable in scope"
~needle:"this signature introduces t, so write t here"
"(defn f [x $t] i32 (do x (let [v (vec-new $u)] (free v) 0)))";
rejects_check "and a cast over one tells the same story"
~needle:"this signature introduces t, so write t here"
"(defn f [x i32 d $t] $t {:where (numeric? $t)} (do d ($u x)))";
rejects_check "two variables in scope are both named"
~needle:"introduces t and u, so write one of those"
"(defn f [a $t b $u] i32 (do a b (let [v (vec-new $w)] (free v) 0)))";
(* Where no variable is in scope there is none to name, and the answer is
the rule: a sigil binds, and only a defn signature is a binding site. *)
rejects_check "a sigil in a struct field, where nothing can bind one"
~needle:"only a defn signature can"
"(defstruct S [v $t])";
(* ── The builtin table against the arms it describes ──────────────
[Check.builtins] is what the editor's C-c C-v and M-. read for a name no
program wrote — [arena-new] and the seventy-seven others. A table like
that is worth less than nothing once it is stale: a builtin added without
an entry answers nothing, and an entry for an arm that was deleted
describes a name that no longer exists, which is worse because it reads
as authoritative.
There is no way to reflect over an OCaml match, so this reads the source
instead. The two regions are [named_call]'s arms and [var]'s, each from
its own [and] down to the catch-all at the same indentation, and the
names are the string literals in the arm heads. It is a regex over one
file and costs nothing, which is why it is in the default run rather
than behind an alias.
The catch-all is [ | _ ->] and not [ | _], because a guarded arm is
not one: [named_call] opens with [| _ when shadows_builtin ...], which
is a name the program defined taking its own call over, and stopping
there would read the region as empty and report every builtin as
undescribed. Guarded arms in between are skipped by the same rule that
skips a comment — they carry no string literal in the head. *)
let arm_names () =
let src =
In_channel.with_open_bin "../lib/check.ml" In_channel.input_all
in
let lines = String.split_on_char '\n' src in
let starts_with p s =
String.length s >= String.length p && String.sub s 0 (String.length p) = p
in
let quoted line =
let out = ref [] and i = ref 0 and n = String.length line in
while !i < n do
if line.[!i] = '"' then begin
let j = ref (!i + 1) in
while !j < n && line.[!j] <> '"' do incr j done;
if !j < n then out := String.sub line (!i + 1) (!j - !i - 1) :: !out;
i := !j + 1
end else incr i
done;
List.rev !out
in
let region head =
let rec drop = function
| [] -> []
| l :: rest -> if starts_with head l then rest else drop rest
in
let rec take = function
| [] -> []
| l :: rest ->
if starts_with " | _ ->" l then []
else if starts_with " | \"" l then quoted l @ take rest
else take rest
in
take (drop lines)
in
region "and named_call " @ region "and var ctx "
in
let arms = arm_names () in
let table = List.map (fun (n, _, _) -> n) Check.builtins in
check "every builtin arm is described" (arms <> [] && List.length arms > 60);
List.iter
(fun n ->
if not (List.mem n table) then begin
incr failures;
Printf.printf
"FAIL the builtin %s has an arm in check.ml and no entry in \
Check.builtins\n" n
end)
arms;
List.iter
(fun n ->
if not (List.mem n arms) then begin
incr failures;
Printf.printf
"FAIL Check.builtins describes %s, which is no longer an arm\n" n
end)
table;
(* A signature and a line, for every one of them: an entry that is present
and empty answers the question no better than a missing one. *)
List.iter
(fun (n, sign, doc) ->
if sign = "" || doc = "" then begin
incr failures;
Printf.printf "FAIL the builtin %s has no %s\n" n
(if sign = "" then "signature" else "description")
end)
Check.builtins;
(* ── Memory diagnostics, --warn-memory ──────────────────────────
[Check.memory_sites] over a checked program: which lines allocate, on
which heap, and — the half that is harder to keep true — which lines do
not.
Pinned exactly, location and message both, and the location matters as
much as the wording: the whole feature is a squiggle under a character,
and a pass that found the right number of sites at the wrong columns
would draw them under the wrong forms. The file is fixed to [<test>] so
the prelude's own pushes, which are real and are not the caller's
business, stay out of the comparison. *)
let memory name src want =
let got =
List.map
(fun (d : Loc.diag) ->
(d.Loc.dloc.Loc.line, d.Loc.dloc.Loc.col, d.Loc.kind, d.Loc.dmsg))
(Check.memory_sites ~file:"<test>" (checked src))
in
let show (l, c, k, m) = Printf.sprintf "\n %d:%d %s %S" l c k m in
if got <> want then begin
incr failures;
Printf.printf "FAIL %s\n wanted:%s\n got:%s\n" name
(String.concat "" (List.map show want))
(String.concat "" (List.map show got))
end
in
let gc = "memory/gc" and native = "memory/native" in
(* The collected heap. Every row here is a [gc_alloc] in flan_dyn.c on the
way through, and the negatives between them are the point: a typed
[vec-new] takes no block, and neither does an immediate. *)
memory "the collected heap, and what does not touch it"
"(defonce wide i64 999999999999999)\n\
(defonce small i32 7)\n\
(defn take [x] () (print x))\n\
(defn main [] ()\n\
\ (let [tv (vec-new i32)\n\
\ dv (vec-new dyn)\n\
\ m {:a 1}]\n\
\ (take \"hi\")\n\
\ (take 5)\n\
\ (take true)\n\
\ (take nil)\n\
\ (take :kw)\n\
\ (take 1.5)\n\
\ (take small)\n\
\ (take wide)\n\
\ (push tv 1)\n\
\ (push dv 2)))"
[ (6, 12, gc, "allocates: a dyn vector is an object on the collector's heap");
(7, 11, gc, "allocates: a dyn map is an object on the collector's heap");
(8, 11, gc,
"allocates: a string crossing into dyn is copied onto the collector's \
heap");
(* The only integer here that can leave the 48-bit payload. [small] is an
i32 widened to i64 at the crossing and provably cannot, [5] is a
literal inside the range, and neither is named. *)
(15, 11, gc,
"may allocate: an i64 outside ±2^47 does not fit a dyn's payload and \
spills onto the collector's heap");
(* The typed push, which is the native side; [(push dv 2)] on the line
below it is the dyn runtime's own vector growing itself and is not a
site the program can do anything about. *)
(16, 5, native,
"may allocate: a push past the Vec's capacity grows it through its \
allocator") ];
(* The allocator side, and the two shapes of [flan_vec_init]: [slurp] sizes
the Vec to the file and takes a block here, [(vec-new i32 a)] passes a
capacity of zero and takes none. Same runtime entry point, two answers,
which is why the classifier reads the capacity argument rather than the
symbol alone. *)
memory "an allocator the program named"
"(defonce gv (Vec i64) (vec-new i64))\n\
(defn take [x] () (print x))\n\
(defn arith [a b] () (take (+ a b)))\n\
(defn main [] ()\n\
\ (let [a (arena-new 4096)\n\
\ tm (map-new string i32 a)\n\
\ tv (vec-new i32 a)\n\
\ txt (slurp \"x\" a)]\n\
\ (take gv)\n\
\ (put tm \"k\" 1)\n\
\ (reserve tv 4)\n\
\ (arith 1 2)\n\
\ (print (len txt))))"
[ (5, 11, native,
"allocates: an arena takes its whole region from the host here");
(8, 13, native,
"allocates: the Vec is sized up front and takes its block from its \
allocator here");
(* A (Vec i64) crossing into dyn is a view, and the view record is a
heap object even though not one element is copied. *)
(9, 11, 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");
(10, 5, native,
"may allocate: a put past the map's load factor grows its block through \
its allocator");
(11, 5, native,
"may allocate: a reserve past the Vec's capacity grows it through its \
allocator") ];
(* Two negatives on their own, because they are the ones a careless
classifier gets wrong and a test that only counted rows would not catch.
[(+ a b)] over two dyns ends in [flan_dyn_from_i64] and can spill — but
nothing static knows the operands, and a squiggle under every dyn
addition is the false positive this pass exists not to have. *)
memory "dyn arithmetic stays immediate, and an empty program is silent"
"(defn take [x] () (print x))\n\
(defn add2 [a b] () (take (+ a b)))\n\
(defn main [] () (add2 1 2))"
[];
(* ── The acceptance program checks end to end ──────────────────── *)
accepts "calc-me.flan type checks"
(In_channel.with_open_bin "../calc-me.flan" In_channel.input_all);
Test_support.report ()