diff --git a/FIX.org b/FIX.org index 17f6851..0830ec5 100644 --- a/FIX.org +++ b/FIX.org @@ -1217,3 +1217,75 @@ function dyn_ops.c *calls*, the call is compiled against the header and the symbol has to resolve against flan_dyn.o, so a rename, a removal or a changed argument list is a compile or link error in =dune test=. A function nothing here calls gets neither. Both comments now say that instead. + +* 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 )~ is the zeroed static global it has always been and +~(defvar x )~ 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 )~ 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 )] 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. diff --git a/lib/ast.ml b/lib/ast.ml index 80c50c5..a6c3fae 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -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 diff --git a/lib/check.ml b/lib/check.ml index 0bd25c3..e1c01be 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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 )], 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 @@ -7629,6 +7735,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) -> diff --git a/lib/load.ml b/lib/load.ml index 7766635..3c8c7c8 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -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 diff --git a/lib/parse.ml b/lib/parse.ml index 6e04863..3210cf6 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -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 diff --git a/test/programs/defvar-dyn.flan b/test/programs/defvar-dyn.flan new file mode 100644 index 0000000..e4bf5f3 --- /dev/null +++ b/test/programs/defvar-dyn.flan @@ -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 )] 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 "")) diff --git a/test/programs/dev-rerun.flan b/test/programs/dev-rerun.flan index 1e8fb59..691a018 100644 --- a/test/programs/dev-rerun.flan +++ b/test/programs/dev-rerun.flan @@ -36,6 +36,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.] — a name a program can write, since [.] ;; is an ordinary symbol constituent. This one is exactly the old spelling of @@ -51,9 +59,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. diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index b9fd9e6..7c30cce 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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 )] 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 diff --git a/test/test_dev.ml b/test/test_dev.ml index bc2d0ff..0a14909 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -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 diff --git a/test/test_flan.ml b/test/test_flan.ml index 8c474ae..b756171 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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); @@ -790,6 +803,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"; @@ -1759,6 +1804,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... *)