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:
parent
6d82221978
commit
2b5d793d7a
172
lib/check.ml
172
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
|
||||
|
||||
@ -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 "<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
|
||||
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 [] ())";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user