feat(compiler): iteration 5 Tasks 1-4 — modules, language surface, switch, typedef records + enum variants
- 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.
This commit is contained in:
parent
641a41e25c
commit
0df9b4fe31
123 changed files with 7866 additions and 176 deletions
|
|
@ -166,13 +166,24 @@ let build_lookup (sources : (string * string) list) : Woc_lib.Diag.source_lookup
|
|||
List.iter (fun (f, src) -> Hashtbl.replace tbl f src) sources;
|
||||
fun f -> Hashtbl.find_opt tbl f
|
||||
|
||||
(* Pre-existing defect, fixed here (haxe-parity Task 1, modules): this
|
||||
used to gate printing on `has_error` alone, so a collector holding
|
||||
*only* warnings (no error at all — e.g. WO-W201's gc-suggestion, or
|
||||
this task's own WO-W202 unused-`use`) printed nothing and exited 0,
|
||||
indistinguishable from a collector with zero diagnostics. A warning
|
||||
nobody ever sees is a dead feature, not a working one — this task's
|
||||
own unused-`use` warning needs to actually reach stderr to be worth
|
||||
having, which is what surfaced this. Still exits 0 whenever nothing
|
||||
is an error (unchanged contract); the only behavior change is that a
|
||||
warning-only run now also prints, matching what "diagnostics, if
|
||||
any, print to stderr" (this file's own usage_msg) already promised. *)
|
||||
let finish (collector : Woc_lib.Diag.Collector.t) (lookup : Woc_lib.Diag.source_lookup) : unit =
|
||||
if Woc_lib.Diag.Collector.has_error collector then begin
|
||||
prerr_string (Woc_lib.Diag.Collector.render_all collector lookup);
|
||||
prerr_newline ();
|
||||
exit 1
|
||||
end
|
||||
else exit 0
|
||||
let text = Woc_lib.Diag.Collector.render_all collector lookup in
|
||||
if text <> "" then begin
|
||||
prerr_string text;
|
||||
prerr_newline ()
|
||||
end;
|
||||
exit (Woc_lib.Diag.Collector.exit_code collector)
|
||||
|
||||
(* ---- Cross-file symbol resolution (Task 8) ---------------------------
|
||||
|
||||
|
|
@ -206,11 +217,12 @@ let merge_symbols (syms_list : Woc_lib.Types.symbols list) : Woc_lib.Types.symbo
|
|||
interfaces = SM.union keep_first acc.interfaces s.interfaces;
|
||||
free_fns = SM.union keep_first acc.free_fns s.free_fns;
|
||||
typedefs = SM.union keep_first acc.typedefs s.typedefs;
|
||||
unions = SM.union keep_first acc.unions s.unions;
|
||||
modules = acc.modules @ s.modules;
|
||||
})
|
||||
Woc_lib.Types.{
|
||||
classes = SM.empty; interfaces = SM.empty; free_fns = SM.empty;
|
||||
typedefs = SM.empty; modules = [];
|
||||
typedefs = SM.empty; unions = SM.empty; modules = [];
|
||||
}
|
||||
syms_list
|
||||
|
||||
|
|
@ -273,19 +285,64 @@ let check_symbol_collisions (collector : Woc_lib.Diag.Collector.t)
|
|||
syms.interfaces))
|
||||
per_file
|
||||
|
||||
let typecheck_all (collector : Woc_lib.Diag.Collector.t)
|
||||
(parsed : (string * Woc_lib.Ast.program) list) : Woc_lib.Types.symbols =
|
||||
(* ---- module identity (haxe-parity Task 1, modules) --------------------
|
||||
|
||||
A file's module is its directory, relative to the root `woc` was
|
||||
pointed at — exactly the directory structure discover_dir above
|
||||
already walks, just not thrown away this time. "." denotes the root
|
||||
module itself (Filename.dirname's own convention for a name with no
|
||||
directory part — reused rather than inventing a second sentinel). A
|
||||
single-file invocation (root is not a directory — the bare-path
|
||||
`woc <file.wo>` form) has exactly one file and therefore exactly one
|
||||
module: "." unconditionally, since there is no sibling directory
|
||||
structure to differ from. *)
|
||||
let module_of_file ~(root : string) (file : string) : string =
|
||||
if not (Sys.is_directory root) then "."
|
||||
else
|
||||
let root_norm =
|
||||
if String.length root > 0 && root.[String.length root - 1] = '/' then
|
||||
String.sub root 0 (String.length root - 1)
|
||||
else root
|
||||
in
|
||||
let prefix = root_norm ^ "/" in
|
||||
let plen = String.length prefix in
|
||||
let rel =
|
||||
if String.length file >= plen && String.sub file 0 plen = prefix then
|
||||
String.sub file plen (String.length file - plen)
|
||||
else file (* defensive: discover_dir always builds full = Filename.concat root rel', so this never triggers *)
|
||||
in
|
||||
Filename.dirname rel
|
||||
|
||||
(* Returns the existing global, flat-merged `syms` (owner.ml's and most of
|
||||
emit.ml's own view — unchanged by this task) alongside the new
|
||||
per-module tables (CRITICAL 1 review finding: the emitter needs these
|
||||
too, for the one place a flat merge is the wrong answer — see
|
||||
Types.module_symbols' own doc comment). *)
|
||||
let typecheck_all (collector : Woc_lib.Diag.Collector.t) ~(root : string)
|
||||
(parsed : (string * Woc_lib.Ast.program) list) :
|
||||
Woc_lib.Types.symbols * (string, Woc_lib.Types.symbols) Hashtbl.t =
|
||||
let per_file_syms =
|
||||
List.map
|
||||
(fun (f, prog) -> (f, Woc_lib.Types.collect_declarations ~file:f prog collector))
|
||||
parsed
|
||||
in
|
||||
check_symbol_collisions collector per_file_syms;
|
||||
let module_of = module_of_file ~root in
|
||||
Woc_lib.Types.check_modules collector ~module_of per_file_syms parsed;
|
||||
let module_syms = Woc_lib.Types.module_symbols ~module_of per_file_syms in
|
||||
let syms = merge_symbols (List.map snd per_file_syms) in
|
||||
List.iter
|
||||
(fun (f, prog) -> Woc_lib.Types.typecheck_program ~file:f prog syms collector)
|
||||
parsed;
|
||||
syms
|
||||
(* `~file_syms` (hotfix, multi-file double-report): `per_file_syms` and
|
||||
`parsed` are both `List.map`s over the same original file list, in
|
||||
the same order, so pairing them positionally is exact -- each
|
||||
file's own collect_declarations output goes with that same file's
|
||||
own prog. `syms` (the merged table) is still passed through
|
||||
separately for cross-file resolution; see typecheck_program's own
|
||||
doc comment for what narrows and what doesn't. *)
|
||||
List.iter2
|
||||
(fun (f, prog) (_, file_syms) ->
|
||||
Woc_lib.Types.typecheck_program ~file:f ~module_of ~module_syms ~file_syms prog syms collector)
|
||||
parsed per_file_syms;
|
||||
(syms, module_syms)
|
||||
|
||||
let dump_tokens path =
|
||||
let sources = discover_and_read path in
|
||||
|
|
@ -316,7 +373,7 @@ let dump_owner path =
|
|||
let collector = Woc_lib.Diag.Collector.create () in
|
||||
let multi = List.length sources > 1 in
|
||||
let parsed = parse_all collector sources in
|
||||
let syms = typecheck_all collector parsed in
|
||||
let syms, _module_syms = typecheck_all collector ~root:path parsed in
|
||||
List.iter
|
||||
(fun (f, prog) ->
|
||||
let tables = Woc_lib.Owner.analyze ~file:f prog syms collector in
|
||||
|
|
@ -332,7 +389,7 @@ let check_only path =
|
|||
let sources = discover_and_read path in
|
||||
let collector = Woc_lib.Diag.Collector.create () in
|
||||
let parsed = parse_all collector sources in
|
||||
let syms = typecheck_all collector parsed in
|
||||
let syms, _module_syms = typecheck_all collector ~root:path parsed in
|
||||
List.iter (fun (f, prog) -> ignore (Woc_lib.Owner.analyze ~file:f prog syms collector)) parsed;
|
||||
finish collector (build_lookup sources)
|
||||
|
||||
|
|
@ -348,14 +405,16 @@ let compile_image path =
|
|||
let sources = discover_and_read path in
|
||||
let collector = Woc_lib.Diag.Collector.create () in
|
||||
let parsed = parse_all collector sources in
|
||||
let syms = typecheck_all collector parsed in
|
||||
let syms, module_syms = typecheck_all collector ~root:path parsed in
|
||||
let units =
|
||||
List.map
|
||||
(fun (f, prog) ->
|
||||
{ Woc_lib.Emit.file = f; prog; tables = Woc_lib.Owner.analyze ~file:f prog syms collector })
|
||||
parsed
|
||||
in
|
||||
let image = Woc_lib.Emit.emit ~syms collector units in
|
||||
let image =
|
||||
Woc_lib.Emit.emit ~syms ~module_of:(module_of_file ~root:path) ~module_syms collector units
|
||||
in
|
||||
(collector, build_lookup sources, image)
|
||||
|
||||
let write_file path contents =
|
||||
|
|
|
|||
|
|
@ -136,6 +136,13 @@ type field = {
|
|||
`+`/`-`, tighter than comparison — this ladder's ordering). *)
|
||||
type unop = Neg
|
||||
|
||||
(* `And`/`Or` (haxe-parity Task 2): real keywords, spelled as words, not
|
||||
`&&`/`||` — the spec amendment's own wording. `Bool`-typed operands
|
||||
only (types.ml wires this through, no truthiness); short-circuit,
|
||||
lowered to compare-and-jump on the existing JZ/JMP opcodes (emit.ml),
|
||||
no new opcode. Own precedence level, looser than every comparison —
|
||||
see parser.ml's ladder doc for the exact ordering (`or` loosest, then
|
||||
`and`, then comparison). *)
|
||||
type binop =
|
||||
| Add
|
||||
| Sub
|
||||
|
|
@ -149,6 +156,8 @@ type binop =
|
|||
| Le
|
||||
| Gt
|
||||
| Ge
|
||||
| And
|
||||
| Or
|
||||
|
||||
type expr = {
|
||||
id : int;
|
||||
|
|
@ -190,6 +199,35 @@ and expr_kind =
|
|||
| Binary of binop * expr * expr
|
||||
| Ctor of string * (string * expr) list
|
||||
| DbStub of Token.t list
|
||||
(* haxe-parity Task 2: one `${expr}` interpolation site, produced only
|
||||
by the string-interpolation desugar (parser.ml) — never written
|
||||
directly by a parse rule the way every other expr_kind is. Its
|
||||
*textification* (pass through if already Text, `int_to_text` if
|
||||
Int, a diagnostic for anything else) is a type-directed decision
|
||||
deferred to emit.ml, since the parser has no type information yet;
|
||||
"desugars at parse time to concatenation" covers the chain SHAPE
|
||||
(a `Binary(Concat, ...)` of StrLit/Interp segments), not this one
|
||||
leaf's textification. *)
|
||||
| Interp of expr
|
||||
(* haxe-parity Task 3: `switch subject { case v1, v2: <stmts> ...
|
||||
default: <stmts> }`. One construct for both positions (the brief's
|
||||
own words: "statement position is the expression with a discarded
|
||||
value") — `stmt` has no separate switch node; a bare `switch {...}`
|
||||
statement is simply this same node wrapped in `ExprStmt`, exactly
|
||||
like a bare `select ...` call already is. Arms carry a `stmt list`
|
||||
body (not a single `expr`) because the sample's own sites do —
|
||||
`case "tail_log": if args == nil { return err(...); } ... return
|
||||
self.tools.tail_log(...);` is not reducible to one expression — so
|
||||
`expr`/`stmt` must be mutually recursive from here down (this is
|
||||
the one place `expr_kind` reaches into `stmt`; every other node
|
||||
above predates this task and never needed to). `values = []` means
|
||||
`default` (`is_default = true`); a `case` always has at least one
|
||||
value, and — the sample's own `alias_of`/cron.wo shape,
|
||||
`case "@daily", "@midnight": ...` — may have more than one,
|
||||
matching on any of them. No guards, no ranges: the sample never
|
||||
uses either, so neither is grammar here (YAGNI, recorded in the
|
||||
task report). *)
|
||||
| Switch of expr * switch_arm list
|
||||
|
||||
(* ---- statements (Task 5) ---------------------------------------------
|
||||
|
||||
|
|
@ -210,7 +248,7 @@ and expr_kind =
|
|||
else-block whose sole statement is itself an `If` — rather than a
|
||||
third `else_body` shape, so dump.ml's block-rendering code (already
|
||||
written once, for `then_body`) renders the chain for free. *)
|
||||
type stmt = {
|
||||
and stmt = {
|
||||
s_id : int;
|
||||
s_pos : pos;
|
||||
s_kind : stmt_kind;
|
||||
|
|
@ -242,6 +280,76 @@ and stmt_kind =
|
|||
}
|
||||
| Return of expr option
|
||||
| ExprStmt of expr
|
||||
(* haxe-parity Task 2: loop control. Both reuse owner.ml's scope-end
|
||||
drop machinery (see Owner.DBreak/DContinue) so an owned value still
|
||||
alive in the loop body is dropped at the jump, not left to leak;
|
||||
emit.ml refuses to lower either one outside a loop (WO-E403 —
|
||||
"cannot lower", the same convention as every other construct with
|
||||
no legal target, since nothing upstream tracks loop nesting as a
|
||||
parse- or type-error). *)
|
||||
| Break
|
||||
| Continue
|
||||
(* `do { body } while cond` — body runs at least once, then the
|
||||
condition gates repeating it. Lowered onto the same JZ/JMP pair
|
||||
`while`/`for` already use, just reordered (parser.ml/emit.ml). *)
|
||||
| DoWhile of {
|
||||
body : stmt list;
|
||||
cond : expr;
|
||||
}
|
||||
|
||||
(* haxe-parity Task 3: one arm of a `switch`. `values = []` iff
|
||||
`is_default`; a `case` arm's `values` is never empty (parser
|
||||
contract, mirrored — not re-checked — by every later stage). No
|
||||
per-arm `id`: nothing downstream keys a side table on "this specific
|
||||
arm" independent of the `Switch` expr that owns it (the shared
|
||||
`Switch.id` is the drop-scope/branch-join node for every arm, one
|
||||
per-arm string label telling them apart — exactly how `If`'s THEN/
|
||||
ELSE already share `s_id` and differ only by label). *)
|
||||
and switch_arm = {
|
||||
arm_pos : pos;
|
||||
values : expr list;
|
||||
is_default : bool;
|
||||
body : stmt list;
|
||||
}
|
||||
|
||||
(* haxe-parity Task 3 (review fix, Critical 1): the arm order a
|
||||
switch's own lowering actually walks — `default` moved to the end,
|
||||
regardless of where it sits in the source. `default` has no
|
||||
comparison of its own (it matches unconditionally); lowering the
|
||||
arms in raw *source* order therefore made any `case` arm written
|
||||
after a `default` permanently unreachable dead code (nothing ever
|
||||
jumps into it, and `default`'s own body jumps straight to the
|
||||
switch's exit, never falling through) — a real, reviewer-reproduced
|
||||
bug, not a theoretical one. Both `owner.ml` (`analyze_switch`,
|
||||
whose drop-scope/JOIN-DROP tables are keyed "ARM<i>" by this order)
|
||||
and `emit.ml` (`emit_switch`, the compare-and-jump chain itself)
|
||||
call this SAME function rather than each re-deriving the reorder
|
||||
independently — the two-file fix the review flagged, done once so
|
||||
the "ARM<i>" indices the two files hand each other can never drift
|
||||
apart. `List.partition` is stable (documented in the stdlib): every
|
||||
`case` arm keeps its own relative order, and — malformed, not
|
||||
otherwise rejected — more than one `default` would too, all pushed
|
||||
after every `case`. *)
|
||||
let switch_lowering_order (arms : switch_arm list) : switch_arm list =
|
||||
let cases, defaults = List.partition (fun (a : switch_arm) -> not a.is_default) arms in
|
||||
cases @ defaults
|
||||
|
||||
(* haxe-parity Task 2: `const NAME = <literal>` — a compile-time value,
|
||||
substituted for every unshadowed `Ident NAME` reference by a
|
||||
dedicated post-parse pass (parser.ml's own const-substitution step,
|
||||
run at the end of `parse`) rather than threaded through
|
||||
typecheck/owner/emit as a new resolvable name: after substitution a
|
||||
const reference simply *is* the literal expr it names, so every later
|
||||
stage needs zero const-specific code. `value` is restricted by the
|
||||
parser to a literal (`IntLit`/`StrLit`/`BoolLit`, optionally
|
||||
`Unary(Neg, IntLit)`) — never a general expression, matching the
|
||||
brief's own "= literal", not "= expr". *)
|
||||
type const_decl = {
|
||||
id : int;
|
||||
pos : pos;
|
||||
name : string;
|
||||
value : expr;
|
||||
}
|
||||
|
||||
(* A signature shared shape (name/params/ret) appears twice: as an
|
||||
interface method (no body) and as a class/type/free-fn method (body
|
||||
|
|
@ -266,6 +374,15 @@ type method_decl = {
|
|||
(* Task 4 captured this as a verbatim token span (brace-depth counter
|
||||
only); Task 5 parses it for real. *)
|
||||
body : stmt list;
|
||||
(* haxe-parity Task 1 (modules): true only for a top-level free `fn`
|
||||
parsed with a leading `pub` marker. method_decl is shared with
|
||||
class methods (Task 4's own design — see this file's module doc),
|
||||
but `pub` is a Task-1-scoped, top-level-declaration-only marker
|
||||
(classes, interfaces, free fns); method-level visibility is a
|
||||
different, not-yet-designed question, so parse_method always
|
||||
passes `pub = false` for a class body's own methods — this field
|
||||
is meaningful only when the surrounding decl is `Fn`. *)
|
||||
pub : bool;
|
||||
}
|
||||
|
||||
(* `@table(name: "...", index: [a, b], index: [c])` — optional storage
|
||||
|
|
@ -287,10 +404,31 @@ type class_decl = {
|
|||
deliberate divergence from rt's plan-13 asymmetry, where a plain
|
||||
`type`'s `fn` was skip-discarded — see parser.ml's module doc). *)
|
||||
is_class : bool;
|
||||
(* haxe-parity Task 4: true for `typedef Name = { ... }` — a
|
||||
STRUCTURAL record alias, reusing this same node (same field
|
||||
grammar, same downstream ctor/field machinery) rather than a
|
||||
parallel decl kind. What the flag changes downstream: two records
|
||||
with the same shape are the SAME type (emit.ml dedups them onto one
|
||||
class-table entry; types.ml's arm unification compares shapes, not
|
||||
names). A record body is fields only — the parser never puts a
|
||||
method or const inside one, so `methods`/`consts` are always []
|
||||
here. `is_class` is false whenever this is true. *)
|
||||
is_record : bool;
|
||||
is_gc : bool; (* @gc — reference semantics, spec section 3/4 *)
|
||||
table : table_cfg option; (* @table(...) — absent unless annotated *)
|
||||
fields : field list;
|
||||
methods : method_decl list;
|
||||
(* haxe-parity Task 2: class-level `const NAME = literal` (bare, no
|
||||
`static` — `static const` is Task 7's syntax, deliberately not
|
||||
handled here so it falls through to a clean parse error, counted
|
||||
against the gap until Task 7 lands). Scoped to this class's own
|
||||
methods only by the same post-parse substitution pass that handles
|
||||
top-level consts — see const_decl's own doc comment. *)
|
||||
consts : const_decl list;
|
||||
(* haxe-parity Task 1 (modules): `pub` marker — false (private to the
|
||||
declaring module) unless the declaration was written `pub class`/
|
||||
`pub type`. *)
|
||||
pub : bool;
|
||||
}
|
||||
|
||||
type interface_decl = {
|
||||
|
|
@ -298,11 +436,51 @@ type interface_decl = {
|
|||
pos : pos;
|
||||
name : string;
|
||||
methods : method_sig list; (* signatures only — no fields, no bodies *)
|
||||
pub : bool; (* haxe-parity Task 1 — see class_decl.pub *)
|
||||
}
|
||||
|
||||
(* haxe-parity Task 1 (modules): `use fs` (a reserved stdlib namespace)
|
||||
or `use shared/util` (project-relative, slash-separated path
|
||||
segments naming another discovered module's directory). `segments`
|
||||
is never empty — the parser requires at least one identifier. *)
|
||||
type use_decl = {
|
||||
id : int;
|
||||
pos : pos;
|
||||
segments : string list;
|
||||
}
|
||||
|
||||
(* haxe-parity Task 4: one variant of a union declaration
|
||||
(`type Name = A | B | C(field: Type, ...)`). `v_fields` is the
|
||||
payload, in declaration order — [] for a bare variant. No per-variant
|
||||
`id`: like switch_arm, nothing downstream keys a side table on "this
|
||||
specific variant" independent of the union that owns it (a variant's
|
||||
identity downstream is (union, ordinal) — its tag). *)
|
||||
type variant_decl = {
|
||||
v_pos : pos;
|
||||
v_name : string;
|
||||
v_fields : (string * field_ty) list;
|
||||
}
|
||||
|
||||
(* haxe-parity Task 4: `type Name = V1 | V2 | ...` — a tagged union.
|
||||
All-bare unions (every `v_fields` empty) lower to plain integer tags
|
||||
(the variant's ordinal), no heap object and no class-table entry;
|
||||
a union with at least one payload variant lowers every variant to a
|
||||
small heap object whose class-table entry the compiler generates
|
||||
(docs/plan/oop-vm/00-wob-format.md, "enum payload variants"). *)
|
||||
type union_decl = {
|
||||
id : int;
|
||||
pos : pos;
|
||||
name : string;
|
||||
variants : variant_decl list;
|
||||
pub : bool;
|
||||
}
|
||||
|
||||
type decl =
|
||||
| Class of class_decl
|
||||
| Interface of interface_decl
|
||||
| Fn of method_decl (* free (non-method) top-level function *)
|
||||
| Use of use_decl
|
||||
| Const of const_decl (* haxe-parity Task 2: top-level `const NAME = literal` *)
|
||||
| Union of union_decl (* haxe-parity Task 4: `type Name = A | B | ...` *)
|
||||
|
||||
type program = { decls : decl list }
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ let builtin_name = function
|
|||
| 10 -> "map_set"
|
||||
| 11 -> "map_get"
|
||||
| 12 -> "map_has"
|
||||
| 13 -> "int_to_text"
|
||||
| 14 -> "variant_tag"
|
||||
| n -> Printf.sprintf "builtin%d" n
|
||||
|
||||
let kind_name = function
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ let kind_label (k : Token.kind) : string =
|
|||
| Token.Ident s -> Printf.sprintf "IDENT(%s)" s
|
||||
| Token.Int n -> Printf.sprintf "INT(%d)" n
|
||||
| Token.Str s -> Printf.sprintf "STR(%s)" s
|
||||
| Token.InterpStr segs ->
|
||||
let part_str = function
|
||||
| Token.SText s -> Printf.sprintf "TEXT(%s)" s
|
||||
| Token.SExpr s -> Printf.sprintf "EXPR(%s)" s
|
||||
in
|
||||
Printf.sprintf "INTERP_STR(%s)" (String.concat "," (List.map part_str segs))
|
||||
| Token.KwType -> "KW_TYPE"
|
||||
| Token.KwClass -> "KW_CLASS"
|
||||
| Token.KwInterface -> "KW_INTERFACE"
|
||||
|
|
@ -44,6 +50,19 @@ let kind_label (k : Token.kind) : string =
|
|||
| Token.KwFalse -> "KW_FALSE"
|
||||
| Token.KwInsert -> "KW_INSERT"
|
||||
| Token.KwSelect -> "KW_SELECT"
|
||||
| Token.KwUse -> "KW_USE"
|
||||
| Token.KwPub -> "KW_PUB"
|
||||
| Token.KwBreak -> "KW_BREAK"
|
||||
| Token.KwContinue -> "KW_CONTINUE"
|
||||
| Token.KwDo -> "KW_DO"
|
||||
| Token.KwConst -> "KW_CONST"
|
||||
| Token.KwAnd -> "KW_AND"
|
||||
| Token.KwOr -> "KW_OR"
|
||||
| Token.KwInline -> "KW_INLINE"
|
||||
| Token.KwSwitch -> "KW_SWITCH"
|
||||
| Token.KwCase -> "KW_CASE"
|
||||
| Token.KwDefault -> "KW_DEFAULT"
|
||||
| Token.KwTypedef -> "KW_TYPEDEF"
|
||||
| Token.LBrace -> "LBRACE"
|
||||
| Token.RBrace -> "RBRACE"
|
||||
| Token.LParen -> "LPAREN"
|
||||
|
|
@ -168,6 +187,8 @@ let binop_str : Ast.binop -> string = function
|
|||
| Ast.Le -> "<="
|
||||
| Ast.Gt -> ">"
|
||||
| Ast.Ge -> ">="
|
||||
| Ast.And -> "and"
|
||||
| Ast.Or -> "or"
|
||||
|
||||
(* Raw token span shared by both DbStub renderings below: a statement-
|
||||
position DbStub (dump_stmt) and an expression-position one nested
|
||||
|
|
@ -198,6 +219,20 @@ let rec expr_str (e : Ast.expr) : string =
|
|||
(String.concat ", "
|
||||
(List.map (fun (fname, fval) -> Printf.sprintf "%s: %s" fname (expr_str fval)) fields))
|
||||
| Ast.DbStub toks -> Printf.sprintf "DB_STUB(%s)" (dbstub_tokens_str toks)
|
||||
| Ast.Interp inner -> Printf.sprintf "INTERP(%s)" (expr_str inner)
|
||||
(* haxe-parity Task 3: arm bodies are `stmt list`, not one `expr` — no
|
||||
golden AST/bc fixture pins a switch (direct assertions instead, see
|
||||
runner.ml, same convention haxe-parity Task 2 used), so this is a
|
||||
one-line-per-arm-header summary ("readable enough to eyeball", this
|
||||
file's own module-doc contract), not a full unparse of every arm's
|
||||
statements. *)
|
||||
| Ast.Switch (subject, arms) ->
|
||||
let arm_str (a : Ast.switch_arm) =
|
||||
if a.Ast.is_default then "default: ..."
|
||||
else Printf.sprintf "case %s: ..." (String.concat ", " (List.map expr_str a.Ast.values))
|
||||
in
|
||||
Printf.sprintf "SWITCH %s { %s }" (expr_str subject)
|
||||
(String.concat " " (List.map arm_str arms))
|
||||
|
||||
(* dump_stmt — one line per statement (LINE:COL KIND detail), matching
|
||||
dump_field/dump_method_sig's "one descriptive line" convention;
|
||||
|
|
@ -235,12 +270,25 @@ let rec dump_stmt (s : Ast.stmt) : string list =
|
|||
| Ast.ExprStmt { Ast.kind = Ast.DbStub toks; _ } ->
|
||||
[ Printf.sprintf "%s DB_STUB %s" (pos_str s.Ast.s_pos) (dbstub_tokens_str toks) ]
|
||||
| Ast.ExprStmt e -> [ Printf.sprintf "%s EXPR %s" (pos_str s.Ast.s_pos) (expr_str e) ]
|
||||
| Ast.Break -> [ Printf.sprintf "%s BREAK" (pos_str s.Ast.s_pos) ]
|
||||
| Ast.Continue -> [ Printf.sprintf "%s CONTINUE" (pos_str s.Ast.s_pos) ]
|
||||
| Ast.DoWhile { body; cond } ->
|
||||
Printf.sprintf "%s DO" (pos_str s.Ast.s_pos) :: indent_block body
|
||||
@ [ Printf.sprintf "%s WHILE %s" (pos_str s.Ast.s_pos) (expr_str cond) ]
|
||||
|
||||
(* [header; body statements...] — body lines are already indented two
|
||||
spaces; callers nesting this under a class add one more level of
|
||||
indent uniformly, same as before Task 5. *)
|
||||
(* `pub` (haxe-parity Task 1, modules) prefixes the header when set; a
|
||||
plain (non-pub) declaration renders byte-identical to every
|
||||
pre-Task-1 golden fixture — this is additive, never a reformat of
|
||||
the unmarked case. *)
|
||||
let pub_prefix (pub : bool) : string = if pub then "PUB " else ""
|
||||
|
||||
let dump_method (m : Ast.method_decl) : string list =
|
||||
let header = Printf.sprintf "%s METHOD %s" (pos_str m.pos) (sig_str m.name m.params m.ret) in
|
||||
let header =
|
||||
Printf.sprintf "%s %sMETHOD %s" (pos_str m.pos) (pub_prefix m.pub) (sig_str m.name m.params m.ret)
|
||||
in
|
||||
let body_lines = List.concat_map (fun s -> List.map (fun l -> " " ^ l) (dump_stmt s)) m.body in
|
||||
header :: body_lines
|
||||
|
||||
|
|
@ -261,24 +309,56 @@ let annotations_header (is_gc : bool) (table : Ast.table_cfg option) : string =
|
|||
in
|
||||
gc_part ^ table_part
|
||||
|
||||
(* haxe-parity Task 2: `CONST NAME = <literal>` — top-level or (bare)
|
||||
class-level. *)
|
||||
let dump_const (c : Ast.const_decl) : string =
|
||||
Printf.sprintf "%s CONST %s = %s" (pos_str c.pos) c.name (expr_str c.value)
|
||||
|
||||
let dump_class (c : Ast.class_decl) : string list =
|
||||
let kw = if c.is_class then "CLASS" else "TYPE" in
|
||||
let kw = if c.is_record then "TYPEDEF" else if c.is_class then "CLASS" else "TYPE" in
|
||||
let header =
|
||||
Printf.sprintf "%s %s %s%s" (pos_str c.pos) kw c.name (annotations_header c.is_gc c.table)
|
||||
Printf.sprintf "%s %s%s %s%s" (pos_str c.pos) (pub_prefix c.pub) kw c.name
|
||||
(annotations_header c.is_gc c.table)
|
||||
in
|
||||
let field_lines = List.map (fun f -> " " ^ dump_field f) c.fields in
|
||||
let const_lines = List.map (fun cd -> " " ^ dump_const cd) c.consts in
|
||||
let method_lines = List.concat_map (fun m -> List.map (fun l -> " " ^ l) (dump_method m)) c.methods in
|
||||
(header :: field_lines) @ method_lines
|
||||
(header :: field_lines) @ const_lines @ method_lines
|
||||
|
||||
let dump_interface (i : Ast.interface_decl) : string list =
|
||||
let header = Printf.sprintf "%s INTERFACE %s" (pos_str i.pos) i.name in
|
||||
let header = Printf.sprintf "%s %sINTERFACE %s" (pos_str i.pos) (pub_prefix i.pub) i.name in
|
||||
let method_lines = List.map (fun m -> " " ^ dump_method_sig m) i.methods in
|
||||
header :: method_lines
|
||||
|
||||
(* USE <path>, segments joined by '/' exactly as written in source
|
||||
(`use shared/util` -> "shared/util") — no resolution performed here,
|
||||
this is a syntax-level dump like every other dump_* function. *)
|
||||
let dump_use (u : Ast.use_decl) : string list =
|
||||
[ Printf.sprintf "%s USE %s" (pos_str u.pos) (String.concat "/" u.segments) ]
|
||||
|
||||
(* UNION Name = A | B(f: T) — one line, variants rendered inline the
|
||||
same "readable enough to eyeball" way expr_str renders expressions
|
||||
(haxe-parity Task 4). *)
|
||||
let dump_union (u : Ast.union_decl) : string list =
|
||||
let variant_str (v : Ast.variant_decl) =
|
||||
match v.Ast.v_fields with
|
||||
| [] -> v.Ast.v_name
|
||||
| fs ->
|
||||
Printf.sprintf "%s(%s)" v.Ast.v_name
|
||||
(String.concat ", "
|
||||
(List.map (fun (n, ty) -> Printf.sprintf "%s: %s" n (field_ty_str ty)) fs))
|
||||
in
|
||||
[ Printf.sprintf "%s %sUNION %s = %s" (pos_str u.Ast.pos) (pub_prefix u.Ast.pub) u.Ast.name
|
||||
(String.concat " | " (List.map variant_str u.Ast.variants))
|
||||
]
|
||||
|
||||
let dump_decl : Ast.decl -> string list = function
|
||||
| Ast.Class c -> dump_class c
|
||||
| Ast.Interface i -> dump_interface i
|
||||
| Ast.Fn f -> dump_method f
|
||||
| Ast.Const c -> [ dump_const c ]
|
||||
| Ast.Use u -> dump_use u
|
||||
| Ast.Union u -> dump_union u
|
||||
|
||||
let dump_ast (prog : Ast.program) : string =
|
||||
let lines = List.concat_map dump_decl prog.decls in
|
||||
|
|
@ -413,6 +493,8 @@ let dump_owner (t : Owner.tables) : string =
|
|||
| Owner.DBranchJoin label ->
|
||||
Printf.sprintf "%s JOIN-DROP %s %s" pos label (drop_items_str d.Owner.dr_items)
|
||||
| Owner.DLiveMask -> Printf.sprintf "%s LIVE-MASK %s" pos (drop_items_str d.Owner.dr_items)
|
||||
| Owner.DBreak -> Printf.sprintf "%s BREAK %s" pos (drop_items_str d.Owner.dr_items)
|
||||
| Owner.DContinue -> Printf.sprintf "%s CONTINUE %s" pos (drop_items_str d.Owner.dr_items)
|
||||
in
|
||||
let rc_line (r : Owner.rc_site) =
|
||||
Printf.sprintf "%s %s %s %s" (owner_pos_str r.Owner.rc_pos)
|
||||
|
|
|
|||
1259
compiler/src/emit.ml
1259
compiler/src/emit.ml
File diff suppressed because it is too large
Load diff
|
|
@ -105,7 +105,8 @@ let read_ident_chars lx =
|
|||
plus uppercase-only INSERT/SELECT. Deliberately absent: self, me,
|
||||
subscribe, receive, and lowercase insert/select — those fall
|
||||
through to the `_ -> None` case below and lex as plain Ident,
|
||||
matching rt and the CLAUDE.md gotcha this task exists to preserve. *)
|
||||
matching rt and the CLAUDE.md gotcha this task exists to preserve.
|
||||
`use`/`pub` (haxe-parity Task 1, modules) added on top of that set. *)
|
||||
let keyword_kind = function
|
||||
| "type" -> Some Token.KwType
|
||||
| "class" -> Some Token.KwClass
|
||||
|
|
@ -122,10 +123,82 @@ let keyword_kind = function
|
|||
| "in" -> Some Token.KwIn
|
||||
| "true" -> Some Token.KwTrue
|
||||
| "false" -> Some Token.KwFalse
|
||||
| "use" -> Some Token.KwUse
|
||||
| "pub" -> Some Token.KwPub
|
||||
| "break" -> Some Token.KwBreak
|
||||
| "continue" -> Some Token.KwContinue
|
||||
| "do" -> Some Token.KwDo
|
||||
| "const" -> Some Token.KwConst
|
||||
| "and" -> Some Token.KwAnd
|
||||
| "or" -> Some Token.KwOr
|
||||
| "inline" -> Some Token.KwInline
|
||||
| "switch" -> Some Token.KwSwitch
|
||||
| "case" -> Some Token.KwCase
|
||||
| "default" -> Some Token.KwDefault
|
||||
| "typedef" -> Some Token.KwTypedef
|
||||
| "INSERT" -> Some Token.KwInsert
|
||||
| "SELECT" -> Some Token.KwSelect
|
||||
| _ -> None
|
||||
|
||||
(* haxe-parity Task 2: scans the raw source of one `${...}` interpolation
|
||||
body, starting right after the `{` (caller already consumed `$` and
|
||||
`{`). Returns that raw, unlexed text -- the parser re-tokenizes it as a
|
||||
full expression (parser.ml's own desugar-to-Concat step; this is a
|
||||
mechanical extraction only, no semantics). Tracks brace depth so a
|
||||
nested `{}` (a constructor literal inside an interpolation,
|
||||
`${Point{x:1}.x}`) doesn't end the scan early, and skips a nested
|
||||
string literal verbatim (honoring its own backslash escapes) so a
|
||||
quote or brace *inside* that nested string can't confuse either
|
||||
count. Runs off the end of the file the same silent way an
|
||||
unterminated outer string does -- the caller's own EOF handling picks
|
||||
up right after. *)
|
||||
let read_interp_expr lx =
|
||||
let buf = Buffer.create 16 in
|
||||
let depth = ref 0 in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
match peek lx with
|
||||
| None -> continue_ := false
|
||||
| Some '}' when !depth = 0 ->
|
||||
ignore (advance lx);
|
||||
continue_ := false
|
||||
| Some ('{' as c) ->
|
||||
incr depth;
|
||||
Buffer.add_char buf c;
|
||||
ignore (advance lx)
|
||||
| Some ('}' as c) ->
|
||||
decr depth;
|
||||
Buffer.add_char buf c;
|
||||
ignore (advance lx)
|
||||
| Some (('"' | '\'') as q) ->
|
||||
Buffer.add_char buf q;
|
||||
ignore (advance lx);
|
||||
let scanning = ref true in
|
||||
while !scanning do
|
||||
match peek lx with
|
||||
| None -> scanning := false
|
||||
| Some c when c = q ->
|
||||
Buffer.add_char buf c;
|
||||
ignore (advance lx);
|
||||
scanning := false
|
||||
| Some '\\' -> (
|
||||
Buffer.add_char buf '\\';
|
||||
ignore (advance lx);
|
||||
match peek lx with
|
||||
| Some c ->
|
||||
Buffer.add_char buf c;
|
||||
ignore (advance lx)
|
||||
| None -> scanning := false)
|
||||
| Some c ->
|
||||
Buffer.add_char buf c;
|
||||
ignore (advance lx)
|
||||
done
|
||||
| Some c ->
|
||||
Buffer.add_char buf c;
|
||||
ignore (advance lx)
|
||||
done;
|
||||
Buffer.contents buf
|
||||
|
||||
let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
|
||||
Token.t list =
|
||||
let lx = make src in
|
||||
|
|
@ -171,6 +244,16 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
|
|||
let quote = c in
|
||||
ignore (advance lx);
|
||||
let buf = Buffer.create 16 in
|
||||
(* haxe-parity Task 2: segments accumulate here only when at
|
||||
least one `${...}` is actually found (flush_text below); a
|
||||
plain string never touches `parts` at all, so it emits the
|
||||
exact same `Token.Str` it always did -- see the `match !parts`
|
||||
dispatch after the loop. *)
|
||||
let parts = ref [] in
|
||||
let flush_text () =
|
||||
parts := Token.SText (Buffer.contents buf) :: !parts;
|
||||
Buffer.clear buf
|
||||
in
|
||||
let scanning = ref true in
|
||||
while !scanning do
|
||||
match peek lx with
|
||||
|
|
@ -184,6 +267,17 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
|
|||
| Some c when c = quote ->
|
||||
ignore (advance lx);
|
||||
scanning := false
|
||||
| Some '$' when peek_at lx 1 = Some '{' ->
|
||||
(* Unescaped `${` -- `\$` never reaches here, it is fully
|
||||
consumed by the backslash branch below, one dispatch
|
||||
earlier, so this is always a genuine interpolation start,
|
||||
never an escaped `$` that happens to be followed by `{`. *)
|
||||
flush_text ();
|
||||
ignore (advance lx);
|
||||
(* '$' *)
|
||||
ignore (advance lx);
|
||||
(* '{' *)
|
||||
parts := Token.SExpr (read_interp_expr lx) :: !parts
|
||||
| Some '\\' -> (
|
||||
(* Captured before advancing: this is the backslash's own
|
||||
position, so a dangling-escape diagnostic points at the
|
||||
|
|
@ -196,6 +290,10 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
|
|||
| Some '\\' -> Buffer.add_char buf '\\'
|
||||
| Some '"' -> Buffer.add_char buf '"'
|
||||
| Some '\'' -> Buffer.add_char buf '\''
|
||||
(* `\$` -- not one of the escapes above, so it falls into
|
||||
this catch-all exactly like any other unrecognized
|
||||
backslash sequence, producing a literal `$` that the `$`
|
||||
dispatch above never sees (it already advanced past it). *)
|
||||
| Some other -> Buffer.add_char buf other
|
||||
| None ->
|
||||
report_unterminated_escape esc_line esc_col;
|
||||
|
|
@ -204,7 +302,11 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
|
|||
ignore (advance lx);
|
||||
Buffer.add_char buf other
|
||||
done;
|
||||
emit (Token.Str (Buffer.contents buf)) line col
|
||||
flush_text ();
|
||||
(match List.rev !parts with
|
||||
| [] -> emit (Token.Str "") line col
|
||||
| [ Token.SText s ] -> emit (Token.Str s) line col
|
||||
| segs -> emit (Token.InterpStr segs) line col)
|
||||
end
|
||||
else if is_digit c then begin
|
||||
let n = ref 0 in
|
||||
|
|
|
|||
|
|
@ -243,6 +243,13 @@ type drop_item = {
|
|||
DReturn — every live owned local in *all* enclosing scopes at an
|
||||
early (or final) return, after the returned value's own
|
||||
move: the DROPs that must run before the frame leaves.
|
||||
DBreak/DContinue (haxe-parity Task 2) — every live owned local in
|
||||
every scope from here up to *and including* the nearest
|
||||
enclosing loop's own body scope (WHILE/FOR/DO — never
|
||||
beyond it, since a break/continue only exits the loop, not
|
||||
the function). Same "reuse DReturn's own machinery" shape,
|
||||
bounded to the loop instead of the whole function — see
|
||||
live_holders_upto.
|
||||
DOverwrite — the value an assignment overwrites (see the module doc).
|
||||
DBranchJoin — join normalization: the locals the *other* branch of an if
|
||||
moved and this one did not, dropped at this branch's end so
|
||||
|
|
@ -260,6 +267,8 @@ type drop_kind =
|
|||
| DOverwrite
|
||||
| DBranchJoin of string
|
||||
| DLiveMask
|
||||
| DBreak
|
||||
| DContinue
|
||||
|
||||
type drop_site = {
|
||||
dr_node : int;
|
||||
|
|
@ -370,6 +379,15 @@ type ctx = {
|
|||
sink : sink;
|
||||
fn_name : string;
|
||||
mutable scopes : scope list; (* innermost first *)
|
||||
(* haxe-parity Task 2: the enclosing While/For/DoWhile statement ids,
|
||||
innermost first — the nearest enclosing loop's own `s.s_id`, which
|
||||
is also its own scope's `sc_node` (analyze_block ~node:s.s_id).
|
||||
Empty outside any loop; a break/continue found there records no
|
||||
drop site at all (nothing to bound the drop to) and leaves the
|
||||
actual rejection to emit.ml, which has no jump target to lower it
|
||||
onto — the same WO-E403 "no legal target" convention as every
|
||||
other construct with nothing to lower to. *)
|
||||
mutable loop_stack : int list;
|
||||
(* false during a loop's probe pass: no diagnostics, no table entries *)
|
||||
mutable recording : bool;
|
||||
(* set when the current path has returned; a diverged path contributes
|
||||
|
|
@ -399,7 +417,15 @@ let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass =
|
|||
if Types.is_builtin_scalar n then Copy
|
||||
else if Types.is_gc_class ctx.syms n then Gc
|
||||
else if Types.StringMap.mem n ctx.syms.Types.classes then Owned
|
||||
else Copy (* unknown type: WO-E225 already reported by types.ml *)
|
||||
else (
|
||||
(* haxe-parity Task 4: an all-bare union value is a plain integer
|
||||
tag — Copy, exactly like a builtin scalar (moving/aliasing it
|
||||
is copying an int). A payload union value is a heap variant
|
||||
object — Owned, one owner, dropped at scope end like any other
|
||||
non-@gc instance. *)
|
||||
match Types.StringMap.find_opt n ctx.syms.Types.unions with
|
||||
| Some u -> if u.Types.u_has_payload then Owned else Copy
|
||||
| None -> Copy (* unknown type: WO-E225 already reported by types.ml *))
|
||||
| Ref _ -> Copy
|
||||
| Multi _ | Map _ -> Owned
|
||||
| Nullable _ -> Copy (* unreachable: unwrapped above *)
|
||||
|
|
@ -447,8 +473,8 @@ and stmt_writes_self (s : Ast.stmt) : bool =
|
|||
| If { then_body; else_body; _ } ->
|
||||
body_writes_self then_body
|
||||
|| (match else_body with Some (_, b) -> body_writes_self b | None -> false)
|
||||
| While { body; _ } | For { body; _ } -> body_writes_self body
|
||||
| Let _ | Return _ | ExprStmt _ -> false
|
||||
| While { body; _ } | For { body; _ } | DoWhile { body; _ } -> body_writes_self body
|
||||
| Let _ | Return _ | ExprStmt _ | Break | Continue -> false
|
||||
|
||||
(* A resolved callee: its parameter conventions (positional), its return
|
||||
type, and — for a method call — whether the receiver is borrowed
|
||||
|
|
@ -463,22 +489,108 @@ type callee = {
|
|||
ce_recv_excl : bool;
|
||||
}
|
||||
|
||||
(* haxe-parity Task 4: a bare variant reference (`Pending`) or a variant
|
||||
construction (`Failed("x")`) types as its union — locals always win
|
||||
first (find_local / resolve_callee run before this), so a shadowing
|
||||
binding is never mistaken for a variant. *)
|
||||
let variant_union_ty (ctx : ctx) (n : string) : Ast.field_ty option =
|
||||
match Types.find_variant ctx.syms n with
|
||||
| Some (u, _) -> Some (Ast.Scalar u.Types.u_name)
|
||||
| None -> None
|
||||
|
||||
let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
|
||||
match e.kind with
|
||||
| IntLit _ -> Some (Scalar "Int")
|
||||
| StrLit _ -> Some (Scalar "Text")
|
||||
| BoolLit _ -> Some (Scalar "Bool")
|
||||
| Ident n -> ( match find_local ctx n with Some l -> Some l.l_ty | None -> None)
|
||||
| Ident n -> (
|
||||
match find_local ctx n with
|
||||
| Some l -> Some l.l_ty
|
||||
| None -> variant_union_ty ctx n)
|
||||
| Field (base, f) -> (
|
||||
match expr_ty ctx base with
|
||||
| Some bt -> ( match unwrap_nullable bt with Scalar cn -> field_ty_of ctx cn f | _ -> None)
|
||||
| None -> None)
|
||||
| Index (base, _) -> ( match expr_ty ctx base with Some bt -> elem_ty bt | None -> None)
|
||||
| Call (callee, _) -> ( match resolve_callee ctx callee with Some c -> c.ce_ret | None -> None)
|
||||
| Call (callee, _) -> (
|
||||
match resolve_callee ctx callee with
|
||||
| Some c -> c.ce_ret
|
||||
| None -> (
|
||||
(* an unresolved Ident callee may be a variant construction — its
|
||||
value is a fresh Owned variant object of the union's type, and
|
||||
missing this here is a real leak (analyze_let's None-fallback
|
||||
classifies as Copy, so the object would never be dropped). *)
|
||||
match callee.kind with
|
||||
| Ident n -> variant_union_ty ctx n
|
||||
| _ -> None))
|
||||
| Unary (_, o) -> expr_ty ctx o
|
||||
| Binary _ -> None (* arithmetic/comparison: Copy either way *)
|
||||
| Ctor (cn, _) -> Some (Scalar cn)
|
||||
| Interp _ -> Some (Scalar "Text") (* an interpolation always produces Text *)
|
||||
| DbStub _ -> None
|
||||
| Switch (subject, arms) ->
|
||||
(* review fix, Critical 3: this was `None` ("not chased", the same
|
||||
call as `Binary`/`DbStub` above) — a real, reviewer-reproduced
|
||||
leak, not a theoretical gap: `analyze_let`'s own fallback for
|
||||
`expr_ty = None` is `Scalar "Int"` (Copy), so an *unannotated*
|
||||
`let v = switch ... { case ...: SomeClass{...}; ... }` was
|
||||
classified Copy and never dropped, even for a plain class with
|
||||
no union involved. Mirrors emit.ml's own `ty_of_expr` Switch
|
||||
case exactly (same shape, this file's own `Ast.field_ty`/`ctx`
|
||||
types instead of `Types.typ`/`pctx`) rather than duplicating
|
||||
types.ml's `typecheck_switch` unification: the first arm's
|
||||
trailing `ExprStmt`'s own type wins — types.ml already proved
|
||||
every other arm agrees, or reported WO-E201 if not, so trusting
|
||||
the first arm here is not a second, weaker check, just this
|
||||
file's own narrower deriver reading the same fact.
|
||||
|
||||
Task 4 fix round 1 (review Critical 1b): an arm may yield its own
|
||||
payload BINDING (`case Boxed(b): b;` — the escape shape the
|
||||
log-watcher's own return pattern uses). The binding is not a
|
||||
local at derivation time (it exists only during the arm's own
|
||||
walk), so the plain recursive call typed the whole switch `None`
|
||||
-> the `Scalar "Int"` fallback -> Copy — a leak (unannotated
|
||||
`let`) or a bogus downstream error. `binding_ty_of_arm` reads the
|
||||
binding's type straight off the subject's variant declaration. *)
|
||||
(match arms with
|
||||
| [] -> None
|
||||
| first :: _ -> (
|
||||
match List.rev first.Ast.body with
|
||||
| { Ast.s_kind = ExprStmt ve; _ } :: _ -> (
|
||||
match ve.Ast.kind with
|
||||
| Ident n -> (
|
||||
match binding_ty_of_arm ctx subject first n with
|
||||
| Some fty -> Some fty
|
||||
| None -> expr_ty ctx ve)
|
||||
| _ -> expr_ty ctx ve)
|
||||
| _ -> None))
|
||||
|
||||
(* The declared type of payload binding [n], when [arm]'s pattern binds
|
||||
it off [subject]'s union — None whenever this is not that shape. *)
|
||||
and binding_ty_of_arm (ctx : ctx) (subject : Ast.expr) (arm : Ast.switch_arm) (n : string) :
|
||||
Ast.field_ty option =
|
||||
match expr_ty ctx subject with
|
||||
| Some (Scalar sn) -> (
|
||||
match Types.StringMap.find_opt sn ctx.syms.Types.unions with
|
||||
| Some u when u.Types.u_has_payload -> (
|
||||
match arm.Ast.values with
|
||||
| [ { Ast.kind = Ast.Call ({ Ast.kind = Ast.Ident vname; _ }, bargs); _ } ] -> (
|
||||
match
|
||||
List.find_opt (fun (vi : Types.variant_info) -> vi.Types.vi_name = vname)
|
||||
u.Types.u_variants
|
||||
with
|
||||
| Some vi when List.length bargs = List.length vi.Types.vi_fields ->
|
||||
let rec zip (args : Ast.expr list) fields =
|
||||
match (args, fields) with
|
||||
| { Ast.kind = Ast.Ident bn; _ } :: _, (_, fty) :: _ when bn = n -> Some fty
|
||||
| _ :: ta, _ :: tf -> zip ta tf
|
||||
| _ -> None
|
||||
in
|
||||
zip bargs vi.Types.vi_fields
|
||||
| _ -> None)
|
||||
| _ -> None)
|
||||
| _ -> None)
|
||||
| _ -> None
|
||||
|
||||
and resolve_callee (ctx : ctx) (callee : Ast.expr) : callee option =
|
||||
let params_of ps = List.map (fun (n, _, conv) -> (n, conv)) ps in
|
||||
|
|
@ -683,6 +795,23 @@ let is_live_holder (l : local) : bool =
|
|||
let live_holders (ctx : ctx) : local list =
|
||||
List.concat_map (fun sc -> List.filter is_live_holder sc.sc_locals) ctx.scopes
|
||||
|
||||
(* haxe-parity Task 2: every live holder from the innermost scope up to
|
||||
and including the scope whose sc_node is [loop_node] (the nearest
|
||||
enclosing loop's own body scope) — DBreak/DContinue's own bound
|
||||
version of live_holders, which goes all the way to the function's
|
||||
own outermost scope (return's job: leave the whole frame). A
|
||||
break/continue only leaves the loop, so scopes *outside* it are
|
||||
untouched — their own DScope drop still runs later, at the loop's
|
||||
normal exit. *)
|
||||
let live_holders_upto (ctx : ctx) (loop_node : int) : local list =
|
||||
let rec go = function
|
||||
| [] -> []
|
||||
| (sc : scope) :: rest ->
|
||||
let here = List.filter is_live_holder sc.sc_locals in
|
||||
if sc.sc_node = loop_node then here else here @ go rest
|
||||
in
|
||||
go ctx.scopes
|
||||
|
||||
let owned_items (ls : local list) : drop_item list =
|
||||
ls
|
||||
|> List.filter (fun l -> l.l_class = Owned)
|
||||
|
|
@ -913,9 +1042,11 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
|
|||
| Binary (_, a, b) ->
|
||||
read_expr ctx a;
|
||||
read_expr ctx b
|
||||
| Interp inner -> read_expr ctx inner
|
||||
| DbStub _ ->
|
||||
(* trap-capable: the frame needs its drop map here *)
|
||||
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx))
|
||||
| Switch (subject, arms) -> analyze_switch ctx e.id subject arms
|
||||
|
||||
(* The root of a place expression is already accounted for by use_place;
|
||||
what still needs walking are index subexpressions and a non-place base
|
||||
|
|
@ -947,6 +1078,43 @@ and analyze_ctor (ctx : ctx) (cn : string) (fields : (string * Ast.expr) list) :
|
|||
transfers — that ordering is what makes `f(x, take x)` a
|
||||
move-while-borrowed rather than a use-after-move. *)
|
||||
and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast.expr list) : unit =
|
||||
(* haxe-parity Task 4: a variant construction is not a call — it
|
||||
lowers to NEW + SETF, no CALL instruction — so every place-shaped
|
||||
payload argument is a ctor-field ESCAPE (analyze_ctor's own rule:
|
||||
the value is stored into the fresh object, which owns it from
|
||||
here), never a borrow-for-the-duration-of-the-call. Getting this
|
||||
wrong is a double free, not an imprecision: a local moved into the
|
||||
payload would otherwise stay Live and be dropped at scope end on
|
||||
top of the variant object's own recursive drop. No LIVE-MASK is
|
||||
recorded either — there is no trap-capable call site to sync a
|
||||
drop map at. A declared free fn of the same name shadows the
|
||||
variant (the same flat-table resolution resolve_callee itself
|
||||
uses). *)
|
||||
let variant_ctor =
|
||||
match callee.kind with
|
||||
| Ident n when not (Types.StringMap.mem n ctx.syms.Types.free_fns) ->
|
||||
Types.find_variant ctx.syms n
|
||||
| _ -> None
|
||||
in
|
||||
match variant_ctor with
|
||||
| Some (_, vi) ->
|
||||
List.iteri
|
||||
(fun i (fe : Ast.expr) ->
|
||||
read_expr ctx fe;
|
||||
match place_of fe with
|
||||
| None -> ()
|
||||
| Some p ->
|
||||
let fname =
|
||||
match List.nth_opt vi.Types.vi_fields i with
|
||||
| Some (n, _) -> n
|
||||
| None -> Printf.sprintf "arg%d" (i + 1)
|
||||
in
|
||||
if
|
||||
transfer ctx p
|
||||
~what:(Printf.sprintf "cannot be stored in `%s.%s`" vi.Types.vi_name fname)
|
||||
then record_move ctx p (MvCtorField fname))
|
||||
args
|
||||
| None ->
|
||||
let resolved = resolve_callee ctx callee in
|
||||
(* receiver *)
|
||||
let recv =
|
||||
|
|
@ -1130,6 +1298,128 @@ and branch_join_drops (ctx : ctx) ~(node : int) ~(label : string) ~(pos : Ast.po
|
|||
in
|
||||
record_drop ctx ~node ~pos ~kind:(DBranchJoin label) ~items
|
||||
|
||||
(* haxe-parity Task 3: `switch`'s own arms are alternate flows joining
|
||||
back together after the switch — exactly what `if`/`else` already
|
||||
is, generalized from two branches to N (one per arm). Reused, not
|
||||
reinvented, per the brief's own instruction: each arm is its own
|
||||
`analyze_block` (so an arm-local owned value that is never moved
|
||||
still gets its ordinary DScope drop at that arm's own end — nothing
|
||||
special to write for that half); a diverging arm (every path inside
|
||||
it returned) drops out of the join exactly like a diverging `if`
|
||||
branch does; and a value moved in *some* arms but not others gets
|
||||
`branch_join_drops`'s own JOIN-DROP treatment, called once per kept
|
||||
arm against the join of every *other* (non-diverging) arm's ending
|
||||
state — the N-way shape of the same "the branch that kept it drops
|
||||
it at its own end" rule the module doc above states for two.
|
||||
|
||||
The subject is read (`read_expr`, never `transfer`) exactly once,
|
||||
before any arm runs: it is compared against, never consumed — "the
|
||||
SUBJECT's ownership — borrowed for the comparison, not consumed" per
|
||||
this task's own brief. Case values are read the same way; for this
|
||||
task's scalar/Text subjects they are always literals, so this is a
|
||||
no-op today and only matters once a union variant tag becomes a real
|
||||
bound reference (Task 4). *)
|
||||
and analyze_switch (ctx : ctx) (node : int) (subject : Ast.expr) (arms : Ast.switch_arm list) :
|
||||
unit =
|
||||
read_expr ctx subject;
|
||||
(* haxe-parity Task 4: over a union-typed subject the case "values"
|
||||
are variant PATTERNS (`case Ok:`, `case Failed(reason):`), not
|
||||
expressions — never read as such (a pattern's binding names are
|
||||
unbound on purpose; reading them would be noise at best). The
|
||||
subject's own union-ness comes from this pass's own expr_ty,
|
||||
deliberately NOT unwrapped through `?T` (types.ml's subj_union
|
||||
makes the same call — a `?Union` subject stays on the plain-value
|
||||
path until Task 6). *)
|
||||
let subj_union =
|
||||
match expr_ty ctx subject with
|
||||
| Some (Scalar n) -> Types.StringMap.find_opt n ctx.syms.Types.unions
|
||||
| _ -> None
|
||||
in
|
||||
(match subj_union with
|
||||
| Some _ -> ()
|
||||
| None ->
|
||||
List.iter (fun (a : Ast.switch_arm) -> List.iter (read_expr ctx) a.Ast.values) arms);
|
||||
(* A payload pattern's bindings are BORROWS of the subject's own
|
||||
fields (`case Failed(reason):` reads `reason` straight out of the
|
||||
variant object via GETF — the subject keeps owning the payload, so
|
||||
the binding must never be dropped by the arm or the subject's own
|
||||
drop double-frees). Declared into the arm's own scope, exactly like
|
||||
a `for` cursor is into its loop's (same Borrowed state, same
|
||||
l_holds = false); when the subject is a place, the binding's source
|
||||
place is that place plus the field projection, so moving the
|
||||
subject out from under a live binding is the ordinary WO-E302. *)
|
||||
let arm_bindings (a : Ast.switch_arm) : local list =
|
||||
match subj_union with
|
||||
| None -> []
|
||||
| Some u -> (
|
||||
match a.Ast.values with
|
||||
| [ { Ast.kind = Ast.Call ({ Ast.kind = Ast.Ident vname; _ }, args); _ } ] -> (
|
||||
match
|
||||
List.find_opt (fun (v : Types.variant_info) -> v.Types.vi_name = vname)
|
||||
u.Types.u_variants
|
||||
with
|
||||
| Some vi when List.length args = List.length vi.Types.vi_fields ->
|
||||
let subj_place = place_of subject in
|
||||
List.concat
|
||||
(List.map2
|
||||
(fun (arg : Ast.expr) (fname, fty) ->
|
||||
match arg.Ast.kind with
|
||||
| Ast.Ident bn ->
|
||||
let src =
|
||||
match subj_place with
|
||||
| Some p ->
|
||||
Some { p with projs = p.projs @ [ PField fname ]; pnode = arg.Ast.id }
|
||||
| None -> None
|
||||
in
|
||||
[ { l_name = bn; l_ty = fty; l_class = oclass_of ctx fty;
|
||||
l_node = arg.Ast.id; l_pos = arg.Ast.pos; l_holds = false; l_src = src;
|
||||
l_bkind = AShared; l_state = Borrowed arg.Ast.pos } ]
|
||||
| _ -> [])
|
||||
args vi.Types.vi_fields)
|
||||
| _ -> [])
|
||||
| _ -> [])
|
||||
in
|
||||
let entry = snapshot ctx in
|
||||
let div0 = ctx.diverged in
|
||||
(* review fix, Critical 1: walk the *lowering* order (default last),
|
||||
not raw source order — see Ast.switch_lowering_order's own doc
|
||||
comment. "ARM<i>" must be the same index emit.ml's own
|
||||
emit_switch hands the owner tables, or every DScope/JOIN-DROP
|
||||
lookup below silently misses. *)
|
||||
let results =
|
||||
List.mapi
|
||||
(fun i (a : Ast.switch_arm) ->
|
||||
restore entry;
|
||||
ctx.diverged <- div0;
|
||||
let label = Printf.sprintf "ARM%d" i in
|
||||
analyze_block ctx ~pre:(arm_bindings a) ~node ~pos:a.Ast.arm_pos ~label a.Ast.body;
|
||||
(label, a.Ast.arm_pos, snapshot ctx, ctx.diverged))
|
||||
(Ast.switch_lowering_order arms)
|
||||
in
|
||||
let non_diverged = List.filter (fun (_, _, _, d) -> not d) results in
|
||||
match non_diverged with
|
||||
| [] ->
|
||||
(* every arm diverged (or there were no arms at all — a malformed
|
||||
switch types.ml already reports on): nothing reachable follows,
|
||||
so — mirroring analyze_stmt's own `If` case, which restores
|
||||
*some* snapshot purely for hygiene even though it is provably
|
||||
unobservable — restore the last arm's ending state, if any. *)
|
||||
(match List.rev results with (_, _, sn, _) :: _ -> restore sn | [] -> ());
|
||||
ctx.diverged <- true
|
||||
| (_, _, first_sn, _) :: rest ->
|
||||
List.iter
|
||||
(fun (label, pos, sn, _) ->
|
||||
match List.filter (fun (l, _, _, _) -> l <> label) non_diverged with
|
||||
| [] -> () (* the only non-diverging arm: nothing else could have moved anything *)
|
||||
| (_, _, first_other, _) :: rest_other ->
|
||||
let moved_elsewhere =
|
||||
List.fold_left (fun acc (_, _, s, _) -> join acc s) first_other rest_other
|
||||
in
|
||||
branch_join_drops ctx ~node ~label ~pos ~moving:moved_elsewhere ~other:sn)
|
||||
non_diverged;
|
||||
restore (List.fold_left (fun acc (_, _, s, _) -> join acc s) first_sn rest);
|
||||
ctx.diverged <- div0
|
||||
|
||||
and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
|
||||
match s.s_kind with
|
||||
| Let { name; ty; value } -> analyze_let ctx s name ty value
|
||||
|
|
@ -1182,16 +1472,19 @@ and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
|
|||
ctx.diverged <- div0
|
||||
end
|
||||
| While { cond; body } ->
|
||||
ctx.loop_stack <- s.s_id :: ctx.loop_stack;
|
||||
fixpoint ctx
|
||||
(fun () ->
|
||||
read_expr ctx cond;
|
||||
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"WHILE" body)
|
||||
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"WHILE" body);
|
||||
ctx.loop_stack <- List.tl ctx.loop_stack
|
||||
| For { var; iter; body } ->
|
||||
read_expr ctx iter;
|
||||
let src = place_of iter in
|
||||
let item_ty =
|
||||
match expr_ty ctx iter with Some t -> ( match elem_ty t with Some e -> e | None -> t) | None -> Scalar "Int"
|
||||
in
|
||||
ctx.loop_stack <- s.s_id :: ctx.loop_stack;
|
||||
fixpoint ctx
|
||||
(fun () ->
|
||||
push_scope ctx ~node:s.s_id ~pos:s.s_pos ~label:"FOR";
|
||||
|
|
@ -1202,7 +1495,22 @@ and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
|
|||
l_pos = s.s_pos; l_holds = false; l_src = src; l_bkind = AShared;
|
||||
l_state = Borrowed s.s_pos };
|
||||
List.iter (analyze_stmt ctx) body;
|
||||
pop_scope ctx)
|
||||
pop_scope ctx);
|
||||
ctx.loop_stack <- List.tl ctx.loop_stack
|
||||
| DoWhile { body; cond } ->
|
||||
(* `do { body } while cond` — body always runs before the condition
|
||||
is ever consulted, so it is analyzed first; still wrapped in
|
||||
`fixpoint` for the same reason `while`/`for` are (a *second*
|
||||
iteration's move state must be joined against the first's, the
|
||||
brief's own loop rule — see fixpoint's doc comment). *)
|
||||
ctx.loop_stack <- s.s_id :: ctx.loop_stack;
|
||||
fixpoint ctx
|
||||
(fun () ->
|
||||
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"DO" body;
|
||||
read_expr ctx cond);
|
||||
ctx.loop_stack <- List.tl ctx.loop_stack
|
||||
| Break -> analyze_break ctx s
|
||||
| Continue -> analyze_continue ctx s
|
||||
|
||||
(* The brief's loop rule: probe the body once with recording off, join
|
||||
that with the entry state, then analyze for real against the joined
|
||||
|
|
@ -1223,8 +1531,14 @@ and fixpoint (ctx : ctx) (run : unit -> unit) : unit =
|
|||
restore (join entry after);
|
||||
ctx.diverged <- div0
|
||||
|
||||
and analyze_block (ctx : ctx) ~node ~pos ~label (body : Ast.stmt list) : unit =
|
||||
(* `~pre` (haxe-parity Task 4): locals to declare into the fresh scope
|
||||
before its statements run — a payload pattern's bindings, and nothing
|
||||
else today. Borrows only (l_holds = false), so pop_scope's drop
|
||||
recording never sees them; every pre-existing call site passes
|
||||
nothing and is byte-identical. *)
|
||||
and analyze_block (ctx : ctx) ?(pre = []) ~node ~pos ~label (body : Ast.stmt list) : unit =
|
||||
push_scope ctx ~node ~pos ~label;
|
||||
List.iter (declare ctx) pre;
|
||||
List.iter (analyze_stmt ctx) body;
|
||||
pop_scope ctx
|
||||
|
||||
|
|
@ -1360,6 +1674,38 @@ and analyze_return (ctx : ctx) (s : Ast.stmt) (opt : Ast.expr option) : unit =
|
|||
release_gc ctx ~node:s.s_id ~pos:s.s_pos live;
|
||||
ctx.diverged <- true
|
||||
|
||||
(* haxe-parity Task 2: `break`/`continue` reuse analyze_return's own
|
||||
drop machinery verbatim, bounded to the nearest enclosing loop
|
||||
instead of the whole function (live_holders_upto vs. live_holders —
|
||||
see that function's doc comment) — this is "the one non-trivial bit"
|
||||
the brief calls out: an owned value still alive in the loop body at
|
||||
a `break`/`continue` gets its DROP recorded right here, at the jump,
|
||||
not left to leak. `ctx.diverged <- true` afterward mirrors
|
||||
analyze_return's own reasoning exactly: the rest of *this* block is
|
||||
unreachable, and the surrounding if/fixpoint machinery already knows
|
||||
how to fold that into a branch join or a loop's own entry/exit meet
|
||||
(the same normalization a `return` inside a loop or an `if` already
|
||||
gets, unchanged by this task). Outside any loop, `loop_stack` is
|
||||
empty and nothing is recorded — see that field's own doc comment for
|
||||
why emit.ml, not this pass, is the actual gate for that case. *)
|
||||
and analyze_break (ctx : ctx) (s : Ast.stmt) : unit =
|
||||
(match ctx.loop_stack with
|
||||
| [] -> ()
|
||||
| loop_node :: _ ->
|
||||
let live = live_holders_upto ctx loop_node in
|
||||
record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DBreak ~items:(owned_items live);
|
||||
release_gc ctx ~node:s.s_id ~pos:s.s_pos live);
|
||||
ctx.diverged <- true
|
||||
|
||||
and analyze_continue (ctx : ctx) (s : Ast.stmt) : unit =
|
||||
(match ctx.loop_stack with
|
||||
| [] -> ()
|
||||
| loop_node :: _ ->
|
||||
let live = live_holders_upto ctx loop_node in
|
||||
record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DContinue ~items:(owned_items live);
|
||||
release_gc ctx ~node:s.s_id ~pos:s.s_pos live);
|
||||
ctx.diverged <- true
|
||||
|
||||
(* ============================================================
|
||||
Per-function driver
|
||||
============================================================ *)
|
||||
|
|
@ -1396,8 +1742,8 @@ let resolve_rc (ctx : ctx) : unit =
|
|||
let analyze_fn ~(file : string) (syms : Types.symbols) (coll : Diag.Collector.t) (sink : sink)
|
||||
~(self_class : string option) (m : Ast.method_decl) : unit =
|
||||
let ctx =
|
||||
{ file; syms; coll; sink; fn_name = m.name; scopes = []; recording = true; diverged = false;
|
||||
fn_rcs = []; rc_groups = Hashtbl.create 8; rc_escaped = Hashtbl.create 8;
|
||||
{ file; syms; coll; sink; fn_name = m.name; scopes = []; loop_stack = []; recording = true;
|
||||
diverged = false; fn_rcs = []; rc_groups = Hashtbl.create 8; rc_escaped = Hashtbl.create 8;
|
||||
clobbered = Hashtbl.create 8 }
|
||||
in
|
||||
push_scope ctx ~node:m.id ~pos:m.pos ~label:"BODY";
|
||||
|
|
@ -1430,7 +1776,10 @@ let analyze ~(file : string) (prog : Ast.program) (syms : Types.symbols)
|
|||
| Ast.Class c ->
|
||||
List.iter (fun m -> analyze_fn ~file syms coll sink ~self_class:(Some c.name) m) c.methods
|
||||
| Ast.Interface _ -> ()
|
||||
| Ast.Fn f -> analyze_fn ~file syms coll sink ~self_class:None f)
|
||||
| Ast.Fn f -> analyze_fn ~file syms coll sink ~self_class:None f
|
||||
| Ast.Use _ -> ()
|
||||
| Ast.Const _ -> ()
|
||||
| Ast.Union _ -> () (* haxe-parity Task 4: no bodies to analyze *))
|
||||
prog.decls;
|
||||
(* Source order: sort by position, stably, so entries sharing a
|
||||
position keep the order the walk produced. Node ids can *not* be
|
||||
|
|
|
|||
|
|
@ -127,6 +127,15 @@ let fail (st : state) (site : Ast.pos) (code : string) (message : string) : 'a =
|
|||
let syntax_code = Diag.parsing_prefix ^ "01" (* WO-E101: generic syntax error *)
|
||||
let table_code = Diag.parsing_prefix ^ "02" (* WO-E102: invalid @table(...) configuration *)
|
||||
|
||||
(* haxe-parity Task 2: the haxe keyword verdict table's `inline` row —
|
||||
"adopt (values): const compile-time values; inline *functions*
|
||||
rejected — optimization is the compiler's job". `const` (below) is
|
||||
the adopted half; this is the reject half's own diagnostic, cited at
|
||||
`inline`'s own position, one per bad declaration (parse_program's
|
||||
existing try/with resyncs past the whole discarded `inline fn ...`
|
||||
body, same as any other bad top-level declaration). *)
|
||||
let inline_fn_code = Diag.parsing_prefix ^ "03" (* WO-E103 *)
|
||||
|
||||
let unexpected (st : state) (what : string) : 'a =
|
||||
let p = peek_pos st in
|
||||
fail st p syntax_code
|
||||
|
|
@ -149,6 +158,19 @@ let expect_ident (st : state) (what : string) : string =
|
|||
s
|
||||
| _ -> unexpected st what
|
||||
|
||||
(* haxe-parity Task 4: `type` is a legal FIELD name (the sample's own
|
||||
wire-format key — mcp.wo's `ToolText = { type: Text, ... }` carries
|
||||
JSON's `type` verbatim), so the three field-NAME positions (a field
|
||||
declaration, a constructor-literal key, a `.field` access) accept the
|
||||
keyword and treat it as the plain name "type". Field positions only —
|
||||
everywhere else `type` stays the declaration keyword it is. *)
|
||||
let expect_field_name (st : state) (what : string) : string =
|
||||
match peek st with
|
||||
| Token.KwType ->
|
||||
ignore (advance st);
|
||||
"type"
|
||||
| _ -> expect_ident st what
|
||||
|
||||
(* ---- field/service/policy/on disambiguation --------------------------
|
||||
|
||||
Lookahead only: Ident immediately followed by Colon. Same shape rt
|
||||
|
|
@ -205,9 +227,23 @@ let sync_to_next_top_level (st : state) : unit =
|
|||
decr depth;
|
||||
ignore (advance st)
|
||||
| Token.KwType when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.KwTypedef when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.KwClass when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.KwInterface when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.KwFn when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.KwUse when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.KwConst when !depth = 0 && st.pos > start -> continue_ := false
|
||||
(* KwPub deliberately NOT a sync point (unlike every other top-level
|
||||
starter above): `pub(read)` (Task 7's field-accessor marker, not
|
||||
this task's — ast.ml's method_decl.pub doc comment) recurs
|
||||
*inside* an already-broken class/type body one field at a time,
|
||||
and making KwPub a stop point would turn one coarse "whole class
|
||||
discarded" diagnostic into one diagnostic per `pub(read)` field
|
||||
line. Leaving it out preserves the pre-existing coarse-recovery
|
||||
behavior there — the brief's own "leave it unparsed" option for
|
||||
`pub(` — while a genuinely top-level `pub` still needs no sync
|
||||
help at all: it's handled inline by parse_program on the
|
||||
error-free path, and recovery only ever runs after a failure. *)
|
||||
| Token.At when !depth = 0 && st.pos > start -> continue_ := false
|
||||
| Token.Ident s when !depth = 0 && st.pos > start && is_sync_ident s -> continue_ := false
|
||||
| _ -> ignore (advance st)
|
||||
|
|
@ -330,7 +366,17 @@ let parse_field_ty (st : state) : Ast.field_ty =
|
|||
Ast.Map (k, v)
|
||||
| Token.Ident name ->
|
||||
ignore (advance st);
|
||||
Ast.Scalar name
|
||||
(* haxe-parity Task 4: one qualified segment (`json.Value` — the
|
||||
sample's own stdlib-reserved type in a record field). Kept as
|
||||
one dotted Scalar name; whether it resolves is types.ml's
|
||||
question (is_known_type_name treats a reserved-stdlib head as
|
||||
UNKNOWN-BUT-RESERVED, same convention as `fs.stat(...)` calls). *)
|
||||
if peek st = Token.Dot then begin
|
||||
ignore (advance st);
|
||||
let member = expect_ident st "qualified type name" in
|
||||
Ast.Scalar (name ^ "." ^ member)
|
||||
end
|
||||
else Ast.Scalar name
|
||||
| _ -> unexpected st "a field type"
|
||||
in
|
||||
if !nullable then Ast.Nullable base_ty else base_ty
|
||||
|
|
@ -394,9 +440,17 @@ let parse_default_expr (st : state) : Ast.default_expr =
|
|||
end
|
||||
else Ast.DefaultOpaque (collect_default_tokens st)
|
||||
|
||||
let parse_field (st : state) : Ast.field =
|
||||
(* `~comma_ends` (haxe-parity Task 4): a typedef record body may list
|
||||
its fields on one line, comma-separated (`typedef HttpResp = {
|
||||
status: Int, body: Text }` — the sample's own shape), so a depth-0
|
||||
comma ends the field there exactly like a newline does in a
|
||||
class/type body; the comma itself is left for the record loop to
|
||||
consume. Every pre-existing call site passes nothing and keeps the
|
||||
class-body behavior byte-identical (a comma there is still the same
|
||||
"unexpected" error as before). *)
|
||||
let parse_field ?(comma_ends = false) (st : state) : Ast.field =
|
||||
let pos = peek_pos st in
|
||||
let name = expect_ident st "field name" in
|
||||
let name = expect_field_name st "field name" in
|
||||
expect st Token.Colon "':'";
|
||||
let ty = parse_field_ty st in
|
||||
let default = ref None in
|
||||
|
|
@ -412,6 +466,7 @@ let parse_field (st : state) : Ast.field =
|
|||
| Token.Eq ->
|
||||
ignore (advance st);
|
||||
default := Some (parse_default_expr st)
|
||||
| Token.Comma when comma_ends -> continue_ := false
|
||||
| Token.Newline | Token.RBrace | Token.Eof -> continue_ := false
|
||||
| _ -> unexpected st "an annotation, '=', or end of field"
|
||||
done;
|
||||
|
|
@ -487,6 +542,8 @@ let parse_sig_head (st : state) : sig_head =
|
|||
Precedence ladder, loosest to tightest (parse_expr is the entry
|
||||
point; each level's loop is left-associative):
|
||||
|
||||
or or (haxe-parity Task 2)
|
||||
and and (haxe-parity Task 2)
|
||||
comparison == != < <= > >=
|
||||
concat ..
|
||||
additive + -
|
||||
|
|
@ -498,7 +555,11 @@ let parse_sig_head (st : state) : sig_head =
|
|||
|
||||
This ordering matches Lua's (concat binds looser than +/-, tighter
|
||||
than comparison) — see ast.ml's module doc for why `..`/Concat is
|
||||
this task's own addition, not a straight rt port.
|
||||
this task's own addition, not a straight rt port. `and`/`or` sit
|
||||
above comparison per the spec amendment's own words ("or binds
|
||||
loosest, then and, then comparison") — real keywords (KwAnd/KwOr),
|
||||
never `&&`/`||`, so `a == 1 and b == 2` parses with no parens: `and`
|
||||
only ever sees fully-formed comparisons as its operands.
|
||||
|
||||
End-of-statement convention mirrors parse_field's: a "simple"
|
||||
statement (let/assign/return/expr-statement/DbStub) must end at an
|
||||
|
|
@ -650,7 +711,37 @@ let with_no_brace (st : state) (value : bool) (f : unit -> 'a) : 'a =
|
|||
|
||||
(* ---- expression parsing -------------------------------------------------- *)
|
||||
|
||||
let rec parse_expr (st : state) : Ast.expr = parse_comparison st
|
||||
let rec parse_expr (st : state) : Ast.expr = parse_or st
|
||||
|
||||
and parse_or (st : state) : Ast.expr =
|
||||
let lhs = ref (parse_and st) in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
match peek st with
|
||||
| Token.KwOr ->
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
let rhs = parse_and st in
|
||||
lhs := { Ast.id; pos; kind = Ast.Binary (Ast.Or, !lhs, rhs) }
|
||||
| _ -> continue_ := false
|
||||
done;
|
||||
!lhs
|
||||
|
||||
and parse_and (st : state) : Ast.expr =
|
||||
let lhs = ref (parse_comparison st) in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
match peek st with
|
||||
| Token.KwAnd ->
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
let rhs = parse_comparison st in
|
||||
lhs := { Ast.id; pos; kind = Ast.Binary (Ast.And, !lhs, rhs) }
|
||||
| _ -> continue_ := false
|
||||
done;
|
||||
!lhs
|
||||
|
||||
and parse_comparison (st : state) : Ast.expr =
|
||||
let lhs = ref (parse_concat st) in
|
||||
|
|
@ -741,7 +832,7 @@ and parse_postfix (st : state) : Ast.expr =
|
|||
| Token.Dot ->
|
||||
let pos = peek_pos st in
|
||||
ignore (advance st);
|
||||
let name = expect_ident st "field or method name" in
|
||||
let name = expect_field_name st "field or method name" in
|
||||
base := { Ast.id = fresh_id st; pos; kind = Ast.Field (!base, name) }
|
||||
| Token.LParen ->
|
||||
let pos = peek_pos st in
|
||||
|
|
@ -791,20 +882,90 @@ and parse_ctor_literal (st : state) : Ast.expr =
|
|||
let fields = ref [] in
|
||||
let continue_ = ref (peek st <> Token.RBrace) in
|
||||
while !continue_ do
|
||||
let fname = expect_ident st "constructor field name" in
|
||||
let fname = expect_field_name st "constructor field name" in
|
||||
expect st Token.Colon "':'";
|
||||
let fval = parse_expr st in
|
||||
fields := (fname, fval) :: !fields;
|
||||
skip_newlines st;
|
||||
if accept st Token.Comma then skip_newlines st else continue_ := false
|
||||
if accept st Token.Comma then begin
|
||||
skip_newlines st;
|
||||
(* trailing comma before the close (haxe-parity Task 4 — the
|
||||
sample's own multi-line record literals end `..., }`) *)
|
||||
if peek st = Token.RBrace then continue_ := false
|
||||
end
|
||||
else continue_ := false
|
||||
done;
|
||||
skip_newlines st;
|
||||
expect st Token.RBrace "'}'";
|
||||
{ Ast.id; pos; kind = Ast.Ctor (name, List.rev !fields) }
|
||||
|
||||
(* haxe-parity Task 3: `switch subject { case v1, v2: <stmts> ... default:
|
||||
<stmts> }` — the sample's own shape (grepped every `switch` site in
|
||||
docs/examples/log-watcher/*.wo first). The subject is parsed
|
||||
`no_brace` for the exact reason if/while/for's own conditions are:
|
||||
`switch res { ... }` must not read `res {` as a constructor literal
|
||||
swallowing the switch's own body. Arms have no brace of their own
|
||||
(the sample never wraps a case body in `{ }`) — [parse_switch_arm_body]
|
||||
is [parse_block]'s loop with `case`/`default`/`}` as its stop set
|
||||
instead of `}` alone, and no brace to expect/consume. `default` is
|
||||
grammar-optional here; whether it's *required* depends on the
|
||||
subject's type (scalar/Text: yes; a union: only if a variant is
|
||||
missing — Task 4's territory), which is a typecheck-time question
|
||||
(WO-E208), not a parse-time one. *)
|
||||
and parse_switch_arm_body (st : state) : Ast.stmt list =
|
||||
let stmts = ref [] in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
skip_newlines st;
|
||||
match peek st with
|
||||
| Token.KwCase | Token.KwDefault | Token.RBrace -> continue_ := false
|
||||
| Token.Eof -> fail st (peek_pos st) syntax_code "unexpected end of input inside switch arm"
|
||||
| _ -> (
|
||||
try stmts := parse_stmt st :: !stmts
|
||||
with Parse_error -> sync_to_next_stmt st)
|
||||
done;
|
||||
List.rev !stmts
|
||||
|
||||
and parse_switch_expr (st : state) : Ast.expr =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
(* 'switch' *)
|
||||
let subject = parse_expr_no_brace st in
|
||||
expect st Token.LBrace "'{' to open switch body";
|
||||
let arms = ref [] in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
skip_newlines st;
|
||||
match peek st with
|
||||
| Token.RBrace ->
|
||||
ignore (advance st);
|
||||
continue_ := false
|
||||
| Token.Eof -> fail st (peek_pos st) syntax_code "unexpected end of input inside switch body"
|
||||
| Token.KwCase ->
|
||||
let arm_pos = peek_pos st in
|
||||
ignore (advance st);
|
||||
let values = ref [ parse_expr st ] in
|
||||
while accept st Token.Comma do
|
||||
values := parse_expr st :: !values
|
||||
done;
|
||||
expect st Token.Colon "':' after switch case value(s)";
|
||||
let body = parse_switch_arm_body st in
|
||||
arms := { Ast.arm_pos; values = List.rev !values; is_default = false; body } :: !arms
|
||||
| Token.KwDefault ->
|
||||
let arm_pos = peek_pos st in
|
||||
ignore (advance st);
|
||||
expect st Token.Colon "':' after `default`";
|
||||
let body = parse_switch_arm_body st in
|
||||
arms := { Ast.arm_pos; values = []; is_default = true; body } :: !arms
|
||||
| _ -> unexpected st "`case`, `default`, or '}' in switch body"
|
||||
done;
|
||||
{ Ast.id; pos; kind = Ast.Switch (subject, List.rev !arms) }
|
||||
|
||||
and parse_primary (st : state) : Ast.expr =
|
||||
match peek st with
|
||||
| k when is_select_trigger k -> parse_dbstub_expr st
|
||||
| Token.KwSwitch -> parse_switch_expr st
|
||||
| Token.Int n ->
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
|
|
@ -815,6 +976,10 @@ and parse_primary (st : state) : Ast.expr =
|
|||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
{ Ast.id; pos; kind = Ast.StrLit s }
|
||||
| Token.InterpStr segs ->
|
||||
let pos = peek_pos st in
|
||||
ignore (advance st);
|
||||
desugar_interp st pos segs
|
||||
| Token.KwTrue ->
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
|
|
@ -841,6 +1006,67 @@ and parse_primary (st : state) : Ast.expr =
|
|||
{ Ast.id; pos; kind = Ast.Ident s }
|
||||
| _ -> unexpected st "an expression"
|
||||
|
||||
(* haxe-parity Task 2: desugars one interpolated string's segments into a
|
||||
`..`/Concat chain of StrLit (text) and Interp (embedded expression)
|
||||
nodes — the "desugars at parse time to concatenation" the brief
|
||||
names. Each `Token.SExpr raw` segment is a full expression's *raw
|
||||
source*, captured verbatim by the lexer (token.ml/lexer.ml's own doc
|
||||
comments) — re-tokenized and re-parsed here via a fresh, nested
|
||||
lexer/parser state over just that substring. Every generated node
|
||||
(StrLit/Interp/the Concat spine) shares the outer string literal's
|
||||
own single position: this AST has no source *ranges* (ast.ml's
|
||||
module doc), and per-segment positions would need the lexer to track
|
||||
an offset into the interpolation that nothing downstream needs today.
|
||||
Known, disclosed imprecision: a malformed `${...}` expression's own
|
||||
error therefore reports at the whole string's start, not the
|
||||
sub-expression's real column — acceptable since the sub-parse still
|
||||
raises a real, correctly-coded diagnostic, just at a coarser site.
|
||||
|
||||
Review fix (Important, post-Task-2): a genuine sub-parse failure
|
||||
(`"${1 +}"`, not just trailing garbage — `"${1 2}"`) used to add its
|
||||
OWN diagnostic straight to `st.collector` from inside `parse_expr
|
||||
sub_st`, at `sub_st`'s own uncorrected line/col (that lexer counts
|
||||
from 1:1 over the raw substring, so the reported position landed on
|
||||
some unrelated line of the *real* file), and then let `Parse_error`
|
||||
propagate straight past this function, skipping the "malformed
|
||||
${...}" framing entirely. The sub-lex/sub-parse now runs against a
|
||||
private, throwaway collector — nothing it reports (a lex error, a
|
||||
parse error, or reaching a non-`Eof` leftover) ever touches the real
|
||||
collector — so every failure inside collapses to exactly the one
|
||||
diagnostic below, at the outer string's own position. *)
|
||||
and desugar_interp (st : state) (pos : Ast.pos) (segs : Token.str_part list) : Ast.expr =
|
||||
let mk_str s = { Ast.id = fresh_id st; pos; kind = Ast.StrLit s } in
|
||||
let mk_interp inner = { Ast.id = fresh_id st; pos; kind = Ast.Interp inner } in
|
||||
let parse_segment_expr (raw : string) : Ast.expr =
|
||||
let sub_collector = Diag.Collector.create () in
|
||||
let sub_toks = Lexer.tokenize sub_collector ~file:st.file raw in
|
||||
let sub_st = make sub_collector ~file:st.file sub_toks in
|
||||
let parsed =
|
||||
try
|
||||
let e = parse_expr sub_st in
|
||||
if peek sub_st = Token.Eof && not (Diag.Collector.has_error sub_collector) then Some e
|
||||
else None
|
||||
with Parse_error -> None
|
||||
in
|
||||
match parsed with
|
||||
| Some e -> e
|
||||
| None -> fail st pos syntax_code "malformed \"${...}\" interpolation expression"
|
||||
in
|
||||
let parts =
|
||||
List.filter_map
|
||||
(function
|
||||
| Token.SText "" -> None
|
||||
| Token.SText s -> Some (mk_str s)
|
||||
| Token.SExpr raw -> Some (mk_interp (parse_segment_expr raw)))
|
||||
segs
|
||||
in
|
||||
match parts with
|
||||
| [] -> mk_str ""
|
||||
| first :: rest ->
|
||||
List.fold_left
|
||||
(fun acc e -> { Ast.id = fresh_id st; pos; kind = Ast.Binary (Ast.Concat, acc, e) })
|
||||
first rest
|
||||
|
||||
(* ---- statement parsing --------------------------------------------------- *)
|
||||
|
||||
and parse_block (st : state) : Ast.stmt list =
|
||||
|
|
@ -925,6 +1151,42 @@ and parse_return_stmt (st : state) : Ast.stmt =
|
|||
end_of_stmt st;
|
||||
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.Return value }
|
||||
|
||||
(* haxe-parity Task 2: `break`/`continue`. Whether either actually sits
|
||||
inside a loop is not checked here (this parser has no loop-nesting
|
||||
state, unlike `state.no_brace`) — emit.ml is the gate, exactly the
|
||||
existing WO-E403 convention: a construct the emitter has no legal
|
||||
jump target for is a diagnostic, not invented bytecode. *)
|
||||
and parse_break_stmt (st : state) : Ast.stmt =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
end_of_stmt st;
|
||||
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.Break }
|
||||
|
||||
and parse_continue_stmt (st : state) : Ast.stmt =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
end_of_stmt st;
|
||||
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.Continue }
|
||||
|
||||
(* `do { body } while cond` — no `parse_expr_no_brace` needed for `cond`:
|
||||
unlike `if`/`while`, nothing braced follows it (the statement just
|
||||
ends), so a constructor literal there is never ambiguous with a
|
||||
trailing block, the same reasoning `parse_return_stmt`'s value
|
||||
already relies on. *)
|
||||
and parse_do_while_stmt (st : state) : Ast.stmt =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
(* 'do' *)
|
||||
let body = parse_block st in
|
||||
skip_newlines st;
|
||||
expect st Token.KwWhile "`while` after `do { ... }`";
|
||||
let cond = parse_expr st in
|
||||
end_of_stmt st;
|
||||
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.DoWhile { body; cond } }
|
||||
|
||||
and parse_stmt (st : state) : Ast.stmt =
|
||||
match peek st with
|
||||
| k when is_insert_trigger k ->
|
||||
|
|
@ -938,6 +1200,9 @@ and parse_stmt (st : state) : Ast.stmt =
|
|||
| Token.KwWhile -> parse_while_stmt st
|
||||
| Token.KwFor -> parse_for_stmt st
|
||||
| Token.KwReturn -> parse_return_stmt st
|
||||
| Token.KwBreak -> parse_break_stmt st
|
||||
| Token.KwContinue -> parse_continue_stmt st
|
||||
| Token.KwDo -> parse_do_while_stmt st
|
||||
| _ ->
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
|
|
@ -957,15 +1222,20 @@ and parse_stmt (st : state) : Ast.stmt =
|
|||
and [looks_like_ctor]. *)
|
||||
and parse_expr_no_brace (st : state) : Ast.expr = with_no_brace st true (fun () -> parse_expr st)
|
||||
|
||||
let parse_method (st : state) : Ast.method_decl =
|
||||
(* `pub` (haxe-parity Task 1, modules) defaults to false: a class body's
|
||||
own methods always call this with no `~pub` argument (method-level
|
||||
visibility is a different, not-yet-designed question — see
|
||||
ast.ml's method_decl.pub doc comment), so this default is what keeps
|
||||
every existing call site's behavior byte-identical. *)
|
||||
let parse_method ?(pub = false) (st : state) : Ast.method_decl =
|
||||
let h = parse_sig_head st in
|
||||
let body = parse_block st in
|
||||
{ Ast.id = h.s_id; pos = h.s_pos; name = h.s_name; params = h.s_params; ret = h.s_ret; body }
|
||||
{ Ast.id = h.s_id; pos = h.s_pos; name = h.s_name; params = h.s_params; ret = h.s_ret; body; pub }
|
||||
|
||||
(* A free top-level function is grammatically identical to a class
|
||||
method (signature + brace-delimited body span) — Task 6's brief
|
||||
("free-fn tables") is why this exists as real grammar. *)
|
||||
let parse_fn_decl (st : state) : Ast.method_decl = parse_method st
|
||||
let parse_fn_decl ~(pub : bool) (st : state) : Ast.method_decl = parse_method ~pub st
|
||||
|
||||
(* Interface signatures have no body: the line ends at a Newline (which
|
||||
is consumed) or at the interface's own closing brace / EOF (left for
|
||||
|
|
@ -1040,6 +1310,51 @@ let skip_on_block (st : state) : unit =
|
|||
| _ -> ignore (advance st)
|
||||
done
|
||||
|
||||
(* ---- const declaration (haxe-parity Task 2) -----------------------------
|
||||
|
||||
`const NAME = <literal>` — top-level or (bare, no `static`) class-level.
|
||||
The brief's own wording is "= literal", not "= expr": restricted here
|
||||
to Int (optionally `-`-prefixed, folded directly into the literal —
|
||||
no `Unary` wrapper needed for a compile-time value), Text, or Bool.
|
||||
Resolved by a dedicated post-parse substitution pass (see `parse`,
|
||||
below) rather than threaded through typecheck/owner/emit as a new
|
||||
kind of name. *)
|
||||
let parse_const_literal (st : state) : Ast.expr =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
match peek st with
|
||||
| Token.Int n ->
|
||||
ignore (advance st);
|
||||
{ Ast.id; pos; kind = Ast.IntLit n }
|
||||
| Token.Dash -> (
|
||||
ignore (advance st);
|
||||
match peek st with
|
||||
| Token.Int n ->
|
||||
ignore (advance st);
|
||||
{ Ast.id; pos; kind = Ast.IntLit (-n) }
|
||||
| _ -> unexpected st "an integer literal after '-'")
|
||||
| Token.Str s ->
|
||||
ignore (advance st);
|
||||
{ Ast.id; pos; kind = Ast.StrLit s }
|
||||
| Token.KwTrue ->
|
||||
ignore (advance st);
|
||||
{ Ast.id; pos; kind = Ast.BoolLit true }
|
||||
| Token.KwFalse ->
|
||||
ignore (advance st);
|
||||
{ Ast.id; pos; kind = Ast.BoolLit false }
|
||||
| _ -> unexpected st "a literal (Int, Text, or Bool)"
|
||||
|
||||
let parse_const_decl (st : state) : Ast.const_decl =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
(* 'const' *)
|
||||
let name = expect_ident st "const name" in
|
||||
expect st Token.Eq "'=' in const declaration";
|
||||
let value = parse_const_literal st in
|
||||
end_of_stmt st;
|
||||
{ Ast.id; pos; name; value }
|
||||
|
||||
(* ---- class / type declaration -------------------------------------------
|
||||
|
||||
Task 4 brief: "class Name { ... } and type Name { ... } — identical
|
||||
|
|
@ -1048,7 +1363,7 @@ let skip_on_block (st : state) : unit =
|
|||
skip-discarded) — so both keywords get the same body loop here,
|
||||
`is_class` recorded purely as data for later stages, never gating
|
||||
what's parsed. *)
|
||||
let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
|
||||
let parse_class_or_type ?(pub = false) (st : state) (ann : type_annotations) : Ast.class_decl =
|
||||
let pos = peek_pos st in
|
||||
let is_class = peek st = Token.KwClass in
|
||||
if is_class then ignore (advance st) else expect st Token.KwType "`type` or `class`";
|
||||
|
|
@ -1057,6 +1372,7 @@ let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
|
|||
let id = fresh_id st in
|
||||
let fields = ref [] in
|
||||
let methods = ref [] in
|
||||
let consts = ref [] in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
skip_newlines st;
|
||||
|
|
@ -1067,6 +1383,13 @@ let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
|
|||
| Token.Eof ->
|
||||
fail st (peek_pos st) syntax_code "unexpected end of input inside type/class body"
|
||||
| Token.KwFn -> methods := parse_method st :: !methods
|
||||
(* bare `const` only — `static const` (Task 7's `static`) is not
|
||||
recognized here at all: `static` lexes as a plain Ident, matches
|
||||
none of this loop's arms (not looks_like_field: the next token is
|
||||
`const`, not a Colon), and falls through to the same clean
|
||||
"expected a field, method, or ..." error every other unrecognized
|
||||
class-body construct gets — no half-swallow, per the brief. *)
|
||||
| Token.KwConst -> consts := parse_const_decl st :: !consts
|
||||
(* looks_like_field MUST be checked before is_sync_ident: `on`/
|
||||
`service`/`policy` are plain Idents here (Task 3 deliberately kept
|
||||
them as usable identifiers, unlike rt where they're real keywords
|
||||
|
|
@ -1085,19 +1408,120 @@ let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
|
|||
pos;
|
||||
name;
|
||||
is_class;
|
||||
is_record = false;
|
||||
is_gc = ann.is_gc;
|
||||
table = ann.table;
|
||||
fields = List.rev !fields;
|
||||
methods = List.rev !methods;
|
||||
consts = List.rev !consts;
|
||||
pub;
|
||||
}
|
||||
|
||||
(* ---- typedef record declaration (haxe-parity Task 4) ---------------------
|
||||
|
||||
`typedef Name = { field: Type [= default] [, ...] ?opt: Type ... }` —
|
||||
a structural record alias. Fields only (no methods, no consts, no
|
||||
service/policy/on leniency — a record is pure data shape); separators
|
||||
are newlines OR commas (the sample writes both: multi-line SupConfig,
|
||||
single-line HttpResp). A `?` prefixing the field NAME (`?detections:
|
||||
Text`, the Haxe `@:optional` spelling) desugars to the field typed
|
||||
`?T` — "?fields land as nullable-by-shape" (the task brief's own
|
||||
words): one representation, `Nullable`, whether the `?` was written
|
||||
on the name or on the type, so Task 6's forced-handling work has a
|
||||
single shape to tighten. *)
|
||||
let parse_record_decl ?(pub = false) (st : state) : Ast.class_decl =
|
||||
let pos = peek_pos st in
|
||||
ignore (advance st);
|
||||
(* 'typedef' *)
|
||||
let name = expect_ident st "typedef name" in
|
||||
expect st Token.Eq "'=' in typedef declaration";
|
||||
expect st Token.LBrace "'{' to open typedef record body";
|
||||
let id = fresh_id st in
|
||||
let fields = ref [] in
|
||||
let continue_ = ref true in
|
||||
while !continue_ do
|
||||
skip_newlines st;
|
||||
match peek st with
|
||||
| Token.RBrace ->
|
||||
ignore (advance st);
|
||||
continue_ := false
|
||||
| Token.Eof -> fail st (peek_pos st) syntax_code "unexpected end of input inside typedef body"
|
||||
| _ ->
|
||||
let optional = accept st Token.Question in
|
||||
let f = parse_field ~comma_ends:true st in
|
||||
let f =
|
||||
if optional then
|
||||
match f.Ast.ty with
|
||||
| Ast.Nullable _ -> f (* `?opt: ?T` — already nullable, don't double-wrap *)
|
||||
| ty -> { f with Ast.ty = Ast.Nullable ty }
|
||||
else f
|
||||
in
|
||||
fields := f :: !fields;
|
||||
ignore (accept st Token.Comma)
|
||||
done;
|
||||
{
|
||||
Ast.id;
|
||||
pos;
|
||||
name;
|
||||
is_class = false;
|
||||
is_record = true;
|
||||
is_gc = false;
|
||||
table = None;
|
||||
fields = List.rev !fields;
|
||||
methods = [];
|
||||
consts = [];
|
||||
pub;
|
||||
}
|
||||
|
||||
(* ---- union declaration (haxe-parity Task 4) -------------------------------
|
||||
|
||||
`type Name = V1 | V2 | V3(field: Type, ...)` — a tagged union,
|
||||
sharing the `type` keyword with the struct form (`type Name { ... }`)
|
||||
and disambiguated by the token after the name (`=` vs `{`, decided by
|
||||
parse_program's two-token lookahead before either parser runs). A
|
||||
variant's payload reuses the field grammar's `name: Type` pairs,
|
||||
comma-separated inside its parens; a newline is allowed after a `|`
|
||||
(so a long union can wrap) but the declaration otherwise ends the way
|
||||
a `use`/`let` line does (end_of_stmt). *)
|
||||
let parse_union_decl ?(pub = false) (st : state) : Ast.union_decl =
|
||||
let pos = peek_pos st in
|
||||
ignore (advance st);
|
||||
(* 'type' *)
|
||||
let name = expect_ident st "union name" in
|
||||
let id = fresh_id st in
|
||||
expect st Token.Eq "'=' in union declaration";
|
||||
let parse_variant () : Ast.variant_decl =
|
||||
let v_pos = peek_pos st in
|
||||
let v_name = expect_ident st "variant name" in
|
||||
let v_fields = ref [] in
|
||||
if accept st Token.LParen then begin
|
||||
let more = ref (peek st <> Token.RParen) in
|
||||
while !more do
|
||||
let fname = expect_ident st "payload field name" in
|
||||
expect st Token.Colon "':'";
|
||||
let fty = parse_field_ty st in
|
||||
v_fields := (fname, fty) :: !v_fields;
|
||||
if not (accept st Token.Comma) then more := false
|
||||
done;
|
||||
expect st Token.RParen "')'"
|
||||
end;
|
||||
{ Ast.v_pos; v_name; v_fields = List.rev !v_fields }
|
||||
in
|
||||
let variants = ref [ parse_variant () ] in
|
||||
while accept st Token.Pipe do
|
||||
skip_newlines st;
|
||||
variants := parse_variant () :: !variants
|
||||
done;
|
||||
end_of_stmt st;
|
||||
{ Ast.id; pos; name; variants = List.rev !variants; pub }
|
||||
|
||||
(* ---- interface declaration ----------------------------------------------
|
||||
|
||||
Signatures only — no fields, no bodies (Task 4 brief: "interface Name
|
||||
{ fn sig... } (signatures only)"). Anything other than `fn` inside an
|
||||
interface body is a parse error; interfaces don't get the
|
||||
service/policy/on leniency class/type bodies get. *)
|
||||
let parse_interface (st : state) : Ast.interface_decl =
|
||||
let parse_interface ?(pub = false) (st : state) : Ast.interface_decl =
|
||||
let pos = peek_pos st in
|
||||
expect st Token.KwInterface "`interface`";
|
||||
let name = expect_ident st "interface name" in
|
||||
|
|
@ -1115,7 +1539,31 @@ let parse_interface (st : state) : Ast.interface_decl =
|
|||
| Token.KwFn -> methods := parse_iface_sig st :: !methods
|
||||
| _ -> unexpected st "a method signature (`fn ...`)"
|
||||
done;
|
||||
{ Ast.id; pos; name; methods = List.rev !methods }
|
||||
{ Ast.id; pos; name; methods = List.rev !methods; pub }
|
||||
|
||||
(* ---- use declaration (haxe-parity Task 1, modules) ----------------------
|
||||
|
||||
`use fs` (a reserved stdlib namespace) or `use shared/util` (a
|
||||
project-relative path — slash-separated directory segments naming
|
||||
another discovered module's directory). Which of those two a given
|
||||
path actually is, and whether it resolves at all, is the resolver's
|
||||
job (types.ml) — this only demands at least one identifier segment,
|
||||
with `/`-separated continuations, ended the same way a `let`/return
|
||||
statement is (`end_of_stmt`: optional `;`, then newline/EOF — there
|
||||
is no enclosing block at top level, but end_of_stmt's RBrace arm is
|
||||
harmless dead code here, never reached). *)
|
||||
let parse_use_decl (st : state) : Ast.use_decl =
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
ignore (advance st);
|
||||
(* 'use' *)
|
||||
let first = expect_ident st "module name" in
|
||||
let segments = ref [ first ] in
|
||||
while accept st Token.Slash do
|
||||
segments := expect_ident st "module path segment" :: !segments
|
||||
done;
|
||||
end_of_stmt st;
|
||||
{ Ast.id; pos; segments = List.rev !segments }
|
||||
|
||||
(* ---- top-level program ---------------------------------------------------
|
||||
|
||||
|
|
@ -1124,6 +1572,15 @@ let parse_interface (st : state) : Ast.interface_decl =
|
|||
try/with: on Parse_error (already reported at its raise site, see
|
||||
[fail]), sync to the next top-level construct and keep going —
|
||||
exactly one diagnostic per broken declaration, never a cascade. *)
|
||||
(* `type Name = ...` is a union; `type Name { ... }` stays the struct
|
||||
form — two-token lookahead past the name (haxe-parity Task 4),
|
||||
mirroring looks_like_ctor's identifier-then-brace trick one token
|
||||
further out. Anything else after the name falls to
|
||||
parse_class_or_type's own "expected '{'" error, unchanged. *)
|
||||
let looks_like_union (st : state) : bool =
|
||||
(match (tok_at st (st.pos + 1)).kind with Token.Ident _ -> true | _ -> false)
|
||||
&& (tok_at st (st.pos + 2)).kind = Token.Eq
|
||||
|
||||
let parse_program (st : state) : Ast.program =
|
||||
let decls = ref [] in
|
||||
let continue_ = ref true in
|
||||
|
|
@ -1133,10 +1590,40 @@ let parse_program (st : state) : Ast.program =
|
|||
else begin
|
||||
(try
|
||||
match peek st with
|
||||
| Token.KwType when looks_like_union st ->
|
||||
decls := Ast.Union (parse_union_decl st) :: !decls
|
||||
| Token.KwType | Token.KwClass ->
|
||||
decls := Ast.Class (parse_class_or_type st no_annotations) :: !decls
|
||||
| Token.KwTypedef -> decls := Ast.Class (parse_record_decl st) :: !decls
|
||||
| Token.KwInterface -> decls := Ast.Interface (parse_interface st) :: !decls
|
||||
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl st) :: !decls
|
||||
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl ~pub:false st) :: !decls
|
||||
| Token.KwUse -> decls := Ast.Use (parse_use_decl st) :: !decls
|
||||
| Token.KwConst -> decls := Ast.Const (parse_const_decl st) :: !decls
|
||||
| Token.KwInline ->
|
||||
let ipos = peek_pos st in
|
||||
ignore (advance st);
|
||||
(match peek st with
|
||||
| Token.KwFn ->
|
||||
fail st ipos inline_fn_code
|
||||
"`inline fn` is rejected — optimization is the compiler's job (const values are \
|
||||
the adopted half of this row)"
|
||||
| _ -> unexpected st "`fn` after `inline`")
|
||||
| Token.KwPub -> (
|
||||
ignore (advance st);
|
||||
(* consume 'pub'; a stray '(' here (`pub(read)` at top level —
|
||||
Task 7's field-accessor marker, not this task's) falls
|
||||
through to the same clean "expected ... after `pub`" error
|
||||
as any other unrecognized token, rather than being
|
||||
half-parsed. *)
|
||||
match peek st with
|
||||
| Token.KwType when looks_like_union st ->
|
||||
decls := Ast.Union (parse_union_decl ~pub:true st) :: !decls
|
||||
| Token.KwType | Token.KwClass ->
|
||||
decls := Ast.Class (parse_class_or_type ~pub:true st no_annotations) :: !decls
|
||||
| Token.KwTypedef -> decls := Ast.Class (parse_record_decl ~pub:true st) :: !decls
|
||||
| Token.KwInterface -> decls := Ast.Interface (parse_interface ~pub:true st) :: !decls
|
||||
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl ~pub:true st) :: !decls
|
||||
| _ -> unexpected st "`type`, `class`, `interface`, or `fn` after `pub`")
|
||||
| Token.At ->
|
||||
let ann = parse_type_annotations st in
|
||||
skip_newlines st;
|
||||
|
|
@ -1150,6 +1637,134 @@ let parse_program (st : state) : Ast.program =
|
|||
done;
|
||||
{ Ast.decls = List.rev !decls }
|
||||
|
||||
(* ---- const substitution (haxe-parity Task 2) ----------------------------
|
||||
|
||||
Runs once, over the whole freshly-parsed program, right before `parse`
|
||||
returns it. Replaces every unshadowed `Ident NAME` with the literal
|
||||
expr NAME's `const` declared, so typecheck/owner/emit see a plain
|
||||
literal and need zero const-specific code anywhere downstream — the
|
||||
same "desugar early, touch nothing later" shape string interpolation
|
||||
already uses in this file. Scope-aware exactly like types.ml's own
|
||||
local-shadows-a-`use`-alias fix (Task 1 review): a local, parameter,
|
||||
`for` variable, or `self` binding of the same name always wins over a
|
||||
const of that name, so `fn f(CHUNK: Int) { return CHUNK }` next to a
|
||||
top-level `const CHUNK = 65536` still returns the parameter, not
|
||||
65536. A const's own value is grammar-restricted to a literal
|
||||
(parse_const_literal) — never an `Ident` — so one const's value can
|
||||
never need substitution itself; no ordering/cycle question arises. *)
|
||||
module StringMap = Map.Make (String)
|
||||
module StringSet = Set.Make (String)
|
||||
|
||||
let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : Ast.expr) : Ast.expr =
|
||||
match e.Ast.kind with
|
||||
| Ast.IntLit _ | Ast.StrLit _ | Ast.BoolLit _ | Ast.DbStub _ -> e
|
||||
| Ast.Ident name ->
|
||||
if StringSet.mem name bound then e
|
||||
else ( match StringMap.find_opt name consts with Some v -> { e with Ast.kind = v.Ast.kind } | None -> e)
|
||||
| Ast.Field (base, fname) -> { e with Ast.kind = Ast.Field (subst_expr consts bound base, fname) }
|
||||
| Ast.Index (base, idx) ->
|
||||
{ e with Ast.kind = Ast.Index (subst_expr consts bound base, subst_expr consts bound idx) }
|
||||
| Ast.Call (callee, args) ->
|
||||
{ e with Ast.kind = Ast.Call (subst_expr consts bound callee, List.map (subst_expr consts bound) args) }
|
||||
| Ast.Unary (op, operand) -> { e with Ast.kind = Ast.Unary (op, subst_expr consts bound operand) }
|
||||
| Ast.Binary (op, l, r) ->
|
||||
{ e with Ast.kind = Ast.Binary (op, subst_expr consts bound l, subst_expr consts bound r) }
|
||||
| Ast.Ctor (cn, fields) ->
|
||||
{ e with Ast.kind = Ast.Ctor (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) }
|
||||
| Ast.Interp inner -> { e with Ast.kind = Ast.Interp (subst_expr consts bound inner) }
|
||||
| Ast.Switch (subject, arms) ->
|
||||
{ e with
|
||||
Ast.kind =
|
||||
Ast.Switch
|
||||
( subst_expr consts bound subject,
|
||||
List.map
|
||||
(fun (a : Ast.switch_arm) ->
|
||||
{ a with
|
||||
Ast.values = List.map (subst_expr consts bound) a.Ast.values;
|
||||
body = subst_block consts bound a.Ast.body;
|
||||
})
|
||||
arms )
|
||||
}
|
||||
|
||||
(* `let` extends the rest of *this* block only, exactly like types.ml's
|
||||
walk_stmt/walk_block: a nested block's own `let`s never leak back out
|
||||
to the caller's bound set.
|
||||
|
||||
`subst_expr` now reaches into `Switch`'s own `stmt list` arm bodies
|
||||
(haxe-parity Task 3), so it and `subst_block`/`subst_stmt` are one
|
||||
`and`-chain from here on, not two separate `let rec` groups — the
|
||||
same merge ast.ml's own `expr`/`stmt` needed for the same reason. *)
|
||||
and subst_block (consts : Ast.expr StringMap.t) (bound : StringSet.t) (body : Ast.stmt list) :
|
||||
Ast.stmt list =
|
||||
let bound_ref = ref bound in
|
||||
List.map
|
||||
(fun (s : Ast.stmt) ->
|
||||
let s' = subst_stmt consts !bound_ref s in
|
||||
(match s.Ast.s_kind with
|
||||
| Ast.Let { name; _ } -> bound_ref := StringSet.add name !bound_ref
|
||||
| _ -> ());
|
||||
s')
|
||||
body
|
||||
|
||||
and subst_stmt (consts : Ast.expr StringMap.t) (bound : StringSet.t) (s : Ast.stmt) : Ast.stmt =
|
||||
let e = subst_expr consts bound in
|
||||
match s.Ast.s_kind with
|
||||
| Ast.Let { name; ty; value } -> { s with Ast.s_kind = Ast.Let { name; ty; value = e value } }
|
||||
| Ast.Assign { target; value } -> { s with Ast.s_kind = Ast.Assign { target = e target; value = e value } }
|
||||
| Ast.If { cond; then_body; else_body } ->
|
||||
{ s with
|
||||
Ast.s_kind =
|
||||
Ast.If
|
||||
{ cond = e cond;
|
||||
then_body = subst_block consts bound then_body;
|
||||
else_body = Option.map (fun (p, b) -> (p, subst_block consts bound b)) else_body
|
||||
}
|
||||
}
|
||||
| Ast.While { cond; body } ->
|
||||
{ s with Ast.s_kind = Ast.While { cond = e cond; body = subst_block consts bound body } }
|
||||
| Ast.For { var; iter; body } ->
|
||||
let bound' = StringSet.add var bound in
|
||||
{ s with Ast.s_kind = Ast.For { var; iter = e iter; body = subst_block consts bound' body } }
|
||||
| Ast.Return opt -> { s with Ast.s_kind = Ast.Return (Option.map e opt) }
|
||||
| Ast.ExprStmt ex -> { s with Ast.s_kind = Ast.ExprStmt (e ex) }
|
||||
| Ast.Break | Ast.Continue -> s
|
||||
| Ast.DoWhile { body; cond } ->
|
||||
{ s with Ast.s_kind = Ast.DoWhile { body = subst_block consts bound body; cond = e cond } }
|
||||
|
||||
let params_bound (base : StringSet.t) (params : Ast.param list) : StringSet.t =
|
||||
List.fold_left (fun acc (p : Ast.param) -> StringSet.add p.Ast.name acc) base params
|
||||
|
||||
let const_map (consts : Ast.const_decl list) : Ast.expr StringMap.t =
|
||||
List.fold_left (fun acc (c : Ast.const_decl) -> StringMap.add c.Ast.name c.Ast.value acc) StringMap.empty consts
|
||||
|
||||
let subst_consts (prog : Ast.program) : Ast.program =
|
||||
let top_consts =
|
||||
const_map (List.filter_map (function Ast.Const c -> Some c | _ -> None) prog.Ast.decls)
|
||||
in
|
||||
let decls' =
|
||||
List.map
|
||||
(function
|
||||
| Ast.Class c ->
|
||||
(* class consts win over top-level ones on a name collision --
|
||||
innermost scope wins, matching how a param/local also
|
||||
outranks either. *)
|
||||
let merged = StringMap.fold StringMap.add (const_map c.Ast.consts) top_consts in
|
||||
let methods' =
|
||||
List.map
|
||||
(fun (m : Ast.method_decl) ->
|
||||
let bound = params_bound (StringSet.singleton "self") m.Ast.params in
|
||||
{ m with Ast.body = subst_block merged bound m.Ast.body })
|
||||
c.Ast.methods
|
||||
in
|
||||
Ast.Class { c with Ast.methods = methods' }
|
||||
| Ast.Fn fn ->
|
||||
let bound = params_bound StringSet.empty fn.Ast.params in
|
||||
Ast.Fn { fn with Ast.body = subst_block top_consts bound fn.Ast.body }
|
||||
| (Ast.Interface _ | Ast.Use _ | Ast.Const _ | Ast.Union _) as d -> d)
|
||||
prog.Ast.decls
|
||||
in
|
||||
{ Ast.decls = decls' }
|
||||
|
||||
let parse (collector : Diag.Collector.t) ~(file : string) (toks : Token.t list) : Ast.program =
|
||||
let st = make collector ~file toks in
|
||||
parse_program st
|
||||
subst_consts (parse_program st)
|
||||
|
|
|
|||
|
|
@ -20,11 +20,25 @@
|
|||
(CLAUDE.md gotcha — this is the whole reason Task 3 exists as a
|
||||
from-scratch lexer rather than a copy of rt's). *)
|
||||
|
||||
type str_part =
|
||||
| SText of string (* literal text, escapes already applied *)
|
||||
| SExpr of string (* raw, unlexed source of one `${...}`'s body *)
|
||||
|
||||
type kind =
|
||||
(* literals *)
|
||||
| Ident of string
|
||||
| Int of int
|
||||
| Str of string
|
||||
(* haxe-parity Task 2: a string literal containing at least one
|
||||
`${expr}` interpolation. Alternating text/expr segments, in source
|
||||
order; SExpr carries the *raw, unlexed* source text between the
|
||||
`${` and its matching `}` (nested braces/strings skipped verbatim
|
||||
by the lexer's own scan) -- the parser re-tokenizes/re-parses it as
|
||||
a real expression, which is where "desugars at parse time to
|
||||
concatenation" actually happens (ast.ml/parser.ml). A plain string
|
||||
with no `${` never produces this -- it still lexes as a bare Str,
|
||||
byte-identical to every pre-existing fixture. *)
|
||||
| InterpStr of str_part list
|
||||
(* milestone-1 keywords *)
|
||||
| KwType
|
||||
| KwClass
|
||||
|
|
@ -41,6 +55,41 @@ type kind =
|
|||
| KwIn
|
||||
| KwTrue
|
||||
| KwFalse
|
||||
(* haxe-parity Task 1 (modules): `use <path>` top-level import and the
|
||||
`pub` visibility marker on class/interface/fn declarations. Real
|
||||
keywords, not positionally-recognized idents like insert/select or
|
||||
ref/multi/map -- neither name is used as an identifier anywhere in
|
||||
the existing corpus/fixtures, so there is no rt-parity or
|
||||
field-name collision to dodge (see lexer.ml's module doc for why
|
||||
those other names stayed idents). *)
|
||||
| KwUse
|
||||
| KwPub
|
||||
(* haxe-parity Task 2 (small control surface): break/continue/do-while,
|
||||
const values, and/or booleans, and inline-fn rejection (the haxe
|
||||
verdict table's own row: "const compile-time values; inline
|
||||
*functions* rejected"). All real keywords -- none collides with an
|
||||
existing corpus identifier (grepped before adding, same discipline
|
||||
Task 1 used for use/pub). *)
|
||||
| KwBreak
|
||||
| KwContinue
|
||||
| KwDo
|
||||
| KwConst
|
||||
| KwAnd
|
||||
| KwOr
|
||||
| KwInline
|
||||
(* haxe-parity Task 3: `switch`/`case`/`default` — real keywords (none
|
||||
collides with an existing corpus/sample identifier, grepped first,
|
||||
same discipline Tasks 1/2 used for use/pub/break/etc.). *)
|
||||
| KwSwitch
|
||||
| KwCase
|
||||
| KwDefault
|
||||
(* haxe-parity Task 4: `typedef Name = { ... }` structural records. A
|
||||
real keyword (grepped the corpus/sample first, same discipline as
|
||||
every keyword above — `typedef` appears only as this declaration's
|
||||
own leading word, never as an identifier). Union declarations reuse
|
||||
the existing KwType (`type Name = A | B` vs. the struct form
|
||||
`type Name { ... }` — disambiguated by the token after the name). *)
|
||||
| KwTypedef
|
||||
(* uppercase-only SQL-layer stubs (Task 5 parses these into a DbStub
|
||||
span); lowercase "insert"/"select" are plain Ident, never these. *)
|
||||
| KwInsert
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
9
compiler/test/fixtures/driver/module-unused-use/unused.wo
vendored
Normal file
9
compiler/test/fixtures/driver/module-unused-use/unused.wo
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- haxe-parity Task 1 (modules): `use fs` is declared but this file
|
||||
-- never calls anything through the `fs` alias -- WO-W202. A warning,
|
||||
-- not an error: the bare `woc <path>` exit-code contract (0 clean, 1
|
||||
-- diagnostics-*with-an-error*-reported) means this still exits 0.
|
||||
use fs
|
||||
|
||||
fn main() {
|
||||
print("hello")
|
||||
}
|
||||
3
compiler/test/fixtures/driver/multifile-single-report/a.wo
vendored
Normal file
3
compiler/test/fixtures/driver/multifile-single-report/a.wo
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- Hotfix (multi-file double-report): see b.wo and runner.ml's own
|
||||
-- "multifile single-report" test block for what this pins.
|
||||
class Item { n: Int }
|
||||
9
compiler/test/fixtures/driver/multifile-single-report/b.wo
vendored
Normal file
9
compiler/test/fixtures/driver/multifile-single-report/b.wo
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- `it.price` is the one real bug: Item (a.wo) has no `price` field,
|
||||
-- only `n`. Before the hotfix, this body-level WO-E202 check re-ran
|
||||
-- once per OTHER discovered file too (a.wo's own pass here), so a
|
||||
-- 2-file program reported it twice -- once correctly at b.wo, once
|
||||
-- phantom-stamped with a.wo's path at the same (nonexistent) line/col.
|
||||
fn main() {
|
||||
let it = Item{n:1}
|
||||
print_int(it.price)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -47,13 +47,64 @@ claiming ownership of that work.
|
|||
|
||||
## Dead-code register
|
||||
|
||||
Ten `WO-E2xx` codes are declared as named constants in `types.ml` with no
|
||||
call site anywhere in the front end — `grep '~code:'` finds exactly five
|
||||
**Update (haxe-parity Task 1, modules):** `WO-E210` below has since been
|
||||
wired — the module concept it was blocked on now exists
|
||||
(`compiler/src/types.ml`'s `check_modules`) — so it is no longer one of the
|
||||
ten; it has moved up into `docs/plan/oop-vm/01-error-catalog.md`'s main
|
||||
table. The table below is left as this doc's own historical record of the
|
||||
state Task 6b found, not edited to match current reality (same convention
|
||||
this doc already uses elsewhere for its "historical record" blocks).
|
||||
|
||||
**Update (hotfix, 2026-08-11):** `WO-E209` below has also since been
|
||||
wired — a real bug forced it: `print(7)` compiled clean and segfaulted
|
||||
`wovm` (`print` wants a `Text`, a heap-string pointer; a bare `Int` is a
|
||||
plain int64 register, which the VM's `str_check` dereferenced as a wild
|
||||
pointer with no runtime tag to catch it first). It too is no longer one
|
||||
of the ten; it has moved up into `docs/plan/oop-vm/01-error-catalog.md`'s
|
||||
main table. The check (`compiler/src/types.ml`'s `check_builtin_call` and
|
||||
`confident_typ`) covers exactly the arity-and-signature gap the row below
|
||||
describes, but conservatively: an argument is only checked against a
|
||||
builtin's expected type when it is *confidently* known — a literal,
|
||||
`self`, a parameter's or class field's declared type, or a `let` whose
|
||||
value traces back to one of those — never a guess, so an unresolved name
|
||||
or an UNKNOWN-BUT-RESERVED stdlib call result stays unchecked rather than
|
||||
risk a false positive. Same convention as the `WO-E210` note above: the
|
||||
table below is left unedited.
|
||||
|
||||
**Update (haxe-parity Task 2, 2026-08-11):** `WO-E201` below has also
|
||||
since been wired — the row's own gap ("`Binary`/`Unary` in `types.ml`
|
||||
don't check operand types at all") is exactly what stayed true for
|
||||
every operator *except* the two Task 2 added: `and`/`or` are `Bool`-only
|
||||
by the language's own design (no truthiness), and their operands *are*
|
||||
now checked, via the same "confident-type, stay silent when underivable"
|
||||
contract `WO-E209` uses. Every other `Binary`/`Unary` operator (the
|
||||
arithmetic ladder, comparisons' own operand types) remains exactly as
|
||||
unchecked as this row describes — this is a new, narrow call site, not
|
||||
a general fix of the row's own gap. It too has moved up into
|
||||
`docs/plan/oop-vm/01-error-catalog.md`'s main table. Same convention as
|
||||
the notes above: the table below is left unedited.
|
||||
|
||||
**Update (haxe-parity Task 3, 2026-08-12):** `WO-E208` below has also
|
||||
since been wired — the row's own blocker ("no `switch` keyword exists
|
||||
in `token.ml`/`lexer.ml`/`parser.ml` yet") is exactly what haxe-parity
|
||||
Task 3 closed. The check (`compiler/src/types.ml`'s `typecheck_switch`)
|
||||
is unconditional today, for every subject: no union type exists in
|
||||
`typ` yet, so "exhaustiveness over a union, missing `default` only
|
||||
when a variant is uncovered" — the row's own description — is not yet
|
||||
a distinct code path, just the same E208 Task 4 will branch ahead of
|
||||
(a `TUnion` arm, not a second check) once tagged unions land. `WO-E201`
|
||||
picked up a second, unrelated call site in the same task: a `switch`
|
||||
expression's arms disagree on their yielded type. Both have moved up
|
||||
into `docs/plan/oop-vm/01-error-catalog.md`'s main table. Same
|
||||
convention as the notes above: the table below is left unedited.
|
||||
|
||||
Ten `WO-E2xx` codes were declared as named constants in `types.ml` with no
|
||||
call site anywhere in the front end — `grep '~code:'` found exactly five
|
||||
sites (`WO-W201`, `WO-E202`, `WO-E206`, `WO-E207`, `WO-E225`); the other ten
|
||||
declared constants are never referenced by a `Diag.error`/`Diag.warning`
|
||||
declared constants were never referenced by a `Diag.error`/`Diag.warning`
|
||||
call. `docs/plan/oop-vm/01-error-catalog.md`'s "Reserved, not yet emitted"
|
||||
section already lists all ten; this table adds *why* each is dead and who,
|
||||
if anyone, is expected to wire it:
|
||||
section listed all ten at the time; this table adds *why* each was dead and
|
||||
who, if anyone, was expected to wire it:
|
||||
|
||||
| Code | Meaning | Why it's dead |
|
||||
|------|---------|----------------|
|
||||
|
|
|
|||
|
|
@ -44,10 +44,74 @@ All integers little-endian; offsets are absolute file offsets.
|
|||
| 30 | DB_STUB | trap T_DB "engine not linked" (spec: SQL-layer statements in milestone 1) |
|
||||
| 31 | TRAP Bx | explicit trap with code Bx |
|
||||
|
||||
**Builtins:** now (ms), print (text), print_int, words (whitespace token count), multi_new/multi_push/multi_get/count/latest, map_new/map_set/map_get/map_has.
|
||||
**Builtins:** now (ms), print (text), print_int, words (whitespace token count), multi_new/multi_push/multi_get/count/latest, map_new/map_set/map_get/map_has, int_to_text (haxe-parity Task 2), variant_tag (haxe-parity Task 4 — see "Enum payload variants" below).
|
||||
|
||||
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT.
|
||||
|
||||
## Enum payload variants (haxe-parity compiler Task 4)
|
||||
|
||||
`.wob` v1 is unchanged — no new section, no new header field, no version
|
||||
bump. A union with at least one payload variant (`type Status = Pending |
|
||||
Failed(reason: Text)`) compiles to **one ordinary class-table entry per
|
||||
variant**, named `"<Union>.<Variant>"` in the constant pool (source
|
||||
identifiers can never contain a dot, so the composite name cannot collide
|
||||
with a declared class — the same convention the method table already uses
|
||||
for `"Class.method"`). A variant's payload fields are the entry's fields,
|
||||
declaration order, ordinary kind bytes — so a variant object is dropped,
|
||||
masked, and cycle-scanned exactly like any other instance, including
|
||||
recursive payload frees, with zero collector changes.
|
||||
|
||||
**The variant tag IS the class-table index**, carried by the object
|
||||
header's existing `class_id` field — nothing new is stored and `NEW`
|
||||
needs no change. The one VM addition is builtin **14 `variant_tag`**:
|
||||
register A = the header `class_id` of the object in register B, so a
|
||||
`switch` over a payload union reads the tag once and compares it against
|
||||
`LOADK`-ed class-id constants — no per-arm allocation. It traps
|
||||
`T_BOUNDS` on a null receiver or a native (`WO_CLS_*`) class id, the same
|
||||
defense `ICALL` keeps; a non-pointer register stays the compiler's to
|
||||
prevent (untyped registers, the residual-check doctrine). `variant_tag`
|
||||
is compiler-internal: it is not a source-callable name and does not
|
||||
appear in [`08-builtin-surface.md`](08-builtin-surface.md).
|
||||
|
||||
An **all-bare union** (`type CronResult = Ok | ErrorFinal | Miss`) never
|
||||
reaches this file's format at all: its values are plain integer ordinals
|
||||
(0, 1, 2 … in declaration order) in `WO_K_SCALAR` positions, compared
|
||||
with `EQ` — no class entries, no heap objects, no `variant_tag`.
|
||||
|
||||
**Payload move-out** (Task 4 fix rounds 1–2): a `switch` arm that yields
|
||||
its own payload binding as the switch's value (`case Boxed(b): b;`) MOVES
|
||||
the payload out of the variant object — **pointer-kind fields only**
|
||||
(OWNED/GCREF/TEXT/MULTI/MAP). The convention needs no format or collector
|
||||
change: the compiler emits a `SETF` writing zero into the moved field
|
||||
right after the value lands in its new owner's register, and the shell's
|
||||
ordinary recursive drop plan — which already skips zero slots for every
|
||||
kind (`runtime/src/gc.c wo_drop_kind`) — thereby frees the shell only.
|
||||
Escaping a **SCALAR** field (Int/Bool/Timestamp/Id/`ref`, a bare-union
|
||||
tag) is a plain COPY: no ownership moves and the field is left intact —
|
||||
nulling it would corrupt the subject with a value indistinguishable from
|
||||
a legitimate 0. A DISCARDED yield (statement-position switch) does not
|
||||
null either: the shell keeps the payload and frees it as usual.
|
||||
**Re-reading a moved-out payload is nil**: the field holds the zero word,
|
||||
so a later `switch` over the same subject GETFs 0 into the binding and
|
||||
any use of it traps `T_BOUNDS` ("null receiver") — memory-safe and
|
||||
defined, the residual-check doctrine's direction; a later task may
|
||||
promote this to a compile-time partial-move diagnostic (WO-E301 family).
|
||||
One companion rule on the caller side: an **owned heap temporary** passed
|
||||
as a borrow argument — a record/class constructor literal, a variant
|
||||
construction, or an owned-returning call (`peek(Pay{})`,
|
||||
`get(Boxed(Pay{}))`) — is copied to a stable register below the call
|
||||
window and `DROP`ped by the caller once the call returns (`take`
|
||||
arguments are the callee's to drop; places are their scope's; `@gc` and
|
||||
`Text` temporaries are excluded — the rc system's and the Copy-aliasing
|
||||
story's, respectively). Recursive drop is correct both ways, because a
|
||||
payload the callee moved out left the field nulled.
|
||||
|
||||
**Typedef records** (`typedef Name = { ... }`) are ordinary class-table
|
||||
entries too, with one compiler-side convention the loader never sees: two
|
||||
records with the same shape (same ordered fields, same types, same
|
||||
defaults) share a single entry — structural aliasing decided entirely at
|
||||
emit time.
|
||||
|
||||
## Single-binary trailer (`woc build`, plan 3 Task 6)
|
||||
|
||||
This section is **not part of the `.wob` format above** — `.wob` v1 is unchanged.
|
||||
|
|
|
|||
|
|
@ -27,40 +27,68 @@ half of the story ("moved here" / "borrowed here" / etc.).
|
|||
| WO-E001 | an input byte the lexer doesn't recognize as the start of any token. Reported once per bad byte, which is then skipped — one bad byte never stops the whole file. | `unknown character '$'` |
|
||||
| WO-E002 | a string literal's backslash escape is the last byte of the file, with no character left to escape (a plain unterminated string with no dangling backslash is *not* an error — rt parity). | `unterminated string escape` |
|
||||
|
||||
## WO-E1xx — parsing (Tasks 4–5, `compiler/src/parser.ml`)
|
||||
## WO-E1xx — parsing (Tasks 4–5, `compiler/src/parser.ml`; WO-E103 haxe-parity Task 2)
|
||||
|
||||
| code | meaning | example message |
|
||||
| --- | --- | --- |
|
||||
| WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` |
|
||||
| WO-E102 | an invalid `@table(...)` configuration: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` |
|
||||
| WO-E103 | haxe-parity Task 2. `inline fn ...` — the haxe keyword verdict table's own reject half of the `inline` row (`const` values are the adopted half). The whole declaration is discarded by the usual top-level recovery, same as any other bad declaration. | `` `inline fn` is rejected — optimization is the compiler's job `` |
|
||||
|
||||
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`; WO-E215 plan 3 Task 2, `compiler/src/types.ml`)
|
||||
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`; WO-E215 plan 3 Task 2, `compiler/src/types.ml`; WO-E210/E216–E218/W202 haxe-parity Task 1, `compiler/src/types.ml`; WO-E201 haxe-parity Task 2, `compiler/src/types.ml`; WO-E208 haxe-parity Task 3, `compiler/src/types.ml`; WO-E203 haxe-parity Task 4, `compiler/src/types.ml`)
|
||||
|
||||
| code | meaning | example message |
|
||||
| --- | --- | --- |
|
||||
| WO-E201 | haxe-parity Task 2. An `and`/`or` operand whose type is confidently known (the same narrow, "stay silent when underivable" deriver WO-E209 uses — `confident_typ`) and is not `Bool` — this language has no truthiness. Reserved since Task 6, its first real emission site. Haxe-parity Task 3 gave it two more sites: a `switch` expression's arms disagree on their yielded type (the switch's own type is fixed by the first arm — types.ml's "first wins" convention — every later arm is checked against it, via the regular `.typ` inference, not `confident_typ`; since haxe-parity Task 4 the comparison is *structural* — two typedef records with the same shape are the same type, `typ_equal`); and (review fix, Critical 2) a `case` value whose representation (`WO_K_TEXT` vs. `WO_K_SCALAR`) doesn't match the switch subject's — a real VM segfault if unchecked (a `Text` subject picks EQS, and EQS's `str_check` dereferences whatever sits in a mismatched `Int` case value's register), checked via `confident_typ`, silent when either side is unresolved. Haxe-parity Task 4 added the union-subject site: a `case` value over a confidently union-typed subject that does not name one of that union's variants (a misspelled variant, a variant of some other union, or a plain literal — union arms match variants, never values). Task 4's fix round 1 added three inverse/porosity sites, each a reviewer-reproduced silent-wrong-behavior hole: a variant-named `case` over a confidently **`?Union`** subject (it can never match — `switch` does not narrow `?T`; the message points at handling nil first, since forced handling is Task 6's), a variant-named `case` over a confidently **non-union** subject (`switch n { case Lo: }` over `n: Int` silently ordinal-matched), and a **cross-union `==`/`!=`** (`X == P` from two different bare unions was silently true whenever the ordinals matched; same-union comparison stays legal). Lexical scope wins at every one of these sites — a local sharing a variant's name is never misread as one. | `` `Wat` is not a variant of union `Kind` `` |
|
||||
| WO-E203 | haxe-parity Task 4 (`typedef` records + enum payload variants). A payload variant's argument count doesn't match its declaration, at either of the two places payload fields are positional: a construction (`Failed("a", "b")` against `Failed(reason: Text)`) or a `switch` pattern (`case Failed(a, b):`). The pattern site also rejects a non-name argument (`case Failed("x"):` — payload fields are bound positionally, never matched by value) and a payload-binding pattern sharing its arm with other values (`case Failed(r), Pending:` — the binding would be meaningless on the other match). Reserved since plan 2 Task 6; these are its first real emission sites, scoped to variant payloads only — user `fn`/method call arity is still the emitter's WO-E403, unchanged (see "Reserved, not yet emitted" below for the history of that gap). | `` variant `Failed` of `Status` takes 1 payload argument(s), given 2 `` |
|
||||
| WO-W201 *(warning)* | a class has recursive/shared structure (a field, directly or through `ref`/`multi`/`map`/`?`, refers back to its own class) that the ownership pass cannot prove disjoint, has no `@table`, and has no `@unique` field — suggests `@gc`. | `Node 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).` |
|
||||
| WO-E202 | a `.field` access names a field that the base's class (a *declared* class — an unresolved/placeholder expression type never triggers this) doesn't have. | `unknown field \`price\` on \`Product\`` |
|
||||
| WO-E206 | a constructor literal (`ClassName { ... }`) omits a field the class declares (no default). | `missing field \`sku\` in constructor of \`Product\`` |
|
||||
| WO-E207 | a constructor literal names a class that isn't declared anywhere in the (possibly multi-file) program. | `unknown type \`Widget\` in constructor` |
|
||||
| WO-E206 | a constructor literal (`ClassName { ... }`) omits a field the class declares that is neither defaulted nor nullable. Haxe-parity Task 4 narrowed it from "omits any field with no default was already the rule, but defaults were unenforceable" to the real omittability rule: a field with a declared default is filled by the emitter (`TailState {}` — the sample's defaults-fill-in pattern), and a `?`-typed field omitted is nil (the zero word `NEW` already leaves), for classes and typedef records alike. | `missing field \`sku\` in constructor of \`Product\`` |
|
||||
| WO-E207 | a constructor literal names a class that isn't declared anywhere in the (possibly multi-file) program. `typedef` records (haxe-parity Task 4) are classes to this check — a record name resolves here like any declared class. | `unknown type \`Widget\` in constructor` |
|
||||
| WO-E208 | haxe-parity Task 3 (`switch` as expression); the union exhaustiveness rule is haxe-parity Task 4's. Two subject regimes: (1) a **union-typed** subject (derived via `confident_typ`, the "stay silent when underivable" deriver — an underivable subject falls to regime 2) needs no `default` exactly when every variant is covered by some arm; a gap fires this code and names the missing variants, in declaration order. A `default` always satisfies it. (2) every **other** subject (scalars, Text, and anything underivable) keeps Task 3's unconditional rule: no `default` arm is always this error. A `?Union` subject is deliberately regime 2 until Task 6's forced-handling work legalizes narrowing it. | `` switch over `Kind` has no `default` arm and does not cover: Mid, Hi `` |
|
||||
| WO-E209 | hotfix (2026-08-11, round 2). A builtin call (`print`, `print_int`, `words`, `now`, `push`, `get`, `count`, `latest`, `set`, `has`, `multi_new`, `map_new`) given the wrong number of arguments, or an argument whose type is confidently known and does not match the builtin's signature (source of truth: [`08-builtin-surface.md`](08-builtin-surface.md)). Motivated by a real segfault: `print(7)` compiled clean and crashed `wovm` — `print` wants a `Text` (a heap-string pointer), and the VM's `str_check` dereferences whatever register it is handed as a `wo_str*` with no runtime tag to check first, so a bare `Int` was a wild pointer read. `types.ml`'s own `confident_typ` (deliberately narrower than the typechecker's regular `.typ` inference — see its doc comment) derives an argument's type from a literal, `self`, a parameter's declared type, a class field's declared type, a `Ctor` naming a real declared class, a `let` whose value was itself confidently typed, or (round 2 — round 1 missed this, a real second segfault repro: `print(takesSecret(box))` where `takesSecret` is declared `-> Int`) a `Call` whose declared signature is known: a free fn (resolved own-module-first-then-used-modules, never the flat whole-program symbol merge — the same Critical-1 bug shape haxe-parity Task 1 already fixed for the emitter), a class method off a confidently-typed receiver, an interface method's signature, or another builtin's own confident return type (`words`/`count` → `Int`, `now` → `Timestamp`, `has` → `Bool`, …). `ReqInt` accepts any non-`Text` builtin scalar (`Int`/`Bool`/`Timestamp`/`Id` all share the identical `WO_K_SCALAR` runtime representation — found as a real false positive against `print_int(has(...))` once builtin return-chasing went live). Anything still not chased (an unresolved name, an `Index`/`Binary` result, a qualified free-fn call through a `use` alias, an UNKNOWN-BUT-RESERVED stdlib call) is left unchecked rather than guessed at — see "Remaining unchecked surface" below. A user-declared free `fn` of the same name always wins over the builtin table (08-builtin-surface.md's shadowing rule; also resolved module-aware, not via the flat merge), so a shadowed name is never checked here either. Arity mismatches are also still caught later, at emission (`WO-E403`, unchanged) — this is an earlier, additional gate over the same contract, not a replacement. | `` builtin `print` expects Text, got `Int` `` |
|
||||
| WO-E210 | haxe-parity Task 1 (modules). A `Ctor`/bare-call name resolves — it's declared, somewhere — but not in this file's own module and not through any `use` edge either; the module it actually lives in is named as a hint. Reserved by Task 6's own brief, genuinely blocked until a module concept existed at all (see the nullable-types-implementation.md handoff) — this is its first real emission site. | `` `helper` is declared in module `shared/util`, which is not `use`d here `` |
|
||||
| WO-E214 | a class or interface name is declared more than once across the files a directory discovers (one program, multiple files — Task 8). Reported at the *later*-discovered declaration (sorted by path), with the first declaration as the related site; the merged symbol table keeps the first one, so this is what stops that silent keep from also hiding a real shape conflict. Driver-level, not `types.ml` — reuses the `types_prefix` range because it's a symbol-table concern, not a lexing/parsing/ownership one. | `class \`Dup\` already declared in \`a_first.wo\`` |
|
||||
| WO-E215 | a class, interface, or free `fn` name is declared more than once in the *same file* (`collect_declarations`'s own `StringMap.add` silently dropped the earlier one — Task 1 review, found while building the plan-3 emitter, fixed in Task 2). Reported at the later declaration, with the first as the related site — the same shape as WO-E214, one file instead of two; the symbol table keeps the first declaration. Class/interface names and free-fn names are separate namespaces, so a class and a fn sharing a name never collide here. | `class \`Dup\` already declared` |
|
||||
| WO-E225 | a field's declared type name isn't a builtin scalar, a declared class, or a declared interface. Checked once per field declaration, at the field's own position. | `unknown type \`Wdiget\`` |
|
||||
| WO-E215 | a class, interface, free `fn`, or (haxe-parity Task 4) union name is declared more than once in the *same file* (`collect_declarations`'s own `StringMap.add` silently dropped the earlier one — Task 1 review, found while building the plan-3 emitter, fixed in Task 2). Task 4 also fires it for a *variant* name reused within a file's unions (inside one union or across two — variants share one flat value namespace, so a bare `Ok` reference could not otherwise pick a tag), reported at the reusing variant with the owning union as the related site. Reported at the later declaration, with the first as the related site — the same shape as WO-E214, one file instead of two; the symbol table keeps the first declaration. Class/interface names and free-fn names are separate namespaces, so a class and a fn sharing a name never collide here. | `class \`Dup\` already declared` |
|
||||
| WO-E216 | haxe-parity Task 1 (modules). A `use <path>` names something that is neither one of the six reserved stdlib namespaces (`fs`, `proc`, `net`, `time`, `json`, `env`) nor a directory this program actually discovers. | `` unknown module `nosuchmodule` — not a discovered project module and not a reserved stdlib namespace `` |
|
||||
| WO-E217 | haxe-parity Task 1 (modules). A qualified reference (`alias.name(...)`) names a real declaration in a real, `use`d module, but that declaration has no `pub` marker — private to its own module. | `` `hidden` is not `pub` in module `secret` `` |
|
||||
| WO-E218 | haxe-parity Task 1 (modules). A bare (unqualified) name resolves as `pub` in *more than one* used module — "collisions diagnose rather than shadow silently" (the plan's own words): resolution never silently picks a winner among used modules, it fails loudly and names every alias that matched. | `` `thing` is ambiguous — exported `pub` by more than one used module (a, b) `` |
|
||||
| WO-E225 | a field's declared type name isn't a builtin scalar, a declared class (typedef records included), a declared interface, or (haxe-parity Task 4) a declared union. Also checked, same shape, on a payload variant's field types. A dotted name whose head is a reserved stdlib namespace (`json.Value`) is accepted as UNKNOWN-BUT-RESERVED — the same plan-9 convention `fs.stat(...)` calls get; any other dotted name is as unknown as a misspelling. Checked once per field declaration, at the field's own position. | `unknown type \`Wdiget\`` |
|
||||
| WO-W202 *(warning)* | haxe-parity Task 1 (modules). A file's own `use` clause is never actually referenced — neither a bare name resolving through it nor a qualified `alias.name(...)` call — anywhere in that file's surviving parse tree. | `` unused `use fs` `` |
|
||||
| WO-W203 *(warning)* | haxe-parity Task 3 review fix (Critical 1). A `switch`'s `default` arm is not textually last — no longer a silent dead-code trap (`default` is lowered last regardless of source position, `Ast.switch_lowering_order`), but still surprising source; fired once per switch, at `default`'s own position. | `` `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 `` |
|
||||
|
||||
### Reserved, not yet emitted
|
||||
|
||||
`type_mismatch_code` (WO-E201), `bad_arity_code` (WO-E203),
|
||||
`unknown_fn_code` (WO-E204), `non_exhaustive_switch_code` (WO-E208),
|
||||
`invalid_builtin_code` (WO-E209), `module_not_imported_code` (WO-E210),
|
||||
`unknown_fn_code` (WO-E204),
|
||||
`nullable_used_without_check_code` (WO-E211), `nullable_assign_mismatch_code`
|
||||
(WO-E212), and `missing_nil_check_code` (WO-E213) are declared in `types.ml`
|
||||
— the range is reserved — but as of Task 7 nothing in the front end ever
|
||||
— the range is reserved — but as of this task nothing in the front end ever
|
||||
raises them; there is no call site and therefore no real example
|
||||
message to catalog. They read like placeholders for checks Task 6's own
|
||||
plan brief named (type mismatch, bad arity, unsatisfied interface, …)
|
||||
that the shipped typechecker doesn't yet implement. Listed here so a
|
||||
conformance fixture (plan 3) or a future reader doesn't assume one of
|
||||
these codes is reachable today; move a code up into the table above in
|
||||
the same commit that wires its first real emission site.
|
||||
that the shipped typechecker doesn't yet implement. `module_not_imported_code`
|
||||
(WO-E210) — the one member of this list Task 6's brief named that a *later*
|
||||
task, not a gap in Task 6's own shipped work, was blocking — has moved up
|
||||
into the table above: haxe-parity Task 1 gave it its first real emission
|
||||
site. `invalid_builtin_code` (WO-E209) has also since moved up into the
|
||||
table above — a 2026-08-11 hotfix wired it (a real `print(7)` segfault
|
||||
forced the issue; see its row above) — and `type_mismatch_code` (WO-E201)
|
||||
has too: haxe-parity Task 2 wired it for `and`/`or`'s non-`Bool`-operand
|
||||
check (see its row above). `non_exhaustive_switch_code` (WO-E208) has also
|
||||
moved up: haxe-parity Task 3 (`switch` as expression) wired it — the exact
|
||||
"genuinely blocked: no `switch` keyword exists yet" gap the nullable-types
|
||||
dead-code register named is closed (see its row above). `bad_arity_code`
|
||||
(WO-E203) has now moved up too: haxe-parity Task 4 wired it for variant
|
||||
payload arity (construction and pattern sites — see its row above). That
|
||||
wiring is deliberately narrower than the code's own name promises: a
|
||||
user-declared `fn`/method call's arity is still ungated here and caught
|
||||
only at emission (WO-E403), the same pre-existing gap the WO-E209 hotfix
|
||||
note already disclosed for builtins. Four of the original ten remain dead
|
||||
(WO-E204, WO-E211, WO-E212, WO-E213 — the last three are Task 6's own
|
||||
forced-handling work). Listed here so a conformance fixture (plan 3) or a
|
||||
future reader doesn't assume one of the remaining codes is reachable
|
||||
today; move a code up into the table above in the same commit that wires
|
||||
its first real emission site.
|
||||
|
||||
### Reachable but unenforced
|
||||
|
||||
|
|
@ -125,9 +153,10 @@ written when any of these fire (`woc --emit` writes nothing on exit 1).
|
|||
| --- | --- | --- |
|
||||
| WO-E401 | the method needs more than 64 registers — the VM's register window (`runtime/src/wob.h` `WO_MAX_REGS`, enforced by the loader). Reported once per method, at the method's own position. | `` `wide` needs more than 64 registers — the VM's register window is 64 slots; split the method or reduce the number of live locals `` |
|
||||
| WO-E402 | a value that does not fit an instruction field: a constant/class/method/interface-slot index above 65535 (`LOADK`/`NEW`/`CALL`/`ICALL` carry a 16-bit operand), a field index above 255 (`GETF`/`SETF` carry a byte), or a jump farther than the signed 16-bit displacement. | `field index 300 exceeds the 8-bit GETF/SETF field` |
|
||||
| WO-E403 | a construct the v1 instruction set cannot express, or a call the emitter cannot lower correctly. The full source-surface contract is [`08-builtin-surface.md`](08-builtin-surface.md); the cases raised here are: an unresolved name; a call to something that is neither a declared `fn` nor a builtin; a wrong argument count (nothing upstream checks arity — WO-E203 is declared and never raised — and a mismatched call reserves a window the callee does not read, which the loader rejects); a field/method on a type that is not a declared class; `multi_new()`/`map_new()` with no destination of declared type (the element kinds are the container's runtime drop plan and cannot be guessed); an element write into a `multi` (v1 has `multi_push`/`multi_get`, no element store); a `for` over a `map` (v1 exposes no key enumeration). Several of these are cases the typechecker's placeholder types let through — the emitter is the first stage that must be exact. | `` `for` can only iterate a `multi` — the v1 builtins expose no key enumeration for a `map` `` |
|
||||
| WO-E403 | a construct the v1 instruction set cannot express, or a call the emitter cannot lower correctly. The full source-surface contract is [`08-builtin-surface.md`](08-builtin-surface.md); the cases raised here are: an unresolved name; a call to something that is neither a declared `fn` nor a builtin; a wrong argument count (nothing upstream checks arity — WO-E203 is declared and never raised — and a mismatched call reserves a window the callee does not read, which the loader rejects); a field/method on a type that is not a declared class; `multi_new()`/`map_new()` with no destination of declared type (the element kinds are the container's runtime drop plan and cannot be guessed); an element write into a `multi` (v1 has `multi_push`/`multi_get`, no element store); a `for` over a `map` (v1 exposes no key enumeration); (haxe-parity Task 2) `break`/`continue` with no enclosing loop — nothing upstream tracks loop nesting to reject it earlier; (haxe-parity Task 2) a `"${expr}"` interpolant whose type is neither `Text` nor `Int`, or is unresolvable; (haxe-parity Task 4) a field default the emitter cannot lower when a constructor literal omits that field — defaults are opaque token spans by design (ast.ml), and the lowerable set is exactly the sample's own shapes: Int (optionally negated), Text, and Bool literals, `now()`, and `[]` (an empty container, kinds from the field's declared type). Several of these are cases the typechecker's placeholder types let through — the emitter is the first stage that must be exact. | `` `for` can only iterate a `multi` — the v1 builtins expose no key enumeration for a `map` `` |
|
||||
| WO-E404 | an ownership-table entry the emitter could not honor: a residual borrow site whose operand has no register at the guarded region, a residual region no lowering wrapped at all (checked at the end of every compilation unit — owner.ml anchors regions on several different node kinds, and one nobody consumed would ship the aliasing check silently disabled), or a drop/rc site naming a local that has no register. Emitting such a region unguarded would drop the single enforcement a residual site exists for, so it fails instead. | `` residual borrow site in `shuffle` names an operand with no live register — the runtime guard cannot be placed `` |
|
||||
| WO-E405 | the program entry (the zero-arg free fn `main`, selected by name) declares a return type other than `Int`. The systems-track spec (`docs/superpowers/specs/2026-08-01-systems-track-design.md:70`) makes the entry's return value the process exit code, so any other declared return type was never legal — this is the check that finally says so. `main` with no return annotation at all is unaffected (nothing declared to contradict `Int`); every other milestone-1 fixture uses that form. Reported once, at `main`'s own position. | `` entry `main` declares return type `Node` — the entry's return value is the process exit code, so it must return `Int` `` |
|
||||
| WO-E406 | haxe-parity Task 1 (modules). A call through a reserved stdlib alias (`use fs`/`proc`/`net`/`time`/`json`/`env`) survived typechecking — `types.ml` accepts it as UNKNOWN-BUT-RESERVED, since the six namespaces' members arrive in plan 9 — and reached emission. There is nothing to lower it to yet; an unused `use fs` never reaches this code at all, only a call that actually goes through it. | `` stdlib module `fs` is not linked in this milestone (called as `fs.stat`) `` |
|
||||
|
||||
## Completeness method
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,35 @@ placed directly inside a kind directory (not in its own subdirectory) is
|
|||
never picked up — no error, no run, it just silently does not exist as a
|
||||
fixture. If a fixture stops appearing in the tally, check that first.
|
||||
|
||||
**`run/` and `compile-fail/` compile the fixture's own *directory*, not
|
||||
just `fixture.wo`** (haxe-parity Task 1, modules) — `woc --emit
|
||||
<fixture-dir> -o <scratch>.wob`, letting `woc`'s own multi-file discovery
|
||||
find every `.wo` file under it. For a fixture with no other `.wo` file
|
||||
beside `fixture.wo` (every fixture that predates modules, and the large
|
||||
majority since) this is behavior-identical to compiling `fixture.wo`
|
||||
alone. What it's *for*: a module fixture puts its extra module(s) in a
|
||||
**subdirectory** (`greet/greet.wo`, `secret/secret.wo`, `a/a.wo`, ...) —
|
||||
each subdirectory is its own module (a `.wo` file's module is its
|
||||
directory), so this is how a `run`/`compile-fail` fixture exercises a
|
||||
real `use` across module boundaries at all.
|
||||
|
||||
**Guarded, not open season**: exactly one top-level `.wo` file
|
||||
(`fixture.wo` itself) is required directly inside the fixture's own
|
||||
directory — `scripts/oop-e2e.sh`'s `assert_one_top_level_wo` counts
|
||||
`<fixture-dir>/*.wo` (never recursing into subdirectories) and fails the
|
||||
fixture by name, before compiling anything, if that count isn't exactly
|
||||
1. Without this, a second `.wo` file dropped loose beside `fixture.wo`
|
||||
(not a module fixture's intentional subdirectory — a mistake, or worse)
|
||||
silently joins the compile as a second file in the *same* module (Task
|
||||
8's own discovery contract: every same-directory file is unconditionally
|
||||
visible to every other) — confirmed exploitable: an alphabetically-
|
||||
earlier stray `fn main` hijacks the fixture's own entry point with zero
|
||||
diagnostics, since free fns are excluded from the cross-file collision
|
||||
check (`01-error-catalog.md`'s WO-E214 row is classes/interfaces only).
|
||||
A subdirectory full of `.wo` files is unaffected by this guard — that's
|
||||
a different module by construction, exactly the shape a module fixture
|
||||
is supposed to have.
|
||||
|
||||
## `run/` — compiles, runs, exact stdout
|
||||
|
||||
**Files:** `fixture.wo`, `fixture.out`.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ maps to one `BUILTIN` id of the format doc.
|
|||
| `get(c, k)` | `multi_get` / `map_get` | 2 | element by index, or value by key (a missing key traps `KEY`) |
|
||||
| `set(m, k, v)` | `map_set` | 3 | insert or replace in a `map` |
|
||||
| `has(m, k)` | `map_has` | 2 | `1`/`0` |
|
||||
| `int_to_text(n)` | `int_to_text` | 1 | decimal rendering of an `Int`, as a fresh owned `Text` — haxe-parity Task 2's one fenced VM addition, the type-directed half of string interpolation (below); also directly callable |
|
||||
|
||||
`get`, `set`, `push`, `count` and `has` resolve on the container they are
|
||||
given, so one source name covers the `multi` and `map` ids the runtime
|
||||
|
|
@ -96,6 +97,43 @@ type, which is also a typed destination.
|
|||
- `self` occupies the callee's `r0`, so a method's argument count is
|
||||
`1 + parameters`.
|
||||
|
||||
## Modules (haxe-parity Task 1)
|
||||
|
||||
A `.wo` file's **module is its directory** — no manifest, no declared
|
||||
module name. Every file sharing a directory sees every other same-
|
||||
directory file's declarations unconditionally (Task 8's existing
|
||||
multi-file discovery, unchanged); a name declared in a *different*
|
||||
directory needs `use` to become visible at all, and even then only if
|
||||
it is marked `pub`.
|
||||
|
||||
- **`pub`** on a top-level `class`/`type`/`interface`/`fn` exports it
|
||||
outside its own module. Default is private-to-module — visible to
|
||||
every file in the same directory, invisible to every other module
|
||||
regardless of `use` (`WO-E217` if referenced anyway). `pub` on a
|
||||
class/interface method, or the `pub(read)` field-accessor marker
|
||||
(Haxe's `(default, null)`), is a different, later feature — not this
|
||||
one.
|
||||
- **`use fs`** (a bare, single-segment name) is a **reserved stdlib
|
||||
namespace**: exactly `fs`, `proc`, `net`, `time`, `json`, `env`, no
|
||||
others, and always stdlib even if a same-named project directory
|
||||
exists. A call through one (`fs.stat(...)`) typechecks as
|
||||
UNKNOWN-BUT-RESERVED — no E207/E225/arity check, since the six
|
||||
namespaces' members arrive in plan 9 — and is `WO-E406` only if such
|
||||
a call survives all the way to emission; an unused `use fs` compiles
|
||||
clean (modulo `WO-W202`, below).
|
||||
- **`use shared/util`** (slash-separated segments) is **project-
|
||||
relative**: it must name a directory this program's own discovery
|
||||
actually finds, or `WO-E216`. The alias a call site uses is always
|
||||
the path's *last* segment (`util.fn(...)`, not `shared.fn(...)`).
|
||||
- **Resolution order** for a bare (unqualified) name: this file's own
|
||||
module, unconditionally; then every `use`d module's `pub` surface. If
|
||||
more than one used module exports the same `pub` name, that is
|
||||
`WO-E218` — collisions diagnose rather than silently pick a winner.
|
||||
A qualified reference (`alias.name(...)`) skips straight to its
|
||||
named module; `WO-E217` if `name` exists there but isn't `pub`.
|
||||
- A `use` clause never referenced (bare or qualified) anywhere in its
|
||||
own file is `WO-W202` — a warning, so it does not fail the build.
|
||||
|
||||
## Program entry
|
||||
|
||||
The entry point is the **zero-argument free `fn main`**. A `main` that
|
||||
|
|
@ -113,6 +151,8 @@ Lowered by the emitter, not added to the format:
|
|||
| `a > b`, `a >= b` | `LT` / `LE` with the operands swapped |
|
||||
| `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise |
|
||||
| `a .. b` | `CONCAT` — `+` is arithmetic only, never string addition |
|
||||
| `a and b` | evaluate `a`; `JZ` past evaluating `b` (result stays `a`'s value); else evaluate `b` into the same register (haxe-parity Task 2) |
|
||||
| `a or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) |
|
||||
|
||||
## Not lowerable in milestone 1
|
||||
|
||||
|
|
@ -125,6 +165,50 @@ Each is `WO-E403` at the offending site, never invented bytecode:
|
|||
- a field or method on a type that is not a declared class — including a
|
||||
class named only inside `multi T` / `ref T`, which `types.ml`'s
|
||||
unknown-type check (`WO-E225`) does not look inside.
|
||||
- `break`/`continue` outside any loop (haxe-parity Task 2) — nothing
|
||||
upstream tracks loop nesting to reject it earlier, so the emitter's
|
||||
own "no legal jump target" gate is the only one.
|
||||
- interpolating (`"${expr}"`) a value that is neither `Text` nor `Int`
|
||||
(haxe-parity Task 2) — the brief's own scope; a class, `multi`, `map`,
|
||||
or other scalar has no defined textification here.
|
||||
|
||||
## Small control surface (haxe-parity Task 2)
|
||||
|
||||
`break`/`continue`/`do...while`, `const`, `and`/`or`, and string
|
||||
interpolation — the haxe keyword verdict table's low-risk batch, added
|
||||
2026-08-11.
|
||||
|
||||
- **`break`/`continue`** reuse the owner pass's own `return`-drop
|
||||
machinery, bounded to the nearest enclosing loop instead of the whole
|
||||
function: an owned value still alive in the loop body is dropped at
|
||||
the `break`/`continue` site itself, not left to leak (proven under
|
||||
ASan, `tests/corpus/run/lang-break-owned-drop/`). `continue`'s actual
|
||||
jump target depends on loop shape — `while`'s own condition check,
|
||||
`for`'s increment step, or `do...while`'s condition check — but the
|
||||
drop-set computation is identical either way.
|
||||
- **`do { body } while cond`** — the body always runs at least once;
|
||||
lowered onto the same `JZ`/`JMP` pair `while`/`for` already use, just
|
||||
reordered.
|
||||
- **`const NAME = <literal>`** (top-level, or bare — no `static` —
|
||||
class-level) is resolved entirely by `parser.ml`, before typecheck
|
||||
ever runs: every unshadowed reference is replaced by the literal it
|
||||
names, so nothing downstream (types/owner/emit) has any const-specific
|
||||
code at all. A local/parameter/`self` of the same name always shadows
|
||||
it. `static const` is Task 7's own syntax (`static`), not recognized
|
||||
here.
|
||||
- **`and`/`or`** are real keywords (never `&&`/`||`), one precedence
|
||||
level below comparison (`or` loosest, then `and`, then comparison —
|
||||
so `a == 1 and b == 2` needs no parens). `Bool`-typed operands only,
|
||||
no truthiness: a confidently-non-`Bool` operand is `WO-E201`. Short-
|
||||
circuit, lowered to compare-and-jump above — no new opcode.
|
||||
- **String interpolation** (`"${expr}"`) desugars at parse time to a
|
||||
`..` (`Concat`) chain of text segments and embedded expressions; each
|
||||
embedded expression's *textification* is decided at emit time, once
|
||||
its type is known: `Text` passes through untouched, `Int` is wrapped
|
||||
in `int_to_text` (above), anything else is `WO-E403` (see "Not
|
||||
lowerable in milestone 1"). `\$` is a literal `$` (so `\${x}` stays
|
||||
literal, never interpolates); a lone `$` not followed by `{` is also
|
||||
literal, unconditionally.
|
||||
|
||||
## `?T`
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,34 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
|
|||
R[A] = wo_map_has(m, R[B + 1]) ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
case WO_B_INT_TO_TEXT: { /* haxe-parity compiler Task 2: string interpolation */
|
||||
char buf[32];
|
||||
int len = snprintf(buf, sizeof buf, "%lld", (long long)(int64_t)R[B]);
|
||||
wo_str *s = wo_str_new(rt, buf, (uint32_t)len);
|
||||
if (!s) {
|
||||
*msg = "out of memory";
|
||||
return WO_T_OOM;
|
||||
}
|
||||
R[A] = (uint64_t)(uintptr_t)s;
|
||||
return 0;
|
||||
}
|
||||
case WO_B_VARIANT_TAG: { /* haxe-parity compiler Task 4: enum payload variants */
|
||||
/* the tag IS the header's class_id (wob.h's convention). Null and
|
||||
* native-class receivers trap BOUNDS — same defense ICALL keeps;
|
||||
* a non-pointer register is the compiler's to prevent (untyped
|
||||
* registers, the residual-check doctrine). */
|
||||
if (!R[B]) {
|
||||
*msg = "null receiver";
|
||||
return WO_T_BOUNDS;
|
||||
}
|
||||
wo_hdr *o = (wo_hdr *)(uintptr_t)R[B];
|
||||
if (o->class_id >= vm->mod->class_cnt) {
|
||||
*msg = "variant tag of a native value";
|
||||
return WO_T_BOUNDS;
|
||||
}
|
||||
R[A] = o->class_id;
|
||||
return 0;
|
||||
}
|
||||
default: /* unreachable: loader validated the id */
|
||||
*msg = "unknown builtin";
|
||||
return WO_T_EXPLICIT;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
|
|||
[WO_B_WORDS] = 1, [WO_B_MULTI_NEW] = 0, [WO_B_MULTI_PUSH] = 2,
|
||||
[WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1,
|
||||
[WO_B_MAP_NEW] = 0, [WO_B_MAP_SET] = 3, [WO_B_MAP_GET] = 2,
|
||||
[WO_B_MAP_HAS] = 2,
|
||||
[WO_B_MAP_HAS] = 2, [WO_B_INT_TO_TEXT] = 1,
|
||||
[WO_B_VARIANT_TAG] = 1,
|
||||
};
|
||||
|
||||
static int vtab_cmp(const void *a, const void *b) {
|
||||
|
|
|
|||
|
|
@ -138,8 +138,22 @@ enum {
|
|||
WO_B_MAP_SET = 10,
|
||||
WO_B_MAP_GET = 11, /* missing key traps WO_T_KEY */
|
||||
WO_B_MAP_HAS = 12,
|
||||
/* haxe-parity compiler Task 2: string interpolation's Int -> Text
|
||||
* conversion (`"${count} lines"`). (i64) -> a fresh owned Text of
|
||||
* its decimal rendering. */
|
||||
WO_B_INT_TO_TEXT = 13,
|
||||
/* haxe-parity compiler Task 4: enum payload variants. A payload
|
||||
* union's variants are compiler-generated class-table entries; the
|
||||
* variant TAG is the object header's existing class_id (no new
|
||||
* header field, no new flag — docs/plan/oop-vm/00-wob-format.md,
|
||||
* "enum payload variants"). (variant object) -> i64 tag: reads
|
||||
* r[B]'s header class_id into r[A] so a switch over a payload
|
||||
* union compares tags without a per-arm allocation. Traps
|
||||
* WO_T_BOUNDS on a null receiver or a native class id — the same
|
||||
* defense ICALL keeps for a miscompiled receiver. */
|
||||
WO_B_VARIANT_TAG = 14,
|
||||
};
|
||||
#define WO_B_MAX 12u
|
||||
#define WO_B_MAX 14u
|
||||
|
||||
/* ---- instruction encode/decode: op:8 A:8 then B:8 C:8 or Bx:16 ---- */
|
||||
static inline uint32_t wo_ins_abc(uint8_t op, uint8_t a, uint8_t b, uint8_t c) {
|
||||
|
|
|
|||
|
|
@ -81,17 +81,51 @@ extract_error_codes() {
|
|||
grep -oE 'error (WO-E[0-9]+):' "$1" | sed -E 's/error (WO-E[0-9]+):/\1/'
|
||||
}
|
||||
|
||||
# haxe-parity Task 1 review, IMPORTANT 3: compiling the fixture's own
|
||||
# directory (not just fixture.wo) means woc's own multi-file discovery
|
||||
# picks up *any* .wo file dropped there, not only fixture.wo -- a stray
|
||||
# second .wo directly beside fixture.wo silently joins the compile as a
|
||||
# second file in the SAME module (Task 8's own discovery contract: every
|
||||
# file sharing a directory is unconditionally visible to every other),
|
||||
# and its own free fns/classes are exactly as reachable as fixture.wo's
|
||||
# own. Confirmed exploitable: an alphabetically-earlier stray `fn main`
|
||||
# hijacks the fixture's entry point with zero diagnostics (free fns are
|
||||
# excluded from the cross-file collision check, docs/plan/oop-vm/
|
||||
# 01-error-catalog.md's WO-E214 row). A *subdirectory* full of .wo files
|
||||
# is a different module on purpose -- that's the whole point of a
|
||||
# module fixture (greet/, secret/, a/, b/, ...) -- so this only counts
|
||||
# files directly inside $1, never recursing into subdirectories.
|
||||
assert_one_top_level_wo() {
|
||||
local dir="$1" name="$2"
|
||||
local top_level=("$dir"/*.wo)
|
||||
if [[ ${#top_level[@]} -ne 1 ]]; then
|
||||
bad "$name" "expected exactly one top-level .wo file in $dir, found ${#top_level[@]} -- module fixtures belong in a subdirectory, not loose beside fixture.wo"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
run_fixture() {
|
||||
local dir="$1" name="run/$(basename "$1")"
|
||||
local prefix wob out err rc
|
||||
|
||||
if [[ ! -f "$dir/fixture.wo" ]]; then bad "$name" "missing fixture.wo"; return; fi
|
||||
if [[ ! -f "$dir/fixture.out" ]]; then bad "$name" "missing fixture.out"; return; fi
|
||||
assert_one_top_level_wo "$dir" "$name" || return
|
||||
|
||||
prefix="$(tmp_prefix "$name")"
|
||||
wob="$prefix.wob" out="$prefix.out" err="$prefix.err"
|
||||
|
||||
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
|
||||
# Compile the fixture's own directory, not just fixture.wo directly:
|
||||
# for a fixture with no other .wo file beside fixture.wo (every
|
||||
# pre-haxe-parity fixture), woc's directory discovery finds exactly
|
||||
# that one file, so this is behavior-identical to compiling
|
||||
# "$dir/fixture.wo" on its own. It's what lets a fixture exercise a
|
||||
# real cross-module `use` (haxe-parity Task 1) by adding a nested
|
||||
# module directory (e.g. `greet/greet.wo`) beside fixture.wo — woc's
|
||||
# own multi-file discovery (Task 8) then compiles both as one
|
||||
# program, exactly like a real project layout would.
|
||||
timeout "$TIMEOUT" "$WOC" --emit "$dir" -o "$wob" >/dev/null 2>"$err"
|
||||
rc=$?
|
||||
if [[ $rc -eq 124 ]]; then
|
||||
bad "$name" "woc timed out after ${TIMEOUT}s"
|
||||
|
|
@ -211,6 +245,7 @@ compile_fail_fixture() {
|
|||
|
||||
if [[ ! -f "$dir/fixture.wo" ]]; then bad "$name" "missing fixture.wo"; return; fi
|
||||
if [[ ! -f "$dir/fixture.code" ]]; then bad "$name" "missing fixture.code"; return; fi
|
||||
assert_one_top_level_wo "$dir" "$name" || return
|
||||
|
||||
prefix="$(tmp_prefix "$name")"
|
||||
wob="$prefix.wob" err="$prefix.err"
|
||||
|
|
@ -221,7 +256,16 @@ compile_fail_fixture() {
|
|||
return
|
||||
fi
|
||||
|
||||
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
|
||||
# Compile the fixture's own directory, not just fixture.wo directly:
|
||||
# for a fixture with no other .wo file beside fixture.wo (every
|
||||
# pre-haxe-parity fixture), woc's directory discovery finds exactly
|
||||
# that one file, so this is behavior-identical to compiling
|
||||
# "$dir/fixture.wo" on its own. It's what lets a fixture exercise a
|
||||
# real cross-module `use` (haxe-parity Task 1) by adding a nested
|
||||
# module directory (e.g. `greet/greet.wo`) beside fixture.wo — woc's
|
||||
# own multi-file discovery (Task 8) then compiles both as one
|
||||
# program, exactly like a real project layout would.
|
||||
timeout "$TIMEOUT" "$WOC" --emit "$dir" -o "$wob" >/dev/null 2>"$err"
|
||||
rc=$?
|
||||
|
||||
if [[ $rc -eq 124 ]]; then
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- haxe-parity Task 2: `and`/`or` are Bool-only, no truthiness --
|
||||
-- `count` is confidently Int (a literal-valued local), so this is a
|
||||
-- real, statically-provable type error, not a guess.
|
||||
fn main() -> Int {
|
||||
let count = 1
|
||||
if count and true {
|
||||
print("unreachable")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E403
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- haxe-parity Task 2: `break` outside any loop has no legal jump
|
||||
-- target -- emit.ml's own "cannot lower" gate (WO-E403), the same
|
||||
-- convention every other construct with nothing to lower to uses.
|
||||
-- Nothing upstream (parser/types/owner) tracks loop nesting to reject
|
||||
-- this earlier.
|
||||
fn main() -> Int {
|
||||
break
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E209
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- Hotfix round 2 (WO-E209): `takesSecret` is declared `-> Int`, so its
|
||||
-- call result is exactly as confidently `Int` as a bare literal would
|
||||
-- be -- passing it to `print` (wants `Text`) is the same class of bug
|
||||
-- `print(7)` is, just one call deeper. Controller-verified real repro:
|
||||
-- this compiled clean and segfaulted `wovm` before `confident_typ`
|
||||
-- chased a free fn's own declared return type (round 1 only chased
|
||||
-- literals/fields/params, missing this shape entirely).
|
||||
class Box {
|
||||
fn hidden() -> Int {
|
||||
return 7
|
||||
}
|
||||
}
|
||||
|
||||
fn takesSecret(box: Box) -> Int {
|
||||
return box.hidden()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
print(takesSecret(Box{}))
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E209
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
-- Hotfix round 2 (WO-E209): a direct method call, not a free fn wrapping
|
||||
-- one -- `Box.hidden` is declared `-> Int`, and the receiver `b` is
|
||||
-- confidently `Box` because it was built the ordinary way (`let b =
|
||||
-- Box{}`, a `Ctor`, which `confident_typ` also had to start chasing to
|
||||
-- reach method calls off a receiver built this way rather than only a
|
||||
-- parameter). `print(b.hidden())` is the same class of bug as
|
||||
-- `print(7)`, dispatched through a method instead of a free fn.
|
||||
class Box {
|
||||
fn hidden() -> Int {
|
||||
return 7
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let b = Box{}
|
||||
print(b.hidden())
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E209
|
||||
11
tests/corpus/compile-fail/lang-builtin-arg-type/fixture.wo
Normal file
11
tests/corpus/compile-fail/lang-builtin-arg-type/fixture.wo
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Hotfix (WO-E209): `print` wants a `Text` -- a heap-string pointer,
|
||||
-- .wob kind WO_K_TEXT -- and a bare Int literal is a WO_K_SCALAR
|
||||
-- register holding a raw int64 (docs/plan/oop-vm/08-builtin-surface.md's
|
||||
-- `print(t)` row). Before this check existed, this compiled clean and
|
||||
-- segfaulted `wovm`: the VM's `str_check` (runtime/src/vm.c) dereferences
|
||||
-- whatever register it's handed as a `wo_str*` with no runtime tag to
|
||||
-- check first, so the literal `7` was a wild pointer read. The exact
|
||||
-- repro that motivated wiring this diagnostic up.
|
||||
fn main() {
|
||||
print(7)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E209
|
||||
9
tests/corpus/compile-fail/lang-builtin-arity/fixture.wo
Normal file
9
tests/corpus/compile-fail/lang-builtin-arity/fixture.wo
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- Hotfix (WO-E209): `now` takes zero arguments
|
||||
-- (docs/plan/oop-vm/08-builtin-surface.md's `now()` row) -- this
|
||||
-- exercises the wrong-arity half of the same diagnostic, caught at
|
||||
-- typecheck time. Arity mismatches were already caught later, at
|
||||
-- emission (`WO-E403`, `emit.ml`) -- that check stays as-is; this is an
|
||||
-- earlier, additional gate over the same contract, not a replacement.
|
||||
fn main() {
|
||||
now(1)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E103
|
||||
11
tests/corpus/compile-fail/lang-inline-fn-rejected/fixture.wo
Normal file
11
tests/corpus/compile-fail/lang-inline-fn-rejected/fixture.wo
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- haxe-parity Task 2: the keyword verdict table's `inline` row --
|
||||
-- `const` values are adopted (see run/lang-const-usage); inline
|
||||
-- *functions* are rejected outright, optimization being the
|
||||
-- compiler's job, not the source language's.
|
||||
inline fn double(n: Int) -> Int {
|
||||
return n + n
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return double(2)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E101
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- haxe-parity Task 2 review fix (Important): a genuine sub-parse
|
||||
-- failure inside "${...}" (`1 +`, not just trailing garbage) must
|
||||
-- report at this string literal's own position with the malformed-
|
||||
-- interpolation message -- not at the sub-lexer's own uncorrected
|
||||
-- line/col (which used to land on some unrelated line of this real
|
||||
-- file). See compiler/test/runner.ml's direct assertion for the exact
|
||||
-- position/message pin; this fixture only pins the outcome (WO-E101).
|
||||
fn main() -> Int {
|
||||
print("prefix ${1 +} suffix")
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E202
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- Hotfix repro (multi-file double-report): a body-level check (here
|
||||
-- WO-E202, unknown field) must fire exactly once, tagged with THIS
|
||||
-- file -- not once per OTHER discovered file too. `it.price` is the
|
||||
-- one real bug (Item has no `price` field, only `n`). other/helper.wo
|
||||
-- exists purely to make this a two-file program: the bug needs N>1
|
||||
-- discovered files to reproduce at all (with one file, there is no
|
||||
-- "other file's pass" to re-report under). See
|
||||
-- .superpowers/sdd/2026-08-01-haxe-parity-language/hotfix-e209-report.md's
|
||||
-- "Disclosed, NOT fixed" section for the original diagnosis.
|
||||
class Item { n: Int }
|
||||
|
||||
fn main() {
|
||||
let it = Item{n:1}
|
||||
print_int(it.price)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Unrelated file, deliberately trivial and error-free -- its only job
|
||||
-- is being a second discovered file in this program (see fixture.wo's
|
||||
-- own comment for why). No Ctor/Call site here means no module `use`
|
||||
-- gating gets exercised either -- this fixture is purely about the
|
||||
-- double-report bug, not the module system.
|
||||
fn helper() -> Int {
|
||||
return 1
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
-- haxe-parity Task 3: arm-type unification. `default` yields `Int`
|
||||
-- while the earlier `case` arms yield `Text` -- the switch's own type
|
||||
-- is fixed by the first arm (types.ml's own "first wins" convention),
|
||||
-- so this is a real, statically-provable mismatch, not a guess.
|
||||
fn classify(n: Int) -> Text {
|
||||
let label = switch n {
|
||||
case 1: "one";
|
||||
case 2: "two";
|
||||
default: 0;
|
||||
};
|
||||
return label;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- haxe-parity Task 3, re-review fix (Critical 2 residual): the switch
|
||||
-- subject is a BUILTIN CALL, not a variable -- `int_to_text(n)` is
|
||||
-- confidently `Text` because the builtin's return type is declared.
|
||||
-- Before the fix, types.ml's `builtin_confident_ret` had no
|
||||
-- `int_to_text` entry (drifted from emit.ml's `builtin_ret`, which
|
||||
-- does), so the E201 check stayed silent while the emitter still
|
||||
-- chose EQS -- and EQS str_check'd the raw int case label at runtime:
|
||||
-- a zero-diagnostic compile that segfaulted the VM. This fixture pins
|
||||
-- the two tables back in sync.
|
||||
fn classify(n: Int) -> Int {
|
||||
let v = switch int_to_text(n) {
|
||||
case 1: 0;
|
||||
default: 1;
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- haxe-parity Task 3, review fix (Critical 2): the reverse direction
|
||||
-- of the Text/Int representation mismatch -- an `Int` subject against
|
||||
-- a `Text` case label doesn't crash the VM (EQ just compares two
|
||||
-- int64s), but it is equally wrong: the case value's own register
|
||||
-- never holds the subject's representation, so the comparison is
|
||||
-- silently always-false, and the case can never fire. `n` is
|
||||
-- confidently `Int` (a declared parameter), so this is a real,
|
||||
-- statically-provable mismatch, not a guess.
|
||||
fn describe(n: Int) -> Text {
|
||||
let v = switch n {
|
||||
case "one": "matched";
|
||||
default: "other";
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
-- haxe-parity Task 4, fix round 1 (review Major): the inverse of the
|
||||
-- union-subject direction — a case naming a KNOWN variant over an `Int`
|
||||
-- subject silently ordinal-matched (`Lo` is tag 0, so `f(0)` took the
|
||||
-- `case Lo:` arm). WO-E201, the same family as the existing
|
||||
-- lang-switch-int-subject-text-case.
|
||||
type K = Lo | Mid | Hi
|
||||
|
||||
fn f(n: Int) -> Int {
|
||||
return switch n {
|
||||
case Lo: 99;
|
||||
default: 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
print_int(f(0))
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E208
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- haxe-parity Task 3: scalars/Text require `default` -- no union type
|
||||
-- exists yet (Task 4), so this is unconditional for every subject this
|
||||
-- task supports; `n` is confidently `Int` (a declared parameter), so
|
||||
-- this is a real, statically-provable gap, not a guess.
|
||||
fn classify(n: Int) -> Text {
|
||||
let label = switch n {
|
||||
case 1: "one";
|
||||
case 2: "two";
|
||||
};
|
||||
return label;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- haxe-parity Task 3, review fix (Critical 2): a `Text` subject
|
||||
-- compared against an `Int` case label is not merely a type error --
|
||||
-- unchecked, it is a real VM segfault: emit.ml's EQ-vs-EQS choice
|
||||
-- reads only the SUBJECT's type, so `s: Text` picks EQS, and EQS's
|
||||
-- own `str_check` dereferences whatever sits in the case value's
|
||||
-- register as a `wo_str*` -- a raw int64 (`1`) read as a heap address.
|
||||
-- `s` is confidently `Text` (a declared parameter), so this is a
|
||||
-- real, statically-provable mismatch, not a guess.
|
||||
fn describe(s: Text) -> Text {
|
||||
let v = switch s {
|
||||
case 1: "one";
|
||||
default: "other";
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0;
|
||||
}
|
||||
3
tests/corpus/compile-fail/lang-use-collision/a/a.wo
Normal file
3
tests/corpus/compile-fail/lang-use-collision/a/a.wo
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub fn thing() -> Int {
|
||||
return 1
|
||||
}
|
||||
3
tests/corpus/compile-fail/lang-use-collision/b/b.wo
Normal file
3
tests/corpus/compile-fail/lang-use-collision/b/b.wo
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub fn thing() -> Int {
|
||||
return 2
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E218
|
||||
11
tests/corpus/compile-fail/lang-use-collision/fixture.wo
Normal file
11
tests/corpus/compile-fail/lang-use-collision/fixture.wo
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- haxe-parity Task 1 (modules): `a` and `b` both export a `pub fn
|
||||
-- thing`. Resolution goes file -> own module -> used modules -> stdlib
|
||||
-- (this file's own module has no `thing` at all), and at the "used
|
||||
-- modules" tier both `a` and `b` match — collisions diagnose rather
|
||||
-- than shadow silently (WO-E218), never a first-used-wins pick.
|
||||
use a
|
||||
use b
|
||||
|
||||
fn main() {
|
||||
print_int(thing())
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E217
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- haxe-parity Task 1 (modules): `secret.hidden()` names a real fn in a
|
||||
-- real, `use`d module — but `hidden` is not `pub` (see secret/secret.wo),
|
||||
-- so this is WO-E217, not a lookup failure.
|
||||
use secret
|
||||
|
||||
fn main() {
|
||||
print_int(secret.hidden())
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- haxe-parity Task 1 (modules): `hidden` has no `pub` marker, so it is
|
||||
-- private to this module — visible to every file in `secret/` itself,
|
||||
-- but not to a `use secret` caller elsewhere. That is exactly what
|
||||
-- fixture.wo tries and WO-E217 catches.
|
||||
fn hidden() -> Int {
|
||||
return 1
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E406
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- haxe-parity Task 1 (modules): `fs` is a reserved stdlib namespace —
|
||||
-- `use fs` resolves and `fs.stat(...)` typechecks as UNKNOWN-BUT-
|
||||
-- RESERVED (no E207/E225/arity error; the six namespaces' members
|
||||
-- arrive in plan 9). A call through it that survives all the way to
|
||||
-- emission is WO-E406, not silently accepted and not a generic
|
||||
-- WO-E403 "cannot resolve the receiver" -- there is nothing wrong with
|
||||
-- the reference, only nothing to lower it to yet.
|
||||
use fs
|
||||
|
||||
fn main() {
|
||||
print_int(fs.stat("x"))
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E216
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- haxe-parity Task 1 (modules): `nosuchmodule` is neither one of the
|
||||
-- six reserved stdlib namespaces (fs, proc, net, time, json, env) nor a
|
||||
-- directory this program actually discovers -- WO-E216.
|
||||
use nosuchmodule
|
||||
|
||||
fn main() {
|
||||
print("x")
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E203
|
||||
10
tests/corpus/compile-fail/lang-variant-arity/fixture.wo
Normal file
10
tests/corpus/compile-fail/lang-variant-arity/fixture.wo
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- haxe-parity Task 4: constructing a payload variant with the wrong
|
||||
-- number of payload arguments is WO-E203 (bad arity -- reserved since
|
||||
-- plan 2 Task 6, this is its first real emission site). `Failed`
|
||||
-- declares exactly one payload field.
|
||||
type Status = Pending | Failed(reason: Text)
|
||||
|
||||
fn main() -> Int {
|
||||
let st = Failed("a", "b")
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- haxe-parity Task 4, fix round 1 (review Major): two bare unions share
|
||||
-- the ordinal-tag representation, so `X == P` across DIFFERENT unions
|
||||
-- was silently true (both ordinal 0). WO-E201 — values of different
|
||||
-- unions never compare equal.
|
||||
type A = X | Yv
|
||||
|
||||
type B = P | Qv
|
||||
|
||||
fn main() -> Int {
|
||||
if X == P { print("cross-eq") }
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E208
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
-- haxe-parity Task 4: WO-E208's union exhaustiveness rule -- a switch
|
||||
-- over a union with no `default` must cover every variant; the
|
||||
-- diagnostic names the missing ones (`Hi` here). Scalars/Text keep the
|
||||
-- unconditional default-required rule (lang-switch-missing-default).
|
||||
type Kind = Lo | Mid | Hi
|
||||
|
||||
fn f(k: Kind) -> Int {
|
||||
return switch k {
|
||||
case Lo: 1;
|
||||
case Mid: 2;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E201
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
-- haxe-parity Task 4, fix round 1 (review Critical 2): a variant-named
|
||||
-- case over a `?Union` subject can NEVER match (the subject is not
|
||||
-- narrowed by `switch` — `?T` forced handling is Task 6's), so it
|
||||
-- compiled clean and always took `default`. WO-E201, pointing at the
|
||||
-- nil case first.
|
||||
type St = Pending | Failed(m: Text)
|
||||
|
||||
typedef R = { ?st: St }
|
||||
|
||||
fn f(r: R) -> Text {
|
||||
return switch r.st {
|
||||
case Pending: "p";
|
||||
default: "nil";
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let a = R { st: Pending }
|
||||
print(f(a))
|
||||
return 0
|
||||
}
|
||||
3
tests/corpus/run/emit-ctor-tail-position/fixture.out
Normal file
3
tests/corpus/run/emit-ctor-tail-position/fixture.out
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
3
|
||||
1
|
||||
2
|
||||
35
tests/corpus/run/emit-ctor-tail-position/fixture.wo
Normal file
35
tests/corpus/run/emit-ctor-tail-position/fixture.wo
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- pre-existing emit_ctor register collision, found during haxe-parity
|
||||
-- Task 3's re-review (out-of-scope encounter, fixed as its own hotfix):
|
||||
-- a constructor in TAIL position (`return Box{...}`) gets its dst from
|
||||
-- emit_tail's allocate-then-un-reserve convention, so dst sits AT
|
||||
-- f_temp -- and emit_ctor's per-field value temp then lands in dst
|
||||
-- itself, clobbering the just-NEW'd object pointer before SETF reads
|
||||
-- it (NEW r0; LOADK r0,k; SETF r0,f0,r0 -- an int64 stored through as
|
||||
-- a heap pointer, VM segfault with zero diagnostics). A `let`-bound
|
||||
-- ctor never collides (its dst is a local, below f_temp), which is why
|
||||
-- the whole milestone-1 corpus missed it. Same defect family as the
|
||||
-- Task 3 emit_switch placeholder-dst collision, different call site.
|
||||
class Box {
|
||||
n: Int
|
||||
}
|
||||
|
||||
class Pair {
|
||||
a: Int
|
||||
b: Int
|
||||
}
|
||||
|
||||
fn make() -> Box {
|
||||
return Box{n: 3};
|
||||
}
|
||||
|
||||
fn make_pair() -> Pair {
|
||||
return Pair{a: 1, b: 2};
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
print_int(make().n);
|
||||
let p = make_pair();
|
||||
print_int(p.a);
|
||||
print_int(p.b);
|
||||
return 0;
|
||||
}
|
||||
3
tests/corpus/run/lang-and-or-short-circuit/fixture.out
Normal file
3
tests/corpus/run/lang-and-or-short-circuit/fixture.out
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
and-precedence-ok
|
||||
and-short-circuit-ok
|
||||
or-short-circuit-ok
|
||||
26
tests/corpus/run/lang-and-or-short-circuit/fixture.wo
Normal file
26
tests/corpus/run/lang-and-or-short-circuit/fixture.wo
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- haxe-parity Task 2: `and`/`or` -- real keywords, own precedence level
|
||||
-- (looser than comparison, so `a == 1 and b == 2` needs no parens),
|
||||
-- short-circuit lowering to compare-and-jump (JZ + JMP, no new opcode).
|
||||
-- The short-circuit proof is real, not asserted: `1 / zero` traps
|
||||
-- (division by zero) if it is ever actually evaluated, so a broken
|
||||
-- (always-evaluate-both-sides) lowering would crash this fixture
|
||||
-- outright instead of merely printing a wrong answer.
|
||||
fn main() -> Int {
|
||||
let a = 1
|
||||
let b = 2
|
||||
if a == 1 and b == 2 {
|
||||
print("and-precedence-ok")
|
||||
}
|
||||
let zero = 0
|
||||
if false and (1 / zero == 0) {
|
||||
print("SHOULD-NOT-PRINT-and")
|
||||
} else {
|
||||
print("and-short-circuit-ok")
|
||||
}
|
||||
if true or (1 / zero == 0) {
|
||||
print("or-short-circuit-ok")
|
||||
} else {
|
||||
print("SHOULD-NOT-PRINT-or")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
2
tests/corpus/run/lang-break-owned-drop/fixture.out
Normal file
2
tests/corpus/run/lang-break-owned-drop/fixture.out
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
1
|
||||
done
|
||||
159
tests/corpus/run/lang-break-owned-drop/fixture.wo
Normal file
159
tests/corpus/run/lang-break-owned-drop/fixture.wo
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
-- haxe-parity Task 2: break/continue drop-set correctness.
|
||||
-- `Big` has 130 Int fields (runtime/test's own malloc-path trick,
|
||||
-- test_rc.c's doc comment: field_cnt*8 bytes exceeds the arena's
|
||||
-- 1024-byte size-class ceiling, so a missed DROP is a hard ASan leak,
|
||||
-- not a silent, unobservable miss). At i == 1, `big` is still live
|
||||
-- (never returned or moved) when `break` fires -- this is the DROP
|
||||
-- under test: owner.ml's DBreak table entry, emitted at the break site
|
||||
-- itself, must destroy it there, not leave it to leak. Verified under
|
||||
-- `runtime/build/wovm_asan` (see task-2-report.md for the RED/GREEN
|
||||
-- ASan evidence); this fixture's own oop-e2e.sh check (plain wovm)
|
||||
-- pins the *output*, not the leak-freedom -- the two are complementary,
|
||||
-- not redundant (a correct DROP is also silently correct output-wise;
|
||||
-- only ASan's leak checker actually proves the destructor ran).
|
||||
class Big {
|
||||
f0: Int
|
||||
f1: Int
|
||||
f2: Int
|
||||
f3: Int
|
||||
f4: Int
|
||||
f5: Int
|
||||
f6: Int
|
||||
f7: Int
|
||||
f8: Int
|
||||
f9: Int
|
||||
f10: Int
|
||||
f11: Int
|
||||
f12: Int
|
||||
f13: Int
|
||||
f14: Int
|
||||
f15: Int
|
||||
f16: Int
|
||||
f17: Int
|
||||
f18: Int
|
||||
f19: Int
|
||||
f20: Int
|
||||
f21: Int
|
||||
f22: Int
|
||||
f23: Int
|
||||
f24: Int
|
||||
f25: Int
|
||||
f26: Int
|
||||
f27: Int
|
||||
f28: Int
|
||||
f29: Int
|
||||
f30: Int
|
||||
f31: Int
|
||||
f32: Int
|
||||
f33: Int
|
||||
f34: Int
|
||||
f35: Int
|
||||
f36: Int
|
||||
f37: Int
|
||||
f38: Int
|
||||
f39: Int
|
||||
f40: Int
|
||||
f41: Int
|
||||
f42: Int
|
||||
f43: Int
|
||||
f44: Int
|
||||
f45: Int
|
||||
f46: Int
|
||||
f47: Int
|
||||
f48: Int
|
||||
f49: Int
|
||||
f50: Int
|
||||
f51: Int
|
||||
f52: Int
|
||||
f53: Int
|
||||
f54: Int
|
||||
f55: Int
|
||||
f56: Int
|
||||
f57: Int
|
||||
f58: Int
|
||||
f59: Int
|
||||
f60: Int
|
||||
f61: Int
|
||||
f62: Int
|
||||
f63: Int
|
||||
f64: Int
|
||||
f65: Int
|
||||
f66: Int
|
||||
f67: Int
|
||||
f68: Int
|
||||
f69: Int
|
||||
f70: Int
|
||||
f71: Int
|
||||
f72: Int
|
||||
f73: Int
|
||||
f74: Int
|
||||
f75: Int
|
||||
f76: Int
|
||||
f77: Int
|
||||
f78: Int
|
||||
f79: Int
|
||||
f80: Int
|
||||
f81: Int
|
||||
f82: Int
|
||||
f83: Int
|
||||
f84: Int
|
||||
f85: Int
|
||||
f86: Int
|
||||
f87: Int
|
||||
f88: Int
|
||||
f89: Int
|
||||
f90: Int
|
||||
f91: Int
|
||||
f92: Int
|
||||
f93: Int
|
||||
f94: Int
|
||||
f95: Int
|
||||
f96: Int
|
||||
f97: Int
|
||||
f98: Int
|
||||
f99: Int
|
||||
f100: Int
|
||||
f101: Int
|
||||
f102: Int
|
||||
f103: Int
|
||||
f104: Int
|
||||
f105: Int
|
||||
f106: Int
|
||||
f107: Int
|
||||
f108: Int
|
||||
f109: Int
|
||||
f110: Int
|
||||
f111: Int
|
||||
f112: Int
|
||||
f113: Int
|
||||
f114: Int
|
||||
f115: Int
|
||||
f116: Int
|
||||
f117: Int
|
||||
f118: Int
|
||||
f119: Int
|
||||
f120: Int
|
||||
f121: Int
|
||||
f122: Int
|
||||
f123: Int
|
||||
f124: Int
|
||||
f125: Int
|
||||
f126: Int
|
||||
f127: Int
|
||||
f128: Int
|
||||
f129: Int
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let i = 0
|
||||
while i < 3 {
|
||||
let big = Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 }
|
||||
if i == 1 {
|
||||
break
|
||||
}
|
||||
print_int(big.f0)
|
||||
i = i + 1
|
||||
}
|
||||
print("done")
|
||||
return 0
|
||||
}
|
||||
3
tests/corpus/run/lang-const-usage/fixture.out
Normal file
3
tests/corpus/run/lang-const-usage/fixture.out
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
hi
|
||||
hi
|
||||
box
|
||||
26
tests/corpus/run/lang-const-usage/fixture.wo
Normal file
26
tests/corpus/run/lang-const-usage/fixture.wo
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- haxe-parity Task 2: `const NAME = literal` -- top-level and
|
||||
-- (bare, non-static) class-level, both usable in expressions. Resolved
|
||||
-- by parser.ml's own post-parse substitution: by the time typecheck/
|
||||
-- owner/emit see this file, GREETING_COUNT and LABEL are already the
|
||||
-- literals they name.
|
||||
const GREETING_COUNT = 2
|
||||
|
||||
class Box {
|
||||
const LABEL = "box"
|
||||
n: Int
|
||||
|
||||
fn describe() -> Text {
|
||||
return LABEL
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let i = 0
|
||||
while i < GREETING_COUNT {
|
||||
print("hi")
|
||||
i = i + 1
|
||||
}
|
||||
let b = Box { n: 1 }
|
||||
print(b.describe())
|
||||
return 0
|
||||
}
|
||||
5
tests/corpus/run/lang-continue/fixture.out
Normal file
5
tests/corpus/run/lang-continue/fixture.out
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
1
|
||||
2
|
||||
4
|
||||
5
|
||||
done
|
||||
24
tests/corpus/run/lang-continue/fixture.wo
Normal file
24
tests/corpus/run/lang-continue/fixture.wo
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- haxe-parity Task 2: `continue` in a `for` loop -- re-enters right
|
||||
-- before the increment step (not `while`'s condition-check target),
|
||||
-- so the loop still advances past the skipped element instead of
|
||||
-- looping forever on it.
|
||||
class Nums {
|
||||
items: multi Int
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let box = Nums { items: multi_new() }
|
||||
push(box.items, 1)
|
||||
push(box.items, 2)
|
||||
push(box.items, 3)
|
||||
push(box.items, 4)
|
||||
push(box.items, 5)
|
||||
for n in box.items {
|
||||
if n == 3 {
|
||||
continue
|
||||
}
|
||||
print_int(n)
|
||||
}
|
||||
print("done")
|
||||
return 0
|
||||
}
|
||||
1
tests/corpus/run/lang-ctor-temp-arg-drop/fixture.out
Normal file
1
tests/corpus/run/lang-ctor-temp-arg-drop/fixture.out
Normal file
|
|
@ -0,0 +1 @@
|
|||
100
|
||||
159
tests/corpus/run/lang-ctor-temp-arg-drop/fixture.wo
Normal file
159
tests/corpus/run/lang-ctor-temp-arg-drop/fixture.wo
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
-- haxe-parity Task 4, fix round 2 (review NEW 2): an OWNED heap
|
||||
-- temporary passed as a borrow argument — a record/class constructor
|
||||
-- literal or an owned-returning call, not just a variant construction
|
||||
-- (fix round 1's scope) — is reaped by the caller after the call.
|
||||
-- Before: `peek(Pay{})` in a loop leaked one object per iteration
|
||||
-- (reviewer's m5c probe: maxrss 10,780 KB vs a 1,532 KB control over
|
||||
-- 300k iterations; arena-backed and LSan-invisible, so this fixture
|
||||
-- uses `Big` — 130 Int fields, past the arena's 1024-byte ceiling —
|
||||
-- to make any miss a hard ASan failure). `take` arguments stay the
|
||||
-- callee's to drop (the m6a/m6c-proven path) and places stay their
|
||||
-- scope's; both are pinned by runner.ml assertions.
|
||||
typedef Big = {
|
||||
f0: Int = 1
|
||||
f1: Int = 1
|
||||
f2: Int = 1
|
||||
f3: Int = 1
|
||||
f4: Int = 1
|
||||
f5: Int = 1
|
||||
f6: Int = 1
|
||||
f7: Int = 1
|
||||
f8: Int = 1
|
||||
f9: Int = 1
|
||||
f10: Int = 1
|
||||
f11: Int = 1
|
||||
f12: Int = 1
|
||||
f13: Int = 1
|
||||
f14: Int = 1
|
||||
f15: Int = 1
|
||||
f16: Int = 1
|
||||
f17: Int = 1
|
||||
f18: Int = 1
|
||||
f19: Int = 1
|
||||
f20: Int = 1
|
||||
f21: Int = 1
|
||||
f22: Int = 1
|
||||
f23: Int = 1
|
||||
f24: Int = 1
|
||||
f25: Int = 1
|
||||
f26: Int = 1
|
||||
f27: Int = 1
|
||||
f28: Int = 1
|
||||
f29: Int = 1
|
||||
f30: Int = 1
|
||||
f31: Int = 1
|
||||
f32: Int = 1
|
||||
f33: Int = 1
|
||||
f34: Int = 1
|
||||
f35: Int = 1
|
||||
f36: Int = 1
|
||||
f37: Int = 1
|
||||
f38: Int = 1
|
||||
f39: Int = 1
|
||||
f40: Int = 1
|
||||
f41: Int = 1
|
||||
f42: Int = 1
|
||||
f43: Int = 1
|
||||
f44: Int = 1
|
||||
f45: Int = 1
|
||||
f46: Int = 1
|
||||
f47: Int = 1
|
||||
f48: Int = 1
|
||||
f49: Int = 1
|
||||
f50: Int = 1
|
||||
f51: Int = 1
|
||||
f52: Int = 1
|
||||
f53: Int = 1
|
||||
f54: Int = 1
|
||||
f55: Int = 1
|
||||
f56: Int = 1
|
||||
f57: Int = 1
|
||||
f58: Int = 1
|
||||
f59: Int = 1
|
||||
f60: Int = 1
|
||||
f61: Int = 1
|
||||
f62: Int = 1
|
||||
f63: Int = 1
|
||||
f64: Int = 1
|
||||
f65: Int = 1
|
||||
f66: Int = 1
|
||||
f67: Int = 1
|
||||
f68: Int = 1
|
||||
f69: Int = 1
|
||||
f70: Int = 1
|
||||
f71: Int = 1
|
||||
f72: Int = 1
|
||||
f73: Int = 1
|
||||
f74: Int = 1
|
||||
f75: Int = 1
|
||||
f76: Int = 1
|
||||
f77: Int = 1
|
||||
f78: Int = 1
|
||||
f79: Int = 1
|
||||
f80: Int = 1
|
||||
f81: Int = 1
|
||||
f82: Int = 1
|
||||
f83: Int = 1
|
||||
f84: Int = 1
|
||||
f85: Int = 1
|
||||
f86: Int = 1
|
||||
f87: Int = 1
|
||||
f88: Int = 1
|
||||
f89: Int = 1
|
||||
f90: Int = 1
|
||||
f91: Int = 1
|
||||
f92: Int = 1
|
||||
f93: Int = 1
|
||||
f94: Int = 1
|
||||
f95: Int = 1
|
||||
f96: Int = 1
|
||||
f97: Int = 1
|
||||
f98: Int = 1
|
||||
f99: Int = 1
|
||||
f100: Int = 1
|
||||
f101: Int = 1
|
||||
f102: Int = 1
|
||||
f103: Int = 1
|
||||
f104: Int = 1
|
||||
f105: Int = 1
|
||||
f106: Int = 1
|
||||
f107: Int = 1
|
||||
f108: Int = 1
|
||||
f109: Int = 1
|
||||
f110: Int = 1
|
||||
f111: Int = 1
|
||||
f112: Int = 1
|
||||
f113: Int = 1
|
||||
f114: Int = 1
|
||||
f115: Int = 1
|
||||
f116: Int = 1
|
||||
f117: Int = 1
|
||||
f118: Int = 1
|
||||
f119: Int = 1
|
||||
f120: Int = 1
|
||||
f121: Int = 1
|
||||
f122: Int = 1
|
||||
f123: Int = 1
|
||||
f124: Int = 1
|
||||
f125: Int = 1
|
||||
f126: Int = 1
|
||||
f127: Int = 1
|
||||
f128: Int = 1
|
||||
f129: Int = 1
|
||||
}
|
||||
|
||||
fn mk() -> Big { return Big {} }
|
||||
|
||||
fn peek(b: Big) -> Int { return b.f0 }
|
||||
|
||||
fn main() -> Int {
|
||||
let i = 0
|
||||
let acc = 0
|
||||
while i < 50 {
|
||||
acc = acc + peek(Big {})
|
||||
acc = acc + peek(mk())
|
||||
i = i + 1
|
||||
}
|
||||
print_int(acc)
|
||||
return 0
|
||||
}
|
||||
2
tests/corpus/run/lang-do-while/fixture.out
Normal file
2
tests/corpus/run/lang-do-while/fixture.out
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
5
|
||||
done
|
||||
14
tests/corpus/run/lang-do-while/fixture.wo
Normal file
14
tests/corpus/run/lang-do-while/fixture.wo
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- haxe-parity Task 2: `do { body } while cond` -- body runs at least
|
||||
-- once. `n` starts already past the condition (5, not < 3) so the
|
||||
-- printed "0" below only happens because the body ran unconditionally
|
||||
-- before the condition was ever checked; a `while`-shaped (check-first)
|
||||
-- lowering would print nothing at all.
|
||||
fn main() -> Int {
|
||||
let n = 5
|
||||
do {
|
||||
print_int(n)
|
||||
n = n + 1
|
||||
} while n < 3
|
||||
print("done")
|
||||
return 0
|
||||
}
|
||||
3
tests/corpus/run/lang-interp-string/fixture.out
Normal file
3
tests/corpus/run/lang-interp-string/fixture.out
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
log-watcher: 3 lines
|
||||
price is ${100} exactly
|
||||
a lone $ stays literal
|
||||
13
tests/corpus/run/lang-interp-string/fixture.wo
Normal file
13
tests/corpus/run/lang-interp-string/fixture.wo
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- haxe-parity Task 2: string interpolation. Text interpolants pass
|
||||
-- through untouched; an Int interpolant (`count`) desugars through the
|
||||
-- new `int_to_text` builtin. `\$` produces a literal `$` (so a whole
|
||||
-- `${...}` sequence stays literal text when the `$` was escaped); a
|
||||
-- lone `$` not followed by `{` also stays literal, unconditionally.
|
||||
fn main() -> Int {
|
||||
let count = 3
|
||||
let name = "log-watcher"
|
||||
print("${name}: ${count} lines")
|
||||
print("price is \${100} exactly")
|
||||
print("a lone $ stays literal")
|
||||
return 0
|
||||
}
|
||||
4
tests/corpus/run/lang-switch-arm-drop/fixture.out
Normal file
4
tests/corpus/run/lang-switch-arm-drop/fixture.out
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
skip
|
||||
1
|
||||
skip
|
||||
done
|
||||
166
tests/corpus/run/lang-switch-arm-drop/fixture.wo
Normal file
166
tests/corpus/run/lang-switch-arm-drop/fixture.wo
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
-- haxe-parity Task 3: `switch` arm-local drop correctness. `Big` has
|
||||
-- 130 Int fields (the same runtime/test malloc-path trick
|
||||
-- tests/corpus/run/lang-break-owned-drop/fixture.wo already uses:
|
||||
-- field_cnt*8 bytes exceeds the arena's 1024-byte size-class ceiling,
|
||||
-- so a missed DROP is a hard ASan leak, not a silent, unobservable
|
||||
-- miss). `big` is created inside `case 1:` only (i == 1, one of three
|
||||
-- loop iterations), never moved, never returned -- this is the DROP
|
||||
-- under test: owner.ml's `analyze_switch` must record a DScope drop
|
||||
-- for it at that ARM's own end (the switch's own drop-scope contract,
|
||||
-- not the enclosing `while`'s), and emit.ml's `emit_switch` must
|
||||
-- actually place it there. Proven under `runtime/build/wovm_asan`
|
||||
-- (task-3-report.md has the RED/GREEN evidence: RED was a real bug
|
||||
-- this fixture itself found, not a hypothetical -- a statement-position
|
||||
-- switch's own placeholder `dst` register collided with `big`'s own
|
||||
-- register, so the arm's trailing `print_int(big.f0)` silently
|
||||
-- overwrote `big`'s only reference before the DROP ran); this
|
||||
-- fixture's own oop-e2e.sh check (plain wovm) pins the *output*, not
|
||||
-- the leak-freedom -- the two are complementary, not redundant.
|
||||
class Big {
|
||||
f0: Int
|
||||
f1: Int
|
||||
f2: Int
|
||||
f3: Int
|
||||
f4: Int
|
||||
f5: Int
|
||||
f6: Int
|
||||
f7: Int
|
||||
f8: Int
|
||||
f9: Int
|
||||
f10: Int
|
||||
f11: Int
|
||||
f12: Int
|
||||
f13: Int
|
||||
f14: Int
|
||||
f15: Int
|
||||
f16: Int
|
||||
f17: Int
|
||||
f18: Int
|
||||
f19: Int
|
||||
f20: Int
|
||||
f21: Int
|
||||
f22: Int
|
||||
f23: Int
|
||||
f24: Int
|
||||
f25: Int
|
||||
f26: Int
|
||||
f27: Int
|
||||
f28: Int
|
||||
f29: Int
|
||||
f30: Int
|
||||
f31: Int
|
||||
f32: Int
|
||||
f33: Int
|
||||
f34: Int
|
||||
f35: Int
|
||||
f36: Int
|
||||
f37: Int
|
||||
f38: Int
|
||||
f39: Int
|
||||
f40: Int
|
||||
f41: Int
|
||||
f42: Int
|
||||
f43: Int
|
||||
f44: Int
|
||||
f45: Int
|
||||
f46: Int
|
||||
f47: Int
|
||||
f48: Int
|
||||
f49: Int
|
||||
f50: Int
|
||||
f51: Int
|
||||
f52: Int
|
||||
f53: Int
|
||||
f54: Int
|
||||
f55: Int
|
||||
f56: Int
|
||||
f57: Int
|
||||
f58: Int
|
||||
f59: Int
|
||||
f60: Int
|
||||
f61: Int
|
||||
f62: Int
|
||||
f63: Int
|
||||
f64: Int
|
||||
f65: Int
|
||||
f66: Int
|
||||
f67: Int
|
||||
f68: Int
|
||||
f69: Int
|
||||
f70: Int
|
||||
f71: Int
|
||||
f72: Int
|
||||
f73: Int
|
||||
f74: Int
|
||||
f75: Int
|
||||
f76: Int
|
||||
f77: Int
|
||||
f78: Int
|
||||
f79: Int
|
||||
f80: Int
|
||||
f81: Int
|
||||
f82: Int
|
||||
f83: Int
|
||||
f84: Int
|
||||
f85: Int
|
||||
f86: Int
|
||||
f87: Int
|
||||
f88: Int
|
||||
f89: Int
|
||||
f90: Int
|
||||
f91: Int
|
||||
f92: Int
|
||||
f93: Int
|
||||
f94: Int
|
||||
f95: Int
|
||||
f96: Int
|
||||
f97: Int
|
||||
f98: Int
|
||||
f99: Int
|
||||
f100: Int
|
||||
f101: Int
|
||||
f102: Int
|
||||
f103: Int
|
||||
f104: Int
|
||||
f105: Int
|
||||
f106: Int
|
||||
f107: Int
|
||||
f108: Int
|
||||
f109: Int
|
||||
f110: Int
|
||||
f111: Int
|
||||
f112: Int
|
||||
f113: Int
|
||||
f114: Int
|
||||
f115: Int
|
||||
f116: Int
|
||||
f117: Int
|
||||
f118: Int
|
||||
f119: Int
|
||||
f120: Int
|
||||
f121: Int
|
||||
f122: Int
|
||||
f123: Int
|
||||
f124: Int
|
||||
f125: Int
|
||||
f126: Int
|
||||
f127: Int
|
||||
f128: Int
|
||||
f129: Int
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let i = 0;
|
||||
while i < 3 {
|
||||
switch i {
|
||||
case 1:
|
||||
let big = Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 };
|
||||
print_int(big.f0);
|
||||
default:
|
||||
print("skip");
|
||||
}
|
||||
i = i + 1;
|
||||
}
|
||||
print("done");
|
||||
return 0;
|
||||
}
|
||||
3
tests/corpus/run/lang-switch-class-arm-leak/fixture.out
Normal file
3
tests/corpus/run/lang-switch-class-arm-leak/fixture.out
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
1
|
||||
1
|
||||
1
|
||||
161
tests/corpus/run/lang-switch-class-arm-leak/fixture.wo
Normal file
161
tests/corpus/run/lang-switch-class-arm-leak/fixture.wo
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
-- haxe-parity Task 3, review fix (Critical 3): `let w = switch ...`
|
||||
-- with NO `: Type` annotation used to leak every single iteration --
|
||||
-- owner.ml's `expr_ty` returned `None` for a `Switch` (a deliberate,
|
||||
-- but wrong, "not chased" call from the original task), and
|
||||
-- `analyze_let`'s own fallback for `expr_ty = None` is `Scalar "Int"`
|
||||
-- (Copy-classified, never dropped) -- so an unannotated switch-valued
|
||||
-- `let` binding a plain class (no union involved) was silently never
|
||||
-- freed. `Big` has 130 Int fields (the same arena-ceiling trick
|
||||
-- lang-break-owned-drop/lang-switch-arm-drop already use: 1040 bytes
|
||||
-- crosses the 1024-byte size-class ceiling, so a missed DROP is a
|
||||
-- hard, observable ASan leak, not a silent miss lost in the arena's
|
||||
-- own small-object reuse). RED (fix disabled): 3168 bytes leaked
|
||||
-- across the 3 loop iterations, one object each, confirmed under
|
||||
-- `runtime/build/wovm_asan` (task-3-report.md has the transcript).
|
||||
-- GREEN (fix restored, this fixture): clean under both plain `wovm`
|
||||
-- and `wovm_asan`.
|
||||
class Big {
|
||||
f0: Int
|
||||
f1: Int
|
||||
f2: Int
|
||||
f3: Int
|
||||
f4: Int
|
||||
f5: Int
|
||||
f6: Int
|
||||
f7: Int
|
||||
f8: Int
|
||||
f9: Int
|
||||
f10: Int
|
||||
f11: Int
|
||||
f12: Int
|
||||
f13: Int
|
||||
f14: Int
|
||||
f15: Int
|
||||
f16: Int
|
||||
f17: Int
|
||||
f18: Int
|
||||
f19: Int
|
||||
f20: Int
|
||||
f21: Int
|
||||
f22: Int
|
||||
f23: Int
|
||||
f24: Int
|
||||
f25: Int
|
||||
f26: Int
|
||||
f27: Int
|
||||
f28: Int
|
||||
f29: Int
|
||||
f30: Int
|
||||
f31: Int
|
||||
f32: Int
|
||||
f33: Int
|
||||
f34: Int
|
||||
f35: Int
|
||||
f36: Int
|
||||
f37: Int
|
||||
f38: Int
|
||||
f39: Int
|
||||
f40: Int
|
||||
f41: Int
|
||||
f42: Int
|
||||
f43: Int
|
||||
f44: Int
|
||||
f45: Int
|
||||
f46: Int
|
||||
f47: Int
|
||||
f48: Int
|
||||
f49: Int
|
||||
f50: Int
|
||||
f51: Int
|
||||
f52: Int
|
||||
f53: Int
|
||||
f54: Int
|
||||
f55: Int
|
||||
f56: Int
|
||||
f57: Int
|
||||
f58: Int
|
||||
f59: Int
|
||||
f60: Int
|
||||
f61: Int
|
||||
f62: Int
|
||||
f63: Int
|
||||
f64: Int
|
||||
f65: Int
|
||||
f66: Int
|
||||
f67: Int
|
||||
f68: Int
|
||||
f69: Int
|
||||
f70: Int
|
||||
f71: Int
|
||||
f72: Int
|
||||
f73: Int
|
||||
f74: Int
|
||||
f75: Int
|
||||
f76: Int
|
||||
f77: Int
|
||||
f78: Int
|
||||
f79: Int
|
||||
f80: Int
|
||||
f81: Int
|
||||
f82: Int
|
||||
f83: Int
|
||||
f84: Int
|
||||
f85: Int
|
||||
f86: Int
|
||||
f87: Int
|
||||
f88: Int
|
||||
f89: Int
|
||||
f90: Int
|
||||
f91: Int
|
||||
f92: Int
|
||||
f93: Int
|
||||
f94: Int
|
||||
f95: Int
|
||||
f96: Int
|
||||
f97: Int
|
||||
f98: Int
|
||||
f99: Int
|
||||
f100: Int
|
||||
f101: Int
|
||||
f102: Int
|
||||
f103: Int
|
||||
f104: Int
|
||||
f105: Int
|
||||
f106: Int
|
||||
f107: Int
|
||||
f108: Int
|
||||
f109: Int
|
||||
f110: Int
|
||||
f111: Int
|
||||
f112: Int
|
||||
f113: Int
|
||||
f114: Int
|
||||
f115: Int
|
||||
f116: Int
|
||||
f117: Int
|
||||
f118: Int
|
||||
f119: Int
|
||||
f120: Int
|
||||
f121: Int
|
||||
f122: Int
|
||||
f123: Int
|
||||
f124: Int
|
||||
f125: Int
|
||||
f126: Int
|
||||
f127: Int
|
||||
f128: Int
|
||||
f129: Int
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
let i = 0;
|
||||
while i < 3 {
|
||||
let w = switch i {
|
||||
case 0: Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 };
|
||||
default: Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 };
|
||||
};
|
||||
print_int(w.f0);
|
||||
i = i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
one
|
||||
two
|
||||
many
|
||||
27
tests/corpus/run/lang-switch-default-not-last/fixture.wo
Normal file
27
tests/corpus/run/lang-switch-default-not-last/fixture.wo
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- haxe-parity Task 3, review fix (Critical 1): `default` textually
|
||||
-- BEFORE some `case` arms used to silently kill every arm after it --
|
||||
-- `default` has no comparison of its own, so lowering the arms in raw
|
||||
-- source order meant nothing ever jumped into a `case` written after
|
||||
-- it, and `default`'s own body jumped straight past it to the
|
||||
-- switch's exit. Reviewer-reproduced repro shape, verbatim: with the
|
||||
-- bug, `classify(2)` returned "many" (default's own value) instead of
|
||||
-- "two". Fixed by lowering `default` last regardless of source
|
||||
-- position (ast.ml's `switch_lowering_order`); this fixture pins the
|
||||
-- corrected *runtime* behavior (a `--dump-owner`/typecheck assertion
|
||||
-- can't see this — only actually running the compare-and-jump chain
|
||||
-- proves the arm was reachable again).
|
||||
fn classify(n: Int) -> Text {
|
||||
let v = switch n {
|
||||
default: "many";
|
||||
case 1: "one";
|
||||
case 2: "two";
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
print(classify(1));
|
||||
print(classify(2));
|
||||
print(classify(3));
|
||||
return 0;
|
||||
}
|
||||
3
tests/corpus/run/lang-switch-stmt/fixture.out
Normal file
3
tests/corpus/run/lang-switch-stmt/fixture.out
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
one
|
||||
two
|
||||
many
|
||||
27
tests/corpus/run/lang-switch-stmt/fixture.wo
Normal file
27
tests/corpus/run/lang-switch-stmt/fixture.wo
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- haxe-parity Task 3: `switch` in statement position -- "one construct,
|
||||
-- not two" (the brief's own words): this is the exact same `Ast.Switch`
|
||||
-- node as the value-yielding fixture, just reached as a bare
|
||||
-- `ExprStmt`, its value discarded. Each arm ends in a plain
|
||||
-- `print(...)` (an `ExprStmt`, not `return`) so this exercises the
|
||||
-- real discard-mode write-then-ignore path -- the register-aliasing
|
||||
-- bug this task's own ASan fixture (lang-switch-arm-drop) found lived
|
||||
-- exactly here (a statement-position switch's own placeholder `dst`
|
||||
-- colliding with an arm's own register), so a plain, ownership-free
|
||||
-- version of the same shape is worth pinning on its own.
|
||||
fn describe(n: Int) {
|
||||
switch n {
|
||||
case 1:
|
||||
print("one");
|
||||
case 2:
|
||||
print("two");
|
||||
default:
|
||||
print("many");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
describe(1);
|
||||
describe(2);
|
||||
describe(3);
|
||||
return 0;
|
||||
}
|
||||
9
tests/corpus/run/lang-switch-value/fixture.out
Normal file
9
tests/corpus/run/lang-switch-value/fixture.out
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
OK
|
||||
Gone
|
||||
Gone
|
||||
Server Error
|
||||
Unknown
|
||||
hi alice
|
||||
hi bob
|
||||
hi bob
|
||||
hi stranger
|
||||
38
tests/corpus/run/lang-switch-value/fixture.wo
Normal file
38
tests/corpus/run/lang-switch-value/fixture.wo
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- haxe-parity Task 3: `switch` as an expression -- value-yielding over
|
||||
-- both subjects this task covers (Int, Text), proving arm selection
|
||||
-- (each case fires only for its own value, multi-value `case a, b:`
|
||||
-- fires for either) and the required `default` fires for anything
|
||||
-- else. `status_of`/`greet` mirror the sample's own shape
|
||||
-- (docs/examples/log-watcher/mcp.wo's `status_text`/`dispatch`) closely
|
||||
-- enough to be a real proof, not a toy: a `let`-bound switch result,
|
||||
-- multiple values per case, `default` last.
|
||||
fn status_of(code: Int) -> Text {
|
||||
let text = switch code {
|
||||
case 200: "OK";
|
||||
case 404, 410: "Gone";
|
||||
case 500: "Server Error";
|
||||
default: "Unknown";
|
||||
};
|
||||
return text;
|
||||
}
|
||||
|
||||
fn greet(name: Text) -> Text {
|
||||
return switch name {
|
||||
case "alice": "hi alice";
|
||||
case "bob", "bobby": "hi bob";
|
||||
default: "hi stranger";
|
||||
};
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
print(status_of(200));
|
||||
print(status_of(404));
|
||||
print(status_of(410));
|
||||
print(status_of(500));
|
||||
print(status_of(1));
|
||||
print(greet("alice"));
|
||||
print(greet("bob"));
|
||||
print(greet("bobby"));
|
||||
print(greet("carol"));
|
||||
return 0;
|
||||
}
|
||||
5
tests/corpus/run/lang-typedef-record/fixture.out
Normal file
5
tests/corpus/run/lang-typedef-record/fixture.out
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
7
|
||||
seven
|
||||
1
|
||||
one
|
||||
noted
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue