From 8add0093ba5f0faf0294d878c3348596e7d141f8 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 17:55:59 +0700 Subject: [PATCH] The defvar follow-ups, the spellings other languages use, and two register warts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four the defvar review left behind, plus the two the author's dogfooding notes name. A three-element defvar that is neither a type nor a value gets a paragraph about the fork it stands at, and the paragraph is right for the name that genuinely could have been either. Three names cannot: a data case, which is a third thing with its own spelling; a name another language uses for a type this one has; and a plain type typo, where a confident one-edit suggestion was turning a line into four. Each answers first now. A bracket form never reaches that fork at all — the parser gives it the type reading outright — so a value name inside one landed in [resolve_name] and came back as a lecture about generic code. Both readings at the element that decided it, and the dyn spelling it offers is checked to be a real form. [int] is two edits from [i32] and so outside the one-edit net, correctly: two edits is a guess. But the name is not a guess, it is what four other languages call the default integer, so a short list answers it by name. Nothing goes on that list without one honest answer — [char] and [void] are off it, and the comment says why. A parameter called [i] is not a mistyped [i8]. The machine types size themselves in the name, so a typo keeps the digits and a parameter name has none; that is the rule that stopped (defn idx [v i] dyn ...) being refused. A type written in a two-element defconst was reported as an unknown name, because that form has no type slot and the brackets read as an array literal. A type name inside one is unambiguous — a type and a value cannot share a name here — so it says what happened and names defvar. Two register warts alongside: no message cites a repo filename at the reader any more (plan.org in three, spec-memory.md in one). --- lib/check.ml | 186 +++++++++++++++++++++++++++++++++++++++++++--- test/test_flan.ml | 61 +++++++++++++-- 2 files changed, 231 insertions(+), 16 deletions(-) diff --git a/lib/check.ml b/lib/check.ml index 7c13609..530b16b 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -228,6 +228,35 @@ let one_edit a b = value goes was not a mistyped struct. *) let nearest cands n = List.find_opt (fun c -> c <> n && one_edit n c) cands +(* What the last language called it. [int] is two edits from [i32] and so is + outside [one_edit]'s net, which is right — two edits is a guess — but the + name is not a guess at all: it is what C, Java, Go and Python spell the + default integer, and somebody writing it here has not mistyped anything, + they have not yet learned that this language sizes its integers in the + name. Without this list [int] falls through to the lowercase arm of + [resolve_name] and is reported as generic code over a type variable, which + is a sentence about a feature the reader was not reaching for. + + Short on purpose, and only names with one honest answer. [char] is not + here: C's is a byte, Java's is a UTF-16 unit and Rust's is a scalar value, + and this language has [u8] and rune functions, so there is nothing to + translate it to in three words. Nor [void]: it is a return type and the + answer there is the shape [()], which is [parse]'s message to give and not + this one's. *) +let foreign_spelling = function + | "int" | "integer" -> Some "i32" + | "uint" | "unsigned" -> Some "u32" + | "long" -> Some "i64" + | "ulong" -> Some "u64" + | "short" -> Some "i16" + | "ushort" -> Some "u16" + | "byte" -> Some "u8" + | "float" -> Some "f32" + | "double" -> Some "f64" + | "boolean" -> Some "bool" + | "str" -> Some "string" + | _ -> None + (* The builtin names, for the did-you-mean at a call — [prinltn] is a typo for [println], and [println] is not in any table the checker keeps, it is an arm of the call dispatch. The full [builtins] table is a long way below this @@ -469,7 +498,10 @@ let branch ctx f = (* ── Type resolution ───────────────────────────────────────────────── *) let unimplemented loc what milestone = - fail loc "%s is not implemented yet — milestone %d (see plan.org)" + (* No repo filename in a message. Somebody meeting this wants to know that + the thing is not there yet and roughly how far off it is; where the + schedule is written down is the compiler's business, not theirs. *) + fail loc "%s is not implemented yet — it is milestone %d work" what milestone (* ── where predicates ────────────────────────────────────────────────── @@ -801,6 +833,9 @@ and resolve_name env ~seen loc n = (* A typo in a primitive is lowercase too, and the type-variable rule below would otherwise report [f65] as unimplemented generics and send you to plan.org instead of to the character you mistyped. *) + | _ when foreign_spelling n <> None -> + Loc.failk "check/unknown-type" loc "unknown type %s — Flan spells it %s" + n (Option.get (foreign_spelling n)) | _ when near_miss env n <> None -> Loc.failk "check/unknown-type" loc "unknown type %s — did you mean %s?" n (Option.get (near_miss env n)) @@ -879,7 +914,22 @@ let is_type_name env n = that it was meant to be one. That case is the feature working as specified, and it is the residual the parent owns. *) let dyn_param_or_typo env n loc = - match near_miss env n with + (* [(defn idx [v i] dyn ...)] is two dyn parameters, and [i] is one edit + from [i8], so the did-you-mean used to accuse a perfectly ordinary + parameter name of being a mistyped type. What separates the two is the + digits: this language sizes its machine types in the name, so a typo in + one keeps them — [f65] for [f64], [i33] for [i32] — while [i], [v], [n] + and [x] carry none and are what parameters are actually called. A name + with no digit, one edit from a type that has one, is a parameter; the + suggestion is dropped and the dyn reading stands, which is the reading + the writer meant. *) + let has_digit s = String.exists (fun c -> c >= '0' && c <= '9') s in + let suggestion = + match near_miss env n with + | Some m when has_digit m && not (has_digit n) -> None + | m -> m + in + match suggestion with | Some m -> Loc.failk "check/unknown-type" loc "unknown type %s — did you mean %s? A parameter with no type is dyn, so \ @@ -983,7 +1033,33 @@ let defvar_reads_as_type env (t : Ast.texpr) = 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 = +(* Both readings, and the paragraph that explains them — but only when both + readings really are open. Three things get in ahead of it, because each one + knows which of the two the writer meant and the paragraph would bury that + under a lecture about a fork they are not standing at: + + a case name, which is a third thing entirely and has its own spelling; a + name another language spells for a type this one has under a different + name; and a plain type typo, where a confident one-edit suggestion turns a + one-line answer into four lines of unrelated reading. The paragraph is for + the name that genuinely could have been either and is neither. *) +let defvar_neither env loc gname n ~values ~cases = + (match List.assoc_opt n cases with + | Some dname -> + Loc.failk "check/defvar-case-not-type" loc + "%s is a case of the data type %s, and a case is not a type of its \ + own — the global's type is the data type: (defvar %s %s). Assign the \ + case you want, as (set %s (%s.%s {.field value ...}))" + n dname gname dname gname dname n + | None -> ()); + (match foreign_spelling n with + | Some m -> + Loc.failk "check/unknown-type" loc "unknown type %s — Flan spells it %s" n m + | None -> ()); + (match near_miss env n with + | Some m -> + Loc.failk "check/unknown-type" loc "unknown type %s — did you mean %s?" n m + | None -> ()); let hint = match near_miss env ~also:values n with | Some m -> Printf.sprintf " — did you mean %s?" m @@ -1001,6 +1077,21 @@ let defvar_neither env loc gname n ~values = 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. *) +(* Case name -> the data type it belongs to, read off the declarations rather + than out of [env.cases]: this runs inside [collect], which has registered + the data type *names* by here but not resolved their cases, so the table + would be empty. Last writer wins, exactly as [env.cases] does, and for the + same reason — this is only ever asked "what is this a case of", and two + data types may share a case name. *) +let case_owners (decls : Ast.decl list) = + List.concat_map + (fun (d : Ast.decl) -> + match d.Ast.d with + | Ast.Defdata (dn, vs) -> + List.map (fun (v : Ast.variant) -> (v.Ast.vname, dn)) vs + | _ -> []) + decls + let value_names env (decls : Ast.decl list) = let declared = List.filter_map @@ -1022,18 +1113,62 @@ let value_names env (decls : Ast.decl list) = 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. *) +(* A bracket form whose element names a value. [(defvar g [a b])] parses as a + type and stays one — type wins wherever there is a type reading, which is + the rule — so the element had to name an element type, and [b] names a + defvar. Left alone this reaches [resolve_name], where a lowercase name that + is no type is a type variable, and the answer is a paragraph about generic + code the writer was not asking for. + + Both readings, and both spellings, at the element that decided it. The dyn + spelling is the one that actually works: [(defvar g dyn [a b])] is a dyn + global holding a vector, which is what the brackets meant to whoever wrote + them. *) +let rec bracket_value_element env values (t : Ast.texpr) = + let elem (e : Ast.texpr) = + match e.Ast.t with + | Ast.Tname n when (not (is_type_name env n)) && List.mem n values -> + Some (n, e.Ast.tloc) + | _ -> bracket_value_element env values e + in + match t.Ast.t with + | Ast.Tslice e -> elem e + | Ast.Tarray (_, e) -> elem e + | _ -> None + let settle_defvars env (decls : Ast.decl list) : Ast.decl list = let values = lazy (value_names env decls) in + let cases = lazy (case_owners decls) in + (* A bracket form never reaches the fork below: [Parse.defvar3] gives it + [Zeroed] outright, because a bracket that parses as a type has no second + reading to carry. So the element check runs on both, and it is the only + thing the [Zeroed] arm does. *) + let brackets gname (t : Ast.texpr) = + match bracket_value_element env (Lazy.force values) t with + | Some (v, vloc) -> + Loc.failk "check/defvar-bracket-element-is-a-value" vloc + "%s names a value, not a type, and the brackets around it were read \ + as a type — a defvar's third element is a type wherever there is a \ + type reading, so %s had to be the element type. Write a type there \ + for a zeroed global, or put dyn in front of the same brackets — \ + (defvar %s dyn ...) — for a dyn global holding the vector you wrote" + v v gname + | None -> () + in List.map (fun (d : Ast.decl) -> match d.Ast.d with + | Ast.Defvar (n, Some t, Ast.Zeroed) -> brackets n t; d | Ast.Defvar (n, Some t, Ast.Ambiguous e) -> - if defvar_reads_as_type env t then + if defvar_reads_as_type env t then begin + brackets n t; { d with Ast.d = Ast.Defvar (n, Some t, Ast.Zeroed) } + end else begin (match t.Ast.t with | Ast.Tname s when not (List.mem s (Lazy.force values)) -> defvar_neither env t.Ast.tloc n s ~values:(Lazy.force values) + ~cases:(Lazy.force cases) | _ -> ()); let dyn = { Ast.t = Ast.Tname "dyn"; tloc = t.Ast.tloc } in { d with Ast.d = Ast.Defvar (n, Some dyn, Ast.Init e) } @@ -4337,8 +4472,8 @@ and check_place ctx loc (p : Ast.place) : Tast.place * Types.t = | Some b -> if not b.assignable then fail loc - "%s is a parameter, and parameters are not assignable places \ - (spec-memory.md) — bind a local with let" name; + "%s is a parameter, and a parameter is not a place you can assign \ + to — bind a local with let" name; Tast.Plocal b.slot, b.bty | None -> match Hashtbl.find_opt ctx.env.globals name with @@ -4918,12 +5053,12 @@ and named_call ctx ~want loc name args = (match name with | "=" | "!=" -> fail loc - "%s compares machine numbers, enums and strings; %s has no \ - built-in equality (plan.org, Types)" name (Types.to_string a.Tast.ty) + "%s compares machine numbers, enums and strings, and %s is none of \ + those" name (Types.to_string a.Tast.ty) | _ -> fail loc - "%s orders machine numbers and enums; %s has no built-in ordering \ - (plan.org, Types)" name (Types.to_string a.Tast.ty)); + "%s orders machine numbers and enums, and %s is neither" name + (Types.to_string a.Tast.ty)); prim p Types.Bool [ a; b ] end | "not" -> @@ -7191,6 +7326,33 @@ let rec const_int env (e : Ast.expr) : int64 option = (const_int env x) (y :: rest) | _ -> None +(* [(defconst grid [rows [cols u8]])]. A two-element defconst has no type slot + — the second form is always a value — so the brackets were read as an array + *literal* and [u8] as a name in it, and the refusal that came out was + "unknown name u8", which sends the reader to look for a missing definition + of something the language has had all along. + + A type name inside an array literal is unambiguous evidence, because a type + and a value cannot share a name: [collect]'s claimed table is over every + declaration kind there is. So finding one means the whole form was meant as + a type, and the form that takes one is [defvar]. *) +let rec defconst_type_shaped env gname (v : Ast.expr) = + match v.Ast.e with + | Ast.Arr items -> + List.iter + (fun (i : Ast.expr) -> + match i.Ast.e with + | Ast.Var n when is_type_name env n -> + Loc.failk "check/defconst-is-a-type" i.Ast.loc + "%s is a type, and this is a value: a two-element defconst has no \ + type slot, so the brackets around it were read as an array \ + literal and %s as a name in it. A global declared by its type is \ + a defvar — write (defvar %s ...) with the same brackets" + n n gname + | _ -> defconst_type_shaped env gname i) + items + | _ -> () + let collect env (decls : Ast.decl list) = (* One pass over every declaration kind before any of the others, because the tables below are per-kind — structs, data types, aliases, enums, functions @@ -7493,7 +7655,9 @@ let collect env (decls : Ast.decl list) = Hashtbl.replace env.globals n (ty, false) | Ast.Defconst (n, Some t, _) -> Hashtbl.replace env.globals n (resolve env t, true) - | Ast.Defconst (n, None, v) -> untyped := (n, v) :: !untyped + | Ast.Defconst (n, None, v) -> + defconst_type_shaped env n v; + untyped := (n, v) :: !untyped (* [Classes.expand] ran at the top of [build_program] and left none of these behind, the way [Shim.expand] leaves no [declare-c] behind. A driver that assembled a declaration list and skipped that pass would diff --git a/test/test_flan.ml b/test/test_flan.ml index 41652fe..949b430 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -1661,7 +1661,7 @@ let () = 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:"parameters are 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:"is a constant"; accepts "addr of a local gives a pointer" @@ -1924,13 +1924,13 @@ let () = 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:"no built-in ordering"; + "(defn f [] bool (< \"a\" \"b\"))" ~needle:"orders machine numbers and enums"; rejects_check "no built-in <= on strings" - "(defn f [] bool (<= \"a\" \"b\"))" ~needle:"no built-in ordering"; + "(defn f [] bool (<= \"a\" \"b\"))" ~needle:"orders machine numbers and enums"; rejects_check "no built-in > on strings" - "(defn f [] bool (> \"a\" \"b\"))" ~needle:"no built-in ordering"; + "(defn f [] bool (> \"a\" \"b\"))" ~needle:"orders machine numbers and enums"; rejects_check "no built-in >= on strings" - "(defn f [] bool (>= \"a\" \"b\"))" ~needle:"no built-in ordering"; + "(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. *) @@ -2069,6 +2069,57 @@ let () = 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?"; + (* 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" + "(defvar total i33) (defn f [] ())" + ~needle:"unknown type i33 — did you mean i32?"; + rejects_check "and another language's spelling is answered by name" + "(defvar total int) (defn f [] ())" + ~needle:"unknown type int — Flan spells it i32"; + rejects_check "a data case is not a type, and says what is" + "(defdata Shape [(Circle [r f64])]) (defvar 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: (defvar 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" + "(defvar a i64 1) (defvar b i64 2) (defvar 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" + "(defvar a i64 1) (defvar b i64 2) (defvar g [a b]) (defn f [] ())" + ~needle:"put dyn in front of the same brackets — (defvar g dyn ...)"; + accepts "which is a real form" + "(defvar a i64 1) (defvar b i64 2) (defvar 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 defvar" + "(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 defvar it names is the form that works" + "(defconst rows 4) (defconst cols 4) (defvar 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?"; (* ── Computed global initialisers ────────────────────────────────── The order they run in is the compiler's to choose, so a global written