The dot habit, and a did-you-mean over values

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.
This commit is contained in:
Joseph Ferano 2026-09-20 17:40:45 +07:00
parent 6d82221978
commit 2b5d793d7a
2 changed files with 193 additions and 30 deletions

View File

@ -187,6 +187,45 @@ let declared_note env name =
in in
[ Loc.note at what ] [ 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. (* 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 [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 deletion or a transposition of neighbours. Bounded at one, because two edits
is no longer a typo, it is a guess. *) is no longer a typo, it is a guess. *)
and near_miss env ?(also = []) 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
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 (* [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, 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. *) 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; and pass that" name;
expect ctx loc ~want expect ctx loc ~want
(mk loc (Types.Fn (params, ret)) (Tast.FnAddr (Tast.Fnval name))) (mk loc (Types.Fn (params, ret)) (Tast.FnAddr (Tast.Fnval name)))
| None -> captured ctx loc name; | None -> captured ctx loc name; unknown_name ctx loc name)
Loc.failk "check/unknown-name" loc "unknown name %s" name)
(* What remains of spec-memory.md's ownership section after the repeals of (* 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 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, 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. and a union member is read with the accessor everything else is read with.
That is the whole of what makes punning ordinary code. *) 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 = and fields_named env n : Tast.structure option =
match Hashtbl.find_opt env.structs n with match Hashtbl.find_opt env.structs n with
| Some s -> Some s | 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 match Hashtbl.find_opt ctx.env.globals name with
| Some (_, true) -> fail loc "%s is a constant" name | Some (_, true) -> fail loc "%s is a constant" name
| Some (ty, false) -> Tast.Pglobal name, ty | Some (ty, false) -> Tast.Pglobal name, ty
| None -> captured ctx loc name; | None -> captured ctx loc name; unknown_name ctx loc name)
Loc.failk "check/unknown-name" loc "unknown name %s" name)
| Ast.Pfield (target, name) -> | Ast.Pfield (target, name) ->
let target, sname = struct_target ctx target in let target, sname = struct_target ctx target in
let s = Option.get (fields_named ctx.env sname) in let s = Option.get (fields_named ctx.env sname) in
@ -6380,7 +6477,8 @@ and named_call ctx ~want loc name args =
| None -> | None ->
if Hashtbl.mem ctx.env.datas name then if Hashtbl.mem ctx.env.datas name then
fail loc 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) name name (first_case_name ctx.env name)
else if Hashtbl.mem ctx.env.cases name then else if Hashtbl.mem ctx.env.cases name then
(* [(U.C)] and [(C)]: a case written as a call. Both are how someone (* [(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 else if String.contains name '/' then
unimplemented loc unimplemented loc
(Printf.sprintf "the call %s into an imported package" name) 4 (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 ─────────────────────────────────────── (* ── A call to a generic function ───────────────────────────────────────
The whole of instantiation, and it is at the call site because the call 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.") 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 ─────────────────────────────────── *) (* ── Declarations: pass 1, collect ─────────────────────────────────── *)
(* Constant folding, only over integers and only for defconst — enough for an (* Constant folding, only over integers and only for defconst — enough for an

View File

@ -1555,12 +1555,14 @@ let () =
check "the x86 backend roots its dyn values" check "the x86 backend roots its dyn values"
(contains dyn_asm "flan_dyn_root_push" (contains dyn_asm "flan_dyn_root_push"
&& contains dyn_asm "flan_dyn_root_pop"); && contains dyn_asm "flan_dyn_root_pop");
(* The site travels with the operands, on this backend as on the other. A (* 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 dyn arithmetic trap is the type error of a dynamic program, and it used
print with no file and no line; the string literal below is what the to print with no file and no line. This backend writes a string constant
runtime prints as a GNU prefix in front of the sentence. *) 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" check "the x86 backend hands the dyn operators their site"
(contains dyn_asm "<test>: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 (* 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 what lets the sweep's other MATCHes stand as a regression check on this
lane rather than being re-measured by it. *) 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 name" "(defn f [] i32 nope)" ~needle:"unknown name";
rejects_check "unknown function" "(defn f [] i32 (nope 1))" rejects_check "unknown function" "(defn f [] i32 (nope 1))"
~needle:"unknown function"; ~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 [] ())" rejects_check "defined twice" "(defn f [] ()) (defn f [] ())"
~needle:"defined twice"; ~needle:"defined twice";
accepts "main with no parameters and no return" "(defn main [] ())"; accepts "main with no parameters and no return" "(defn main [] ())";