- Modules: `use`/`pub`, directory-as-module, per-module symbol resolution (a
flat first-wins merge silently ran the wrong `pub fn` body), six reserved
stdlib namespaces typed UNKNOWN-BUT-RESERVED.
- Surface: `and`/`or` (own precedence tier, short-circuit, Bool-only), `${}`
interpolation desugared at parse time, `const`, break/continue with
drop-correct exits, do-while, inline-fn rejection.
- switch expr/stmt: required `default` over scalars/Text, arm unification,
EQ/EQS+JZ lowering, per-arm drop scopes with N-way JOIN-DROP; `default`
sorted last by a shared lowering order (textual order made arms dead).
- typedef records: structural, same shape = one class entry; `?name: T`
nullable-by-shape; emit_ctor fills omitted defaults; `type` as field name.
- Enum variants: all-bare unions = int ordinals; any-payload = one class
entry per variant, tag IS the header class_id (no header field, no format
bump); exhaustive switch without `default`; arity checked both directions.
- Payload escape modeled as move-out (pointer-kind fields only — a scalar
escape is a copy); caller reaps owned heap temps passed by borrow: two
unbounded LSan-blind leaks, 10.5 MB -> 1.5 MB flat over 300k iterations.
- Fixed en route, each with a RED repro: dead E209 builtin-arg check and
`int_to_text` missing from both types.ml builtin tables (both segfaulted
wovm), multi-file phantom double-report, emit_ctor's field temp clobbering
dst in tail position (pre-existing), warnings swallowed without an error.
- Two fenced VM builtins: `int_to_text` (13), `variant_tag` (14).
- 14+565 unit (was 14+401), corpus 71 (was 32) plain and under wovm_asan,
wovm-test + cli_smoke green. Log-watcher 307 -> 93 diagnostics (85 E101 /
4 E207 / 1 E208 / 3 W202); the 5 non-E101 residuals await Task 7 grammar.
2042 lines
No EOL
98 KiB
OCaml
2042 lines
No EOL
98 KiB
OCaml
(* types.ml — Typechecker for `.wo` OOP source (Task 6).
|
|
Two-pass:
|
|
1. Collect all declarations (classes, interfaces, free fns, typedefs)
|
|
2. Typecheck bodies with full symbol tables.
|
|
Produces typed AST + per-class field-kind table. *)
|
|
|
|
open Ast
|
|
|
|
module StringMap = Map.Make(String)
|
|
module StringSet = Set.Make(String)
|
|
|
|
(* ============================================================
|
|
Type representations (internal, resolved)
|
|
============================================================ *)
|
|
|
|
type typ =
|
|
| TScalar of string (* Int, Bool, Text, user class name *)
|
|
| TNullable of typ (* ?T *)
|
|
| TMulti of typ (* multi T *)
|
|
| TMap of typ * typ (* map<K, V> *)
|
|
| TRef of string (* ref T — ID link *)
|
|
| TVoid (* no return *)
|
|
|
|
(* .wob field kinds (docs/plan/oop-vm/00-wob-format.md) *)
|
|
type wob_kind =
|
|
| WO_K_SCALAR (* 0 *)
|
|
| WO_K_OWNED (* 1 *)
|
|
| WO_K_GCREF (* 2 *)
|
|
| WO_K_TEXT (* 3 *)
|
|
| WO_K_MULTI (* 4 *)
|
|
| WO_K_MAP (* 5 *)
|
|
| WO_K_NULLABLE (* 6 *)
|
|
|
|
(* ============================================================
|
|
Symbol tables (Pass 1 output, Pass 2 input)
|
|
============================================================ *)
|
|
|
|
type class_info = {
|
|
name : string;
|
|
is_class : bool;
|
|
is_record : bool; (* haxe-parity Task 4 — see Ast.class_decl.is_record *)
|
|
is_gc : bool;
|
|
table : table_cfg option;
|
|
fields : (string * field_ty * default_expr option * string list) list;
|
|
methods : method_info list;
|
|
id : int;
|
|
pos : pos;
|
|
pub : bool; (* haxe-parity Task 1 (modules) — see Ast.class_decl.pub *)
|
|
}
|
|
|
|
and interface_info = {
|
|
name : string;
|
|
methods : method_sig_info list;
|
|
id : int;
|
|
pos : pos;
|
|
pub : bool; (* haxe-parity Task 1 (modules) *)
|
|
}
|
|
|
|
and method_sig_info = {
|
|
name : string;
|
|
params : (string * field_ty * param_conv) list;
|
|
ret : field_ty option;
|
|
pos : pos;
|
|
id : int;
|
|
}
|
|
|
|
and method_info = {
|
|
name : string;
|
|
params : (string * field_ty * param_conv) list;
|
|
ret : field_ty option;
|
|
body : stmt list;
|
|
mutates : bool;
|
|
is_static : bool;
|
|
id : int;
|
|
pos : pos;
|
|
}
|
|
|
|
and free_fn_info = {
|
|
name : string;
|
|
params : (string * field_ty * param_conv) list;
|
|
ret : field_ty option;
|
|
body : stmt list;
|
|
mutates : bool;
|
|
id : int;
|
|
pos : pos;
|
|
pub : bool; (* haxe-parity Task 1 (modules) *)
|
|
}
|
|
|
|
and typedef_info = {
|
|
name : string;
|
|
fields : (string * field_ty * default_expr option) list;
|
|
id : int;
|
|
pos : pos;
|
|
}
|
|
|
|
(* haxe-parity Task 4: one variant of a union. `vi_tag` is the variant's
|
|
ordinal in its union's declaration order — for an all-bare union that
|
|
IS the runtime value (a plain scalar tag); for a payload-carrying
|
|
union the runtime tag is instead the variant's own class-table id
|
|
(emit.ml assigns it; the object header's class_id field carries it —
|
|
docs/plan/oop-vm/00-wob-format.md's variant convention), and vi_tag
|
|
only orders exhaustiveness messages deterministically. *)
|
|
and variant_info = {
|
|
vi_name : string;
|
|
vi_fields : (string * field_ty) list;
|
|
vi_tag : int;
|
|
}
|
|
|
|
and union_info = {
|
|
u_name : string;
|
|
u_variants : variant_info list;
|
|
(* at least one variant carries payload fields: every value of this
|
|
union is a heap variant object. False = all-bare: pure scalar tags,
|
|
no class-table entries, no heap. *)
|
|
u_has_payload : bool;
|
|
u_id : int;
|
|
u_pos : pos;
|
|
u_pub : bool;
|
|
}
|
|
|
|
and symbols = {
|
|
classes : class_info StringMap.t;
|
|
interfaces : interface_info StringMap.t;
|
|
free_fns : free_fn_info StringMap.t;
|
|
typedefs : typedef_info StringMap.t;
|
|
unions : union_info StringMap.t; (* haxe-parity Task 4 *)
|
|
modules : string list;
|
|
}
|
|
|
|
(* Variant lookup by bare name, across every union in scope — how a
|
|
`Pending`/`Failed(...)` reference resolves at all (variants share one
|
|
flat namespace per program, like free fns; a duplicate within one
|
|
file is WO-E215, below). Unions are few and small, so a fold beats
|
|
maintaining a second, derived map that could drift. *)
|
|
let find_variant_in (unions : union_info StringMap.t) (name : string) :
|
|
(union_info * variant_info) option =
|
|
StringMap.fold
|
|
(fun _ u acc ->
|
|
match acc with
|
|
| Some _ -> acc
|
|
| None -> (
|
|
match List.find_opt (fun v -> v.vi_name = name) u.u_variants with
|
|
| Some v -> Some (u, v)
|
|
| None -> None))
|
|
unions None
|
|
|
|
let find_variant (syms : symbols) (name : string) : (union_info * variant_info) option =
|
|
find_variant_in syms.unions name
|
|
|
|
(* Builtin scalars *)
|
|
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
|
|
|
|
let is_builtin_scalar name = List.mem name builtin_scalars
|
|
|
|
(* haxe-parity Task 1 (modules) / gap-closure amendment: six reserved
|
|
stdlib namespaces, not the plan text's five — `use fs`/`proc`/`net`/
|
|
`time`/`json`/`env` must resolve now (their members arrive in plan
|
|
9); a project module can never legally shadow one of these six
|
|
single-segment names (`check_use_edges` below treats any one-segment
|
|
`use` path whose name is in this list as stdlib, unconditionally,
|
|
never as a project directory search). *)
|
|
let stdlib_modules = [ "fs"; "proc"; "net"; "time"; "json"; "env" ]
|
|
|
|
let is_stdlib_module (name : string) : bool = List.mem name stdlib_modules
|
|
|
|
let rec has_recursive_structure (cls : class_info) : bool =
|
|
List.exists (fun (_, ty, _, _) ->
|
|
match ty with
|
|
| Ast.Scalar name -> name = cls.name (* direct self-reference *)
|
|
| Ast.Ref name -> name = cls.name
|
|
| Ast.Multi name -> name = cls.name (* multi Self *)
|
|
| Ast.Map (k, v) -> k = cls.name || v = cls.name (* map<_, Self> / map<Self, _> *)
|
|
| Ast.Nullable inner -> has_recursive_structure_type inner cls.name
|
|
) cls.fields
|
|
|
|
and has_recursive_structure_type (ty : Ast.field_ty) (cls_name : string) : bool =
|
|
match ty with
|
|
| Ast.Scalar name -> name = cls_name
|
|
| Ast.Ref name -> name = cls_name
|
|
| Ast.Multi name -> name = cls_name
|
|
| Ast.Map (k, v) -> k = cls_name || v = cls_name
|
|
| Ast.Nullable inner -> has_recursive_structure_type inner cls_name
|
|
|
|
(* @unique field -> persistent identity (plan's "When NOT to emit": a
|
|
class with a @unique field should not get the @gc suggestion even if
|
|
it also has recursive/shared structure). Annotation *names* only, per
|
|
Ast.field's own doc comment -- "unique" is what parse_field stores for
|
|
a bare `@unique`. *)
|
|
let has_unique_field (cls : class_info) : bool =
|
|
List.exists (fun (_, _, _, anns) -> List.mem "unique" anns) cls.fields
|
|
|
|
let is_gc_class (syms : symbols) name =
|
|
try
|
|
let cls = StringMap.find name syms.classes in
|
|
cls.is_gc
|
|
with Not_found -> false
|
|
|
|
let gc_suggestion_code = Diag.warning_prefix ^ "201" (* WO-W201 *)
|
|
|
|
let suggest_gc_annotation ~file (cls : class_info) (collector : Diag.Collector.t) : unit =
|
|
if not cls.is_gc && Option.is_none cls.table && not (has_unique_field cls)
|
|
&& has_recursive_structure cls then
|
|
Diag.Collector.add collector
|
|
(Diag.warning ~code:gc_suggestion_code ~file ~line:cls.pos.line ~col:cls.pos.col
|
|
~message:(Printf.sprintf "%s has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default)." cls.name) ())
|
|
|
|
(* Ast.field_ty -> the internal resolved typ. Hoisted out of
|
|
typecheck_program (where it was a local closure) so the .wob emitter
|
|
can reach the same mapping instead of keeping a second copy of it;
|
|
the check pass still calls it under its old local name. *)
|
|
let rec typ_of_field_ty (ft : field_ty) : typ =
|
|
match ft with
|
|
| Scalar name -> TScalar name
|
|
| Ref name -> TRef name
|
|
| Multi inner_name -> TMulti (TScalar inner_name)
|
|
| Map (k_name, v_name) -> TMap (TScalar k_name, TScalar v_name)
|
|
| Nullable inner -> TNullable (typ_of_field_ty inner)
|
|
|
|
(* wob_kind_of_typ: maps internal typ to .wob field kind *)
|
|
let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind =
|
|
let kind_of = function
|
|
| TScalar name ->
|
|
(* Text is NOT a plain scalar slot: a Text field holds a heap
|
|
string, and the runtime's per-kind drop plan
|
|
(runtime/src/gc.c wo_drop_kind) only frees it under
|
|
WO_K_TEXT. Emitting WO_K_SCALAR here leaked every string a
|
|
class owned. Found by the emitter, this function's first
|
|
caller. *)
|
|
if name = "Text" then WO_K_TEXT
|
|
else if is_builtin_scalar name then WO_K_SCALAR
|
|
else if StringMap.mem name syms.unions then
|
|
(* haxe-parity Task 4: an all-bare union value is a plain
|
|
integer tag — SCALAR, or the drop plan would chase the tag
|
|
as a pointer (the exact int-as-pointer segfault family this
|
|
codebase keeps re-finding). A payload union value is a heap
|
|
variant object the slot owns — OWNED, its own class-table
|
|
kinds free the payload recursively. *)
|
|
(if (StringMap.find name syms.unions).u_has_payload then WO_K_OWNED
|
|
else WO_K_SCALAR)
|
|
else if is_gc_class syms name then WO_K_GCREF
|
|
else WO_K_OWNED
|
|
| TNullable _inner -> WO_K_NULLABLE
|
|
| TMulti _ -> WO_K_MULTI
|
|
| TMap _ -> WO_K_MAP
|
|
| TRef _ -> WO_K_SCALAR
|
|
| TVoid -> WO_K_SCALAR
|
|
in
|
|
kind_of t
|
|
|
|
(* ============================================================
|
|
Diagnostics (WO-E2xx)
|
|
============================================================ *)
|
|
|
|
let type_mismatch_code = Diag.types_prefix ^ "01"
|
|
let unknown_field_code = Diag.types_prefix ^ "02"
|
|
let bad_arity_code = Diag.types_prefix ^ "03"
|
|
let unknown_fn_code = Diag.types_prefix ^ "04"
|
|
let unsatisfied_interface_code = Diag.types_prefix ^ "05"
|
|
let incomplete_ctor_code = Diag.types_prefix ^ "06"
|
|
let unknown_type_code = Diag.types_prefix ^ "07"
|
|
let non_exhaustive_switch_code = Diag.types_prefix ^ "08"
|
|
let invalid_builtin_code = Diag.types_prefix ^ "09"
|
|
let module_not_imported_code = Diag.types_prefix ^ "10"
|
|
let nullable_used_without_check_code = Diag.types_prefix ^ "11"
|
|
let nullable_assign_mismatch_code = Diag.types_prefix ^ "12"
|
|
let missing_nil_check_code = Diag.types_prefix ^ "13"
|
|
|
|
(* haxe-parity Task 1 (modules). module_not_imported_code (WO-E210,
|
|
above) was already reserved by Task 6's brief for exactly this: "a
|
|
name used from a module that was never `use`d" — genuinely blocked
|
|
until now on there being a `use`/module concept at all
|
|
(docs/plan/compiler/nullable-types-implementation.md's dead-code
|
|
register). The three below are new — no earlier task named or
|
|
reserved them, because no earlier task had a module system to need
|
|
them for. *)
|
|
let unknown_module_code = Diag.types_prefix ^ "16" (* WO-E216 *)
|
|
let private_name_code = Diag.types_prefix ^ "17" (* WO-E217 *)
|
|
let use_collision_code = Diag.types_prefix ^ "18" (* WO-E218 *)
|
|
let unused_use_code = Diag.warning_prefix ^ "202" (* WO-W202 *)
|
|
|
|
let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *)
|
|
|
|
(* haxe-parity Task 3, review fix (Critical 1). `default` is moved to
|
|
the *end* of the lowering order regardless of where it sits in the
|
|
source (Ast.switch_lowering_order) -- so a `case` arm written after
|
|
it is not a silent, unreachable dead-code trap the way it was
|
|
before that fix, but it is still surprising source: warn once per
|
|
switch shaped that way, naming where `default` actually sits. *)
|
|
let switch_default_not_last_code = Diag.warning_prefix ^ "203" (* WO-W203 *)
|
|
|
|
(* Same-file counterpart to main.ml's cross-file WO-E214 (Task 8 review):
|
|
a duplicate class/interface/fn name declared twice *within one file*
|
|
was silently dropped by collect_declarations's StringMap.add (Task 1
|
|
review, "Known limitations" #6 -- no diagnostic at all). *)
|
|
let duplicate_decl_code = Diag.types_prefix ^ "15" (* WO-E215 *)
|
|
|
|
(* ============================================================
|
|
Pass 1: Declaration Collection
|
|
============================================================ *)
|
|
|
|
(* Reported at the *later* declaration, with the first as the related
|
|
site -- exactly WO-E214's shape. The map keeps the first declaration
|
|
(a duplicate is never added), matching WO-E214's own first-wins rule
|
|
for the merged cross-file table. *)
|
|
let report_duplicate_decl (collector : Diag.Collector.t) ~file ~(kind : string) ~(name : string)
|
|
~(pos : pos) ~(first_pos : pos) : unit =
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:duplicate_decl_code ~file ~line:pos.line ~col:pos.col
|
|
~message:(Printf.sprintf "%s `%s` already declared" kind name)
|
|
~related:
|
|
[ Diag.related_site ~file ~line:first_pos.line ~col:first_pos.col
|
|
~label:(Printf.sprintf "`%s` first declared here" name)
|
|
]
|
|
())
|
|
|
|
let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) : symbols =
|
|
let classes = ref StringMap.empty in
|
|
let interfaces = ref StringMap.empty in
|
|
let free_fns = ref StringMap.empty in
|
|
let typedefs = ref StringMap.empty in
|
|
let unions = ref StringMap.empty in
|
|
let modules = ref [] in
|
|
|
|
List.iter (function
|
|
| Ast.Class c ->
|
|
let fields = List.map (fun (f : Ast.field) ->
|
|
(f.name, f.ty, f.default, f.annotations)
|
|
) c.fields in
|
|
let methods = List.map (fun (m : Ast.method_decl) ->
|
|
{ name = m.name;
|
|
params = List.map (fun (p : Ast.param) -> (p.name, p.ty, p.conv)) m.params;
|
|
ret = m.ret;
|
|
body = m.body;
|
|
mutates = false;
|
|
is_static = false;
|
|
id = m.id;
|
|
pos = m.pos; }
|
|
) (c.methods : Ast.method_decl list) in
|
|
let info = {
|
|
name = c.name;
|
|
is_class = c.is_class;
|
|
is_record = c.is_record;
|
|
is_gc = c.is_gc;
|
|
table = c.table;
|
|
fields = fields;
|
|
methods = methods;
|
|
id = c.id;
|
|
pos = c.pos;
|
|
pub = c.pub;
|
|
} in
|
|
(match StringMap.find_opt c.name !classes with
|
|
| Some (existing : class_info) ->
|
|
report_duplicate_decl collector ~file ~kind:"class" ~name:c.name ~pos:c.pos
|
|
~first_pos:existing.pos
|
|
| None ->
|
|
classes := StringMap.add c.name info !classes;
|
|
suggest_gc_annotation ~file info collector)
|
|
| Ast.Interface i ->
|
|
let methods = List.map (fun (m : Ast.method_sig) ->
|
|
{ name = m.name;
|
|
params = List.map (fun (p : Ast.param) -> (p.name, p.ty, p.conv)) m.params;
|
|
ret = m.ret;
|
|
pos = m.pos;
|
|
id = m.id; }
|
|
) (i.methods : Ast.method_sig list) in
|
|
let info = {
|
|
name = i.name;
|
|
methods = methods;
|
|
id = i.id;
|
|
pos = i.pos;
|
|
pub = i.pub;
|
|
} in
|
|
(match StringMap.find_opt i.name !interfaces with
|
|
| Some (existing : interface_info) ->
|
|
report_duplicate_decl collector ~file ~kind:"interface" ~name:i.name ~pos:i.pos
|
|
~first_pos:existing.pos
|
|
| None -> interfaces := StringMap.add i.name info !interfaces)
|
|
| Ast.Fn f ->
|
|
let info = {
|
|
name = f.name;
|
|
params = List.map (fun (p : Ast.param) -> (p.name, p.ty, p.conv)) f.params;
|
|
ret = f.ret;
|
|
body = f.body;
|
|
mutates = false;
|
|
id = f.id;
|
|
pos = f.pos;
|
|
pub = f.pub;
|
|
} in
|
|
(match StringMap.find_opt f.name !free_fns with
|
|
| Some (existing : free_fn_info) ->
|
|
report_duplicate_decl collector ~file ~kind:"fn" ~name:f.name ~pos:f.pos
|
|
~first_pos:existing.pos
|
|
| None -> free_fns := StringMap.add f.name info !free_fns)
|
|
| Ast.Use _ -> ()
|
|
(* module-graph concern (haxe-parity Task 1), not a declaration —
|
|
handled by check_modules below, over the raw AST directly (it
|
|
needs the *file's* use-edges, not a merged per-name table). *)
|
|
| Ast.Const _ -> ()
|
|
(* haxe-parity Task 2: consts are fully resolved by parser.ml's own
|
|
post-parse substitution pass before typecheck ever runs — every
|
|
reference already *is* the literal it named, so there is
|
|
nothing left for this stage to declare or check. *)
|
|
| Ast.Union u ->
|
|
(* haxe-parity Task 4. Duplicate union names get WO-E215 exactly
|
|
like classes/interfaces/fns do; a variant name reused across
|
|
this file's unions gets it too (variants share one flat value
|
|
namespace — a bare `Ok` reference could not otherwise pick a
|
|
tag). Cross-file variant collisions ride the same first-wins
|
|
merge every other kind already has (WO-E214 covers classes/
|
|
interfaces only — disclosed, not extended here). *)
|
|
let variants =
|
|
List.mapi
|
|
(fun i (v : Ast.variant_decl) ->
|
|
{ vi_name = v.v_name; vi_fields = v.v_fields; vi_tag = i })
|
|
u.variants
|
|
in
|
|
let info = {
|
|
u_name = u.name;
|
|
u_variants = variants;
|
|
u_has_payload = List.exists (fun v -> v.vi_fields <> []) variants;
|
|
u_id = u.id;
|
|
u_pos = u.pos;
|
|
u_pub = u.pub;
|
|
} in
|
|
(match StringMap.find_opt u.name !unions with
|
|
| Some (existing : union_info) ->
|
|
report_duplicate_decl collector ~file ~kind:"union" ~name:u.name ~pos:u.pos
|
|
~first_pos:existing.u_pos
|
|
| None ->
|
|
let seen_own = ref [] in
|
|
List.iter
|
|
(fun (v : Ast.variant_decl) ->
|
|
(match List.assoc_opt v.v_name !seen_own with
|
|
| Some first_pos ->
|
|
report_duplicate_decl collector ~file ~kind:"variant" ~name:v.v_name
|
|
~pos:v.v_pos ~first_pos
|
|
| None -> (
|
|
match find_variant_in !unions v.v_name with
|
|
| Some (other, _) ->
|
|
report_duplicate_decl collector ~file ~kind:"variant" ~name:v.v_name
|
|
~pos:v.v_pos ~first_pos:other.u_pos
|
|
| None -> ()));
|
|
seen_own := (v.v_name, v.v_pos) :: !seen_own)
|
|
u.variants;
|
|
unions := StringMap.add u.name info !unions)
|
|
) prog.decls;
|
|
|
|
{ classes = !classes; interfaces = !interfaces; free_fns = !free_fns;
|
|
typedefs = !typedefs; unions = !unions; modules = !modules }
|
|
|
|
(* ============================================================
|
|
Pass 2: Body Typechecking
|
|
============================================================ *)
|
|
|
|
type expr_type_result = {
|
|
typ : typ;
|
|
is_nil : bool;
|
|
}
|
|
|
|
(* WO-E225's "known type" set: builtin, a declared class (typedef
|
|
records included — they live in `classes`), a declared interface, or
|
|
(haxe-parity Task 4) a declared union. A dotted name whose head is a
|
|
reserved stdlib namespace (`json.Value` — the sample's own record
|
|
field) is UNKNOWN-BUT-RESERVED: accepted here the same way a
|
|
`fs.stat(...)` call is accepted by the module checker, since the six
|
|
namespaces' members arrive in plan 9. Any other dotted name is as
|
|
unknown as a misspelling. *)
|
|
let is_known_type_name (syms : symbols) (name : string) : bool =
|
|
is_builtin_scalar name
|
|
|| StringMap.mem name syms.classes
|
|
|| StringMap.mem name syms.interfaces
|
|
|| StringMap.mem name syms.unions
|
|
|| (match String.index_opt name '.' with
|
|
| Some i -> is_stdlib_module (String.sub name 0 i)
|
|
| None -> false)
|
|
|
|
let rec scalar_name_of (ft : field_ty) : string option =
|
|
match ft with
|
|
| Scalar name -> Some name
|
|
| Nullable inner -> scalar_name_of inner
|
|
| Ref _ | Multi _ | Map _ -> None
|
|
|
|
(* Checked once per field declaration (not at every access/use site), so
|
|
the diagnostic lands at the field's own declaration position and
|
|
never fires more than once for the same bad field. Runs over the raw
|
|
AST rather than `syms.classes` because class_info's fields tuple
|
|
doesn't carry a pos (see Ast.field for that); run from Pass 2 (after
|
|
collect_declarations has fully built `syms`) so a field typed with a
|
|
class declared later in the same file is not a false positive. *)
|
|
let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
|
|
(prog : program) : unit =
|
|
List.iter (function
|
|
| Ast.Class c ->
|
|
List.iter (fun (f : Ast.field) ->
|
|
match scalar_name_of f.ty with
|
|
| Some name when not (is_known_type_name syms name) ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:unknown_type_name_code ~file
|
|
~line:f.pos.line ~col:f.pos.col
|
|
~message:(Printf.sprintf "unknown type `%s`" name) ())
|
|
| _ -> ()
|
|
) c.fields
|
|
| Ast.Union u ->
|
|
(* haxe-parity Task 4: a variant's payload field types get the
|
|
same once-per-declaration WO-E225 check a class field's type
|
|
does, at the variant's own position. *)
|
|
List.iter (fun (v : Ast.variant_decl) ->
|
|
List.iter (fun (_, fty) ->
|
|
match scalar_name_of fty with
|
|
| Some name when not (is_known_type_name syms name) ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:unknown_type_name_code ~file
|
|
~line:v.v_pos.line ~col:v.v_pos.col
|
|
~message:(Printf.sprintf "unknown type `%s`" name) ())
|
|
| _ -> ()
|
|
) v.v_fields
|
|
) u.variants
|
|
| Ast.Interface _ | Ast.Fn _ | Ast.Use _ | Ast.Const _ -> ()
|
|
) prog.decls
|
|
|
|
(* haxe-parity Task 4: structural typedef equality — "two typedefs with
|
|
the same shape are the SAME type" (the task brief's own words). Shape
|
|
= the ordered (name, type) field list; defaults are construction-time
|
|
sugar carried per alias, not part of the shape. Used wherever two
|
|
resolved types are compared for agreement (today: a switch's arm
|
|
unification); nominal names still win everywhere else (`A` = `A`
|
|
short-circuits first). *)
|
|
let record_shapes_equal (syms : symbols) (a : string) (b : string) : bool =
|
|
match (StringMap.find_opt a syms.classes, StringMap.find_opt b syms.classes) with
|
|
| Some ca, Some cb ->
|
|
ca.is_record && cb.is_record
|
|
&& List.length ca.fields = List.length cb.fields
|
|
&& List.for_all2
|
|
(fun (na, ta, _, _) (nb, tb, _, _) -> na = nb && ta = tb)
|
|
ca.fields cb.fields
|
|
| _ -> false
|
|
|
|
let rec typ_equal (syms : symbols) (a : typ) (b : typ) : bool =
|
|
a = b
|
|
|| (match (a, b) with
|
|
| TScalar x, TScalar y -> record_shapes_equal syms x y
|
|
| TNullable x, TNullable y -> typ_equal syms x y
|
|
| TMulti x, TMulti y -> typ_equal syms x y
|
|
| TMap (ka, va), TMap (kb, vb) -> typ_equal syms ka kb && typ_equal syms va vb
|
|
| _ -> false)
|
|
|
|
(* ============================================================
|
|
WO-E209 — builtin call signature checking (hotfix)
|
|
============================================================
|
|
|
|
`print(7)` compiled clean and segfaulted wovm: `print` wants a
|
|
`Text` (a heap-string pointer, .wob kind WO_K_TEXT), the VM's
|
|
`str_check` dereferences whatever register it's handed as a
|
|
`wo_str*` with no runtime tag to check first (registers are
|
|
untyped by design -- the compiler is supposed to be the only gate),
|
|
and a bare `7` is a WO_K_SCALAR int64 sitting in that register --
|
|
a wild pointer read. Source of truth for the table below is
|
|
docs/plan/oop-vm/08-builtin-surface.md; the arities mirror
|
|
emit.ml's `is_builtin_name`/`arity_of` (its own, emission-time
|
|
arity check, WO-E403, kept as-is -- this is a second, earlier gate
|
|
over the same contract, not a replacement). *)
|
|
|
|
type builtin_arg_req =
|
|
| ReqText
|
|
| ReqInt
|
|
| ReqMulti
|
|
| ReqMap
|
|
| ReqContainer (* multi or map, e.g. `count`/`get` resolve on either *)
|
|
| ReqAny (* not checked here -- e.g. push/set/get's key/value args:
|
|
their type depends on the container's own element/key/value
|
|
kind, which this check does not chase *)
|
|
|
|
let builtin_signatures : (string * int * builtin_arg_req list) list =
|
|
[ ("now", 0, []);
|
|
("print", 1, [ ReqText ]);
|
|
("print_int", 1, [ ReqInt ]);
|
|
("words", 1, [ ReqText ]);
|
|
("multi_new", 0, []);
|
|
("map_new", 0, []);
|
|
("push", 2, [ ReqMulti; ReqAny ]);
|
|
("get", 2, [ ReqContainer; ReqAny ]);
|
|
("count", 1, [ ReqContainer ]);
|
|
("latest", 1, [ ReqMulti ]);
|
|
("set", 3, [ ReqMap; ReqAny; ReqAny ]);
|
|
("has", 2, [ ReqMap; ReqAny ]);
|
|
("int_to_text", 1, [ ReqInt ]);
|
|
]
|
|
|
|
let rec unwrap_nullable (t : typ) : typ =
|
|
match t with
|
|
| TNullable inner -> unwrap_nullable inner
|
|
| other -> other
|
|
|
|
(* `ReqInt` accepts any non-`Text` builtin scalar (`Int`, `Bool`,
|
|
`Timestamp`, `Id`), not literally the string "Int" -- `wob_kind_of_typ`
|
|
(above) maps all four to the identical runtime representation,
|
|
`WO_K_SCALAR`, a plain int64 register. The hazard this whole check
|
|
exists for is a *representation* mismatch (`WO_K_TEXT`'s heap-string
|
|
pointer read where a `WO_K_SCALAR` int64 sits, or vice versa) -- and
|
|
`has`/`now` returning `Bool`/`Timestamp` into a `print_int`
|
|
(`docs/plan/oop-vm/08-builtin-surface.md`'s own "1/0" convention for
|
|
`has`) is exactly as runtime-safe as an `Int` there, found as a real
|
|
false positive against `tests/corpus/run/pricing-containers/`'s
|
|
`print_int(has(c.by_name, "shirt"))` once `confident_typ` started
|
|
chasing builtin return types. `Text` stays its own, narrower case:
|
|
it is the one builtin scalar with a genuinely different
|
|
representation. *)
|
|
let is_scalar_shaped (name : string) : bool = is_builtin_scalar name && name <> "Text"
|
|
|
|
let matches_req (req : builtin_arg_req) (t : typ) : bool =
|
|
match req, unwrap_nullable t with
|
|
| ReqAny, _ -> true
|
|
| ReqText, TScalar "Text" -> true
|
|
| ReqInt, TScalar name -> is_scalar_shaped name
|
|
| ReqMulti, TMulti _ -> true
|
|
| ReqMap, TMap _ -> true
|
|
| ReqContainer, (TMulti _ | TMap _) -> true
|
|
| (ReqText | ReqInt | ReqMulti | ReqMap | ReqContainer), _ -> false
|
|
|
|
let req_label = function
|
|
| ReqText -> "Text"
|
|
| ReqInt -> "Int"
|
|
| ReqMulti -> "a `multi`"
|
|
| ReqMap -> "a `map`"
|
|
| ReqContainer -> "a `multi` or `map`"
|
|
| ReqAny -> "any" (* matches_req is always true here -- never rendered *)
|
|
|
|
let rec typ_label (t : typ) : string =
|
|
match t with
|
|
| TScalar name -> name
|
|
| TNullable inner -> "?" ^ typ_label inner
|
|
| TMulti _ -> "multi"
|
|
| TMap _ -> "map"
|
|
| TRef name -> "ref " ^ name
|
|
| TVoid -> "void"
|
|
|
|
(* A builtin call's own confident return type, for when it appears as
|
|
*another* builtin's argument (`print(words(x))` -- `words` returns
|
|
`Int`, so that's `WO-E209` too, not a pass-through). Mirrors emit.ml's
|
|
`builtin_ret` (its own confident return-type table, used when
|
|
lowering an expression position) -- duplicated rather than shared
|
|
because emit.ml operates over `Ast.field_ty`/its own `pctx`/`fstate`,
|
|
not `Types.typ`/`symbols`, and this task leaves emit.ml untouched.
|
|
Kept deliberately in sync: a future builtin whose return type changes
|
|
needs both tables updated together. `multi_new`/`map_new` are absent
|
|
on purpose -- their result's element kind comes from the destination
|
|
(08-builtin-surface.md), not from anything chaseable here. *)
|
|
let builtin_confident_ret (name : string) (arg0 : typ option) : typ option =
|
|
let arg0 = Option.map unwrap_nullable arg0 in
|
|
match name with
|
|
| "int_to_text" -> Some (TScalar "Text")
|
|
| "now" -> Some (TScalar "Timestamp")
|
|
| "print" | "print_int" | "push" | "set" -> Some (TScalar "Int")
|
|
| "words" | "count" -> Some (TScalar "Int")
|
|
| "has" -> Some (TScalar "Bool")
|
|
| "latest" -> ( match arg0 with Some (TMulti e) -> Some e | _ -> None)
|
|
| "get" -> ( match arg0 with Some (TMulti e) -> Some e | Some (TMap (_, v)) -> Some v | _ -> None)
|
|
| _ -> None
|
|
|
|
(* `use_edge`/`uses_of_program`/`path_str` -- relocated here (hotfix)
|
|
from their original home in the "Modules" section, much further
|
|
below, purely so `confident_typ`'s free-fn resolution (inside
|
|
`typecheck_program`, next) can see them; OCaml has no forward
|
|
reference across top-level `let`s. See the "Modules" section itself
|
|
for the full design writeup these belong to. *)
|
|
type use_edge = {
|
|
ue_pos : pos;
|
|
ue_alias : string;
|
|
ue_segments : string list;
|
|
ue_is_stdlib : bool;
|
|
}
|
|
|
|
let uses_of_program (prog : program) : use_edge list =
|
|
List.filter_map
|
|
(function
|
|
| Use u ->
|
|
let alias = match List.rev u.segments with a :: _ -> a | [] -> "" in
|
|
let is_stdlib =
|
|
match u.segments with [ s ] -> is_stdlib_module s | _ -> false
|
|
in
|
|
Some { ue_pos = u.pos; ue_alias = alias; ue_segments = u.segments; ue_is_stdlib = is_stdlib }
|
|
| Class _ | Interface _ | Fn _ | Const _ | Union _ -> None)
|
|
prog.decls
|
|
|
|
let path_str (segments : string list) : string = String.concat "/" segments
|
|
|
|
(* `check_builtin_call` itself is unopinionated about *how* an
|
|
argument's type was derived -- it just compares whatever `typ
|
|
option` it's handed against the signature table. The derivation
|
|
(`confident_typ`, deliberately NOT `typecheck_expr`'s own `.typ`) is
|
|
defined inside `typecheck_program` below, next to `syms`/`env` --
|
|
see its own doc comment there for why a separate, narrower deriver
|
|
is load-bearing for this check's "stay silent when underivable"
|
|
contract. *)
|
|
let check_builtin_call ~file (collector : Diag.Collector.t) (name : string) (call_pos : pos)
|
|
(args : expr list) (confident_types : typ option list) : unit =
|
|
match List.find_opt (fun (n, _, _) -> n = name) builtin_signatures with
|
|
| None -> () (* not one of ours -- WO-E204's unresolved-call territory, not this check's *)
|
|
| Some (_, arity, reqs) ->
|
|
let given = List.length args in
|
|
if given <> arity then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:invalid_builtin_code ~file ~line:call_pos.line ~col:call_pos.col
|
|
~message:(Printf.sprintf "builtin `%s` takes %d argument(s), given %d" name arity given) ())
|
|
else
|
|
List.iter2
|
|
(fun ((req : builtin_arg_req), (arg_e : expr)) (ct : typ option) ->
|
|
match ct with
|
|
| None -> () (* underivable -- stay silent, no false positives *)
|
|
| Some t ->
|
|
if not (matches_req req t) then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:invalid_builtin_code ~file ~line:arg_e.pos.line ~col:arg_e.pos.col
|
|
~message:
|
|
(Printf.sprintf "builtin `%s` expects %s, got `%s`" name (req_label req)
|
|
(typ_label t))
|
|
()))
|
|
(List.combine reqs args) confident_types
|
|
|
|
(* `~file_syms` (hotfix, multi-file double-report): this file's OWN,
|
|
unmerged collect_declarations output — as opposed to `syms` below,
|
|
the whole-program merged table. Before this parameter existed, the
|
|
two StringMap.iter loops at the bottom of this function walked
|
|
`syms.classes`/`syms.free_fns` — the flat, cross-file table —
|
|
regardless of which file was being checked, so a program of N files
|
|
ran every file's bodies through typecheck_method/the free-fn loop
|
|
once per discovered file (N times total, not once), each redundant
|
|
pass re-reporting that body's diagnostics stamped with whichever
|
|
`~file` happened to be current. Every OTHER lookup in this function
|
|
(confident_typ, typecheck_expr's Field/Ctor cases, resolve_free_fn,
|
|
...) still goes through `syms`, unchanged — cross-file field/type/
|
|
call resolution keeps seeing the whole program; only the SET OF
|
|
BODIES actually walked and diagnosed narrows to this file's own. See
|
|
.superpowers/sdd/2026-08-01-haxe-parity-language/hotfix-e209-report.md's
|
|
"Disclosed, NOT fixed" section for the original repro and diagnosis. *)
|
|
let typecheck_program ~file ~(module_of : string -> string)
|
|
~(module_syms : (string, symbols) Hashtbl.t) ~(file_syms : symbols) (prog : program)
|
|
(syms : symbols) (collector : Diag.Collector.t) : unit =
|
|
check_field_types ~file syms collector prog;
|
|
let resolve_field_ty = typ_of_field_ty in
|
|
|
|
(* Free-fn resolution for `confident_typ`'s `Call`-return derivation
|
|
below, and for whether a bare `Ident` callee is a builtin at all
|
|
(a call to it, further down). Own module's own `free_fns`
|
|
unconditionally, then exactly one used module's `pub` export;
|
|
anything else (not found at all, or ambiguous across more than one
|
|
used module) is `None`, not a guess -- that ambiguity is
|
|
`WO-E217`/`WO-E218`'s own territory (`check_module_refs`, below),
|
|
not this derivation's to adjudicate. Deliberately NOT
|
|
`syms.free_fns` (the flat, whole-program merge fed to owner.ml and
|
|
most of emit.ml): two modules sharing a free-fn name would let the
|
|
flat table resolve to whichever module happened to merge first --
|
|
the exact Critical-1 bug shape haxe-parity Task 1 already found
|
|
and fixed for the emitter (emit.ml's `ty_of_expr`/`emit_call`, same
|
|
fix, same reason); this mirrors it here instead of repeating it. *)
|
|
let own_module_syms = Hashtbl.find_opt module_syms (module_of file) in
|
|
let uses_resolved =
|
|
List.filter_map
|
|
(fun (u : use_edge) ->
|
|
if u.ue_is_stdlib then None
|
|
else
|
|
match Hashtbl.find_opt module_syms (path_str u.ue_segments) with
|
|
| Some s -> Some (u, s)
|
|
| None -> None)
|
|
(uses_of_program prog)
|
|
in
|
|
let resolve_free_fn (name : string) : free_fn_info option =
|
|
match own_module_syms with
|
|
| Some s when StringMap.mem name s.free_fns -> StringMap.find_opt name s.free_fns
|
|
| _ -> (
|
|
match
|
|
List.filter_map
|
|
(fun (_, (msyms : symbols)) ->
|
|
match StringMap.find_opt name msyms.free_fns with
|
|
| Some fi when fi.pub -> Some fi
|
|
| _ -> None)
|
|
uses_resolved
|
|
with
|
|
| [ fi ] -> Some fi
|
|
| _ -> None)
|
|
in
|
|
|
|
(* WO-E209's own type deriver -- deliberately NOT typecheck_expr's
|
|
`.typ` below, and deliberately narrower. typecheck_expr hands back
|
|
exactly two placeholder values whenever it can't actually resolve
|
|
an expression -- `TScalar "Int"` (an unresolved `Ident`, a
|
|
`Field`/`Index` it can't type, a `Ctor` naming an unknown class)
|
|
and `TScalar "Bool"` (every `Binary`, unconditionally, regardless
|
|
of the real operator -- `a + b` types as "Bool" today exactly like
|
|
`a == b` does). Both are also genuine, real types an expression
|
|
can legitimately have, so trusting them blindly would misfire on
|
|
ordinary code this corpus actually has: `print(items[0])` (`Index`
|
|
always placeholder-types as `Int`) or `print_int(a + b)` (`Binary`
|
|
always types as `Bool`) would both become false positives.
|
|
`confident_typ` instead only trusts a handful of shapes it can
|
|
trace straight back to a declaration -- a literal, `self`'s or a
|
|
parameter's declared type, a class field's declared type, a `let`
|
|
whose own value was itself confidently typed, or (below) a `Call`
|
|
whose *declared signature* (a free fn, a class method, or an
|
|
interface method's signature) is known -- and returns `None` for
|
|
everything else, which is exactly this check's "stay silent when
|
|
underivable" contract. Its own `cenv` threads through every
|
|
statement in lockstep with `env` below (same `Let`/`If`/`While`/
|
|
`For` shape, same scoping quirks) but never shares storage with
|
|
it, so a placeholder that leaks into `env` through a `let` never
|
|
contaminates `cenv`.
|
|
|
|
A *declared* return type is the opposite of underivable, so a
|
|
`Call` is not blanket-skipped the way `Index`/`Binary` are: a free
|
|
fn's or method's own signature sits in the symbol table exactly
|
|
like a field's declared type does, and passing its result to a
|
|
builtin without ever narrowing it is the same class of bug
|
|
`print(7)` is -- `print(takesSecret(box))` where `takesSecret`
|
|
is declared `-> Int` is exactly as wrong as `print(7)`, just one
|
|
call deeper. A declared return type of `None` (no `-> T` written
|
|
at all) is `TVoid`, not another guess -- still confident, since a
|
|
fn/method with no declared return genuinely has no value to hand
|
|
back in this grammar. What still isn't chased: a *qualified*
|
|
free-fn call (`mod.fn(...)`) falls through the `Field` case below
|
|
with a use-alias `base` that never resolves as a value, landing on
|
|
`None`; `multi_new`/`map_new` (contextual on the destination,
|
|
`builtin_confident_ret`'s own doc comment); and an
|
|
UNKNOWN-BUT-RESERVED stdlib call (`fs.stat(...)`) has no signature
|
|
to find in the first place, so it also falls through to `None`
|
|
unchanged. *)
|
|
let rec confident_typ (cenv : typ StringMap.t) (e : expr) : typ option =
|
|
match e.kind with
|
|
| IntLit _ -> Some (TScalar "Int")
|
|
| StrLit _ -> Some (TScalar "Text")
|
|
| BoolLit _ -> Some (TScalar "Bool")
|
|
| Ident name -> StringMap.find_opt name cenv
|
|
| Field (base, field_name) -> (
|
|
match confident_typ cenv base with
|
|
| Some (TScalar class_name) -> (
|
|
match StringMap.find_opt class_name syms.classes with
|
|
| None -> None
|
|
| Some cls -> (
|
|
match List.find_opt (fun (fname, _, _, _) -> fname = field_name) cls.fields with
|
|
| Some (_, field_ty, _, _) -> Some (resolve_field_ty field_ty)
|
|
| None -> None))
|
|
| _ -> None)
|
|
| Call (callee, args) -> (
|
|
match callee.kind with
|
|
| Ident name -> (
|
|
match resolve_free_fn name with
|
|
| Some fi -> ( match fi.ret with Some ft -> Some (resolve_field_ty ft) | None -> Some TVoid)
|
|
| None -> (
|
|
(* haxe-parity Task 4: a payload-variant construction
|
|
(`Failed("boom")`) is confidently its union's own
|
|
type — the variant name is a declaration lookup, the
|
|
same "traceable straight back to a declaration"
|
|
standard every other confident shape here meets. A
|
|
declared free fn of the same name already won above
|
|
(the shadowing rule); a call position can never be a
|
|
local, so no lexical-shadowing hazard either (the
|
|
bare-Ident variant reference is deliberately NOT
|
|
derived here for exactly that reason — cenv cannot
|
|
distinguish an unconfident local from an unbound
|
|
name). *)
|
|
match find_variant syms name with
|
|
| Some (u, _) -> Some (TScalar u.u_name)
|
|
| None -> (
|
|
match List.find_opt (fun (n, _, _) -> n = name) builtin_signatures with
|
|
| None -> None
|
|
| Some _ ->
|
|
let arg0 = match args with a :: _ -> confident_typ cenv a | [] -> None in
|
|
builtin_confident_ret name arg0)))
|
|
| Field (base, mname) -> (
|
|
(* A method call, dispatched by the *receiver's* confident
|
|
type -- a concrete class first (an ordinary method), an
|
|
interface second (structural dispatch, ICALL). A
|
|
use-alias `base` (a qualified free-fn call) never
|
|
resolves here at all -- `confident_typ` has no binding
|
|
for a bare module alias, only for locals/params/`self`
|
|
-- so it falls straight to `None`, matching the existing
|
|
exemption for stdlib-qualified calls elsewhere in this
|
|
check. *)
|
|
match confident_typ cenv base with
|
|
| Some (TScalar class_name) -> (
|
|
match StringMap.find_opt class_name syms.classes with
|
|
| Some cls -> (
|
|
match List.find_opt (fun (m : method_info) -> m.name = mname) cls.methods with
|
|
| Some m -> (
|
|
match m.ret with Some ft -> Some (resolve_field_ty ft) | None -> Some TVoid)
|
|
| None -> None)
|
|
| None -> (
|
|
match StringMap.find_opt class_name syms.interfaces with
|
|
| Some iface -> (
|
|
match
|
|
List.find_opt (fun (s : method_sig_info) -> s.name = mname) iface.methods
|
|
with
|
|
| Some s -> (
|
|
match s.ret with Some ft -> Some (resolve_field_ty ft) | None -> Some TVoid)
|
|
| None -> None)
|
|
| None -> None))
|
|
| _ -> None)
|
|
| _ -> None)
|
|
| Ctor (class_name, _fields) ->
|
|
(* `Ctor`'s class name is never a placeholder -- unlike
|
|
`Ident`/`Field`/`Index`/`Call`, there is no fallback path
|
|
that invents a `Ctor` node, so the name it carries is always
|
|
exactly what the source wrote. A real declared class makes
|
|
this confident (and is also what lets a receiver built the
|
|
ordinary way, `let b = Box{}`, feed the method-call
|
|
derivation above through `b`'s own `let` binding); an
|
|
unknown class name is already `WO-E207`'s own territory, not
|
|
a fallback this check should trust. *)
|
|
if StringMap.mem class_name syms.classes then Some (TScalar class_name) else None
|
|
| Binary ((Eq | Ne | Lt | Le | Gt | Ge | And | Or), _, _) ->
|
|
(* Unlike Add/Sub/Concat/etc. (still "not chased" below — a real
|
|
placeholder-avoidance gap, not this task's to fix), a
|
|
comparison or `and`/`or` is confidently `Bool` regardless of
|
|
its operands' own types: that is what the operator *means*,
|
|
not a guess the way `TScalar "Int"` would be for e.g. `Index`.
|
|
This is what lets the and/or operand check (below,
|
|
typecheck_expr's own `Binary` case) see through
|
|
`a == 1 and b == 2` without a false "underivable" silence on
|
|
the left-hand comparison. *)
|
|
Some (TScalar "Bool")
|
|
| Interp _ ->
|
|
(* An interpolation always *produces* Text by construction
|
|
(emit.ml decides, per-segment, whether the embedded value
|
|
needs `int_to_text` first) -- unlike the placeholders below,
|
|
this is a fact, not a guess. *)
|
|
Some (TScalar "Text")
|
|
| Index _ | Unary _ | Binary _ | DbStub _ ->
|
|
(* Not chased: `Index`/the arithmetic-ladder `Binary` ops have no
|
|
reliable per-node type in this pass at all (see above);
|
|
`Unary`/`DbStub` would be cheap to add but nothing in this
|
|
task's fixtures or the log-watcher sample needs them, and a
|
|
narrower deriver is the safer default. *)
|
|
None
|
|
| Switch _ ->
|
|
(* haxe-parity Task 3: same "not chased" call as `Index`/`Binary`
|
|
above — `typecheck_expr`'s own `Switch` case (below) is the
|
|
real, full derivation (arm unification, the default-required
|
|
check); a `switch` as a WO-E209 builtin argument (e.g.
|
|
`print(switch x {...})`) is not a shape any fixture or the
|
|
sample needs, so this narrower deriver stays silent rather
|
|
than duplicating that logic here. *)
|
|
None
|
|
in
|
|
|
|
let rec typecheck_expr (env : typ StringMap.t) (cenv : typ StringMap.t) (e : expr) :
|
|
expr_type_result =
|
|
match e.kind with
|
|
| IntLit _ -> { typ = TScalar "Int"; is_nil = false }
|
|
| StrLit _ -> { typ = TScalar "Text"; is_nil = false }
|
|
| BoolLit _ -> { typ = TScalar "Bool"; is_nil = false }
|
|
| Ident name ->
|
|
(try
|
|
let t = StringMap.find name env in
|
|
{ typ = t; is_nil = false }
|
|
with Not_found -> { typ = TScalar "Int"; is_nil = false })
|
|
| Field (base, field_name) ->
|
|
let base_res = typecheck_expr env cenv base in
|
|
(match base_res.typ with
|
|
| TScalar class_name ->
|
|
(* Only a *declared* class can be checked for a missing field.
|
|
typecheck_expr falls back to `TScalar "Int"` for everything
|
|
it cannot type yet (an unresolved builtin call such as the
|
|
spec's own `latest(...)`, an indexed element), so reporting
|
|
on a non-class base turned every one of those placeholders
|
|
into a bogus "unknown field" error -- spec section 3's
|
|
`latest(self.prices).amount` was one. Precision here comes
|
|
back when builtin signatures land; Task 7 surfaced this by
|
|
being the first stage to run the typechecker over a whole
|
|
method body from the CLI. *)
|
|
(match StringMap.find_opt class_name syms.classes with
|
|
| None -> { typ = TScalar "Int"; is_nil = false }
|
|
| Some cls ->
|
|
(match List.find_opt (fun (fname, _, _, _) -> fname = field_name) cls.fields with
|
|
| Some (_, field_ty, _, _) -> { typ = resolve_field_ty field_ty; is_nil = false }
|
|
| None ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:unknown_field_code ~file ~line:e.pos.line ~col:e.pos.col
|
|
~message:(Printf.sprintf "unknown field `%s` on `%s`" field_name class_name) ());
|
|
{ typ = TScalar "Int"; is_nil = false }))
|
|
| _ -> { typ = TScalar "Int"; is_nil = false })
|
|
| Index (base, idx) ->
|
|
let _ = typecheck_expr env cenv base in
|
|
let _ = typecheck_expr env cenv idx in
|
|
{ typ = TScalar "Int"; is_nil = false }
|
|
| Call (callee, args) ->
|
|
List.iter (fun arg -> ignore (typecheck_expr env cenv arg)) args;
|
|
(match callee.kind with
|
|
| Ident name when Option.is_none (resolve_free_fn name) -> (
|
|
(* "A user-declared free fn of the same name always wins"
|
|
(08-builtin-surface.md's shadowing rule) -- resolved
|
|
through `resolve_free_fn` (own module, then used
|
|
modules), NOT the flat `syms.free_fns`: two modules
|
|
sharing a free-fn name would let the flat table pick
|
|
whichever merged first, shadowing the builtin from a
|
|
file that never actually `use`s the module that
|
|
collided with it. A `free_fns` hit means this isn't a
|
|
builtin call at all, so this check has nothing to say
|
|
about it (WO-E203's fn/method arity gap is a separate,
|
|
pre-existing, not-this-task's-to-fix hole). A qualified
|
|
call (`mod.fn(...)`) never has an `Ident` callee -- its
|
|
callee is a `Field` -- so a stdlib call through a
|
|
`use`d alias is exempt automatically, matching its
|
|
UNKNOWN-BUT-RESERVED typing everywhere else. *)
|
|
match find_variant syms name with
|
|
| Some (u, vi) ->
|
|
(* haxe-parity Task 4: constructing a payload variant by
|
|
name — the argument count must match the variant's
|
|
declared payload fields exactly (they are positional).
|
|
WO-E203 (bad arity), reserved since plan 2 Task 6:
|
|
this is its first real emission site, scoped to
|
|
variant constructions (fn/method call arity stays the
|
|
emitter's WO-E403, unchanged). *)
|
|
let want = List.length vi.vi_fields and got = List.length args in
|
|
if got <> want then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:bad_arity_code ~file ~line:e.pos.line ~col:e.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"variant `%s` of `%s` takes %d payload argument(s), given %d"
|
|
vi.vi_name u.u_name want got)
|
|
())
|
|
| None ->
|
|
let confident_types = List.map (confident_typ cenv) args in
|
|
check_builtin_call ~file collector name e.pos args confident_types)
|
|
| _ -> ());
|
|
{ typ = TScalar "Int"; is_nil = false }
|
|
| Unary (_, operand) -> typecheck_expr env cenv operand
|
|
| Binary ((And | Or) as op, left, right) ->
|
|
(* haxe-parity Task 2: `Bool`-typed operands only, no truthiness
|
|
-- wired through the same E209-style "confident-type, stay
|
|
silent when underivable" contract check_builtin_call already
|
|
uses, rather than a duplicate of it, per this task's own
|
|
design note. Chose WO-E201 (`type_mismatch_code`): declared
|
|
in this file since Task 6, never given a real emission site
|
|
until now (docs/plan/oop-vm/01-error-catalog.md's own
|
|
"Reserved, not yet emitted" list) -- and "a non-Bool operand
|
|
where Bool was required" is exactly what that name promises,
|
|
so this is the clean case the brief's own note anticipated,
|
|
not the fallback ("reuse the invalid-operand pattern"). *)
|
|
let _ = typecheck_expr env cenv left in
|
|
let _ = typecheck_expr env cenv right in
|
|
let op_name = match op with And -> "and" | _ -> "or" in
|
|
let check_operand (operand : expr) =
|
|
match confident_typ cenv operand with
|
|
| None -> () (* underivable -- stay silent, no false positives *)
|
|
| Some t ->
|
|
(* `unwrap_nullable` accepts a `?Bool` operand silently --
|
|
no forced-handling diagnostic for the nil case, same
|
|
shape as the pre-existing, disclosed `?T`-enforcement
|
|
gap (docs/plan/compiler/nullable-types-implementation.md:
|
|
"?T is plumbed but not enforced"). Task 6's own
|
|
WO-E211/E212/E213 work should revisit this call site
|
|
too, not just field/return positions. *)
|
|
if unwrap_nullable t <> TScalar "Bool" then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:type_mismatch_code ~file ~line:operand.pos.line
|
|
~col:operand.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"`%s` operand must be `Bool`, got `%s` -- no truthiness in this language"
|
|
op_name (typ_label t))
|
|
())
|
|
in
|
|
check_operand left;
|
|
check_operand right;
|
|
{ typ = TScalar "Bool"; is_nil = false }
|
|
| Binary ((Eq | Ne), left, right) ->
|
|
let _ = typecheck_expr env cenv left in
|
|
let _ = typecheck_expr env cenv right in
|
|
(* Task 4 fix round 1 (review Major): two bare unions share the
|
|
ordinal tag representation, so `X == P` across two DIFFERENT
|
|
unions was silently true whenever the ordinals matched. An
|
|
operand's union is derived from `confident_typ` or — for a
|
|
bare variant reference, which that deriver deliberately skips
|
|
— from the variant table, locals winning first (`env`, the
|
|
Task 1 shadowing rule). Same-union comparison stays legal
|
|
(bare tags compare exactly); one union against anything else
|
|
is left to Task 6's wider porosity work, disclosed. *)
|
|
let union_of_operand (e : expr) : union_info option =
|
|
match confident_typ cenv e with
|
|
| Some t -> (
|
|
match unwrap_nullable t with
|
|
| TScalar n -> StringMap.find_opt n syms.unions
|
|
| _ -> None)
|
|
| None -> (
|
|
match e.kind with
|
|
| (Ident n | Call ({ kind = Ident n; _ }, _)) when not (StringMap.mem n env) -> (
|
|
match find_variant syms n with Some (u, _) -> Some u | None -> None)
|
|
| _ -> None)
|
|
in
|
|
(match (union_of_operand left, union_of_operand right) with
|
|
| Some ul, Some ur when ul.u_name <> ur.u_name ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:type_mismatch_code ~file ~line:e.pos.line ~col:e.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"cannot compare `%s` with `%s` — values of different unions never \
|
|
compare equal (their tags merely share a representation)"
|
|
ul.u_name ur.u_name)
|
|
())
|
|
| _ -> ());
|
|
{ typ = TScalar "Bool"; is_nil = false }
|
|
| Binary (_, left, right) ->
|
|
let _ = typecheck_expr env cenv left in
|
|
let _ = typecheck_expr env cenv right in
|
|
{ typ = TScalar "Bool"; is_nil = false }
|
|
| Interp inner ->
|
|
let _ = typecheck_expr env cenv inner in
|
|
{ typ = TScalar "Text"; is_nil = false }
|
|
| Ctor (class_name, fields) ->
|
|
(try
|
|
let cls = StringMap.find class_name syms.classes in
|
|
let provided = List.map (fun (n, _) -> n) fields in
|
|
(* haxe-parity Task 4: a field with a declared default is
|
|
omittable (the emitter now fills it — `TailState {}`, the
|
|
sample's own pattern), and so is a `?`-typed field
|
|
("?fields land as nullable-by-shape": omitted means nil,
|
|
the zero word NEW already leaves there). Everything else
|
|
stays WO-E206, classes and records alike. *)
|
|
let omittable (default : default_expr option) (fty : field_ty) : bool =
|
|
Option.is_some default || (match fty with Nullable _ -> true | _ -> false)
|
|
in
|
|
List.iter (fun (fname, fty, fdefault, _) ->
|
|
if not (List.mem fname provided) && not (omittable fdefault fty) then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:incomplete_ctor_code ~file ~line:e.pos.line ~col:e.pos.col
|
|
~message:(Printf.sprintf "missing field `%s` in constructor of `%s`" fname class_name) ())
|
|
) cls.fields;
|
|
{ typ = TScalar class_name; is_nil = false }
|
|
with Not_found ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:unknown_type_code ~file ~line:e.pos.line ~col:e.pos.col
|
|
~message:(Printf.sprintf "unknown type `%s` in constructor" class_name) ());
|
|
{ typ = TScalar "Int"; is_nil = false })
|
|
| DbStub _ -> { typ = TVoid; is_nil = false }
|
|
| Switch (subject, arms) -> typecheck_switch ~want_value:true env cenv subject arms
|
|
|
|
(* haxe-parity Task 3: the one deriver behind both `Switch` call sites
|
|
-- `typecheck_expr`'s own case above (every "the value is used"
|
|
position: a `let`'s value, a `return`, nested inside another expr
|
|
-- reached generically, with zero extra code per call site, simply
|
|
because `typecheck_expr` is what every one of those already
|
|
recurses through) and `typecheck_stmt`'s `ExprStmt` case below (the
|
|
one position where the value is NOT used -- "statement position is
|
|
the expression with a discarded value", the brief's own words, so
|
|
this is the ONE place `want_value` differs from the default). Both
|
|
modes always fully typecheck every arm's body (side effects/
|
|
diagnostics inside an arm are never skipped); `want_value` only
|
|
gates whether a value is *required and unified* across arms.
|
|
|
|
Default-required (WO-E208): unconditional here, because no union
|
|
type exists yet (`typ` above has no `TUnion` -- ast.ml's own module
|
|
doc). This is deliberately the exact shape Task 4 extends, not a
|
|
bespoke check to replace: add a `TUnion variants` arm that walks
|
|
the arms' `values` for coverage (naming the missing variants) and
|
|
falls through to this same E208 for every other subject type,
|
|
unchanged.
|
|
|
|
Arm typing avoids double-typechecking each arm's own trailing
|
|
expression: `typecheck_stmt` already knows how to fold a `stmt
|
|
list`, but it discards each statement's own expr *type* (only
|
|
`typecheck_expr`'s caller sees that) -- so the last statement is
|
|
handled specially here rather than calling `typecheck_stmt` on the
|
|
whole body and then re-deriving the tail's type a second time
|
|
(which `diag.ml`'s own (code,file,line,col) dedup would make
|
|
harmless, but doing the double work at all is needless). An arm
|
|
whose last statement is not `ExprStmt` (e.g. every arm in the
|
|
sample's own statement-position switches, which end in `return`)
|
|
types as `TVoid` -- not a special case, just what "no value here"
|
|
naturally is, and it is what makes an arm that fails to yield a
|
|
value in expression position surface as an ordinary arm-type-
|
|
mismatch against its sibling arms, with no separate diagnostic. *)
|
|
and typecheck_switch ~(want_value : bool) (env : typ StringMap.t) (cenv : typ StringMap.t)
|
|
(subject : expr) (arms : switch_arm list) : expr_type_result =
|
|
let subj_res = typecheck_expr env cenv subject in
|
|
(* review fix, Critical 2: a case label whose type is confidently
|
|
Text against a non-Text scalar subject (or vice versa) is not
|
|
just a type error — it is a real VM segfault (reviewer-
|
|
reproduced: `switch s { case 1: ... }` over `s: Text` emits
|
|
EQS on a raw int register, and the VM's `str_check` dereferences
|
|
it as a `wo_str*`; the reverse direction — an Int subject with a
|
|
Text label — is equally wrong, just silently always-false
|
|
rather than a crash).
|
|
|
|
Deliberately built on `confident_typ`, NOT `typecheck_expr`'s
|
|
own `.typ` (round-1 mistake, self-caught before shipping):
|
|
`typecheck_expr` hands back `TScalar "Int"` for *any*
|
|
unresolved base (an unbound `Ident`, and therefore any `Field`
|
|
read through one) — exactly the placeholder that made
|
|
`j.method` (log-watcher's own `mcp.wo`, where `j`'s own `let ...
|
|
as RpcReq` fails to parse today, leaving `j` unbound) look
|
|
"confidently Int" and false-positive against its very real
|
|
`"initialize"`/`"ping"`/... Text case labels. `confident_typ`
|
|
returns `None` for exactly that shape (an unbound `Ident`'s
|
|
`Field`), so this stays silent there — the same "confident, stay
|
|
silent when underivable" contract WO-E209 already established.
|
|
`repr_kind` narrows a confident type to exactly the two
|
|
representations emit.ml's own EQ-vs-EQS choice cares about
|
|
(`WO_K_TEXT` vs `WO_K_SCALAR`); anything else (an unresolved or
|
|
union-typed subject — the sample's own `switch res {...}`/
|
|
`switch names {...}` sites, Task 4's territory, already
|
|
WO-E207'd) is `Other` and never compared. Reuses WO-E201
|
|
(`type_mismatch_code`) — the same code the arm-unification check
|
|
below uses — per the review's own instruction ("wire through
|
|
E201 like arm mismatch"). *)
|
|
let repr_kind (t : typ) : [ `Text | `Scalar | `Other ] =
|
|
match wob_kind_of_typ syms t with
|
|
| WO_K_TEXT -> `Text
|
|
| WO_K_SCALAR -> `Scalar
|
|
| WO_K_OWNED | WO_K_GCREF | WO_K_MULTI | WO_K_MAP | WO_K_NULLABLE -> `Other
|
|
in
|
|
(* haxe-parity Task 4: a union-typed subject switches the arms from
|
|
VALUE comparisons to variant PATTERNS — `case Ok:` names a
|
|
variant (never an expression to evaluate), `case Failed(reason):`
|
|
additionally binds the payload fields for that arm's own body.
|
|
Derived through `confident_typ` exactly like the repr check below
|
|
(and NOT unwrapped through `?T`: a `?Union` subject stays on the
|
|
plain-value path until Task 6's forced-handling work legalizes
|
|
narrowing it) — an underivable subject falls through to the
|
|
scalar rules unchanged, the same "stay silent when underivable"
|
|
contract as every other confident-type consumer. *)
|
|
let subj_union =
|
|
match confident_typ cenv subject with
|
|
| Some (TScalar n) -> StringMap.find_opt n syms.unions
|
|
| _ -> None
|
|
in
|
|
(* Task 4 fix round 1 (review Critical 2): a `?Union` subject with a
|
|
variant-named case compiled clean and could NEVER match — the
|
|
case constructed a fresh variant object (or, for a payload
|
|
pattern, fell into the emitter as an unbound name) and the
|
|
pointer compare was always false, so `default` always won. A
|
|
`?`-typed union is not narrowed here (narrowing is Task 6's
|
|
forced-handling work, deliberately not implemented), so variant
|
|
patterns are meaningless over it — say so, pointing at the nil
|
|
case first. *)
|
|
let subj_opt_union =
|
|
match confident_typ cenv subject with
|
|
| Some (TNullable inner) -> (
|
|
match unwrap_nullable inner with
|
|
| TScalar n -> StringMap.find_opt n syms.unions
|
|
| _ -> None)
|
|
| _ -> None
|
|
in
|
|
(match subj_opt_union with
|
|
| None -> ()
|
|
| Some u ->
|
|
List.iter
|
|
(fun (a : switch_arm) ->
|
|
List.iter
|
|
(fun (v : expr) ->
|
|
match v.kind with
|
|
| (Ident n | Call ({ kind = Ident n; _ }, _))
|
|
when Option.is_some (find_variant syms n) ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:type_mismatch_code ~file ~line:v.pos.line
|
|
~col:v.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"`%s` can never match here — the subject is `?%s`, which may be \
|
|
nil and is not narrowed by `switch`; handle the nil case first \
|
|
(forced `?T` handling arrives with Task 6)"
|
|
n u.u_name)
|
|
())
|
|
| _ -> ())
|
|
a.values)
|
|
arms);
|
|
(match subj_union with
|
|
| None -> ()
|
|
| Some u ->
|
|
let variant_of name = List.find_opt (fun v -> v.vi_name = name) u.u_variants in
|
|
let pattern_err ~code (pos : pos) message =
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code ~file ~line:pos.line ~col:pos.col ~message ())
|
|
in
|
|
List.iter
|
|
(fun (a : switch_arm) ->
|
|
List.iter
|
|
(fun (v : expr) ->
|
|
match v.kind with
|
|
| Ident vname -> (
|
|
match variant_of vname with
|
|
| Some _ -> ()
|
|
| None ->
|
|
pattern_err ~code:type_mismatch_code v.pos
|
|
(Printf.sprintf "`%s` is not a variant of union `%s`" vname u.u_name))
|
|
| Call ({ kind = Ident vname; _ }, args) -> (
|
|
match variant_of vname with
|
|
| None ->
|
|
pattern_err ~code:type_mismatch_code v.pos
|
|
(Printf.sprintf "`%s` is not a variant of union `%s`" vname u.u_name)
|
|
| Some vi ->
|
|
let want = List.length vi.vi_fields and got = List.length args in
|
|
if got <> want then
|
|
pattern_err ~code:bad_arity_code v.pos
|
|
(Printf.sprintf
|
|
"pattern for `%s` binds %d payload field(s), but `%s` declares %d"
|
|
vi.vi_name got vi.vi_name want)
|
|
else if
|
|
not
|
|
(List.for_all
|
|
(fun (arg : expr) ->
|
|
match arg.kind with Ident _ -> true | _ -> false)
|
|
args)
|
|
then
|
|
pattern_err ~code:bad_arity_code v.pos
|
|
(Printf.sprintf
|
|
"pattern for `%s` must bind plain names — payload fields are \
|
|
bound positionally, never matched by value"
|
|
vi.vi_name)
|
|
else if List.length a.values > 1 then
|
|
pattern_err ~code:bad_arity_code v.pos
|
|
(Printf.sprintf
|
|
"a payload-binding pattern (`%s(...)`) must be its arm's only \
|
|
value"
|
|
vi.vi_name))
|
|
| _ ->
|
|
pattern_err ~code:type_mismatch_code v.pos
|
|
(Printf.sprintf
|
|
"switch over union `%s` matches variants — this case value is not one"
|
|
u.u_name))
|
|
a.values)
|
|
arms);
|
|
(match (if Option.is_none subj_union then confident_typ cenv subject else None) with
|
|
| None -> () (* subject underivable (or a union, handled above) -- stay silent *)
|
|
| Some subj_t ->
|
|
let subj_repr = repr_kind subj_t in
|
|
List.iter
|
|
(fun (a : switch_arm) ->
|
|
List.iter
|
|
(fun v ->
|
|
ignore (typecheck_expr env cenv v);
|
|
(* Task 4 fix round 1 (review Major): the inverse of the
|
|
union-subject direction — a case value naming a KNOWN
|
|
variant while the subject is confidently some other
|
|
type silently ordinal-matched (`switch n { case Lo: }`
|
|
over `n: Int` matched n == 0). Lexical scope wins
|
|
first (`env`, every local/param — the Task 1
|
|
shadowing lesson), so a local that happens to share a
|
|
variant's name is never misread as one. *)
|
|
(match v.kind with
|
|
| Ident n | Call ({ kind = Ident n; _ }, _) -> (
|
|
if not (StringMap.mem n env) then
|
|
match find_variant syms n with
|
|
| Some (vu, _) ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:type_mismatch_code ~file ~line:v.pos.line
|
|
~col:v.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"`%s` is a variant of union `%s`, but the switch subject \
|
|
has type `%s`"
|
|
n vu.u_name (typ_label subj_t))
|
|
())
|
|
| None -> ())
|
|
| _ -> ());
|
|
match confident_typ cenv v with
|
|
| None -> ()
|
|
| Some vt -> (
|
|
match (repr_kind vt, subj_repr) with
|
|
| (`Text, `Scalar | `Scalar, `Text) ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:type_mismatch_code ~file ~line:v.pos.line
|
|
~col:v.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"switch case value has type `%s`, but the switch subject \
|
|
has type `%s`"
|
|
(typ_label vt) (typ_label subj_t))
|
|
())
|
|
| _ -> ()))
|
|
a.values)
|
|
arms);
|
|
(* review fix, Critical 1 (the warning half — the reorder itself is
|
|
ast.ml's `switch_lowering_order`, applied downstream in
|
|
owner.ml/emit.ml, not here): a `case` arm textually after
|
|
`default` no longer silently loses to it (that was the bug),
|
|
but it is still surprising source, so this warns once per
|
|
switch shaped that way, anchored at `default`'s own position. *)
|
|
let default_pos = ref None in
|
|
let case_after_default = ref false in
|
|
List.iter
|
|
(fun (a : switch_arm) ->
|
|
match !default_pos with
|
|
| None -> if a.is_default then default_pos := Some a.arm_pos
|
|
| Some _ -> if not a.is_default then case_after_default := true)
|
|
arms;
|
|
(match !default_pos with
|
|
| Some pos when !case_after_default ->
|
|
Diag.Collector.add collector
|
|
(Diag.warning ~code:switch_default_not_last_code ~file ~line:pos.line ~col:pos.col
|
|
~message:
|
|
"`default` is not the last arm -- a `case` written after it still matches (this \
|
|
compiler evaluates `default` last regardless of source position), which reads \
|
|
as dead code"
|
|
())
|
|
| _ -> ());
|
|
(* WO-E208. Union subjects get the exhaustiveness rule the spec's
|
|
switch row promised ("`default` optional when exhaustive"): no
|
|
`default` is fine exactly when every variant is covered by some
|
|
arm; a gap names the missing variants, in declaration order.
|
|
Every other subject keeps Task 3's unconditional rule — scalars
|
|
and Text always require `default`. *)
|
|
(if not (List.exists (fun (a : switch_arm) -> a.is_default) arms) then
|
|
match subj_union with
|
|
| Some u ->
|
|
let covered =
|
|
List.concat_map
|
|
(fun (a : switch_arm) ->
|
|
List.filter_map
|
|
(fun (v : expr) ->
|
|
match v.kind with
|
|
| Ident n | Call ({ kind = Ident n; _ }, _) -> Some n
|
|
| _ -> None)
|
|
a.values)
|
|
arms
|
|
in
|
|
let missing =
|
|
List.filter (fun vi -> not (List.mem vi.vi_name covered)) u.u_variants
|
|
in
|
|
if missing <> [] then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:non_exhaustive_switch_code ~file ~line:subject.pos.line
|
|
~col:subject.pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"switch over `%s` has no `default` arm and does not cover: %s" u.u_name
|
|
(String.concat ", " (List.map (fun vi -> vi.vi_name) missing)))
|
|
())
|
|
| None ->
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:non_exhaustive_switch_code ~file ~line:subject.pos.line
|
|
~col:subject.pos.col
|
|
~message:
|
|
(Printf.sprintf "switch over `%s` has no `default` arm" (typ_label subj_res.typ))
|
|
()));
|
|
(* haxe-parity Task 4: the names a payload pattern binds, typed with
|
|
the variant's own declared field types, visible to that arm's
|
|
body only. Empty for every non-union subject and every bare/
|
|
malformed pattern (the malformed ones already got their own
|
|
diagnostic above — typing the body against fewer names is the
|
|
do-no-harm fallback, not a second error). *)
|
|
let bindings_of (a : switch_arm) : (string * typ) list =
|
|
(* `?Union` too (fix round 1): the variant-named cases over a
|
|
`?Union` subject are already a hard WO-E201 (above) — binding
|
|
the pattern's names anyway keeps the arm BODY typed against
|
|
real names instead of cascading a second, misleading
|
|
arm-mismatch error off an unbound placeholder. *)
|
|
match (match subj_union with Some _ as u -> u | None -> subj_opt_union) with
|
|
| None -> []
|
|
| Some u -> (
|
|
match a.values with
|
|
| [ { kind = Call ({ kind = Ident vname; _ }, args); _ } ] -> (
|
|
match List.find_opt (fun vi -> vi.vi_name = vname) u.u_variants with
|
|
| Some vi when List.length args = List.length vi.vi_fields ->
|
|
List.concat
|
|
(List.map2
|
|
(fun (arg : expr) (_, fty) ->
|
|
match arg.kind with
|
|
| Ident bn -> [ (bn, resolve_field_ty fty) ]
|
|
| _ -> [])
|
|
args vi.vi_fields)
|
|
| _ -> [])
|
|
| _ -> [])
|
|
in
|
|
let arm_value (a : switch_arm) : typ * pos =
|
|
let bound = bindings_of a in
|
|
let benv = List.fold_left (fun m (n, t) -> StringMap.add n t m) env bound in
|
|
let bcenv = List.fold_left (fun m (n, t) -> StringMap.add n t m) cenv bound in
|
|
match List.rev a.body with
|
|
| [] -> (TVoid, a.arm_pos)
|
|
| last :: rev_init ->
|
|
let env', cenv' = List.fold_left typecheck_stmt (benv, bcenv) (List.rev rev_init) in
|
|
(match last.s_kind with
|
|
| ExprStmt e -> ((typecheck_expr env' cenv' e).typ, e.pos)
|
|
| _ ->
|
|
let _ = typecheck_stmt (env', cenv') last in
|
|
(TVoid, last.s_pos))
|
|
in
|
|
let arm_types = List.map arm_value arms in
|
|
(if want_value then
|
|
match arm_types with
|
|
| [] -> ()
|
|
| (ref_typ, _) :: rest ->
|
|
List.iter
|
|
(fun (t, pos) ->
|
|
(* structural, not (=): two same-shape typedef records are
|
|
the same type (haxe-parity Task 4, typ_equal). *)
|
|
if not (typ_equal syms t ref_typ) then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:type_mismatch_code ~file ~line:pos.line ~col:pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"switch arm yields `%s`, but the switch's type is `%s` (from an \
|
|
earlier arm)"
|
|
(typ_label t) (typ_label ref_typ))
|
|
()))
|
|
rest);
|
|
match arm_types with (t, _) :: _ -> { typ = t; is_nil = false } | [] -> { typ = TVoid; is_nil = false }
|
|
|
|
(* `typecheck_switch` (above) folds `typecheck_stmt` over an arm's own
|
|
body, and `typecheck_stmt`'s `ExprStmt` case (below) calls
|
|
`typecheck_switch` in `want_value:false` mode -- the two are
|
|
mutually recursive, so they (and `typecheck_expr`, which
|
|
`typecheck_switch` also calls) must be one `and`-chain, not the
|
|
three separate `let rec ... in` bindings this function had before
|
|
this task. *)
|
|
and typecheck_stmt ((env, cenv) : typ StringMap.t * typ StringMap.t) (s : stmt) :
|
|
typ StringMap.t * typ StringMap.t =
|
|
match s.s_kind with
|
|
| Let { name; ty = _ty; value } ->
|
|
let val_res = typecheck_expr env cenv value in
|
|
let new_cenv =
|
|
match confident_typ cenv value with
|
|
| Some t -> StringMap.add name t cenv
|
|
| None -> StringMap.remove name cenv
|
|
in
|
|
(StringMap.add name val_res.typ env, new_cenv)
|
|
| Assign { target; value } ->
|
|
let _ = typecheck_expr env cenv target in
|
|
let _ = typecheck_expr env cenv value in
|
|
(env, cenv)
|
|
| If { cond; then_body; else_body } ->
|
|
let _ = typecheck_expr env cenv cond in
|
|
let then_result = List.fold_left typecheck_stmt (env, cenv) then_body in
|
|
(match else_body with
|
|
| Some (_, else_body) -> List.fold_left typecheck_stmt (env, cenv) else_body
|
|
| None -> then_result)
|
|
| While { cond; body } ->
|
|
let _ = typecheck_expr env cenv cond in
|
|
List.fold_left typecheck_stmt (env, cenv) body
|
|
| For { var; iter; body } ->
|
|
let iter_res = typecheck_expr env cenv iter in
|
|
let env_body = StringMap.add var iter_res.typ env in
|
|
let cenv_body =
|
|
match confident_typ cenv iter with
|
|
| Some (TMulti inner_t) -> StringMap.add var inner_t cenv
|
|
| _ -> StringMap.remove var cenv
|
|
in
|
|
List.fold_left typecheck_stmt (env_body, cenv_body) body
|
|
| Return opt_e ->
|
|
(match opt_e with Some e -> let _ = typecheck_expr env cenv e in () | None -> ());
|
|
(env, cenv)
|
|
| ExprStmt { kind = Switch (subject, arms); _ } ->
|
|
(* haxe-parity Task 3: the one place `want_value` is false --
|
|
"statement position is the expression with a discarded
|
|
value" (the brief's own words). Every arm's body is still
|
|
fully typechecked (typecheck_switch's own contract); nothing
|
|
here requires or unifies a value the way the generic
|
|
`Switch` case of `typecheck_expr` (used for every other
|
|
position: `let`, `return`, nested inside another expr) does. *)
|
|
let _ = typecheck_switch ~want_value:false env cenv subject arms in
|
|
(env, cenv)
|
|
| ExprStmt e ->
|
|
let _ = typecheck_expr env cenv e in
|
|
(env, cenv)
|
|
| Break | Continue -> (env, cenv)
|
|
| DoWhile { cond; body } ->
|
|
let _ = typecheck_expr env cenv cond in
|
|
List.fold_left typecheck_stmt (env, cenv) body
|
|
in
|
|
|
|
(* `self` is bound to the *enclosing class name*, not a literal "Self":
|
|
"Self" is not a declared class, so every `self.field` access used to
|
|
miss and report a bogus unknown-field error. *)
|
|
let typecheck_method ~(self_class : string) (env : typ StringMap.t) (m : method_info) : bool =
|
|
let param_env = List.fold_left (fun acc (name, ty, _) ->
|
|
StringMap.add name (resolve_field_ty ty) acc) env m.params in
|
|
let env_with_self = StringMap.add "self" (TScalar self_class) param_env in
|
|
(* Every param/`self` is always confidently typed (a declared type
|
|
is mandatory here), so `cenv`'s starting point for a method body
|
|
is exactly `env_with_self`'s own shape -- no ambiguity to guard
|
|
against at the entry to a body, only inside it. *)
|
|
let cenv_with_self = List.fold_left (fun acc (name, ty, _) ->
|
|
StringMap.add name (resolve_field_ty ty) acc)
|
|
(StringMap.singleton "self" (TScalar self_class)) m.params in
|
|
let _ = List.fold_left typecheck_stmt (env_with_self, cenv_with_self) m.body in
|
|
false
|
|
in
|
|
|
|
(* Walk THIS FILE's own classes/free_fns only (`file_syms`, not the
|
|
merged `syms`) -- see this function's own doc comment above for
|
|
why. *)
|
|
StringMap.iter (fun _name cls ->
|
|
let method_env = StringMap.empty in
|
|
List.iter (fun m -> ignore (typecheck_method ~self_class:cls.name method_env m)) cls.methods
|
|
) file_syms.classes;
|
|
|
|
StringMap.iter (fun _name (fn : free_fn_info) ->
|
|
let param_env = List.fold_left (fun acc (name, ty, _) ->
|
|
StringMap.add name (resolve_field_ty ty) acc) StringMap.empty fn.params in
|
|
let param_cenv = List.fold_left (fun acc (name, ty, _) ->
|
|
StringMap.add name (resolve_field_ty ty) acc) StringMap.empty fn.params in
|
|
ignore (List.fold_left typecheck_stmt (param_env, param_cenv) fn.body)
|
|
) file_syms.free_fns;
|
|
|
|
()
|
|
|
|
(* ============================================================
|
|
Entry point
|
|
============================================================ *)
|
|
|
|
(* Single-file convenience wrapper (runner.ml's ~40 direct assertion
|
|
helpers all go through this, never `typecheck_program` directly) --
|
|
its own external signature is unchanged by the hotfix's new
|
|
`~module_of`/`~module_syms` parameters on `typecheck_program`: a
|
|
lone file has no sibling module structure to differ from, so it is
|
|
its own one-and-only module, exactly the convention runner.ml's
|
|
`emit_str` already established for the same single-file case
|
|
(`~module_of:(fun _ -> ".")`, one `module_syms` entry keyed `"."`). *)
|
|
let typecheck ~file (prog : program) (collector : Diag.Collector.t) : symbols * unit =
|
|
let syms = collect_declarations ~file prog collector in
|
|
let module_syms = Hashtbl.create 1 in
|
|
Hashtbl.replace module_syms "." syms;
|
|
(* Single file -- `syms` IS this file's own declarations, so it is
|
|
also exactly this call's `~file_syms` (hotfix). *)
|
|
let () =
|
|
typecheck_program ~file ~module_of:(fun _ -> ".") ~module_syms ~file_syms:syms prog syms collector
|
|
in
|
|
(syms, ())
|
|
|
|
(* ============================================================
|
|
Modules (haxe-parity Task 1): use-based cross-module visibility
|
|
============================================================
|
|
|
|
Layered ON TOP of the existing multi-file discovery (Task 8,
|
|
compiler/bin/main.ml): every file in one module (one directory) still
|
|
sees every other same-module file's declarations unconditionally,
|
|
exactly as before this task — that mechanism (collect_declarations +
|
|
the driver's merge) is UNCHANGED. What is new is a second, additive
|
|
check that walks each file's own `Ctor`/`Call` sites and asks "is the
|
|
declaration this name resolves to actually reachable from here?" —
|
|
own module: always; a `use`d module: only its `pub` names; anything
|
|
else: an error. This never changes *which* declaration a name
|
|
resolves to (main.ml's driver-level merge, emit.ml's lowering, and
|
|
owner.ml's analysis are all untouched) — it only decides whether that
|
|
resolution was legitimate, so a violation is a hard compile error
|
|
before any of those later stages ever run, never a silent shadow.
|
|
|
|
A deliberate scope cut, disclosed rather than silently skipped: only
|
|
`Ctor` (constructor-literal class names) and `Call` (bare `fn(...)`
|
|
and qualified `mod.fn(...)`) sites are checked. Field/parameter/
|
|
return *type* positions (`field: OtherModuleClass`) are not
|
|
module-gated by this task — no fixture upstream of this task needs
|
|
it (log-watcher's own 18 `use` lines are all reserved-stdlib, and
|
|
this task's own fixtures exercise classes/fns through construction
|
|
and calls), and bolting it on would mean walking every field_ty in
|
|
every class, a materially bigger surface than the brief's own
|
|
examples ask for. *)
|
|
|
|
(* `use_edge`/`uses_of_program`/`path_str` moved up above
|
|
`typecheck_program` (hotfix, hoisted alongside `builtin_signatures`) —
|
|
`confident_typ`'s free-fn return-type derivation needs them there too
|
|
now, and OCaml has no forward reference across top-level `let`s. Kept
|
|
conceptually here in reading order; see their real definitions above
|
|
`typecheck_program` for the code. *)
|
|
|
|
(* Merges bare `symbols` values (no diagnostics — collisions within one
|
|
module are already WO-E214/WO-E215's job, upstream of this) purely to
|
|
group per-file declarations into their shared module's surface. A
|
|
small local twin of main.ml's own merge_symbols rather than a shared
|
|
export: main.ml's version is already exercised by 399 passing checks
|
|
and touching it is not this change's job (see this task's own
|
|
"surgical changes" instruction). *)
|
|
let merge_syms_for_module (syms_list : symbols list) : symbols =
|
|
let keep_first _key a _b = Some a in
|
|
List.fold_left
|
|
(fun (acc : symbols) (s : symbols) ->
|
|
{
|
|
classes = StringMap.union keep_first acc.classes s.classes;
|
|
interfaces = StringMap.union keep_first acc.interfaces s.interfaces;
|
|
free_fns = StringMap.union keep_first acc.free_fns s.free_fns;
|
|
typedefs = StringMap.union keep_first acc.typedefs s.typedefs;
|
|
unions = StringMap.union keep_first acc.unions s.unions;
|
|
modules = acc.modules @ s.modules;
|
|
})
|
|
{ classes = StringMap.empty; interfaces = StringMap.empty; free_fns = StringMap.empty;
|
|
typedefs = StringMap.empty; unions = StringMap.empty; modules = [] }
|
|
syms_list
|
|
|
|
(* Generic structural walk over every Ctor/Call site in a program's
|
|
method/fn bodies — deliberately NOT the full typechecker's
|
|
typecheck_expr (that resolves types; this only needs to *locate*
|
|
constructor names and call callees, own recursion kept separate and
|
|
small rather than teaching typecheck_expr a second, unrelated job).
|
|
|
|
Threads `bound` — the set of names currently in lexical scope as a
|
|
local/param/`self` — through every visit (IMPORTANT 2 review fix,
|
|
haxe-parity Task 1: `use secret; let secret = Box(); secret.hidden()`
|
|
was a false-positive WO-E217, because the qualified-call check had no
|
|
notion of scope at all and treated any `Ident` matching a `use`
|
|
alias's spelling as that alias, unconditionally. Lexical scope wins
|
|
here exactly like everywhere else in every language with both
|
|
modules and locals — the checker in check_module_refs below is what
|
|
actually acts on `bound`; this walk only has to compute and pass it
|
|
through). Block-scoped like the language's own `let` (a `let` inside
|
|
an `if`'s body does not leak to code after that `if`) — `walk_block`
|
|
folds `bound` across a statement *list*, but every nested-block call
|
|
site passes the accumulated `bound` down and discards what comes
|
|
back, exactly the semantics that keeps a block's own bindings from
|
|
escaping it. *)
|
|
let rec walk_block (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (stmts : stmt list) : unit
|
|
=
|
|
ignore (List.fold_left (fun b s -> walk_stmt b visit s) bound stmts)
|
|
|
|
and walk_stmt (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (s : stmt) : StringSet.t =
|
|
match s.s_kind with
|
|
| Let { name; value; _ } ->
|
|
walk_expr bound visit value;
|
|
StringSet.add name bound
|
|
| Assign { target; value } ->
|
|
walk_expr bound visit target;
|
|
walk_expr bound visit value;
|
|
bound
|
|
| If { cond; then_body; else_body } ->
|
|
walk_expr bound visit cond;
|
|
walk_block bound visit then_body;
|
|
(match else_body with Some (_, b) -> walk_block bound visit b | None -> ());
|
|
bound
|
|
| While { cond; body } ->
|
|
walk_expr bound visit cond;
|
|
walk_block bound visit body;
|
|
bound
|
|
| For { var; iter; body } ->
|
|
walk_expr bound visit iter;
|
|
walk_block (StringSet.add var bound) visit body;
|
|
bound
|
|
| Return (Some e) ->
|
|
walk_expr bound visit e;
|
|
bound
|
|
| Return None -> bound
|
|
| ExprStmt e ->
|
|
walk_expr bound visit e;
|
|
bound
|
|
| Break | Continue -> bound
|
|
| DoWhile { cond; body } ->
|
|
walk_block bound visit body;
|
|
walk_expr bound visit cond;
|
|
bound
|
|
|
|
and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : expr) : unit =
|
|
visit bound e;
|
|
match e.kind with
|
|
| IntLit _ | StrLit _ | BoolLit _ | Ident _ -> ()
|
|
| Field (b, _) -> walk_expr bound visit b
|
|
| Index (b, i) ->
|
|
walk_expr bound visit b;
|
|
walk_expr bound visit i
|
|
| Call (callee, args) ->
|
|
walk_expr bound visit callee;
|
|
List.iter (walk_expr bound visit) args
|
|
| Unary (_, o) -> walk_expr bound visit o
|
|
| Binary (_, l, r) ->
|
|
walk_expr bound visit l;
|
|
walk_expr bound visit r
|
|
| Ctor (_, fields) -> List.iter (fun (_, v) -> walk_expr bound visit v) fields
|
|
| Interp inner -> walk_expr bound visit inner
|
|
| DbStub _ -> ()
|
|
| Switch (subject, arms) ->
|
|
walk_expr bound visit subject;
|
|
List.iter
|
|
(fun (a : switch_arm) ->
|
|
List.iter (walk_expr bound visit) a.values;
|
|
walk_block bound visit a.body)
|
|
arms
|
|
|
|
let walk_program (visit : StringSet.t -> expr -> unit) (prog : program) : unit =
|
|
List.iter
|
|
(function
|
|
| Class c ->
|
|
List.iter
|
|
(fun (m : method_decl) ->
|
|
let params = List.fold_left (fun acc (p : param) -> StringSet.add p.name acc) (StringSet.singleton "self") m.params in
|
|
walk_block params visit m.body)
|
|
c.methods
|
|
| Interface _ -> ()
|
|
| Fn m ->
|
|
let params = List.fold_left (fun acc (p : param) -> StringSet.add p.name acc) StringSet.empty m.params in
|
|
walk_block params visit m.body
|
|
| Use _ -> ()
|
|
| Const _ -> ()
|
|
| Union _ -> ())
|
|
prog.decls
|
|
|
|
(* How a name used from this file resolved, against one declaration-kind
|
|
projection (classes for Ctor, free_fns for Call) of the surrounding
|
|
module graph. *)
|
|
type resolution =
|
|
| ROwn
|
|
| RUsed of string (* the use-edge alias that supplied it *)
|
|
| RCollision of string list (* every alias whose used module exports it `pub` *)
|
|
| RNotImported of string (* the module id it actually lives in *)
|
|
| RNotFound (* not own, not used, not anywhere else either — pre-existing gap (WO-E204/WO-E207's own territory), not this check's to raise *)
|
|
|
|
let resolve_name (get : symbols -> 'a StringMap.t) (pub_of : 'a -> bool) ~(own : symbols)
|
|
~(uses_resolved : (use_edge * symbols) list) ~(others : (string * symbols) list) (name : string) :
|
|
resolution =
|
|
if StringMap.mem name (get own) then ROwn
|
|
else
|
|
let used_hits =
|
|
List.filter_map
|
|
(fun (u, msyms) ->
|
|
match StringMap.find_opt name (get msyms) with
|
|
| Some info when pub_of info -> Some u.ue_alias
|
|
| _ -> None)
|
|
uses_resolved
|
|
in
|
|
match used_hits with
|
|
| [] -> (
|
|
match List.find_opt (fun (_, msyms) -> StringMap.mem name (get msyms)) others with
|
|
| Some (mid, _) -> RNotImported mid
|
|
| None -> RNotFound)
|
|
| [ alias ] -> RUsed alias
|
|
| aliases -> RCollision aliases
|
|
|
|
let report_not_imported (collector : Diag.Collector.t) ~file (pos : pos) ~(name : string)
|
|
~(mid : string) : unit =
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:module_not_imported_code ~file ~line:pos.line ~col:pos.col
|
|
~message:(Printf.sprintf "`%s` is declared in module `%s`, which is not `use`d here" name mid)
|
|
())
|
|
|
|
let report_use_collision (collector : Diag.Collector.t) ~file (pos : pos) ~(name : string)
|
|
~(aliases : string list) : unit =
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:use_collision_code ~file ~line:pos.line ~col:pos.col
|
|
~message:
|
|
(Printf.sprintf "`%s` is ambiguous — exported `pub` by more than one used module (%s)" name
|
|
(String.concat ", " aliases))
|
|
())
|
|
|
|
(* Walks one file's Ctor/Call sites, checking each against the module
|
|
graph the caller (check_modules) has already assembled for it.
|
|
`mark_used` is called once per use-edge alias whenever a reference
|
|
actually resolves through it — the unused-`use` warning's own data. *)
|
|
let check_module_refs (collector : Diag.Collector.t) ~file ~(own : symbols)
|
|
~(uses_resolved : (use_edge * symbols) list) ~(stdlib_aliases : string list)
|
|
~(others : (string * symbols) list) ~(mark_used : string -> unit) (prog : program) : unit =
|
|
let alias_tbl = Hashtbl.create 8 in
|
|
List.iter (fun (u, msyms) -> Hashtbl.replace alias_tbl u.ue_alias msyms) uses_resolved;
|
|
let check_ctor (pos : pos) (name : string) : unit =
|
|
match resolve_name (fun s -> s.classes) (fun (c : class_info) -> c.pub) ~own ~uses_resolved ~others name with
|
|
| ROwn | RNotFound -> ()
|
|
| RUsed alias -> mark_used alias
|
|
| RCollision aliases ->
|
|
(* every alias that contributed to the ambiguity was genuinely
|
|
referenced (that's exactly what makes it ambiguous) — mark
|
|
them all used so the same `use` line doesn't also draw an
|
|
"unused" warning alongside its collision error. *)
|
|
List.iter mark_used aliases;
|
|
report_use_collision collector ~file pos ~name ~aliases
|
|
| RNotImported mid -> report_not_imported collector ~file pos ~name ~mid
|
|
in
|
|
let check_bare_call (pos : pos) (name : string) : unit =
|
|
match resolve_name (fun s -> s.free_fns) (fun (f : free_fn_info) -> f.pub) ~own ~uses_resolved ~others name with
|
|
| ROwn | RNotFound -> ()
|
|
(* RNotFound also covers a builtin (`print`, `len`, ...) or a
|
|
genuinely unresolved name — 08-builtin-surface.md's shadowing
|
|
rule ("a user-declared free fn of the same name always wins")
|
|
already makes ROwn win first when both exist; RNotFound's other
|
|
half (truly nothing resolves) is WO-E204's own pre-existing,
|
|
not-this-task's-to-fix gap (see nullable-types-implementation.md's
|
|
dead-code register) — silence here matches silence there. *)
|
|
| RUsed alias -> mark_used alias
|
|
| RCollision aliases ->
|
|
(* every alias that contributed to the ambiguity was genuinely
|
|
referenced (that's exactly what makes it ambiguous) — mark
|
|
them all used so the same `use` line doesn't also draw an
|
|
"unused" warning alongside its collision error. *)
|
|
List.iter mark_used aliases;
|
|
report_use_collision collector ~file pos ~name ~aliases
|
|
| RNotImported mid -> report_not_imported collector ~file pos ~name ~mid
|
|
in
|
|
let check_qualified_call (bound : StringSet.t) (pos : pos) (alias : string) (member : string) : unit =
|
|
if StringSet.mem alias bound then ()
|
|
(* IMPORTANT 2 review fix: `alias` is a local/param/`self` in scope
|
|
right here — lexical scope wins, exactly like everywhere else
|
|
in every language with both modules and locals. `secret.hidden()`
|
|
where `secret` is `let secret = Box()` is an ordinary method
|
|
call the existing (unrelated, untouched) class-method machinery
|
|
already handles correctly; this module-alias check has nothing
|
|
to say about it at all, not even a used-vs-unused opinion —
|
|
`secret` the module alias was never actually referenced here. *)
|
|
else if List.mem alias stdlib_aliases then mark_used alias
|
|
(* stdlib members arrive in plan 9 — UNKNOWN-BUT-RESERVED here on
|
|
purpose: no E207/E225/arity check, nothing to look up yet. The
|
|
emitter (emit.ml) is the one place that still cares, and only
|
|
if a call through this alias survives all the way to
|
|
emission. *)
|
|
else
|
|
match Hashtbl.find_opt alias_tbl alias with
|
|
| None -> () (* not a use-alias at all: a receiver expression (`obj.method(...)`), unrelated to this check *)
|
|
| Some msyms -> (
|
|
mark_used alias;
|
|
match StringMap.find_opt member msyms.free_fns with
|
|
| None -> () (* unknown fn in that module -- WO-E204's territory, not this check's *)
|
|
| Some (fi : free_fn_info) ->
|
|
if not fi.pub then
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:private_name_code ~file ~line:pos.line ~col:pos.col
|
|
~message:(Printf.sprintf "`%s` is not `pub` in module `%s`" member alias) ()))
|
|
in
|
|
let visit (bound : StringSet.t) (e : expr) : unit =
|
|
match e.kind with
|
|
| Ctor (name, _) -> check_ctor e.pos name
|
|
| Call ({ kind = Ident name; _ }, _) -> check_bare_call e.pos name
|
|
| Call ({ kind = Field ({ kind = Ident alias; _ }, member); pos = fpos; _ }, _) ->
|
|
check_qualified_call bound fpos alias member
|
|
| _ -> ()
|
|
in
|
|
walk_program visit prog
|
|
|
|
(* Groups per-file declarations by module (directory) and merges within
|
|
each group — the module-scoped analogue of main.ml's existing global
|
|
merge, and every module-aware consumer's shared starting point.
|
|
`module_of` maps a discovered file to its module id (see check_modules'
|
|
own doc comment below for the exact convention). Exposed (not just
|
|
inlined into check_modules) because the emitter needs the same
|
|
per-module tables too — CRITICAL 1 review finding (plan 3): free_fns
|
|
staying one flat, globally-merged StringMap all the way through
|
|
emission is exactly what let `a.thing()`/`b.thing()` both silently
|
|
execute the same (whichever-merged-first) body. emit.ml resolves a
|
|
qualified call against *this* table (the aliased module's own,
|
|
unmangled `free_fns`), never the flat one, so two modules' same-named
|
|
`pub fn` are genuinely distinct once qualification disambiguates them. *)
|
|
let module_symbols ~(module_of : string -> string) (per_file_syms : (string * symbols) list) :
|
|
(string, symbols) Hashtbl.t =
|
|
let by_module : (string, symbols list) Hashtbl.t = Hashtbl.create 16 in
|
|
List.iter
|
|
(fun (file, syms) ->
|
|
let mid = module_of file in
|
|
let prev = try Hashtbl.find by_module mid with Not_found -> [] in
|
|
Hashtbl.replace by_module mid (syms :: prev))
|
|
per_file_syms;
|
|
let module_syms : (string, symbols) Hashtbl.t = Hashtbl.create 16 in
|
|
Hashtbl.iter (fun mid syms_list -> Hashtbl.replace module_syms mid (merge_syms_for_module syms_list)) by_module;
|
|
module_syms
|
|
|
|
(* The one entry point the driver (bin/main.ml) calls. `module_of` maps
|
|
a discovered file to its module id (its directory, relative to
|
|
whatever root path was discovered from — the driver's own concern,
|
|
computed once from the same root/rel machinery Task 8's discover_dir
|
|
already has; "." denotes the root module, matching Filename.dirname's
|
|
own convention for a bare name with no directory part).
|
|
`per_file_syms` is exactly what typecheck_all already builds (each
|
|
file's OWN, unmerged collect_declarations output) — grouping it by
|
|
module and merging within each group is `module_symbols`, above.
|
|
`per_file_progs` supplies the bodies to walk. *)
|
|
let check_modules (collector : Diag.Collector.t) ~(module_of : string -> string)
|
|
(per_file_syms : (string * symbols) list) (per_file_progs : (string * program) list) : unit =
|
|
let module_syms = module_symbols ~module_of per_file_syms in
|
|
let known_modules = Hashtbl.fold (fun k _ acc -> k :: acc) module_syms [] in
|
|
List.iter
|
|
(fun (file, prog) ->
|
|
let mid = module_of file in
|
|
let own = try Hashtbl.find module_syms mid with Not_found -> merge_syms_for_module [] in
|
|
let uses = uses_of_program prog in
|
|
(* Defined before the unknown-module check below (not just before
|
|
check_module_refs) so that check can mark_used its own
|
|
offending edge — an unknown-module `use` is already reported
|
|
once, precisely; a second, redundant "unused `use`" on the same
|
|
line would only be noise, not new information. *)
|
|
let used_aliases : (string, unit) Hashtbl.t = Hashtbl.create 8 in
|
|
let mark_used alias = Hashtbl.replace used_aliases alias () in
|
|
List.iter
|
|
(fun (u : use_edge) ->
|
|
if (not u.ue_is_stdlib) && not (List.mem (path_str u.ue_segments) known_modules) then begin
|
|
mark_used u.ue_alias;
|
|
Diag.Collector.add collector
|
|
(Diag.error ~code:unknown_module_code ~file ~line:u.ue_pos.line ~col:u.ue_pos.col
|
|
~message:
|
|
(Printf.sprintf
|
|
"unknown module `%s` — not a discovered project module and not a reserved \
|
|
stdlib namespace"
|
|
(path_str u.ue_segments))
|
|
())
|
|
end)
|
|
uses;
|
|
let uses_resolved =
|
|
List.filter_map
|
|
(fun (u : use_edge) ->
|
|
if u.ue_is_stdlib then None
|
|
else
|
|
match Hashtbl.find_opt module_syms (path_str u.ue_segments) with
|
|
| Some s -> Some (u, s)
|
|
| None -> None)
|
|
uses
|
|
in
|
|
let stdlib_aliases = List.filter_map (fun u -> if u.ue_is_stdlib then Some u.ue_alias else None) uses in
|
|
let used_ids = List.map (fun (u, _) -> path_str u.ue_segments) uses_resolved in
|
|
let others =
|
|
Hashtbl.fold
|
|
(fun m s acc -> if m = mid || List.mem m used_ids then acc else (m, s) :: acc)
|
|
module_syms []
|
|
in
|
|
check_module_refs collector ~file ~own ~uses_resolved ~stdlib_aliases ~others ~mark_used prog;
|
|
List.iter
|
|
(fun (u : use_edge) ->
|
|
if not (Hashtbl.mem used_aliases u.ue_alias) then
|
|
Diag.Collector.add collector
|
|
(Diag.warning ~code:unused_use_code ~file ~line:u.ue_pos.line ~col:u.ue_pos.col
|
|
~message:(Printf.sprintf "unused `use %s`" (path_str u.ue_segments)) ()))
|
|
uses)
|
|
per_file_progs
|
|
|
|
(* ============================================================
|
|
Dump support
|
|
============================================================ *)
|
|
|
|
let pos_str (p : pos) : string = Printf.sprintf "%d:%d" p.line p.col
|
|
|
|
let rec field_ty_str (ft : field_ty) : string =
|
|
match ft with
|
|
| Scalar s -> s
|
|
| Ref s -> "ref " ^ s
|
|
| Multi s -> "multi " ^ s
|
|
| Map (k, v) -> "map<" ^ k ^ ", " ^ v ^ ">"
|
|
| Nullable t -> "?" ^ field_ty_str t
|
|
|
|
let dump_symbols (syms : symbols) : string =
|
|
let class_lines = StringMap.fold (fun _name (cls : class_info) acc ->
|
|
let fields_str = List.map (fun (fname, fty, _fdefault, _fann) ->
|
|
Printf.sprintf " %s FIELD %s: %s" (pos_str cls.pos) fname (field_ty_str fty)
|
|
) cls.fields in
|
|
let methods_str = List.map (fun (m : method_info) ->
|
|
Printf.sprintf " %s METHOD %s" (pos_str m.pos) m.name
|
|
) cls.methods in
|
|
(Printf.sprintf "%s CLASS %s @gc=%b" (pos_str cls.pos) cls.name cls.is_gc)
|
|
:: fields_str @ methods_str @ acc
|
|
) syms.classes [] in
|
|
|
|
let interface_lines = StringMap.fold (fun _name (iface : interface_info) acc ->
|
|
let methods_str = List.map (fun (m : method_sig_info) ->
|
|
Printf.sprintf " %s METHOD %s" (pos_str m.pos) m.name
|
|
) iface.methods in
|
|
(Printf.sprintf "%s INTERFACE %s" (pos_str iface.pos) iface.name)
|
|
:: methods_str @ acc
|
|
) syms.interfaces [] in
|
|
|
|
let fn_lines = StringMap.fold (fun _name (fn : free_fn_info) acc ->
|
|
(Printf.sprintf "%s FN %s" (pos_str fn.pos) fn.name) :: acc
|
|
) syms.free_fns [] in
|
|
|
|
String.concat "\n" (class_lines @ interface_lines @ fn_lines) |