From 2b5d793d7aa9cee834347eb7ee0a1a95f257ce0b Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 17:40:45 +0700 Subject: [PATCH] The dot habit, and a did-you-mean over values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the audit's four cheapest structural wins, and they share a raise point. [p.x] is how C, Go and Odin spell field access, and it arrived here as the symbol [p.x] and left as 'unknown name p.x' — true, and no use to anyone. The head is looked up now, so the refusal can say what [p] actually is, and the struct's declaration comes along as a note. A capitalised head is left alone: [Shape.Circle] is a real spelling and a typo in one is a mistyped case. [near_miss] was written, tested and wired to the type tables alone, so a mistyped value name got the bare refusal. [one_edit] is hoisted out of it so the value side matches on the same rule rather than a second one that would drift, and the candidate list at a value position is the scope, the globals and the functions — plus, at a call, the builtin names, which live in no table the checker keeps and reach the raise through a forward reference. Two repairs alongside: the data-type message's format string carried eleven stray spaces from a wrapped line, and (Pair i32) in a defvar had lost the type fork's 'generics are milestone 5' answer when it started falling down the value fork. --- lib/check.ml | 172 +++++++++++++++++++++++++++++++++++++++------- test/test_flan.ml | 51 ++++++++++++-- 2 files changed, 193 insertions(+), 30 deletions(-) diff --git a/lib/check.ml b/lib/check.ml index 6c2cea3..865c6f7 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -187,6 +187,45 @@ let declared_note env name = in [ Loc.note at what ] +(* One edit apart: a substitution, an insertion, a deletion, or a transposition + of neighbours. Bounded at one, because two edits is no longer a typo, it is + a guess. Hoisted out of [near_miss] so that the did-you-mean over *values* — + function names, globals, locals — matches on exactly the same rule the one + over types has always matched on, rather than on a second one that would + drift. *) +let one_edit a b = + let la = String.length a and lb = String.length b in + if abs (la - lb) > 1 then false + else begin + (* Walk both until they diverge, then require the tails to match with the + single edit applied. *) + let i = ref 0 in + while !i < la && !i < lb && a.[!i] = b.[!i] do incr i done; + let ta s k = String.sub s k (String.length s - k) in + if la = lb then + !i < la + && (ta a (!i + 1) = ta b (!i + 1) + (* stirng/string: two neighbours swapped. *) + || (!i + 1 < la && a.[!i] = b.[!i + 1] && a.[!i + 1] = b.[!i] + && ta a (!i + 2) = ta b (!i + 2))) + else if la < lb then ta a !i = ta b (!i + 1) + else ta a (!i + 1) = ta b !i + end + +(* The same question asked of a candidate list the caller assembles, which for + a value position is the function table, the globals and whatever is in + scope — and nothing from the type tables, because a name written where a + value goes was not a mistyped struct. *) +let nearest cands n = List.find_opt (fun c -> c <> n && one_edit n c) cands + +(* The builtin names, for the did-you-mean at a call — [prinltn] is a typo for + [println], and [println] is not in any table the checker keeps, it is an arm + of the call dispatch. The full [builtins] table is a long way below this + point and carries a signature and a sentence per entry for eldoc; a forward + reference to its names is cheaper than moving it or writing the list twice + and letting the two drift. Filled once, immediately after that table. *) +let builtin_names : string list ref = ref [] + (* What a [break] or a [continue] may be talking about, innermost first. [Lloop] is a loop it is lexically inside, carrying its label if it was given @@ -668,25 +707,6 @@ let rec resolve env ?(seen = []) (t : Ast.texpr) : Types.t = deletion or a transposition of neighbours. Bounded at one, because two edits is no longer a typo, it is a guess. *) and near_miss env ?(also = []) n = - let one_edit a b = - let la = String.length a and lb = String.length b in - if abs (la - lb) > 1 then false - else begin - (* Walk both until they diverge, then require the tails to match with the - single edit applied. *) - let i = ref 0 in - while !i < la && !i < lb && a.[!i] = b.[!i] do incr i done; - let ta s k = String.sub s k (String.length s - k) in - if la = lb then - !i < la - && (ta a (!i + 1) = ta b (!i + 1) - (* stirng/string: two neighbours swapped. *) - || (!i + 1 < la && a.[!i] = b.[!i + 1] && a.[!i + 1] = b.[!i] - && ta a (!i + 2) = ta b (!i + 2))) - else if la < lb then ta a !i = ta b (!i + 1) - else ta a (!i + 1) = ta b !i - end - 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. *) @@ -2840,8 +2860,7 @@ and var ctx loc ~want name = and pass that" name; expect ctx loc ~want (mk loc (Types.Fn (params, ret)) (Tast.FnAddr (Tast.Fnval name))) - | None -> captured ctx loc name; - Loc.failk "check/unknown-name" loc "unknown name %s" name) + | None -> captured ctx loc name; unknown_name ctx loc name) (* What remains of spec-memory.md's ownership section after the repeals of 2026-09-18 is the allocator's side alone: the region rule decides where a @@ -4110,6 +4129,85 @@ and check_match ctx ?(tail = false) ?want loc scrutinee arms = question for the layout and not for this — so [.x] is one path and not two, and a union member is read with the accessor everything else is read with. That is the whole of what makes punning ordinary code. *) +(* Every name a value could be standing under here: what is in scope, the + globals, the functions — generic ones included, since a call to one is + written exactly like a call to any other. No type names: a symbol written + where a value goes was not a mistyped struct, and offering one would send + the reader to the wrong file. *) +and value_candidates ctx = + List.map fst ctx.scope + @ Hashtbl.fold (fun k _ acc -> k :: acc) ctx.env.globals [] + @ Hashtbl.fold (fun k _ acc -> k :: acc) ctx.env.fns [] + @ Hashtbl.fold (fun k _ acc -> k :: acc) ctx.env.gsigs [] + +(* The name nothing answers to, refused with whatever this position can still + tell the reader. + + Two readings get in ahead of the bare refusal. The first is the dot: [p.x] + is how C, Go and Odin spell field access and it is the habit everyone + arrives with, so a symbol with a dot in it and a lowercase head is almost + never a name — it is an accessor written the way the last language wrote + it. The head is looked up, so the sentence can say what [p] actually is + rather than guess, and the struct's declaration comes along as a note when + there is one. Capitalised heads are left alone: [Shape.Circle] is a real + spelling in this language and a typo in one is a mistyped case, not a + dot-infix habit. + + The second is the near miss, over values only — see [value_candidates]. *) +and unknown_name : 'a. ctx -> Loc.t -> string -> 'a = + fun ctx loc name -> + let dot = String.index_opt name '.' in + let head, field = + match dot with + | Some i when i > 0 && i + 1 < String.length name -> + String.sub name 0 i, String.sub name (i + 1) (String.length name - i - 1) + | _ -> "", "" + in + let lower = head <> "" && head.[0] = Char.lowercase_ascii head.[0] + && head.[0] <> Char.uppercase_ascii head.[0] in + if lower then begin + let ty = + match lookup ctx head with + | Some b -> Some b.bty + | None -> Option.map fst (Hashtbl.find_opt ctx.env.globals head) + in + let sname = + match ty with + | Some (Types.Named n) when fields_named ctx.env n <> None -> Some n + | Some (Types.Ptr (Types.Named n)) when fields_named ctx.env n <> None -> Some n + | _ -> None + in + match sname, ty with + | Some sn, _ -> + let s = Option.get (fields_named ctx.env sn) in + let notes = declared_note ctx.env sn in + if Tast.field_index s field <> None then + Loc.failk "check/dot-access" loc ~notes + "unknown name %s — a field is read with an accessor, so write (.%s %s)" + name field head + else + Loc.failk "check/dot-access" loc ~notes + "unknown name %s — a field is read with an accessor, (.%s %s), and \ + %s has no field %s" + name field head sn field + | None, Some t -> + Loc.failk "check/dot-access" loc + "unknown name %s — a dot is part of the name here, not field access. \ + Fields are read with an accessor, (.%s %s), and %s is %s, which has \ + no fields" + name field head head (Types.to_string t) + | None, None -> + Loc.failk "check/unknown-name" loc + "unknown name %s — nothing named %s is in scope either. A field is \ + read with an accessor, (.%s %s), not with a dot" + name head field head + end + else + match nearest (value_candidates ctx) name with + | Some m -> + Loc.failk "check/unknown-name" loc "unknown name %s — did you mean %s?" name m + | None -> Loc.failk "check/unknown-name" loc "unknown name %s" name + and fields_named env n : Tast.structure option = match Hashtbl.find_opt env.structs n with | Some s -> Some s @@ -4154,8 +4252,7 @@ and check_place ctx loc (p : Ast.place) : Tast.place * Types.t = match Hashtbl.find_opt ctx.env.globals name with | Some (_, true) -> fail loc "%s is a constant" name | Some (ty, false) -> Tast.Pglobal name, ty - | None -> captured ctx loc name; - Loc.failk "check/unknown-name" loc "unknown name %s" name) + | None -> captured ctx loc name; unknown_name ctx loc name) | Ast.Pfield (target, name) -> let target, sname = struct_target ctx target in let s = Option.get (fields_named ctx.env sname) in @@ -6380,7 +6477,8 @@ and named_call ctx ~want loc name args = | None -> if Hashtbl.mem ctx.env.datas name then fail loc - "%s is a data type — a data type value names the case too, as (%s.%s {.field value ...})" + "%s is a data type — a data type value names the case too, as \ + (%s.%s {.field value ...})" name name (first_case_name ctx.env name) else if Hashtbl.mem ctx.env.cases name then (* [(U.C)] and [(C)]: a case written as a call. Both are how someone @@ -6397,7 +6495,26 @@ and named_call ctx ~want loc name args = else if String.contains name '/' then unimplemented loc (Printf.sprintf "the call %s into an imported package" name) 4 - else Loc.failk "check/unknown-function" loc "unknown function %s" name + else if args <> [] + && name <> "" && name.[0] = Char.uppercase_ascii name.[0] + && name.[0] <> Char.lowercase_ascii name.[0] + then + (* [(defvar p (Pair i32))]. A capitalised head with arguments is + somebody reaching for a parameterised type, which is what the type + resolver says about [(Pair i32)] when the same text lands in a type + position. Before defvar took either reading, that is the message + this text got; it says the same thing here so the answer does not + depend on which side of the fork the form fell down. *) + Loc.failk "check/unknown-function" loc + "unknown function %s. A capitalised name is a type, and a type given \ + type arguments — (%s ...) — is generic code, which is milestone 5" + name name + else + match nearest (!builtin_names @ value_candidates ctx) name with + | Some m -> + Loc.failk "check/unknown-function" loc + "unknown function %s — did you mean %s?" name m + | None -> Loc.failk "check/unknown-function" loc "unknown function %s" name (* ── A call to a generic function ─────────────────────────────────────── The whole of instantiation, and it is at the call site because the call @@ -6943,6 +7060,11 @@ let builtins : (string * string * string) list = context/allocator.") ] +(* The forward reference declared beside [nearest], filled the moment the table + it names exists. Nothing reads it before a call is checked, and no call is + checked before this module is loaded. *) +let () = builtin_names := List.map (fun (n, _, _) -> n) builtins + (* ── Declarations: pass 1, collect ─────────────────────────────────── *) (* Constant folding, only over integers and only for defconst — enough for an diff --git a/test/test_flan.ml b/test/test_flan.ml index 10ccff0..6a29808 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -1555,12 +1555,14 @@ let () = 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; the string literal below is what the - runtime prints as a GNU prefix in front of the sentence. *) + (* 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 ":1:21"); + (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. *) @@ -1708,6 +1710,45 @@ let () = 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 "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"; + (* 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. *) + rejects_check "a capitalised dotted name is still a case" + "(data Shape (Circle [r f64])) (defn f [] Shape Shape.Crcle)" + ~needle:"unknown"; + (* [(Pair i32)] in a defvar 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. *) + rejects_check "a capitalised call with arguments is generics" + "(defvar x (Pair i32)) (defn f [] i32 0)" + ~needle:"is generic code, which is milestone 5"; rejects_check "defined twice" "(defn f [] ()) (defn f [] ())" ~needle:"defined twice"; accepts "main with no parameters and no return" "(defn main [] ())";