A three-element defvar reads its third element: a type is zeroed static, anything else is dyn

# Conflicts:
#	FIX.org
This commit is contained in:
Joseph Ferano 2026-09-20 15:14:56 +07:00
commit c4e07256db
10 changed files with 499 additions and 8 deletions

72
FIX.org
View File

@ -1287,3 +1287,75 @@ way of asking. A defvar could not stand in: only the defconst form takes no
type. The one corpus row that relied on an untyped computed defconst,
=(defconst k (g))= ordered before [g], is a typed defvar and still pins the
order-independence it was there for.
* The third element of a defvar decides, 2026-09-20
The author, deciding it: "if it's 3 atoms then it's dyn", and "dispatch the if
it's a type do the right thing."
So ~(defvar x <type>)~ is the zeroed static global it has always been and
~(defvar x <expr>)~ is a dyn global initialised from that expression at
startup. ~(defvar current-color i32)~, ~(defvar grid [rows [cols u32]])~ and
~(defvar p Point)~ all keep their meaning to the letter; ~(defvar score 0)~ is
a dyn holding 0, and ~(defvar game-data (edn/read-file "x.edn"))~ is what
~(defvar game-data dyn (edn/read-file "x.edn"))~ spells out. The four-element
forms are untouched, ~(defvar x dyn <expr>)~ among them.
This is a step in the direction the "dynamic-first dream" names — the author
wants dynamic by default, lowering to static where it can — and it is the
cheapest one available: the dyn spelling stops needing a keyword, and the
static spelling loses nothing. It is the same dispatch the parameter vector
already makes ([(defn f [x y] ...)] is one annotated parameter if [y] names a
type and two dyn parameters if it does not), now in the one other position
where a name could be either.
** Where it is decided
Half in [Parse.defvar3] and half in [Check.settle_defvars], split by what each
one can know.
Parse settles every form a *shape* settles, and that is most of them: [0], a
string, a map, [[1 2 3]] and [(f "x")] are not types by any reading, so the
global is dyn and the third element is its initialiser; [[4 u32]], [()] and
[(Fn [i32] i32)] are types by any reading and keep today's meaning. Note where
the bracket falls — [[n T]] stays a fixed array, so no [(defvar rows [4 u32])]
changed under this — and that [texpr] is called under a handler, because "does
this parse as a type" is a question its refusals answer.
Two shapes are left, and a name and not a shape decides them: a bare symbol,
and [(head arg ...)] with type-shaped arguments. Both readings leave Parse
together — the [texpr] in the [Ast.Defvar] and an [Ast.Ambiguous] expression
beside it — and [Check.collect] picks, at the point where every type name is
registered and just after [pair_decls], which is there for the same reason.
The type reading wins wherever there is one, and a built-in constructor is
recognised by name rather than by whether [resolve] happened to accept it, so
[(Vec i32 i32)] stays a malformed [Vec] instead of becoming a call to
something named [Vec].
An undecided defvar leaves [collect] as a [Zeroed] or as an [Init] at [dyn] —
that is, as [(defvar x dyn <expr>)] exactly. Nothing downstream has a third
case to learn: the startup lifting, the [.init~once.] re-run guard and the
collector root are the ones that form already had, and neither backend was
touched.
** The ambiguous symbol
One namespace covers every declaration kind ([collect]'s [claimed] table), so
a type and a value cannot share a name and the two readings can never both be
live. What the rule *does* create is a symbol that is neither, where the old
"unknown type foo" would now send a reader looking for the wrong mistake:
: foo is neither a type nor a value, and the third element of a defvar has to
: be one or the other: a type there declares a zeroed global of that type —
: (defvar total i64) — and a value there declares a dyn global holding it —
: (defvar total 0). Nothing named foo is declared as either — did you mean fo?
Both readings, both spellings, and the near miss ranges over the value names
as well as the type names — [near_miss] grew an [~also] parameter for it, and
this is its only caller.
** Pinned
test_flan.ml holds the four spellings with their meanings (the type and
whether anything runs at startup, not merely that they compile), the parse
shapes, the collision refusal and the diagnostic verbatim;
test/programs/defvar-dyn.flan is both readings in one program, pinned in
acceptance at the default, -O0 and --x86; and dev-rerun.flan grew a
[(defvar tally 0)] whose line is 4 after three re-runs, which is the claim
that the new spelling goes through the old guard.

View File

@ -211,7 +211,15 @@ and decl_kind =
and variant = { vname : string; vfields : field list; vloc : Loc.t }
and init = Zeroed | Uninit | Init of expr
(* [Ambiguous] is the three-element [(defvar x foo)] and [(defvar x (f y))]:
forms whose third element parses as a type *and* as an expression, so which
one it is cannot be decided until names exist. The [texpr] beside it in
[Defvar] is the type reading and this is the value reading; [Check.collect]
picks, type first a known type name or a built-in type constructor is
[Zeroed], anything else is [Init] of this expression at [dyn]. Everything
whose shape settles it is settled in [Parse] and never becomes one of
these. *)
and init = Zeroed | Uninit | Init of expr | Ambiguous of expr
(* Every top-level name a declaration introduces, whatever kind it is. There is
one top-level namespace, so this is both the set [Load] renames on an import
@ -324,6 +332,11 @@ let mark_pause ~line ~col (ds : decl list) : decl list option =
{ d with d = Defn { f with fbody = pause_call d.dloc :: f.fbody } }
| Defn f -> { d with d = Defn { f with fbody = body f.fbody } }
| Defvar (n, t, Init e) -> { d with d = Defvar (n, t, Init (walk e)) }
(* The value reading of an undecided [defvar] is walked too: if it is the
one that wins it is an initialiser like any other, and if the type
reading wins the expression is dropped whole and the mark with it. *)
| Defvar (n, t, Ambiguous e) ->
{ d with d = Defvar (n, t, Ambiguous (walk e)) }
| Defconst (n, t, e) -> { d with d = Defconst (n, t, walk e) }
| _ -> d
in

View File

@ -667,7 +667,7 @@ let rec resolve env ?(seen = []) (t : Ast.texpr) : Types.t =
(* One edit away from a type that exists — a substitution, an insertion, a
deletion or a transposition of neighbours. Bounded at one, because two edits
is no longer a typo, it is a guess. *)
and near_miss env n =
and near_miss env ?(also = []) n =
let one_edit a b =
let la = String.length a and lb = String.length b in
if abs (la - lb) > 1 then false
@ -687,8 +687,12 @@ and near_miss env n =
else ta a (!i + 1) = ta b !i
end
in
(* [also] widens the candidate list past the types, and exactly one caller
passes it: the defvar whose third element has to be a type *or* a value,
whose suggestion is worth nothing if it can only ever name a type. *)
let candidates =
Types.primitive_names
also
@ Types.primitive_names
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.aliases []
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.structs []
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.datas []
@ -908,6 +912,105 @@ let pair_decls env (decls : Ast.decl list) : Ast.decl list =
| _ -> d)
decls
(* ── The third element of a defvar, decided ────────────────────────────
The author's rule, 2026-09-20: "if it's 3 atoms then it's dyn", and
"dispatch the if it's a type do the right thing". [(defvar current-color
i32)] is the zeroed static it has always been, and [(defvar score 0)] is a
dyn global holding 0 the same thing [(defvar score dyn 0)] spells out,
lowered by the same path and not by a second one.
[Parse] settled every shape a shape can settle and handed the rest over
carrying both readings ([Ast.Ambiguous], beside the type reading in the
same [Defvar]). What is left is the two forms only a name can settle, and
this is the first point where every type name is in hand: the same point
[pair_params] reads, for the same reason a defvar may name a struct
declared fifty lines below it.
The type reading wins wherever there is one. That is what keeps today's
programs meaning today's thing: [(defvar p Point)] is a zeroed [Point],
[(defvar v (Vec i32))] is a zeroed [Vec], and a wrong type argument inside
one stays a type error rather than becoming an unknown function. It is also
why a built-in constructor is checked by name rather than by whether
[resolve] happens to accept it [(Vec i32 i32)] is a malformed [Vec] and
not a call to something called [Vec].
A type and a value cannot share a name: [collect]'s [claimed] table is over
every declaration kind there is, so one name is one declaration and the two
readings can never both be live. *)
let defvar_reads_as_type env (t : Ast.texpr) =
match t.Ast.t with
| Ast.Tname n -> is_type_name env n
| Ast.Tapp (head, _) ->
List.mem head [ "Ptr"; "Option"; "Vec"; "Map"; "Result" ]
(* A slice, a fixed array, a map type or an (Fn ...): [Parse] only carries
one of these over when it read as a type and had no value reading, so
there is nothing here to decide. *)
| _ -> true
(* The bare symbol that is neither. Before the rule there was one reading and
the message was "unknown type"; now the position takes either kind of name,
so a message naming only one of them would send a reader looking for the
wrong mistake. Both readings, both spellings, and the near miss over the
value names as well as the type names. *)
let defvar_neither env loc gname n ~values =
let hint =
match near_miss env ~also:values n with
| Some m -> Printf.sprintf " — did you mean %s?" m
| None -> ""
in
Loc.failk "check/defvar-neither-type-nor-value" loc
"%s is neither a type nor a value, and the third element of a defvar has \
to be one or the other: a type there declares a zeroed global of that \
type (defvar %s i64) and a value there declares a dyn global holding \
it (defvar %s 0). Nothing named %s is declared as either%s"
n gname gname n hint
(* Every name a value could be written under, which is every declaration that
is not a type plus whatever a session already has. The list is only ever
asked "is this name declared at all", so a global that is itself a defvar
still undecided belongs on it: what it resolves to is the next pass's
question, not this one's. *)
let value_names env (decls : Ast.decl list) =
let declared =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with
| Ast.Defvar (n, _, _) | Ast.Defconst (n, _, _) -> Some n
| Ast.Defn fn | Ast.Declare (fn, _) | Ast.DeclareC (fn, _) ->
Some fn.Ast.name
| _ -> None)
decls
in
declared
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.globals []
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.fns []
(* The decision, applied: an undecided defvar leaves this pass as one of the
two forms that already existed, so no pass after it the signature loop
below, [check_global], either backend has a third case to know about. The
dyn reading is rewritten into exactly [(defvar x dyn <expr>)], which is the
whole of "it lowers to the same thing": the startup lifting, the re-run
guard and the collector root are the ones that form already had. *)
let settle_defvars env (decls : Ast.decl list) : Ast.decl list =
let values = lazy (value_names env decls) in
List.map
(fun (d : Ast.decl) ->
match d.Ast.d with
| Ast.Defvar (n, Some t, Ast.Ambiguous e) ->
if defvar_reads_as_type env t then
{ d with Ast.d = Ast.Defvar (n, Some t, Ast.Zeroed) }
else begin
(match t.Ast.t with
| Ast.Tname s when not (List.mem s (Lazy.force values)) ->
defvar_neither env t.Ast.tloc n s ~values:(Lazy.force values)
| _ -> ());
let dyn = { Ast.t = Ast.Tname "dyn"; tloc = t.Ast.tloc } in
{ d with Ast.d = Ast.Defvar (n, Some dyn, Ast.Init e) }
end
| _ -> d)
decls
(* ── Generics: the four operations monomorphisation needs ───────────────
Naming a variable, binding one from an argument, substituting the binding
back in, and spelling the result as a symbol. Everything else about the
@ -6914,6 +7017,9 @@ let collect env (decls : Ast.decl list) =
signature may name a type declared further down and pairing must not depend
on the order the file was written in. *)
let decls = pair_decls env decls in
(* And for the same reason, at the same point: a three-element defvar is a
type or a value by name, and every type name is registered by here. *)
let decls = settle_defvars env decls in
List.iter
(fun (d : Ast.decl) ->
let loc = d.Ast.dloc in
@ -7696,6 +7802,13 @@ let check_global env (d : Ast.decl) : Tast.global option =
let c = ctx () in
let v = check c ~want:ty v in
if Tast.const_init v then v else lift_ginit c d.Ast.dloc n ty v
(* [settle_defvars] turned every one of these into a [Zeroed] or an
[Init] during [collect], and this pass runs over the list that pass
handed back. One arriving here is a driver that checked a global
without collecting first. *)
| Ast.Ambiguous _ ->
fail d.Ast.dloc
"internal: the third element of (defvar %s ...) was never decided" n
in
Some { Tast.gname = n; gty = ty; ginit; gconst = false; gfolded = false }
| Ast.Defconst (n, _, v) ->

View File

@ -399,6 +399,14 @@ let qualify_decl owned alias (d : Ast.decl) : Ast.decl =
Ast.Defvar (qualify alias n, Option.map (rename_texpr owned alias) t,
(match init with
| Ast.Init v -> Ast.Init (rename_expr owned alias [] v)
(* An undecided three-element defvar carries both readings
and neither has been picked yet, so both are renamed
the type half by [rename_texpr] above, the value half
here. Renaming only one would make the import decide
the form, which is [Check]'s decision and not this
pass's. *)
| Ast.Ambiguous v ->
Ast.Ambiguous (rename_expr owned alias [] v)
| other -> other))
| Ast.Defn fn ->
let params = List.map (rename_field owned alias) fn.Ast.params in
@ -739,7 +747,12 @@ let decl_uses acc (d : Ast.decl) =
Option.iter (texpr_uses acc) f.Ast.ret
| Ast.Defvar (_, t, init) ->
Option.iter (texpr_uses acc) t;
(match init with Ast.Init v -> expr_uses acc v | _ -> ())
(* Both readings again: an undecided defvar may turn out to be the one
whose initialiser calls the function, and a dependency this pass misses
is a declaration dropped from the module. *)
(match init with
| Ast.Init v | Ast.Ambiguous v -> expr_uses acc v
| Ast.Zeroed | Ast.Uninit -> ())
| Ast.Defconst (_, t, v) ->
Option.iter (texpr_uses acc) t; expr_uses acc v

View File

@ -1014,6 +1014,55 @@ and pattern (f : Form.t) : Ast.pattern =
Ast.Pctor (ctor, List.map sym binds)
| _ -> fail f "expected a pattern, found %s" (Form.to_string f)
(* ── The third element of a defvar ─────────────────────────────────────
[(defvar x i32)] declares a zeroed static and [(defvar score 0)] declares a
dyn global holding 0, and which one a form is is decided by whether the
third element is a type. The author's rule, 2026-09-20: "if it's 3 atoms
then it's dyn", and "dispatch the if it's a type do the right thing".
Most forms are settled by their shape alone and are settled here: [0], a
string, a map, [[1 2 3]] and [(f "x")] are not types by any reading, so the
global is dyn and its initialiser is the expression; [[4 u32]], [()] and
[(Fn [i32] i32)] are types by any reading and keep exactly the meaning they
have today. Note which side the bracket falls on: [[n T]] stays a fixed
array, so [(defvar rows [4 u32])] is the zeroed grid it always was, and a
*vector literal* of two names is not reachable in this position.
Two shapes are left over, and they are the ones a name decides rather than
a shape: a bare symbol, which is a type name or a value's name, and
[(head arg ...)] with every argument type-shaped, which is [(Vec i32)] or a
call. Both readings are built and carried the [texpr] in the [Defvar] and
the [Ast.Ambiguous] expression beside it and [Check.collect] picks the
type reading whenever the form is a type. Nothing here resolves a name,
because at parse time there are none.
[texpr] is called under a handler on purpose: "does this parse as a type"
is the question, and its refusals are how it answers no. It builds an AST
and touches nothing else, so there is nothing to undo when it raises. *)
let defvar3 (f : Form.t) : Ast.texpr * Ast.init =
let as_type () = match texpr f with t -> Some t | exception Loc.Error _ -> None in
let dyn = { Ast.t = Ast.Tname "dyn"; tloc = f.loc } in
match f.v with
| Sym _ ->
(match as_type () with
(* [Sym "Unit"] is the one symbol [texpr] refuses outright — unit is
spelled [()] and the refusal is about the spelling of a type, so it
stays the error it is rather than becoming a read of a variable
nobody can have declared. *)
| None -> (texpr f, Ast.Zeroed)
| Some t -> (t, Ast.Ambiguous (expr f)))
| List ({ v = Sym _; _ } :: _ :: _) ->
(match as_type () with
| Some ({ Ast.t = Ast.Tapp _; _ } as t) -> (t, Ast.Ambiguous (expr f))
(* [(Fn [i32] i32)] and anything else [texpr] reads as a type without
going through [Tapp] has no call reading to be confused with. *)
| Some t -> (t, Ast.Zeroed)
| None -> (dyn, Ast.Init (expr f)))
| _ ->
(match as_type () with
| Some t -> (t, Ast.Zeroed)
| None -> (dyn, Ast.Init (expr f)))
(* ── Declarations ──────────────────────────────────────────────────── *)
let rec decl (f : Form.t) : Ast.decl =
@ -1332,11 +1381,16 @@ let rec decl (f : Form.t) : Ast.decl =
| List ({ v = Sym "defvar"; _ } :: args) ->
(match args with
| [ n; t ] -> mk (Ast.Defvar (sym n, Some (texpr t), Ast.Zeroed))
| [ n; t ] ->
let ty, init = defvar3 t in
mk (Ast.Defvar (sym n, Some ty, init))
| [ n; t; { v = Sym "uninit"; _ } ] ->
mk (Ast.Defvar (sym n, Some (texpr t), Ast.Uninit))
| [ n; t; v ] -> mk (Ast.Defvar (sym n, Some (texpr t), Ast.Init (expr v)))
| _ -> fail f "defvar is (defvar name Type value?)")
| _ ->
fail f
"defvar is (defvar name Type value?) or (defvar name value) — a \
third element that is not a type is the value of a dyn global")
| List ({ v = Sym "defconst"; _ } :: args) ->
(match args with

View File

@ -0,0 +1,75 @@
;;;; The third element of a defvar, both ways.
;;;;
;;;; 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 that
;;;; expression at startup. So this file is one program holding both kinds
;;;; side by side, and every line it prints is a claim about which reading
;;;; one of its declarations got.
;;;;
;;;; There is no new lowering under any of it. The dyn half is exactly what
;;;; [(defvar x dyn <expr>)] already compiled to — the initialiser lifted into
;;;; the startup function that main calls after the runtime is up, and a
;;;; collector root pushed for the global before it runs — which is why this
;;;; program runs identically on both backends and why neither of them
;;;; learned anything for it.
(defstruct Point [x i32 y i32])
;; ── The type reading, which is every declaration that worked before ──
;; A primitive, a fixed array, a struct and a container: all four are types in
;; the third position, so all four are the zeroed statics they were.
(defvar current-color i32)
(defvar grid [2 [3 u32]])
(defvar origin Point)
(defvar bytes (Vec u8))
;; ── The value reading, which is the new spelling ─────────────────────
;; None of these names a type, so each is a dyn global holding the value its
;; expression produced before the program's own code ran.
(defvar score 0)
(defvar label "start")
(defvar config {:level 1 :name "one"})
(defvar tally seeded)
;; A call, which is the shape the author kept writing: the file is read once,
;; at startup, into a global that outlives main.
(defn load [] dyn {:rows 3 :cols 4})
(defvar game-data (load))
;; And the value the bare symbol above was initialised from, declared *below*
;; it on purpose: which reading a defvar gets is decided with every name in
;; hand, not in the order the file was written.
(defvar seeded i64 7)
(defn main [] ()
;; The statics, untouched: zero, zero, zero, and an empty Vec that is a real
;; empty Vec rather than a placeholder.
(print current-color) (print " ") (print (at grid 1 2)) (print " ")
(print (.x origin)) (print " ") (print (len bytes)) (println "")
;; The dyn globals, as their initialisers left them.
(print score) (print " ") (print label) (print " ")
(print (get config :name)) (print " ") (print tally) (println "")
(print (get game-data :rows)) (print " ") (print (get game-data :cols))
(println "")
;; They are globals and not constants: each is assigned, and the map is
;; mutated in place through the same root the startup function filled.
(set score (+ score 5))
(set label "done")
(put config :level 2)
(set current-color 3)
(set (at grid 1 2) 9)
;; An allocation loop between filling them and reading them back, so the
;; collector runs with the run's own frames on the stack. A dyn global whose
;; root was not pushed reads back as a stale word here rather than as what
;; was stored.
(dotimes [i 20000]
(let [junk {:i i :s "forty-seven bytes of text to fatten each row"}]
(put config :seen (get junk :i))))
(print score) (print " ") (print label) (print " ")
(print (get config :level)) (println "")
(print current-color) (print " ") (print (at grid 1 2)) (println ""))

View File

@ -38,6 +38,14 @@
(defvar state dyn (table))
;; The same thing written the short way: the third element is not a type, so
;; this is a dyn global initialised at startup — the same declaration [state]
;; is, down to the guard flag, because the rule lowers to that form and not to
;; a second one. Its value has to survive a re-run for exactly [counter]'s
;; reason, and if the new spelling had grown a startup path of its own this is
;; the line that would count 1, 1, 1, 1.
(defvar tally 0)
;; The guard flags the fix adds are the compiler's own globals, and they used
;; to be spelled [.init-once.<name>] — a name a program can write, since [.]
;; is an ordinary symbol constituent. This one is exactly the old spelling of
@ -53,9 +61,11 @@
(set zeroed (+ zeroed 2))
(set .init-once.counter (+ .init-once.counter 1))
(put state :runs (+ (get state :runs) 1))
(set tally (+ tally 1))
(print "counter ") (print counter) (println "")
(print "zeroed ") (print zeroed) (println "")
(print "runs ") (print (get state :runs)) (println "")
(print "tally ") (print tally) (println "")
(print "base ") (print base) (println "")
;; Long enough for a client to be served, short enough to park well inside
;; any watchdog — dev-macro.flan's clock, for its reason.

View File

@ -3625,6 +3625,29 @@ level "1"
outputs ~x86:true "dyn: a global, --x86"
"programs/dyn-global.flan" dyn_global_out;
(* The three-element defvar, both readings in one program. The first line
is the four zeroed statics, the next two are the dyn globals as their
initialisers left them, and the last two are what the run stored
read back after twenty thousand allocations, so an unrooted dyn global
comes out stale rather than as what was written.
Three rows for the same reason [dyn-global.flan] has three: the dyn
half lowers to the startup function and the root push, and those are
two backends' worth of code emitted from one shared decision. There is
nothing backend-specific in the *rule* it is settled in [Check], and
what reaches either emitter is the [(defvar x dyn <expr>)] that
already existed which is precisely what these rows are here to keep
true. *)
let defvar_dyn_out = "0 0 0 0\n0 start one 7\n3 4\n5 done 2\n3 9\n" in
outputs "defvar: a type third element and a value third element"
"programs/defvar-dyn.flan" defvar_dyn_out;
outputs ~opt:"-O0"
"defvar: a type third element and a value third element, -O0"
"programs/defvar-dyn.flan" defvar_dyn_out;
outputs ~x86:true
"defvar: a type third element and a value third element, --x86"
"programs/defvar-dyn.flan" defvar_dyn_out;
(* The boundary, both directions, and then the claim that is wrong. The
first four lines are the conversions; the trap is the fifth, and the
runtime owns its wording the compiler could only have said that two

View File

@ -4742,9 +4742,10 @@ let () =
initialiser every *other* time would pass a single re-run.
[programs/dev-rerun.flan] prints one line per case per run, and the
whole assertion is the fourth run's four lines: [counter] computed and
whole assertion is the fourth run's five lines: [counter] computed and
incremented four times, [zeroed] uncomputed and incremented four times,
a computed dyn map whose contents were mutated four times, and a
a computed dyn map whose contents were mutated four times, a dyn global
written the three-element way and incremented four times, and a
[defconst] that no run can have changed. *)
let rsock = tmp "rerun.sock" and rout = tmp "rerun.out" in
(try Sys.remove rsock with Sys_error _ -> ());
@ -4796,6 +4797,11 @@ let () =
and so does the mutation, which is the map still being the map the
first run built. *)
"runs 4";
(* The three-element spelling of a computed dyn global, which is the
explicit one with the keyword left out: it goes through the same
guard because it *is* the same declaration by the time anything
downstream sees it. *)
"tally 4";
(* And a [defconst], which no run can have changed. *)
"base 40" ]
in

View File

@ -453,6 +453,19 @@ let () =
| _ -> check "defvar is ZII" false);
(match (parse_decl "(defvar buf [4 u8] uninit)").d with
| Defvar (_, _, Uninit) -> () | _ -> check "defvar uninit opts out" false);
(* A three-element defvar 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 "(defvar score 0)").d with
| Defvar ("score", Some { t = Tname "dyn"; _ }, Init _) -> ()
| _ -> 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 "(defvar total foo)").d with
| Defvar ("total", Some { t = Tname "foo"; _ }, Ambiguous _) -> ()
| _ -> check "a symbol third element parses undecided" false);
(match (parse_decl "(defvar v (Vec i32))").d with
| Defvar ("v", Some { t = Tapp ("Vec", _); _ }, Ambiguous _) -> ()
| _ -> check "a parenthesised third element parses undecided" false);
(match (parse_decl "(import rl \"vendor:raylib\")").d with
| Import ("rl", "vendor:raylib") -> () | _ -> check "import" false);
@ -804,6 +817,38 @@ let rejects_check name ?needle src =
name n msg
| _ -> ())
(* Which reading a three-element [defvar] 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
let () =
(* ── Literal defaulting and inference ──────────────────────────── *)
infers "int defaults to i32" "42" "i32";
@ -1773,6 +1818,73 @@ let () =
"(defvar g (Vec u8) uninit) (defn f [] ())"
~needle:"steers every read of it";
(* ── The third element of a defvar ─────────────────────────────────
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"
"(defvar current-color i32) (defn f [] i32 current-color)"
"current-color" ~ty:"i32" ~zeroed:true;
defvar_reading "a bracketed type stays a zeroed static array"
"(defvar 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]) (defvar 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]) (defvar 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"
"(defvar v (Vec i32)) (defn f [] i32 (len v))"
"v" ~ty:"(Vec i32)" ~zeroed:true;
rejects_check "a malformed parenthesised type stays a type error"
"(defvar 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"
"(defvar 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}) (defvar 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"
"(defvar seed i64 3) (defvar 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"
"(defvar score dyn 0) (defn f [] dyn score)"
"score" ~ty:"dyn" ~zeroed:false;
defvar_reading "the explicit dyn form with no value is unchanged"
"(defvar config dyn) (defn f [] dyn config)"
"config" ~ty:"dyn" ~zeroed:true;
(* 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"
"(defvar total foo) (defn f [] ())"
~needle:
"foo is neither a type nor a value, and the third element of a defvar \
has to be one or the other: a type there declares a zeroed global of \
that type (defvar total i64) and a value there declares a dyn \
global holding it (defvar total 0). Nothing named foo is declared \
as either";
rejects_check "the near miss is over the value names as well as the types"
"(defvar score i64 1) (defvar total scor) (defn f [] ())"
~needle:"Nothing named scor is declared as either — did you mean score?";
(* ── 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... *)