diff --git a/compiler/bin/main.ml b/compiler/bin/main.ml
index 4ca9688..5ed34ed 100644
--- a/compiler/bin/main.ml
+++ b/compiler/bin/main.ml
@@ -330,7 +330,9 @@ let typecheck_all (collector : Woc_lib.Diag.Collector.t) ~(root : string)
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
+ (* haxe-parity Task 5: the predeclared `Error` record joins the merged
+ table only — see Types.with_builtin_records for why not per-file. *)
+ let syms = Woc_lib.Types.with_builtin_records (merge_symbols (List.map snd per_file_syms)) in
(* `~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
diff --git a/compiler/src/ast.ml b/compiler/src/ast.ml
index a164cd0..da4b8ff 100644
--- a/compiler/src/ast.ml
+++ b/compiler/src/ast.ml
@@ -247,6 +247,20 @@ and expr_kind =
literal without lookahead nothing else needs. *)
| ListLit of expr list
| MapLit
+ (* haxe-parity Task 5: `try body catch (ename) handler` — an expression,
+ like `switch`. `body` is an expression (the workload's only form);
+ `handler` is a `stmt list` so both arm spellings share one shape,
+ exactly as a switch arm does: `catch (e) nil` parses as a single
+ ExprStmt, `catch (e) { ... }` as its statements, and the arm's value
+ is its trailing ExprStmt (an arm with no trailing expression yields
+ nothing, which is legal in statement position). The error record the
+ handler binds is the structured trap error {code, line, method, msg}
+ — the `Error` record type, predeclared by types.ml. *)
+ | Try of {
+ body : expr;
+ ename : string;
+ handler : stmt list;
+ }
(* ---- statements (Task 5) ---------------------------------------------
diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml
index 8ddaaa7..fd81e8a 100644
--- a/compiler/src/dump.ml
+++ b/compiler/src/dump.ml
@@ -63,6 +63,8 @@ let kind_label (k : Token.kind) : string =
| Token.KwCase -> "KW_CASE"
| Token.KwDefault -> "KW_DEFAULT"
| Token.KwTypedef -> "KW_TYPEDEF"
+ | Token.KwTry -> "KW_TRY"
+ | Token.KwCatch -> "KW_CATCH"
| Token.LBrace -> "LBRACE"
| Token.RBrace -> "RBRACE"
| Token.LParen -> "LPAREN"
@@ -222,6 +224,10 @@ let rec expr_str (e : Ast.expr) : string =
| Ast.Interp inner -> Printf.sprintf "INTERP(%s)" (expr_str inner)
| Ast.ListLit items -> Printf.sprintf "[%s]" (String.concat ", " (List.map expr_str items))
| Ast.MapLit -> "{}"
+ (* Like SWITCH above: a one-line summary, not a full unparse of the
+ catch arm's statements. *)
+ | Ast.Try { body; ename; handler } ->
+ Printf.sprintf "TRY %s CATCH (%s) { %d stmt }" (expr_str body) ename (List.length handler)
(* 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
diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml
index 790aeaf..ea9646d 100644
--- a/compiler/src/emit.ml
+++ b/compiler/src/emit.ml
@@ -192,6 +192,10 @@ let op_rc_dec = 28
let op_builtin = 29
let op_db_stub = 30
+(* haxe-parity Task 5: try/catch (runtime/src/wob.h's WOP_TRY/WOP_ENDTRY) *)
+let op_try = 32
+let op_endtry = 33
+
let b_now = 0
let b_print = 1
let b_print_int = 2
@@ -219,6 +223,13 @@ let b_int_to_text = 13
types.ml's builtin_signatures — 08-builtin-surface.md is unchanged). *)
let b_variant_tag = 14
+(* haxe-parity Task 5: fills the catch arm's freshly allocated `Error`
+ record from the trap the VM landed with (field order 0 code, 1 line,
+ 2 method, 3 msg — Types.error_record_fields). runtime/src/wob.h
+ WO_B_ERR_FILL = 15. Compiler-internal, like b_variant_tag: never a
+ source-callable name. *)
+let b_err_fill = 15
+
let ins_abc op a b c = op lor (a lsl 8) lor (b lsl 16) lor (c lsl 24)
let ins_abx op a bx = op lor (a lsl 8) lor (bx lsl 16)
let ins_asbx op a sbx = ins_abx op a (sbx + 32768)
@@ -867,6 +878,9 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option
| ListLit (first :: _) -> (
match ty_of_expr p f first with Some (Scalar n) -> Some (Multi n) | _ -> None)
| ListLit [] | MapLit -> None
+ (* haxe-parity Task 5: a `try` yields its try arm's type — types.ml has
+ already required the catch arm to agree. *)
+ | Try t -> ty_of_expr p f t.body
| Ident n -> (
match List.assoc_opt n f.f_env with
| Some (_, t) -> Some t
@@ -1270,6 +1284,7 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
| Some imm ->
sync_mask p f v e.id;
put f (ins_abc op_builtin dst imm b_map_new))
+ | Try t -> emit_try p f v ~dst ?expected e t.body t.ename t.handler
| Ident n -> (
match lookup_local f n with
| Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0)
@@ -1802,6 +1817,100 @@ and emit_switch ?(want_value = true) (p : pctx) (f : fstate) (v : views) (e : As
List.iter (fun (o, g, _) -> mask_meet f o g) rest;
f.f_div <- div0
+(* haxe-parity Task 5: `try body catch (e) handler`.
+
+ TRY ereg, ->handler register the region; ereg is where the error
+
dst> record lands if it fires
+ ENDTRY the region completed: pop it
+ JMP ->exit
+ handler:
+ NEW ereg, Error the record is the compiler's allocation, so
+ BUILTIN ereg, err_fill its drop is the ordinary scope-end one
+ dst>
+
+ exit:
+
+ The two arms are joined exactly like a switch's: each arm's ending
+ owned/gc masks meet, and each drops what the other moved
+ (emit_join_drops with the labels owner.ml's analyze_try recorded). The
+ VM releases whatever the body itself owned before landing — the live
+ mask at the try site is what it reads — so the handler starts from the
+ entry state, which is what the owner pass assumed. *)
+and emit_try (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr)
+ (body : Ast.expr) (ename : string) (handler : Ast.stmt list) : unit =
+ (* dst is reserved for the whole construct — same reason emit_switch
+ does it: a handler-local `let` must never be handed dst's register *)
+ let saved_nlocals = f.f_nlocals in
+ if f.f_nlocals <= dst then f.f_nlocals <- dst + 1;
+ if f.f_temp <= dst then f.f_temp <- dst + 1;
+ bump f dst;
+ let ereg = alloc_local p f e.pos in
+ f.f_cur_line <- e.pos.line;
+ let try_pc = here f in
+ put f (ins_asbx op_try ereg 0);
+ let entry_owned = f.f_owned and entry_gc = f.f_gc in
+ let div0 = f.f_div in
+ (match expected with
+ | Some t -> emit_expr p f v ~dst ~expected:t body
+ | None -> emit_expr p f v ~dst body);
+ f.f_cur_line <- e.pos.line;
+ put f (ins_abc op_endtry 0 0 0);
+ emit_join_drops p f v ~node:e.id ~label:"TRYBODY";
+ let body_owned = f.f_owned and body_gc = f.f_gc and body_div = f.f_div in
+ let skip_pc = here f in
+ put f (ins_asbx op_jmp 0 0);
+ patch_jump p f ~file:f.f_file ~pos:e.pos try_pc (here f);
+ f.f_owned <- entry_owned;
+ f.f_gc <- entry_gc;
+ f.f_div <- div0;
+ let saved_env = f.f_env and saved_decls = f.f_declared in
+ let saved_locals = f.f_nlocals in
+ (match class_of_name p Types.error_record_name with
+ | Some ecid ->
+ f.f_cur_line <- e.pos.line;
+ put f (ins_abx op_new ereg (check_bx p f e.pos "class" ecid));
+ put f (ins_abc op_builtin ereg ereg b_err_fill)
+ | None ->
+ err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
+ ~message:"no class-table entry for the `Error` record — a `try` cannot bind its error";
+ put f (ins_abx op_loadk ereg (const_int p 0)));
+ f.f_env <- (ename, (ereg, Ast.Scalar Types.error_record_name)) :: f.f_env;
+ Hashtbl.replace f.f_decl e.id ereg;
+ f.f_declared <- e.id :: f.f_declared;
+ mask_set f (match Hashtbl.find_opt v.v_holder e.id with Some k -> k | None -> Owner.LOwned) ereg;
+ (match List.rev handler with
+ | [] -> ()
+ | last :: rev_init -> (
+ List.iter (emit_stmt p f v) (List.rev rev_init);
+ match last.Ast.s_kind with
+ | Ast.ExprStmt ve ->
+ stmt_reset f;
+ f.f_cur_line <- last.Ast.s_pos.line;
+ (match expected with
+ | Some t -> emit_expr p f v ~dst ~expected:t ve
+ | None -> emit_expr p f v ~dst ve)
+ | _ -> emit_stmt p f v last));
+ emit_scope_drops p f v ~node:e.id ~label:"CATCH";
+ emit_rc p f v ~node:e.id ~acquire:false ~groups:(declared_since f saved_decls) ();
+ f.f_nlocals <- saved_locals;
+ f.f_env <- saved_env;
+ f.f_declared <- saved_decls;
+ f.f_temp <- saved_locals;
+ emit_join_drops p f v ~node:e.id ~label:"CATCHJOIN";
+ patch_jump p f ~file:f.f_file ~pos:e.pos skip_pc (here f);
+ f.f_nlocals <- saved_nlocals;
+ (* the state after the try is what both arms agree on *)
+ if body_div then ()
+ else if f.f_div then begin
+ f.f_owned <- body_owned;
+ f.f_gc <- body_gc;
+ f.f_div <- div0
+ end
+ else begin
+ mask_meet f body_owned body_gc;
+ f.f_div <- div0
+ end
+
and emit_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (cn : string)
(fields : (string * Ast.expr) list) : unit =
match class_of_name p cn with
@@ -2921,6 +3030,16 @@ let emit_method (p : pctx) (v : views) ~(file : string) ~(self_class : (int * st
Program assembly
============================================================ *)
+(* haxe-parity Task 5: does this program catch anywhere? Only then does the
+ `Error` record earn a class-table entry — so no image that never writes
+ `try` gains a class it does not use. *)
+let program_uses_try (prog : Ast.program) : bool =
+ let found = ref false in
+ Types.walk_program
+ (fun _ (e : Ast.expr) -> match e.Ast.kind with Ast.Try _ -> found := true | _ -> ())
+ prog;
+ !found
+
(* Structural satisfaction, Go-style (spec section 2): a class satisfies
an interface when it has a method of the same name and parameter count
for every method the interface declares. There is no `implements`
@@ -3056,6 +3175,25 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string)
| Ast.Const _ -> ())
u.prog.decls)
units;
+ (* haxe-parity Task 5: the record `catch (e)` binds needs a real
+ class-table entry (it is an ordinary heap object with two owned Texts,
+ so the drop plan is the ordinary one). Added only for a program that
+ actually catches — every existing image keeps its exact class table —
+ and only when nothing already claims the name. Its field order is
+ Types.error_record_fields, which is the same order the VM's
+ WO_B_ERR_FILL builtin writes. *)
+ if
+ (not (SM.mem Types.error_record_name !class_id))
+ && List.exists (fun u -> program_uses_try u.prog) units
+ then begin
+ let cid = !nclasses in
+ class_id := SM.add Types.error_record_name cid !class_id;
+ incr nclasses;
+ classes :=
+ { cr_name = Types.error_record_name; cr_gc = false;
+ cr_fields = Array.of_list Types.error_record_fields; cr_methods = [] }
+ :: !classes
+ end;
let class_id = !class_id in
let p_classes = Array.of_list (List.rev !classes) in
let p_ifaces = Array.of_list (List.rev !ifaces) in
diff --git a/compiler/src/lexer.ml b/compiler/src/lexer.ml
index 5b88edb..336914f 100644
--- a/compiler/src/lexer.ml
+++ b/compiler/src/lexer.ml
@@ -136,6 +136,8 @@ let keyword_kind = function
| "case" -> Some Token.KwCase
| "default" -> Some Token.KwDefault
| "typedef" -> Some Token.KwTypedef
+ | "try" -> Some Token.KwTry
+ | "catch" -> Some Token.KwCatch
| "INSERT" -> Some Token.KwInsert
| "SELECT" -> Some Token.KwSelect
| _ -> None
diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml
index 5b21ad8..c6e93b3 100644
--- a/compiler/src/owner.ml
+++ b/compiler/src/owner.ml
@@ -511,6 +511,9 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
| ListLit (first :: _) -> (
match expr_ty ctx first with Some (Scalar n) -> Some (Multi n) | _ -> None)
| ListLit [] | MapLit -> None
+ (* haxe-parity Task 5: a `try` yields its try arm's type (types.ml has
+ already required the catch arm to agree). *)
+ | Try t -> expr_ty ctx t.body
| Ident n -> (
match find_local ctx n with
| Some l -> Some l.l_ty
@@ -1063,6 +1066,7 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
literal-specific one. *)
| ListLit items -> List.iter (read_expr ctx) items
| MapLit -> ()
+ | Try t -> analyze_try ctx e t.body t.ename t.handler
| 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))
@@ -1556,6 +1560,51 @@ and fixpoint (ctx : ctx) (run : unit -> unit) : unit =
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. *)
+(* haxe-parity Task 5: `try body catch (e) handler`. The two arms are
+ alternate flows joining at one point — the same shape `if`/`switch`
+ already have, so the same join machinery applies: whatever one arm
+ moved out, the arm that still holds it drops at its own end
+ (branch_join_drops), and the state after the whole expression is the
+ join of both.
+
+ What is genuinely different from a branch is WHERE the catch arm starts
+ from: a trap can be raised anywhere inside the body, so the handler may
+ run after *any prefix* of it. Taking the entry state as the handler's
+ starting point is the conservative reading — it never claims the body's
+ moves happened — and the join then makes the surviving path responsible
+ for the drop. The frame also needs a live mask at the try itself (like
+ every other trap-capable site): that mask is what the VM's unwind uses
+ to release the body's own values before landing in the handler.
+
+ `e` is a local of the predeclared `Error` record, owned by the handler
+ scope (the record and its two Texts are freshly allocated at landing),
+ so pop_scope records its drop like any other owned local's. *)
+and analyze_try (ctx : ctx) (e : Ast.expr) (body : Ast.expr) (ename : string)
+ (handler : Ast.stmt list) : unit =
+ record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx));
+ let entry = snapshot ctx in
+ let div0 = ctx.diverged in
+ read_expr ctx body;
+ let body_sn = snapshot ctx in
+ let body_div = ctx.diverged in
+ restore entry;
+ ctx.diverged <- div0;
+ let ety = Ast.Scalar Types.error_record_name in
+ let ebind =
+ { l_name = ename; l_ty = ety; l_class = oclass_of ctx ety; l_node = e.id; l_pos = e.pos;
+ l_holds = (match oclass_of ctx ety with Copy -> false | _ -> true); l_src = None;
+ l_bkind = AShared; l_state = Live }
+ in
+ analyze_block ctx ~pre:[ ebind ] ~node:e.id ~pos:e.pos ~label:"CATCH" handler;
+ let catch_sn = snapshot ctx in
+ let catch_div = ctx.diverged in
+ if (not body_div) && not catch_div then begin
+ branch_join_drops ctx ~node:e.id ~label:"TRYBODY" ~pos:e.pos ~moving:catch_sn ~other:body_sn;
+ branch_join_drops ctx ~node:e.id ~label:"CATCHJOIN" ~pos:e.pos ~moving:body_sn ~other:catch_sn
+ end;
+ restore (if body_div then catch_sn else if catch_div then body_sn else join body_sn catch_sn);
+ ctx.diverged <- body_div && catch_div
+
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;
diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml
index 057cbc4..a4ab3ed 100644
--- a/compiler/src/parser.ml
+++ b/compiler/src/parser.ml
@@ -719,7 +719,35 @@ let with_no_brace (st : state) (value : bool) (f : unit -> 'a) : 'a =
(* ---- expression parsing -------------------------------------------------- *)
-let rec parse_expr (st : state) : Ast.expr = parse_or st
+let rec parse_expr (st : state) : Ast.expr =
+ match peek st with Token.KwTry -> parse_try st | _ -> parse_or st
+
+(* haxe-parity Task 5: `try body catch (e) arm`. `try` binds looser than
+ every operator, so the body is a full operator expression and `catch`
+ is what ends it (`try a / b catch (e) 0` catches the division, not just
+ `a`). A newline before `catch` is insignificant — the workload wraps
+ long try bodies (mcp.wo's `try self.dispatch(...)` / `catch (e)
+ err(...)`). The arm is either a braced block or one expression; both
+ become a `stmt list`, so `{}` right after the catch variable is always
+ the empty block, never the empty-map literal. *)
+and parse_try (st : state) : Ast.expr =
+ let pos = peek_pos st in
+ let id = fresh_id st in
+ ignore (advance st);
+ (* 'try' *)
+ let body = parse_or st in
+ skip_newlines st;
+ expect st Token.KwCatch "`catch` after a `try` expression";
+ expect st Token.LParen "'(' before the catch variable";
+ let ename = expect_ident st "catch variable name" in
+ expect st Token.RParen "')' after the catch variable";
+ let handler =
+ if peek st = Token.LBrace then with_no_brace st false (fun () -> parse_block st)
+ else
+ let e = parse_expr st in
+ [ { Ast.s_id = fresh_id st; s_pos = e.pos; s_kind = Ast.ExprStmt e } ]
+ in
+ { Ast.id; pos; kind = Ast.Try { body; ename; handler } }
and parse_or (st : state) : Ast.expr =
let lhs = ref (parse_and st) in
@@ -1730,6 +1758,13 @@ let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : As
| Ast.Interp inner -> { e with Ast.kind = Ast.Interp (subst_expr consts bound inner) }
| Ast.ListLit items -> { e with Ast.kind = Ast.ListLit (List.map (subst_expr consts bound) items) }
| Ast.MapLit -> e
+ | Ast.Try { body; ename; handler } ->
+ { e with
+ Ast.kind =
+ Ast.Try
+ { body = subst_expr consts bound body; ename;
+ handler = subst_block consts (StringSet.add ename bound) handler }
+ }
| Ast.Switch (subject, arms) ->
{ e with
Ast.kind =
diff --git a/compiler/src/token.ml b/compiler/src/token.ml
index 7b3eda3..712d019 100644
--- a/compiler/src/token.ml
+++ b/compiler/src/token.ml
@@ -83,6 +83,12 @@ type kind =
| KwSwitch
| KwCase
| KwDefault
+ (* haxe-parity Task 5: `try expr catch (e) arm` — real keywords, and
+ neither appears as an identifier anywhere in the corpus or the
+ driving workload (grepped, the same discipline every keyword above
+ followed). *)
+ | KwTry
+ | KwCatch
(* 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
diff --git a/compiler/src/types.ml b/compiler/src/types.ml
index 029aee5..a5776d5 100644
--- a/compiler/src/types.ml
+++ b/compiler/src/types.ml
@@ -173,6 +173,33 @@ let stdlib_modules = [ "fs"; "proc"; "net"; "time"; "json"; "env" ]
let is_stdlib_module (name : string) : bool = List.mem name stdlib_modules
+(* haxe-parity Task 5: the record `catch (e)` binds — the VM's structured
+ trap error, one shape forever (spec §6). Predeclared rather than
+ written: no source declares it, every program that catches gets it, and
+ the field ORDER here is the contract with the VM's WO_B_ERR_FILL
+ builtin (runtime/src/wob.h), which writes fields 0..3 by index. *)
+let error_record_name = "Error"
+
+let error_record_fields : (string * field_ty) list =
+ [ ("code", Scalar "Int"); ("line", Scalar "Int"); ("method", Scalar "Text");
+ ("msg", Scalar "Text") ]
+
+(* Adds the predeclared records to a symbol table. Applied to the MERGED
+ table only (bin/main.ml), never to a per-file one: one entry per file
+ would read as a cross-file duplicate declaration (WO-E214). A source
+ that declares its own `Error` keeps it — its own fields are then the
+ ones `catch (e)` binds, which is either what it wanted or a type error
+ it will hear about at the use site. *)
+let with_builtin_records (syms : symbols) : symbols =
+ if StringMap.mem error_record_name syms.classes then syms
+ else
+ let info =
+ { name = error_record_name; is_class = false; is_record = true; is_gc = false; table = None;
+ fields = List.map (fun (n, t) -> (n, t, None, [])) error_record_fields; methods = [];
+ id = -1; pos = { line = 0; col = 0 }; pub = true }
+ in
+ { syms with classes = StringMap.add error_record_name info syms.classes }
+
let rec has_recursive_structure (cls : class_info) : bool =
List.exists (fun (_, ty, _, _) ->
match ty with
@@ -844,6 +871,10 @@ let typecheck_program ~file ~(module_of : string -> string)
| ListLit (first :: _) -> (
match confident_typ cenv first with Some t -> Some (TMulti t) | None -> None)
| ListLit [] | MapLit -> None
+ (* haxe-parity Task 5: a `try` expression's type is its try arm's — the
+ handler is checked to agree (typecheck_expr below), so either arm
+ would answer, and the try arm is the one that always has a value. *)
+ | Try { body; _ } -> confident_typ cenv body
| Ident name -> StringMap.find_opt name cenv
| Field (base, field_name) -> (
match confident_typ cenv base with
@@ -1169,6 +1200,40 @@ let typecheck_program ~file ~(module_of : string -> string)
| t :: _ -> { typ = TMulti t; is_nil = false }
| [] -> { typ = TScalar "Int"; is_nil = false })
| MapLit -> { typ = TScalar "Int"; is_nil = false }
+ | Try { body; ename; handler } ->
+ let body_res = typecheck_expr env cenv body in
+ (* The catch arm sees exactly one new name: the error record. *)
+ let herr = TScalar error_record_name in
+ let benv = StringMap.add ename herr env in
+ let bcenv = StringMap.add ename herr cenv in
+ let handler_res =
+ match List.rev handler with
+ | [] -> None
+ | last :: rev_init -> (
+ let env', cenv' = List.fold_left typecheck_stmt (benv, bcenv) (List.rev rev_init) in
+ match last.s_kind with
+ | ExprStmt e -> Some (confident_typ cenv' e, e.pos)
+ | _ ->
+ let _ = typecheck_stmt (env', cenv') last in
+ None)
+ in
+ (* Both arms must yield one type where the value is used. Reported
+ only when BOTH types are confident — the same "stay silent when
+ underivable" contract every other confident-type consumer here
+ follows, which also keeps a `catch (e) nil` arm quiet until
+ optionals land. *)
+ (match (handler_res, confident_typ cenv body) with
+ | Some (Some ht, hpos), Some bt when not (typ_equal syms ht bt) ->
+ Diag.Collector.add collector
+ (Diag.error ~code:type_mismatch_code ~file ~line:hpos.line ~col:hpos.col
+ ~message:
+ (Printf.sprintf
+ "the `catch` arm yields `%s`, but the `try` arm yields `%s` — both arms of \
+ a `try` expression must have one type"
+ (typ_label ht) (typ_label bt))
+ ())
+ | _ -> ());
+ { typ = body_res.typ; is_nil = false }
(* haxe-parity Task 3: the one deriver behind both `Switch` call sites
-- `typecheck_expr`'s own case above (every "the value is used"
@@ -1798,6 +1863,9 @@ and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : e
| Interp inner -> walk_expr bound visit inner
| ListLit items -> List.iter (walk_expr bound visit) items
| MapLit -> ()
+ | Try { body; ename; handler } ->
+ walk_expr bound visit body;
+ walk_block (StringSet.add ename bound) visit handler
| DbStub _ -> ()
| Switch (subject, arms) ->
walk_expr bound visit subject;
diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c
index 06a4cff..fa9e8af 100644
--- a/runtime/src/builtin.c
+++ b/runtime/src/builtin.c
@@ -3,6 +3,7 @@
#include "builtin.h"
#include
+#include
#include
#include "cont.h"
@@ -178,6 +179,43 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = o->class_id;
return 0;
}
+ case WO_B_ERR_FILL: { /* haxe-parity compiler Task 5: try/catch */
+ /* Fills the catch arm's record from the error the VM landed with.
+ * Field order is this builtin's contract with the compiler
+ * (docs/plan/oop-vm/00-wob-format.md): 0 code, 1 line, 2 method,
+ * 3 msg. The record is the compiler's own allocation, so its drop
+ * is the ordinary one and the two fresh Texts belong to it. */
+ if (!R[B]) {
+ *msg = "null error record";
+ return WO_T_BOUNDS;
+ }
+ wo_hdr *o = (wo_hdr *)(uintptr_t)R[B];
+ if (o->class_id >= vm->mod->class_cnt ||
+ vm->mod->classes[o->class_id].field_cnt < 4) {
+ *msg = "error record is not a 4-field class";
+ return WO_T_BOUNDS;
+ }
+ wo_str *meth = wo_str_new(rt, vm->caught.method,
+ (uint32_t)strlen(vm->caught.method));
+ if (!meth) {
+ *msg = "out of memory";
+ return WO_T_OOM;
+ }
+ wo_str *text = wo_str_new(rt, vm->caught.msg,
+ (uint32_t)strlen(vm->caught.msg));
+ if (!text) {
+ wo_str_free(rt, meth);
+ *msg = "out of memory";
+ return WO_T_OOM;
+ }
+ uint64_t *fs = wo_fields(o);
+ fs[0] = vm->caught.code;
+ fs[1] = vm->caught.line;
+ fs[2] = (uint64_t)(uintptr_t)meth;
+ fs[3] = (uint64_t)(uintptr_t)text;
+ R[A] = R[B];
+ return 0;
+ }
default: /* unreachable: loader validated the id */
*msg = "unknown builtin";
return WO_T_EXPLICIT;
diff --git a/runtime/src/loader.c b/runtime/src/loader.c
index 2559ab0..2b3033c 100644
--- a/runtime/src/loader.c
+++ b/runtime/src/loader.c
@@ -41,7 +41,7 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
[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_INT_TO_TEXT] = 1,
- [WO_B_VARIANT_TAG] = 1,
+ [WO_B_VARIANT_TAG] = 1, [WO_B_ERR_FILL] = 1,
};
static int vtab_cmp(const void *a, const void *b) {
@@ -389,6 +389,20 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err,
case WOP_DB_STUB:
case WOP_TRAP:
break;
+ /* haxe-parity compiler Task 5: the handler target is validated
+ exactly like a jump (it IS a jump the VM takes on a trap),
+ and A is the register the catch arm's error record lands
+ in, so it has to be inside the frame. */
+ case WOP_TRY: {
+ RCHK(A);
+ int64_t tgt = (int64_t)pc + 1 + wo_ins_sbx(ins);
+ if (tgt < 0 || tgt >= (int64_t)mm->ninstr)
+ BAIL("method %u pc %u: catch handler out of code", (unsigned)i,
+ (unsigned)pc);
+ break;
+ }
+ case WOP_ENDTRY:
+ break;
default:
BAIL("method %u pc %u: unknown opcode %u", (unsigned)i,
(unsigned)pc, (unsigned)op);
diff --git a/runtime/src/vm.c b/runtime/src/vm.c
index caeb5db..aa4405a 100644
--- a/runtime/src/vm.c
+++ b/runtime/src/vm.c
@@ -28,30 +28,60 @@ void wo_vm_destroy(wo_vm *vm) { wo_rt_destroy(&vm->rt); }
* register does not block its drop — the borrower IS the dying frame.
* Window overlap is safe: a slot dropped by the callee frame is nulled, so
* an outer mask covering the same physical slot sees 0 and skips. */
-static void vm_unwind(wo_vm *vm) {
- const wo_module *mod = vm->mod;
- for (uint32_t d = vm->depth; d > 0; d--) {
- const wo_frame *f = &vm->frames[d - 1];
- const wo_methodrec *me = &mod->methods[f->method];
- uint32_t fpc = (d == vm->depth) ? f->pc : f->pc - 1;
- const wo_dropent *ent = NULL; /* last entry with pc <= fpc */
- for (uint32_t i = 0; i < me->drop_cnt && me->drops[i].pc <= fpc; i++)
- ent = &me->drops[i];
- if (!ent) continue; /* no entry: nothing live in this frame */
- uint64_t *R = vm->regs + f->base;
- for (uint32_t r = 0; r < me->reg_cnt; r++) {
- uint64_t bit = 1ull << r;
- if ((ent->owned & bit) && R[r]) {
- wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
- R[r] = 0;
- }
- if ((ent->gc & bit) && R[r]) {
- wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
- R[r] = 0;
- }
+/* The drop-table entry governing instruction [pc]: the last one recorded
+ * at or before it. NULL = nothing live there. */
+static const wo_dropent *vm_dropent(const wo_methodrec *me, uint32_t pc) {
+ const wo_dropent *ent = NULL;
+ for (uint32_t i = 0; i < me->drop_cnt && me->drops[i].pc <= pc; i++) ent = &me->drops[i];
+ return ent;
+}
+
+/* Release what frame [d-1] owns at [pc] but no longer owns at [keep_pc] —
+ * the values the abandoned region of that frame created. [keep_pc] =
+ * UINT32_MAX means "keep nothing", which is the dying-frame case every
+ * uncaught trap uses. A borrow held by a dying register does not block
+ * its drop — the borrower IS the dying region. */
+static void vm_release_frame(wo_vm *vm, uint32_t d, uint32_t pc, uint32_t keep_pc) {
+ const wo_frame *f = &vm->frames[d - 1];
+ const wo_methodrec *me = &vm->mod->methods[f->method];
+ const wo_dropent *ent = vm_dropent(me, pc);
+ if (!ent) return; /* no entry: nothing live in this frame */
+ uint64_t keep_owned = 0, keep_gc = 0;
+ if (keep_pc != UINT32_MAX) {
+ const wo_dropent *k = vm_dropent(me, keep_pc);
+ if (k) {
+ keep_owned = k->owned;
+ keep_gc = k->gc;
}
}
- vm->depth = 0;
+ uint64_t *R = vm->regs + f->base;
+ for (uint32_t r = 0; r < me->reg_cnt; r++) {
+ uint64_t bit = 1ull << r;
+ if ((ent->owned & bit) && !(keep_owned & bit) && R[r]) {
+ wo_drop_obj(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
+ R[r] = 0;
+ }
+ if ((ent->gc & bit) && !(keep_gc & bit) && R[r]) {
+ wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
+ R[r] = 0;
+ }
+ }
+}
+
+/* Trap unwinding — the spec's "traps never leak" promise (spec §6). Walk
+ * frames innermost to outermost down to (not including) [stop_depth];
+ * in each, the governing instruction is the trap pc for the innermost
+ * frame and the instruction before the saved resume pc — i.e. the CALL —
+ * for every outer frame. Window overlap is safe: a slot dropped by the
+ * callee frame is nulled, so an outer mask covering the same physical
+ * slot sees 0 and skips. stop_depth is 0 for an uncaught trap (the whole
+ * stack dies) and the catching frame's depth for a caught one. */
+static void vm_unwind(wo_vm *vm, uint32_t stop_depth) {
+ for (uint32_t d = vm->depth; d > stop_depth; d--) {
+ const wo_frame *f = &vm->frames[d - 1];
+ vm_release_frame(vm, d, (d == vm->depth) ? f->pc : f->pc - 1, UINT32_MAX);
+ }
+ vm->depth = stop_depth;
}
/* Residual runtime checks the loader cannot do statically (registers are
@@ -89,25 +119,58 @@ static wo_str *str_check(uint64_t v, const char **why) {
return s;
}
+/* Fills [out] with the trap's structured error (spec §6): the code, the
+ * source line of the trapping pc, the trapping method's name, and the
+ * message. One shape forever — the CLI prints it, and the catch arm of a
+ * `try` binds exactly the same four fields. */
+static void vm_fill_err(wo_vm *vm, wo_err *out, uint32_t tcode, const char *fmt, va_list ap) {
+ const wo_module *mod = vm->mod;
+ const wo_frame *f = &vm->frames[vm->depth - 1];
+ const wo_methodrec *me = &mod->methods[f->method];
+ out->code = tcode;
+ out->line = 0; /* last line-table entry with pc <= trapping pc */
+ for (uint32_t i = 0; i < me->line_cnt && me->lines[i].pc <= f->pc; i++)
+ out->line = me->lines[i].line;
+ const wo_str *nm = mod->consts[me->name].s;
+ int nlen = nm->len < 63 ? (int)nm->len : 63;
+ snprintf(out->method, sizeof(out->method), "%.*s", nlen, nm->data);
+ vsnprintf(out->msg, sizeof(out->msg), fmt, ap);
+}
+
+/* 0 = the trap was caught: the stack is unwound down to the catching
+ * frame, that frame's pc now points at the handler, and the caller must
+ * reload and keep interpreting. -1 = uncaught: *err is filled and the
+ * stack is fully unwound (depth 0), exactly as before Task 5. */
static int vm_trap(wo_vm *vm, wo_err *err, uint32_t tcode, const char *fmt,
...) {
- if (err) {
- const wo_module *mod = vm->mod;
- const wo_frame *f = &vm->frames[vm->depth - 1];
- const wo_methodrec *me = &mod->methods[f->method];
- err->code = tcode;
- err->line = 0; /* last line-table entry with pc <= trapping pc */
- for (uint32_t i = 0; i < me->line_cnt && me->lines[i].pc <= f->pc; i++)
- err->line = me->lines[i].line;
- const wo_str *nm = mod->consts[me->name].s;
- int nlen = nm->len < 63 ? (int)nm->len : 63;
- snprintf(err->method, sizeof(err->method), "%.*s", nlen, nm->data);
- va_list ap;
- va_start(ap, fmt);
- vsnprintf(err->msg, sizeof(err->msg), fmt, ap);
- va_end(ap);
+ /* The record the catch arm reads is always filled, even when the
+ * caller passed no err: it is the value `catch (e)` binds. */
+ va_list ap;
+ va_start(ap, fmt);
+ vm_fill_err(vm, &vm->caught, tcode, fmt, ap);
+ va_end(ap);
+ if (vm->ncatch) {
+ const wo_catch *c = &vm->catches[vm->ncatch - 1];
+ uint32_t cdepth = c->depth;
+ uint32_t hpc = c->pc;
+ /* Which instruction governs the catching frame's own live set has
+ * to be decided before unwinding moves the depth: the trapping
+ * instruction when the trap was raised in this very frame, the
+ * CALL (pc - 1, the saved pc is the resume point) when it came
+ * from deeper. */
+ int trapped_here = (cdepth == vm->depth);
+ vm->ncatch--;
+ /* frames above the catching one die whole */
+ vm_unwind(vm, cdepth);
+ /* in the catching frame only the try region's own values die: the
+ * handler's drop entry names what survives into the catch arm */
+ wo_frame *cf = &vm->frames[cdepth - 1];
+ vm_release_frame(vm, cdepth, trapped_here ? cf->pc : cf->pc - 1, hpc);
+ cf->pc = hpc;
+ return 0;
}
- vm_unwind(vm);
+ if (err) *err = vm->caught;
+ vm_unwind(vm, 0);
return -1;
}
@@ -127,11 +190,18 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
R = vm->regs + vm->frames[vm->depth - 1].base; \
} while (0)
-/* pc is post-incremented at dispatch: the trapping instruction is pc-1 */
+/* pc is post-incremented at dispatch: the trapping instruction is pc-1.
+ * A caught trap (vm_trap == 0) has already unwound to the handler's frame
+ * and pointed it at the handler, so the interpreter just reloads and
+ * keeps going — the same macro serves both surfaces. */
#define TRAPF(tcode, ...) \
do { \
vm->frames[vm->depth - 1].pc = pc - 1; \
- return vm_trap(vm, err, tcode, __VA_ARGS__); \
+ if (vm_trap(vm, err, tcode, __VA_ARGS__) == 0) { \
+ RELOAD(); \
+ NEXT(); \
+ } \
+ return -1; \
} while (0)
RELOAD();
@@ -157,6 +227,7 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
[WOP_RELEASE_X] = &&L_RELEASE_X, [WOP_RC_INC] = &&L_RC_INC,
[WOP_RC_DEC] = &&L_RC_DEC, [WOP_BUILTIN] = &&L_BUILTIN,
[WOP_DB_STUB] = &&L_DB_STUB, [WOP_TRAP] = &&L_TRAP,
+ [WOP_TRY] = &&L_TRY, [WOP_ENDTRY] = &&L_ENDTRY,
};
#define CASE(name) L_##name
#define NEXT() \
@@ -262,10 +333,18 @@ dispatch:
NEXT();
}
+/* A frame leaving takes its still-open try regions with it: a `return`
+ * out of a try region never runs its ENDTRY, and a handler pc in a frame
+ * that no longer exists would land the next trap on a dead window. */
+#define DROP_CATCHES() \
+ while (vm->ncatch && vm->catches[vm->ncatch - 1].depth > vm->depth) \
+ vm->ncatch--
+
CASE(RET) : {
uint64_t rv = R[wo_ins_a(ins)];
vm->regs[vm->frames[vm->depth - 1].base] = rv;
vm->depth--;
+ DROP_CATCHES();
if (vm->depth == 0) {
*ret = rv;
return 0;
@@ -276,6 +355,7 @@ dispatch:
CASE(RET0) : {
vm->regs[vm->frames[vm->depth - 1].base] = 0;
vm->depth--;
+ DROP_CATCHES();
if (vm->depth == 0) {
*ret = 0;
return 0;
@@ -434,6 +514,24 @@ dispatch:
CASE(TRAP) : { TRAPF(wo_ins_bx(ins), "explicit trap"); }
+ CASE(TRY) : {
+ if (vm->ncatch >= WO_MAX_CATCH)
+ TRAPF(WO_T_STACK, "catch stack overflow (%u regions)",
+ (unsigned)WO_MAX_CATCH);
+ vm->catches[vm->ncatch].depth = vm->depth;
+ vm->catches[vm->ncatch].pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins));
+ vm->catches[vm->ncatch].reg = wo_ins_a(ins);
+ vm->ncatch++;
+ NEXT();
+ }
+ CASE(ENDTRY) : {
+ /* the try region completed without trapping. Defensive on an
+ * unpaired ENDTRY (a miscompile the loader cannot see): pop
+ * nothing rather than corrupt the stack. */
+ if (vm->ncatch) vm->ncatch--;
+ NEXT();
+ }
+
#ifdef WO_ISO_C
default:
TRAPF(WO_T_EXPLICIT, "unknown opcode"); /* unreachable: loader */
@@ -444,6 +542,7 @@ dispatch:
#undef NEXT
#undef RELOAD
#undef TRAPF
+#undef DROP_CATCHES
}
int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args,
@@ -465,6 +564,7 @@ int wo_vm_call(wo_vm *vm, uint32_t method_idx, const uint64_t *args,
return -1;
}
vm->depth = 1;
+ vm->ncatch = 0; /* catch regions never survive a call boundary */
vm->frames[0].method = method_idx;
vm->frames[0].pc = 0;
vm->frames[0].base = 0;
diff --git a/runtime/src/vm.h b/runtime/src/vm.h
index 017a2b2..097a70e 100644
--- a/runtime/src/vm.h
+++ b/runtime/src/vm.h
@@ -21,12 +21,32 @@ typedef struct wo_frame {
uint32_t base; /* register-window base in the value stack */
} wo_frame;
+/* One live `try` region (haxe-parity compiler Task 5, WOP_TRY). `depth`
+ * is the frame depth that registered it, so a trap raised deeper unwinds
+ * every frame above that one and lands here; `pc` is the handler's
+ * instruction in that frame's method; `reg` is the window-relative
+ * register the error record is built into. */
+typedef struct wo_catch {
+ uint32_t depth;
+ uint32_t pc;
+ uint32_t reg;
+} wo_catch;
+
+#define WO_MAX_CATCH 64u
+
typedef struct wo_vm {
const wo_module *mod;
wo_rt rt;
uint64_t regs[WO_STACK_SLOTS];
wo_frame frames[WO_MAX_FRAMES];
uint32_t depth;
+ /* the catch stack, innermost last; ncatch = 0 means every trap is
+ * the uncaught kind and behaves exactly as it did before Task 5 */
+ wo_catch catches[WO_MAX_CATCH];
+ uint32_t ncatch;
+ /* the error a caught trap landed with, read by WO_B_ERR_FILL while
+ * the catch arm builds its record */
+ wo_err caught;
} wo_vm;
/* heap_cap = arena byte capacity (the CLI's WO_HEAP_MB feeds this) */
diff --git a/runtime/src/wob.h b/runtime/src/wob.h
index d99c918..db52caf 100644
--- a/runtime/src/wob.h
+++ b/runtime/src/wob.h
@@ -120,8 +120,20 @@ enum {
WOP_BUILTIN = 29, /* A B C: r[A] = builtin C, args from r[B] */
WOP_DB_STUB = 30, /* traps WO_T_DB "engine not linked" */
WOP_TRAP = 31, /* Bx: explicit trap */
+ /* haxe-parity compiler Task 5: try/catch over the trap system.
+ * TRY pushes a catch frame {this frame, this window, handler pc =
+ * pc + sBx, error register A}; a trap raised while it is the
+ * innermost one unwinds every frame above this one exactly as an
+ * uncaught trap does (drop maps run, registers null), releases what
+ * the try region itself owned in this frame, and resumes at the
+ * handler instead of leaving the VM. ENDTRY pops it — the try
+ * region completed without trapping. Uncaught behavior is
+ * unchanged: with no catch frame live, a trap is byte-for-byte
+ * today's surface. */
+ WOP_TRY = 32, /* A sBx: push catch frame, handler at pc + sBx */
+ WOP_ENDTRY = 33, /* pop the innermost catch frame */
};
-#define WOP_MAX 31u
+#define WOP_MAX 33u
/* ---- builtin ids (WOP_BUILTIN operand C) ---- */
enum {
@@ -152,8 +164,20 @@ enum {
* 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,
+ /* haxe-parity compiler Task 5: materialize the caught error. The
+ * catch arm's error record is an ordinary compiler-generated class
+ * whose field order this builtin is the contract for — (record
+ * object) -> the same object, with field 0 = code (i64), 1 = line
+ * (i64), 2 = method (fresh owned Text), 3 = msg (fresh owned Text),
+ * read from the error the VM landed here with. The compiler
+ * allocates and owns the record (so its drop is the ordinary one);
+ * the VM only fills it, which is why the pending error never has to
+ * outlive the landing. Traps WO_T_BOUNDS on a receiver that is not
+ * a 4-field class object, WO_T_OOM if either Text cannot be
+ * allocated. */
+ WO_B_ERR_FILL = 15,
};
-#define WO_B_MAX 14u
+#define WO_B_MAX 15u
/* ---- 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) {