From 065ac99224445da68936fe6474f9a54042a29e56 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Fri, 14 Aug 2026 17:08:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20json=20encode/decode=20+=20`as`,=20.wob?= =?UTF-8?q?=20v2=20class=20metadata=20=E2=80=94=20log-watcher=20compiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/examples/log-watcher (1285 lines, 7 files) now compiles clean: 0 diagnostics, a 35KB .wob written. corpus 71/0, woc runtest 565/0, every wovm unit gate green (both dispatch flavors). - .wob v2: each class row gains three u32 per-field arrays — the field's NAME constant, the CLASS it refers to (or the json-raw marker), and a container field's ELEMENT kinds. json is then a runtime service driven by metadata instead of per-type generated code. loader/emitter/disassembler/test assembler all read and write v2; field-name constants are interned with the rest of the pool (interning during serialization silently loses them) - runtime/src/json.c (new): encode by static kind + object headers + class table (nested records need no static knowledge); decode parses and BINDS straight into the target class — keys matched to field names, nested objects built as the field's class, arrays as a multi of the field's element kind, unknown keys skipped, absent keys nil. Malformed input is nil, never a trap - `as`: `json.decode(text) as T` is the one cast this language has (WO-E403 for any other `as`, and for a bare json.decode with no target type). Its result is `?T`, which is why the decode and the target are one instruction - json.Value: a reserved type name for a value the source does not inspect — the raw JSON slice, kind TEXT, re-emitted verbatim by encode - docs: 00-wob-format.md is now the v2 reference (class metadata, TRY/ENDTRY, the whole builtin surface, WO_T_IO); 08-builtin-surface.md documents the text/container builtins, the OS modules with their predeclared records, and json's two documented limits (Bool encodes 0/1, floats truncate) Co-Authored-By: Claude Opus 5 (1M context) --- compiler/src/ast.ml | 7 + compiler/src/disasm.ml | 14 +- compiler/src/dump.ml | 2 + compiler/src/emit.ml | 113 ++++- compiler/src/lexer.ml | 1 + compiler/src/owner.ml | 4 + compiler/src/parser.ml | 14 +- compiler/src/token.ml | 3 + compiler/src/types.ml | 28 +- compiler/test/golden/bc/elision.expected | 20 +- compiler/test/golden/bc/iface.expected | 29 +- compiler/test/golden/bc/owned.expected | 25 +- compiler/test/golden/bc/residual.expected | 56 ++- compiler/test/runner.ml | 18 +- docs/plan/oop-vm/00-wob-format.md | 26 +- docs/plan/oop-vm/08-builtin-surface.md | 83 ++- runtime/src/builtin.c | 3 +- runtime/src/builtin.h | 3 + runtime/src/json.c | 585 ++++++++++++++++++++++ runtime/src/loader.c | 40 +- runtime/src/loader.h | 3 + runtime/src/wob.h | 36 +- runtime/test/wob_build.c | 6 + 23 files changed, 1031 insertions(+), 88 deletions(-) create mode 100644 runtime/src/json.c diff --git a/compiler/src/ast.ml b/compiler/src/ast.ml index 7f1cd9f..d02879b 100644 --- a/compiler/src/ast.ml +++ b/compiler/src/ast.ml @@ -253,6 +253,13 @@ and expr_kind = literal without lookahead nothing else needs. *) | ListLit of expr list | MapLit + (* `expr as Type` — a CHECKED conversion, not a reinterpretation: its only + meaning in this language is "decode this JSON text into that type", + yielding `?Type` (nil when the text does not fit). Anything else is a + WO-E403 at emission: there is no reinterpret-cast in the doctrine (the + systems-track spec's reject table lists `cast`), and this form exists + only because a decode's result type cannot be inferred. *) + | As of expr * field_ty (* 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, diff --git a/compiler/src/disasm.ml b/compiler/src/disasm.ml index 2d7efe5..22d3020 100644 --- a/compiler/src/disasm.ml +++ b/compiler/src/disasm.ml @@ -160,7 +160,7 @@ let dump (img : string) : string = let line fmt = Buffer.add_string out (fmt ^ "\n") in if u32 img 0 <> magic then raise (Bad "bad magic"); let ver = u32 img 4 in - if ver <> 1 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); + if ver <> 2 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); let coff = u32 img 8 and ccnt = u32 img 12 in let koff = u32 img 16 and kcnt = u32 img 20 in let ioff = u32 img 24 and icnt = u32 img 28 in @@ -204,10 +204,20 @@ let dump (img : string) : string = o := !o + 12; let kinds = List.init fcnt (fun j -> kind_name (u8 img (!o + j))) in o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4); + (* v2 per-field metadata: names, referenced class ids, element kinds. The + dump shows each field as name:kind — the names are what json.encode + renders as keys, so a wrong one is worth seeing. *) + let names = List.init fcnt (fun j -> u32 img (!o + (j * 4))) in + o := !o + (fcnt * 12); + let fields = + List.map2 + (fun nmk k -> if nmk = 0xFFFFFFFF then k else Printf.sprintf "%s:%s" (kname nmk) k) + names kinds + in line (Printf.sprintf "c%-3d %s flags=%s fields=[%s]" i (kname nm) (if flags land 1 <> 0 then "gc" else "-") - (String.concat ", " kinds)) + (String.concat ", " fields)) done; (* interfaces + vtable rows *) line "== INTERFACES =="; diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml index 87e0bfe..b2530f2 100644 --- a/compiler/src/dump.ml +++ b/compiler/src/dump.ml @@ -66,6 +66,7 @@ let kind_label (k : Token.kind) : string = | Token.KwTry -> "KW_TRY" | Token.KwCatch -> "KW_CATCH" | Token.KwNil -> "KW_NIL" + | Token.KwAs -> "KW_AS" | Token.LBrace -> "LBRACE" | Token.RBrace -> "RBRACE" | Token.LParen -> "LPAREN" @@ -226,6 +227,7 @@ let rec expr_str (e : Ast.expr) : string = | Ast.ListLit items -> Printf.sprintf "[%s]" (String.concat ", " (List.map expr_str items)) | Ast.MapLit -> "{}" | Ast.NilLit -> "nil" + | Ast.As (inner, ty) -> Printf.sprintf "%s as %s" (expr_str inner) (field_ty_str ty) (* Like SWITCH above: a one-line summary, not a full unparse of the catch arm's statements. *) | Ast.Try { body; ename; handler } -> diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index aa94b57..265524e 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -152,7 +152,7 @@ let stdlib_not_linked_code = Diag.emitter_prefix ^ "06" ============================================================ *) let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) -let wob_version = 1 +let wob_version = 2 (* v2: per-field class-table metadata *) let wob_hdr_size = 44 let wob_none = 0xFFFFFFFF let k_int = 0 @@ -256,6 +256,11 @@ let b_map_key_at = 37 let b_map_val_at = 38 let b_multi_set = 39 +(* json (runtime/src/json.c): encode takes the value's static kind as its + second argument, decode the class id to build as its second. *) +let b_json_encode = 57 +let b_json_decode = 58 + 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) @@ -947,6 +952,7 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option | ListLit [] | MapLit -> None (* haxe-parity Task 6: contextual on its destination (see owner.ml). *) | NilLit -> None + | As (_, ty) -> Some (Nullable ty) (* 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 @@ -1296,6 +1302,34 @@ let check_field_idx (p : pctx) (f : fstate) (pos : Ast.pos) (v : int) : int = takes a bare identifier), so a fresh container must be created somewhere its type is declared — otherwise WO-E403 at the creation site, where the reader can act on it. *) +(* v2 class-table metadata for one field (runtime/src/wob.h): + - field_class: the class a field refers to — its own class for an + owned/@gc field, its ELEMENT's class for a container of records — or the + json-raw marker for a `json.Value` field, else "none". + - field_elem: a container field's element kinds (a multi's element kind; a + map's key kind and value kind packed as the container_imm immediate), + 0 for everything else. + Both exist so json.encode/json.decode can work off metadata instead of + per-type generated code. *) +let wob_field_json_raw = 0xFFFFFFFE + +let field_class_meta (p : pctx) (ty : Ast.field_ty) : int = + let name_of t = match t with Ast.Scalar n -> Some n | _ -> None in + match unwrap ty with + | Ast.Scalar n when n = Types.json_value_type -> wob_field_json_raw + | Ast.Scalar n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none) + | Ast.Multi e | Ast.Map (_, e) -> ( + match name_of (Ast.Scalar e) with + | Some n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none) + | None -> wob_none) + | Ast.Ref _ | Ast.Nullable _ -> wob_none + +let field_elem_meta (p : pctx) (ty : Ast.field_ty) : int = + match unwrap ty with + | Ast.Multi e -> field_kind p (Ast.Scalar e) + | Ast.Map (k, v) -> field_kind p (Ast.Scalar k) lor (field_kind p (Ast.Scalar v) lsl 4) + | _ -> 0 + let container_imm (p : pctx) (expected : Ast.field_ty option) (map : bool) : int option = match expected with | Some t -> ( @@ -1371,6 +1405,34 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e 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 + (* `json.decode(text) as T` is the ONE `as` this language has: a checked + decode, lowered to json_decode(text, class id of T). The decode needs + the target class, and the target class is exactly what `as` names — so + the two are one instruction, never separable. Any other `as` (and a + bare `json.decode(...)` with no `as`) is WO-E403: there is no + reinterpret cast in the doctrine. *) + | As (inner, ty) -> ( + let target = match unwrap ty with Scalar n -> class_of_name p n | _ -> None in + match (inner.kind, target) with + | Call ({ kind = Field ({ kind = Ident "json"; _ }, "decode"); _ }, [ src ]), Some cid -> + let base = alloc_temps p f e.pos 2 in + let save = f.f_temp in + emit_expr p f v ~dst:base src; + f.f_temp <- save; + put f (ins_abx op_loadk (base + 1) (check_bx p f e.pos "constant" (const_int p cid))); + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abc op_builtin dst base b_json_decode) + | Call ({ kind = Field ({ kind = Ident "json"; _ }, "decode"); _ }, _), None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:"`as` needs a declared class or record type to decode into"; + put f (ins_abx op_loadk dst (const_int p 0)) + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message: + "`as` is only a checked JSON decode (`json.decode(text) as T`) — this language has no \ + reinterpret cast"; + put f (ins_abx op_loadk dst (const_int p 0))) | Ident n -> ( match lookup_local f n with | Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0) @@ -2300,11 +2362,38 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As (* the systems stdlib: one builtin per member. A member whose result is a record takes that record's class id as its last argument, so the VM allocates what it fills (sysio.c). *) - match Types.stdlib_member alias mname with + match (if alias = "json" then None else Types.stdlib_member alias mname) with + | None when alias = "json" && mname = "encode" -> ( + (* json.encode(x): the VM needs x's STATIC kind, since a register + alone cannot say whether it holds an i64 or a pointer. + Everything below the top level comes from object headers and + the class table (runtime/src/json.c). *) + match args with + | [ a ] -> + let base = alloc_temps p f e.pos 2 in + let save = f.f_temp in + emit_expr p f v ~dst:base a; + f.f_temp <- save; + let kind = + match ty_of_expr p f a with Some t -> field_kind p t | None -> 3 (* Text *) + in + put f (ins_abx op_loadk (base + 1) (check_bx p f e.pos "constant" (const_int p kind))); + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abc op_builtin dst base b_json_encode) + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:"`json.encode` takes exactly one argument"; + put f (ins_abx op_loadk dst (const_int p 0))) + | None when alias = "json" && mname = "decode" -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message: + "`json.decode(text)` needs a target type — write `json.decode(text) as T`, whose \ + result is `?T`"; + put f (ins_abx op_loadk dst (const_int p 0)) | None -> err p ~code:stdlib_not_linked_code ~file:f.f_file ~pos:e.pos - ~message: - (Printf.sprintf "stdlib module `%s` has no member `%s`" alias mname); + ~message:(Printf.sprintf "stdlib module `%s` has no member `%s`" alias mname); put f (ins_abx op_loadk dst (const_int p 0)) | Some sm -> if List.length args <> sm.Types.sm_arity then begin @@ -3572,6 +3661,13 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) (* names are constants; interning them first keeps the pool's low indexes stable and readable in a disassembly *) let class_name_k = Array.map (fun c -> const_text p c.cr_name) p_classes in + (* Field-name constants are interned HERE, with every other constant, and + never during serialization: the constant pool is written before the + class table, so a name interned later would be missing from the image + (found the hard way — the loader rejected every class). *) + let class_field_names = + Array.map (fun c -> Array.map (fun ((fname : string), _) -> const_text p fname) c.cr_fields) p_classes + in let iface_name_k = Array.map (fun i -> const_text p i.ir_name) p_ifaces in let method_name_k = Array.map (fun m -> const_text p m.mr_name) p_methods in (* ---- pass 2: method bodies ---- *) @@ -3644,7 +3740,14 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) let pad = (4 - (Array.length c.cr_fields mod 4)) mod 4 in for _ = 1 to pad do Buf.u8 cls 0 - done) + done; + (* v2 per-field metadata (wob.h's "class-table field metadata"): the + names json.encode renders as keys, the classes json.decode has to + build for a nested field, and the element kinds a container field + needs when decode creates one. *) + Array.iter (fun kidx -> Buf.u32 cls kidx) class_field_names.(cid); + Array.iter (fun (_, ty) -> Buf.u32 cls (field_class_meta p ty)) c.cr_fields; + Array.iter (fun (_, ty) -> Buf.u32 cls (field_elem_meta p ty)) c.cr_fields) p_classes; let ifs = Buf.create () in Array.iteri diff --git a/compiler/src/lexer.ml b/compiler/src/lexer.ml index 0278fcb..747b780 100644 --- a/compiler/src/lexer.ml +++ b/compiler/src/lexer.ml @@ -139,6 +139,7 @@ let keyword_kind = function | "try" -> Some Token.KwTry | "catch" -> Some Token.KwCatch | "nil" -> Some Token.KwNil + | "as" -> Some Token.KwAs | "INSERT" -> Some Token.KwInsert | "SELECT" -> Some Token.KwSelect | _ -> None diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml index df58b53..1bd0e90 100644 --- a/compiler/src/owner.ml +++ b/compiler/src/owner.ml @@ -514,6 +514,9 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option = (* haxe-parity Task 6: `nil` is the zero word — contextual on its destination, and never something this frame owns. *) | NilLit -> None + (* a checked decode's value is a fresh instance of the named type (or + nil) — owned, so the binding that holds it gets its drop *) + | As (_, ty) -> Some (Nullable ty) (* 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 @@ -1069,6 +1072,7 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit = literal-specific one. *) | ListLit items -> List.iter (read_expr ctx) items | MapLit | NilLit -> () + | As (inner, _) -> read_expr ctx inner | Try t -> analyze_try ctx e t.body t.ename t.handler | DbStub _ -> (* trap-capable: the frame needs its drop map here *) diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index f8abede..96beabd 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -850,6 +850,17 @@ and parse_multiplicative (st : state) : Ast.expr = done; !lhs +(* `as` binds tighter than every binary operator and looser than a call, so + `json.decode(raw) as FileConfig` casts the call's result, and + `x as T == y` compares the cast value. *) +and parse_as (st : state) (e : Ast.expr) : Ast.expr = + if peek st <> Token.KwAs then e + else begin + ignore (advance st); + let ty = parse_field_ty st in + parse_as st { Ast.id = fresh_id st; pos = e.Ast.pos; kind = Ast.As (e, ty) } + end + and parse_unary (st : state) : Ast.expr = match peek st with | Token.Dash -> @@ -858,7 +869,7 @@ and parse_unary (st : state) : Ast.expr = ignore (advance st); let operand = parse_unary st in { Ast.id; pos; kind = Ast.Unary (Ast.Neg, operand) } - | _ -> parse_postfix st + | _ -> parse_as st (parse_postfix st) and parse_postfix (st : state) : Ast.expr = let base = ref (parse_primary st) in @@ -1767,6 +1778,7 @@ 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 | Ast.NilLit -> e + | Ast.As (inner, ty) -> { e with Ast.kind = Ast.As (subst_expr consts bound inner, ty) } | Ast.Try { body; ename; handler } -> { e with Ast.kind = diff --git a/compiler/src/token.ml b/compiler/src/token.ml index 8ebdc8d..6c54168 100644 --- a/compiler/src/token.ml +++ b/compiler/src/token.ml @@ -93,6 +93,9 @@ type kind = identifier — `nil` appears in the corpus and the driving workload only ever as this literal. *) | KwNil + (* haxe-parity: `expr as Type` — the checked-decode cast. Only meaningful + over `json.decode(text)`, whose result has no type until one is named. *) + | KwAs (* 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 f27adfa..1338318 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -173,6 +173,12 @@ let stdlib_modules = [ "fs"; "proc"; "net"; "time"; "json"; "env" ] let is_stdlib_module (name : string) : bool = List.mem name stdlib_modules +(* The one reserved stdlib TYPE name: `json.Value`, a decoded JSON value. + Represented as a Text holding the raw JSON slice (see wob_kind_of_typ), + so `json.encode(v)` re-emits it verbatim and nothing has to model a + dynamic value tree. *) +let json_value_type = "json.Value" + (* 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 @@ -252,7 +258,14 @@ let stdlib_members : stdlib_member list = m "net" "write" 2 54 None None; m "net" "close" 1 55 None None; (* proc *) - m "proc" "run" 2 56 (Some (TNullable (TScalar proc_record_name))) (Some proc_record_name) ] + m "proc" "run" 2 56 (Some (TNullable (TScalar proc_record_name))) (Some proc_record_name); + (* json — both members are lowered specially (emit.ml): encode needs its + argument's static kind, and decode has no type until an `as` names one, + so neither goes through the generic builtin path. They are listed here + for their SIGNATURES: `json.encode(x) -> Text`, and `json.decode(t)` + yielding nothing on its own. *) + m "json" "encode" 1 57 (Some (TScalar "Text")) None; + m "json" "decode" 1 58 None None ] let stdlib_member (m : string) (name : string) : stdlib_member option = List.find_opt (fun s -> s.sm_module = m && s.sm_name = name) stdlib_members @@ -339,7 +352,11 @@ let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind = WO_K_TEXT. Emitting WO_K_SCALAR here leaked every string a class owned. Found by the emitter, this function's first caller. *) - if name = "Text" then WO_K_TEXT + (* `json.Value` is a Text at the representation level: a decoded + value the source never inspects, carrying the raw JSON slice it + came from, which json.encode emits back verbatim. Kinding it TEXT + is what makes it drop correctly and pass through concatenation. *) + if name = "Text" || name = json_value_type then WO_K_TEXT else if is_builtin_scalar name then WO_K_SCALAR else if StringMap.mem name syms.unions then (* haxe-parity Task 4: an all-bare union value is a plain @@ -993,6 +1010,9 @@ let typecheck_program ~file ~(module_of : string -> string) as an empty container literal — nothing about the literal itself says which `?T` it is the absent value of. *) | NilLit -> None + (* A checked decode yields `?T` — the named type, or nil when the text + did not fit it. *) + | As (_, ty) -> Some (TNullable (resolve_field_ty ty)) (* 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. *) @@ -1342,6 +1362,9 @@ let typecheck_program ~file ~(module_of : string -> string) what lets a comparison or a binding treat it as the absent value of whatever `?T` it meets. *) | NilLit -> { typ = TScalar "Int"; is_nil = true } + | As (inner, ty) -> + let _ = typecheck_expr env cenv inner in + { typ = TNullable (resolve_field_ty ty); 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. *) @@ -2024,6 +2047,7 @@ 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 | NilLit -> () + | As (inner, _) -> walk_expr bound visit inner | Try { body; ename; handler } -> walk_expr bound visit body; walk_block (StringSet.add ename bound) visit handler diff --git a/compiler/test/golden/bc/elision.expected b/compiler/test/golden/bc/elision.expected index 2f6464f..eee02b1 100644 --- a/compiler/test/golden/bc/elision.expected +++ b/compiler/test/golden/bc/elision.expected @@ -1,14 +1,16 @@ == CONSTANTS == k0 TEXT "Cache" k1 TEXT "Holder" -k2 TEXT "read" -k3 TEXT "proven" -k4 TEXT "main" -k5 INT 41 -k6 INT 1 +k2 TEXT "hits" +k3 TEXT "cache" +k4 TEXT "read" +k5 TEXT "proven" +k6 TEXT "main" +k7 INT 41 +k8 INT 1 == CLASSES == -c0 Cache flags=gc fields=[SCALAR] -c1 Holder flags=- fields=[GCREF] +c0 Cache flags=gc fields=[hits:SCALAR] +c1 Holder flags=- fields=[cache:GCREF] == INTERFACES == == VTABLES == == METHODS == @@ -31,7 +33,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY] drops: pc 14 owned={} gc={r0} drops: pc 15 owned={} gc={} 0000 NEW r0, c0 - 0001 LOADK r1, k5 + 0001 LOADK r1, k7 0002 SETF r0, f0, r1 0003 NEW r1, c1 0004 MOVE r2, r0 @@ -40,7 +42,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY] 0007 MOVE r4, r1 0008 CALL r4, m1 0009 MOVE r3, r4 - 0010 LOADK r5, k6 + 0010 LOADK r5, k8 0011 ADD r2, r3, r5 0012 BUILTIN r2, r2, print_int 0013 DROP r1 diff --git a/compiler/test/golden/bc/iface.expected b/compiler/test/golden/bc/iface.expected index 63eb825..7ea68e6 100644 --- a/compiler/test/golden/bc/iface.expected +++ b/compiler/test/golden/bc/iface.expected @@ -1,17 +1,18 @@ == CONSTANTS == k0 TEXT "Book" k1 TEXT "Toy" -k2 TEXT "Priced" -k3 TEXT "current_price" -k4 TEXT "quote" -k5 TEXT "main" -k6 INT 2 -k7 INT 3 -k8 INT 10 -k9 INT 5 +k2 TEXT "base" +k3 TEXT "Priced" +k4 TEXT "current_price" +k5 TEXT "quote" +k6 TEXT "main" +k7 INT 2 +k8 INT 3 +k9 INT 10 +k10 INT 5 == CLASSES == -c0 Book flags=- fields=[SCALAR] -c1 Toy flags=- fields=[SCALAR] +c0 Book flags=- fields=[base:SCALAR] +c1 Toy flags=- fields=[base:SCALAR] == INTERFACES == i0 Priced methods=1 slots=s0..s0 == VTABLES == @@ -22,14 +23,14 @@ m0 current_price args=1 regs=3 [class c0] lines: 0->15 drops: (none) 0000 GETF r1, r0, f0 - 0001 LOADK r2, k6 + 0001 LOADK r2, k7 0002 ADD r1, r1, r2 0003 RET r1 m1 current_price args=1 regs=3 [class c1] lines: 0->23 drops: (none) 0000 GETF r1, r0, f0 - 0001 LOADK r2, k7 + 0001 LOADK r2, k8 0002 MUL r1, r1, r2 0003 RET r1 m2 quote args=1 regs=2 [free fn] @@ -45,10 +46,10 @@ m3 main args=0 regs=4 [free fn] [ENTRY] drops: pc 19 owned={r0} gc={} drops: pc 20 owned={} gc={} 0000 NEW r0, c0 - 0001 LOADK r1, k8 + 0001 LOADK r1, k9 0002 SETF r0, f0, r1 0003 NEW r1, c1 - 0004 LOADK r2, k9 + 0004 LOADK r2, k10 0005 SETF r1, f0, r2 0006 MOVE r3, r0 0007 CALL r3, m2 diff --git a/compiler/test/golden/bc/owned.expected b/compiler/test/golden/bc/owned.expected index 8de7595..53ebb4c 100644 --- a/compiler/test/golden/bc/owned.expected +++ b/compiler/test/golden/bc/owned.expected @@ -1,14 +1,15 @@ == CONSTANTS == k0 TEXT "Item" -k1 TEXT "consume" -k2 TEXT "twice" -k3 TEXT "main" -k4 INT 2 -k5 INT 3 -k6 INT 5 -k7 INT 0 +k1 TEXT "n" +k2 TEXT "consume" +k3 TEXT "twice" +k4 TEXT "main" +k5 INT 2 +k6 INT 3 +k7 INT 5 +k8 INT 0 == CLASSES == -c0 Item flags=- fields=[SCALAR] +c0 Item flags=- fields=[n:SCALAR] == INTERFACES == == VTABLES == == METHODS == @@ -31,12 +32,12 @@ m1 twice args=2 regs=6 [free fn] drops: pc 14 owned={r2} gc={} drops: pc 19 owned={} gc={} 0000 NEW r2, c0 - 0001 LOADK r3, k4 + 0001 LOADK r3, k5 0002 SETF r2, f0, r3 0003 MOVE r3, r1 0004 JZ r3, -> 0013 0005 NEW r3, c0 - 0006 LOADK r4, k5 + 0006 LOADK r4, k6 0007 SETF r3, f0, r4 0008 GETF r4, r3, f0 0009 DROP r3 @@ -54,9 +55,9 @@ m2 main args=0 regs=4 [free fn] [ENTRY] lines: 0->24 7->23 drops: (none) 0000 NEW r1, c0 - 0001 LOADK r3, k6 + 0001 LOADK r3, k7 0002 SETF r1, f0, r3 - 0003 LOADK r2, k7 + 0003 LOADK r2, k8 0004 CALL r1, m1 0005 MOVE r0, r1 0006 BUILTIN r0, r0, print_int diff --git a/compiler/test/golden/bc/residual.expected b/compiler/test/golden/bc/residual.expected index 599700e..01758d2 100644 --- a/compiler/test/golden/bc/residual.expected +++ b/compiler/test/golden/bc/residual.expected @@ -1,21 +1,23 @@ == CONSTANTS == k0 TEXT "Item" k1 TEXT "Bag" -k2 TEXT "touch" -k3 TEXT "pair" -k4 TEXT "fixed" -k5 TEXT "touch3" -k6 TEXT "triple" -k7 TEXT "write_through" -k8 TEXT "main" -k9 INT 0 -k10 INT 1 -k11 INT 5 -k12 INT 2 -k13 INT 3 +k2 TEXT "n" +k3 TEXT "items" +k4 TEXT "touch" +k5 TEXT "pair" +k6 TEXT "fixed" +k7 TEXT "touch3" +k8 TEXT "triple" +k9 TEXT "write_through" +k10 TEXT "main" +k11 INT 0 +k12 INT 1 +k13 INT 5 +k14 INT 2 +k15 INT 3 == CLASSES == -c0 Item flags=- fields=[SCALAR] -c1 Bag flags=- fields=[MULTI] +c0 Item flags=- fields=[n:SCALAR] +c1 Bag flags=- fields=[items:MULTI] == INTERFACES == == VTABLES == == METHODS == @@ -48,10 +50,10 @@ m2 fixed args=1 regs=5 [free fn] lines: 0->27 drops: (none) 0000 GETF r3, r0, f0 - 0001 LOADK r4, k9 + 0001 LOADK r4, k11 0002 BUILTIN r1, r3, multi_get 0003 GETF r3, r0, f0 - 0004 LOADK r4, k10 + 0004 LOADK r4, k12 0005 BUILTIN r2, r3, multi_get 0006 CALL r1, m0 0007 RET r1 @@ -97,7 +99,7 @@ m5 write_through args=3 regs=7 [free fn] 0003 GETF r5, r0, f0 0004 MOVE r6, r2 0005 BUILTIN r4, r5, multi_get - 0006 LOADK r5, k11 + 0006 LOADK r5, k13 0007 BORROW_X r4 0008 BORROW_S r3 0009 SETF r4, f0, r5 @@ -114,22 +116,22 @@ m6 main args=0 regs=6 [free fn] [ENTRY] 0002 SETF r0, f0, r1 0003 GETF r1, r0, f0 0004 NEW r2, c0 - 0005 LOADK r3, k10 + 0005 LOADK r3, k12 0006 SETF r2, f0, r3 0007 BUILTIN r1, r1, multi_push 0008 GETF r1, r0, f0 0009 NEW r2, c0 - 0010 LOADK r3, k12 + 0010 LOADK r3, k14 0011 SETF r2, f0, r3 0012 BUILTIN r1, r1, multi_push 0013 GETF r1, r0, f0 0014 NEW r2, c0 - 0015 LOADK r3, k13 + 0015 LOADK r3, k15 0016 SETF r2, f0, r3 0017 BUILTIN r1, r1, multi_push 0018 MOVE r2, r0 - 0019 LOADK r3, k9 - 0020 LOADK r4, k10 + 0019 LOADK r3, k11 + 0020 LOADK r4, k12 0021 CALL r2, m1 0022 MOVE r1, r2 0023 BUILTIN r1, r1, print_int @@ -138,15 +140,15 @@ m6 main args=0 regs=6 [free fn] [ENTRY] 0026 MOVE r1, r2 0027 BUILTIN r1, r1, print_int 0028 MOVE r2, r0 - 0029 LOADK r3, k9 - 0030 LOADK r4, k10 - 0031 LOADK r5, k12 + 0029 LOADK r3, k11 + 0030 LOADK r4, k12 + 0031 LOADK r5, k14 0032 CALL r2, m4 0033 MOVE r1, r2 0034 BUILTIN r1, r1, print_int 0035 MOVE r2, r0 - 0036 LOADK r3, k9 - 0037 LOADK r4, k10 + 0036 LOADK r3, k11 + 0037 LOADK r4, k12 0038 CALL r2, m5 0039 MOVE r1, r2 0040 BUILTIN r1, r1, print_int diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index e5853ba..28635c7 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -2382,7 +2382,7 @@ let validate_image (img : string) : string list = let u64 o = if ok 8 o then String.get_int64_le img o else 0L in let none = 0xFFFFFFFF in if u32 0 <> 0x31424F57 then fail "bad magic"; - if u32 4 <> 1 then fail "unsupported version"; + if u32 4 <> 2 then fail "unsupported version"; let coff = u32 8 and ccnt = u32 12 in let koff = u32 16 and kcnt = u32 20 in let ioff = u32 24 and icnt = u32 28 in @@ -2423,6 +2423,18 @@ let validate_image (img : string) : string list = if u8 (!o + j) > 5 then fail (Printf.sprintf "class %d field %d: bad kind" i j) done; o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4); + (* v2: three u32 arrays of per-field metadata — names (a Text constant or + "not recorded"), the referenced class id (or the json-raw marker), and + container element kinds. Mirrors runtime/src/loader.c's own checks. *) + for j = 0 to fcnt - 1 do + let nmk = u32 (!o + (j * 4)) in + if nmk <> 0xFFFFFFFF && not (text_const nmk) then + fail (Printf.sprintf "class %d field %d: bad name constant" i j); + let fc = u32 (!o + ((fcnt + j) * 4)) in + if fc <> 0xFFFFFFFF && fc <> 0xFFFFFFFE && fc >= kcnt then + fail (Printf.sprintf "class %d field %d: field class out of range" i j) + done; + o := !o + (fcnt * 12); if !o > len then fail (Printf.sprintf "class %d: truncated" i) done; (* interfaces + vtable rows *) @@ -3294,7 +3306,7 @@ let () = (count_substring ~needle:"Status.Pending flags" dump = 1 && count_substring ~needle:"Status.Failed flags" dump = 1); check "t4 table: Status.Failed's payload Text is a TEXT slot" - (count_substring ~needle:"Status.Failed flags=- fields=[TEXT]" dump = 1); + (count_substring ~needle:"Status.Failed flags=- fields=[reason:TEXT]" dump = 1); check "t4 table: bare union gets no class entries" (count_substring ~needle:"Kind" dump - count_substring ~needle:"Kind" (String.concat "" [ "" ]) @@ -3302,7 +3314,7 @@ let () = && count_substring ~needle:"Kind flags" dump = 0 && count_substring ~needle:"Kind.Lo" dump = 0); check "t4 table: a bare-union record field is a SCALAR slot, a payload one OWNED" - (count_substring ~needle:"Holder flags=- fields=[SCALAR, OWNED]" dump = 1) + (count_substring ~needle:"Holder flags=- fields=[k:SCALAR, st:OWNED]" dump = 1) let () = (* lowering shapes: variant_tag for a payload union's switch only; a diff --git a/docs/plan/oop-vm/00-wob-format.md b/docs/plan/oop-vm/00-wob-format.md index 2914643..3dcfa17 100644 --- a/docs/plan/oop-vm/00-wob-format.md +++ b/docs/plan/oop-vm/00-wob-format.md @@ -1,4 +1,4 @@ -# The `.wob` format v1 — normative reference +# The `.wob` format v2 — normative reference > Copied verbatim from the normative section of > [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../../superpowers/plans/2026-08-01-wob-format-and-vm-core.md) @@ -11,11 +11,19 @@ All integers little-endian; offsets are absolute file offsets. -**Header (44 bytes):** magic `"WOB1"`, version 1, then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). +**Header (44 bytes):** magic `"WOB1"`, version 2, then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). **Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL). -**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary. Field kinds: 0 SCALAR, 1 OWNED, 2 GCREF, 3 TEXT, 4 MULTI, 5 MAP. Runtime object layout: 16-byte header then one 8-byte slot per field, in declaration order. +**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order: + +1. `field_names[i]` — constant index of the field's name, or all-ones for "not recorded" (what a hand-built test image writes). +2. `field_class[i]` — the class id the field refers to: its own class for an OWNED/GCREF field, its *element's* class for a container of records; `0xFFFFFFFE` marks a `json.Value` field, whose Text holds a raw JSON slice; all-ones for none. +3. `field_elem[i]` — a container field's element kinds: a MULTI's element kind, or a MAP's key kind in the low nibble and value kind in the next; 0 otherwise. + +Field kinds: 0 SCALAR, 1 OWNED, 2 GCREF, 3 TEXT, 4 MULTI, 5 MAP. Runtime object layout: 16-byte header then one 8-byte slot per field, in declaration order. + +The metadata exists for exactly one reason: `json.encode`/`json.decode` are runtime services driven by class metadata (`runtime/src/json.c`) rather than per-type generated code, so the names a JSON object needs and the shapes a decode has to build must live in the image. Every other part of the runtime ignores it. **Interface section** — per interface: name constant index, method count. Global *slot ids* are assigned sequentially across interfaces in declaration order. Then a vtable row count and rows: class id, interface id, one method index per interface method. @@ -43,10 +51,18 @@ All integers little-endian; offsets are absolute file offsets. | 29 | BUILTIN A B C | register A = builtin C applied to args starting at register B (fixed arity per builtin; `multi_new`/`map_new` carry kind immediates in B instead) | | 30 | DB_STUB | trap T_DB "engine not linked" (spec: SQL-layer statements in milestone 1) | | 31 | TRAP Bx | explicit trap with code Bx | +| 32 | TRY A sBx | push a catch frame for this frame and window: handler at pc + sBx, error record register A (haxe-parity Task 5) | +| 33 | ENDTRY | pop the innermost catch frame — the try region completed without trapping | -**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). +**try/catch (Task 5).** A trap raised while a catch frame is live unwinds every frame *above* the catching one exactly as an uncaught trap does (drop maps run, registers null), then releases what the try region owned in the catching frame — the difference between the drop entry at the trapping instruction and the one at the handler pc — and resumes at the handler instead of leaving the VM. A frame that returns takes its still-open catch frames with it, so a `return` out of a try region cannot leave a handler pointing at a dead window. With no catch frame live, a trap behaves byte-for-byte as it did before v2. The catch arm's error record is an ordinary compiler-allocated object filled by the `err_fill` builtin (field order: 0 code, 1 line, 2 method, 3 msg). -**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT. +**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), err_fill (Task 5's catch record), then the systems stdlib: + +- **text/containers** — len, byte_at, print_err, starts_with, ends_with, index_of, last_index_of, substr, trim, to_lower, char_of, parse_int, split, split_ws, join, slice, pop, shift, sort, reverse, remove, key_at, val_at, multi_set. Ids 16–39; `runtime/src/builtin.c`. +- **the OS half** — fs.exists/list/stat/read_all/read_at/append, time.sleep/local/iso, env.get/stopping, net.listen/accept/read/write/close, proc.run. Ids 40–56; `runtime/src/sysio.c`. A member that returns a record takes that record's **class id as its last argument**, so the VM allocates what it fills without knowing any source type name. +- **json** — encode (value + the value's static kind), decode (text + the class id to build). Ids 57–58; `runtime/src/json.c`. Decode yields the zero word on malformed input rather than trapping, which is what makes `json.decode(t) as T` a checked decode. + +**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT, IO (a syscall the source cannot prevent said no — errno's message rides in the error record). ## Enum payload variants (haxe-parity compiler Task 4) diff --git a/docs/plan/oop-vm/08-builtin-surface.md b/docs/plan/oop-vm/08-builtin-surface.md index 88f5e7d..556e63a 100644 --- a/docs/plan/oop-vm/08-builtin-surface.md +++ b/docs/plan/oop-vm/08-builtin-surface.md @@ -30,15 +30,31 @@ maps to one `BUILTIN` id of the format doc. | `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 | +| `len(x)` | `len` | 1 | byte length of a `Text`, or element/entry count of a container | +| `byte_at(t, i)` | `byte_at` | 2 | byte value at an index; out of range traps `BOUNDS` | +| `print_err(t)` | `print_err` | 1 | a `Text` to stderr, newline-terminated | +| `starts_with(t, p)` / `ends_with(t, s)` | same | 2 | `1`/`0` | +| `index_of(t, n)` / `last_index_of(t, n)` | same | 2 | first/last byte offset, `-1` when absent | +| `substr(t, start, len)` | `substr` | 3 | fresh `Text`, clamped (never traps) | +| `trim(t)` / `to_lower(t)` | same | 1 | fresh `Text` | +| `char_of(b)` | `char_of` | 1 | fresh one-byte `Text` | +| `parse_int(t)` | `parse_int` | 1 | `?Int` — an unparseable text is `0`, which is how `?Int` spells nil | +| `split(t, sep)` / `split_ws(t)` | same | 2 / 1 | fresh `multi Text` | +| `join(m, sep)` | `join` | 2 | fresh `Text` from a `multi Text` | +| `slice(m, from, to)` | `slice` | 3 | fresh `multi` over `[from, to)`; `Text` elements are COPIED, so slice and source never both own one value | +| `pop(m)` / `shift(m)` | same | 1 | removes and returns the last/first element (ownership moves to the caller); empty traps `BOUNDS` | +| `sort(m)` / `reverse(m)` | same | 1 | in place; `sort` compares `Text` by content, everything else as signed integers | +| `remove(m, k)` | `map_remove` | 2 | `1`/`0`; drops the removed key and value | +| `key_at(m, i)` / `val_at(m, i)` | same | 2 | slot-ordered map enumeration — what `for k, v in m` lowers onto | +| `m[i] = v` on a `multi` | `multi_set` | 3 | in-place element write, dropping the element it replaces | `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 keeps apart. **Sugar.** `c[i]` is exactly `get(c, i)` and `m[k] = v` is exactly -`set(m, k, v)`. There is no element *write* into a `multi` — v1 has -`multi_push` and `multi_get` and no element store — so `m[i] = v` on a -`multi` is `WO-E403`. +`set(m, k, v)` for a `map` and `multi_set(m, i, v)` for a `multi` (the +element it replaces is the container's, so the VM drops it). **Shadowing.** A user-declared free `fn` of the same name always wins. A declared name is never silently replaced by a builtin. @@ -218,3 +234,64 @@ rejects `6`), and it needs none: every per-kind drop plan already ignores a zero slot. `?T`'s field kind is therefore `T`'s. Note the consequence for `@gc`: `?SomeGcClass` is a `GCREF` field like any other, so it participates in refcounting and cycle detection normally. + +## The systems stdlib's OS half (`fs`, `time`, `env`, `net`, `proc`) + +Reserved module names resolve to one builtin per member +(`runtime/src/sysio.c`). Every one is a thin blocking libc call, so the +failure surface is uniform: a syscall that fails traps `WO_T_IO` carrying +errno's own message, and the source decides with `try ... catch` whether +that is fatal. Absence is never a trap — a missing path from `fs.stat` and an +unset `env.get` are nil. + +| member | signature | notes | +| --- | --- | --- | +| `fs.exists(path)` | `-> Bool` | | +| `fs.list(dir)` | `-> multi Text` | names only, unsorted; unreadable dir traps `IO` | +| `fs.stat(path)` | `-> ?Stat` | `Stat { size, mtime (ms), inode, dir }` | +| `fs.read_all(path, cap)` | `-> Text` | truncated at `cap` | +| `fs.read_at(path, off, len)` | `-> Text` | short read allowed (a growing file is normal) | +| `fs.append(path, text)` | — | creates the file if absent | +| `time.now()` | `-> Int` | wall-clock ms; the existing `now` builtin | +| `time.sleep(ms)` | — | | +| `time.local(ms)` | `-> TimeParts` | `{ year, month, day, hour, minute, second, dow }`, dow 0 = Sunday | +| `time.iso(ms)` | `-> Text` | UTC, second precision | +| `env.get(name)` | `-> ?Text` | unset is nil | +| `env.stopping()` | `-> Bool` | SIGTERM/SIGINT latch, handlers installed on first use | +| `net.listen(host, port)` | `-> Int` | IPv4, SO_REUSEADDR, backlog 64; returns an fd | +| `net.accept(fd)` | `-> Int` | | +| `net.read(fd, max)` | `-> Text` | one read; the empty Text is EOF | +| `net.write(fd, text)` | — | writes all of it | +| `net.close(fd)` | — | | +| `proc.run(cmd, args)` | `-> ?Proc` | `Proc { code, out, err }`; stdout/stderr captured and capped | + +`Stat`, `TimeParts` and `Proc` are **predeclared records**: no source declares +them, and their field ORDER is the contract with `sysio.c`, which writes them +by index. The compiler passes the record's class id as the member's last +argument, so the VM allocates what it fills. + +## `json` + +`json.encode(x) -> Text` and `json.decode(text) as T -> ?T`. Both are +metadata-driven (`runtime/src/json.c`): the class table's per-field names, +referenced classes and element kinds (`.wob` v2) are what let one +implementation encode and decode any record shape, with no per-type generated +code. + +- `encode` takes the value's *static* kind alongside it, because a register + alone cannot say whether it holds an i64 or a pointer; everything below the + top level comes from object headers and the class table. +- `decode` parses and binds straight into the target class: keys are matched + against field names, a nested object is built as that field's class, an + array as a `multi` of that field's element kind, unknown keys are skipped, + and absent keys stay nil. Malformed input yields nil — never a trap, which + is what makes the `as` form a *checked* decode. `as` exists for no other + purpose: there is no reinterpret cast in the doctrine. +- `json.Value` is a reserved type name for a decoded value the source does not + inspect: it holds the raw JSON slice it came from (kind TEXT) and encodes + back verbatim. + +Two documented limits: a `Bool` field is a SCALAR slot like any other integer, +so it encodes as `0`/`1` rather than `false`/`true` (the kind byte does not +distinguish them); and a JSON number with a fraction or exponent decodes by +truncation to `Int`, since the language has no float. diff --git a/runtime/src/builtin.c b/runtime/src/builtin.c index bfde742..5e9ee54 100644 --- a/runtime/src/builtin.c +++ b/runtime/src/builtin.c @@ -63,7 +63,8 @@ static int elem_cmp(uint8_t kind, uint64_t a, uint64_t b) { int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { wo_rt *rt = &vm->rt; uint8_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins); - /* the OS half lives in its own translation unit — see sysio.c */ + /* the OS half and json live in their own translation units */ + if (C >= WO_B_JSON_ENCODE) return wo_builtin_json(vm, R, ins, msg); if (C >= WO_B_SYS_FIRST) return wo_builtin_sys(vm, R, ins, msg); switch (C) { case WO_B_NOW: { /* wall-clock milliseconds */ diff --git a/runtime/src/builtin.h b/runtime/src/builtin.h index 7717ce0..7153ea0 100644 --- a/runtime/src/builtin.h +++ b/runtime/src/builtin.h @@ -14,4 +14,7 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg); * wo_builtin dispatches every id at or above WO_B_SYS_FIRST here. */ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg); +/* json.encode / json.decode (runtime/src/json.c), same contract again. */ +int wo_builtin_json(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg); + #endif /* WO_BUILTIN_H */ diff --git a/runtime/src/json.c b/runtime/src/json.c new file mode 100644 index 0000000..9a91a39 --- /dev/null +++ b/runtime/src/json.c @@ -0,0 +1,585 @@ +/* json.c — `json.encode` and `json.decode`, driven by class-table metadata + * instead of per-type generated code. + * + * The .wob class table carries, per field, its NAME, the CLASS it refers to + * and a container field's ELEMENT KINDS (wob.h's "class-table field + * metadata", format v2). That is everything both directions need: + * + * encode the top-level value's kind comes from the compiler (a register + * alone cannot say whether it holds an i64 or a pointer); + * everything below it comes from object headers and the class + * table, so a nested record needs no static knowledge at all. + * decode parse-and-bind straight into the target class: an object's keys + * are matched against field names, each value converted to that + * field's kind, a nested object built as that field's class, an + * array built as a `multi` of that field's element kind. Unknown + * keys are skipped; absent keys stay the zero word, which is how + * `?T` spells nil. Malformed input yields nil, never a trap — + * that is what makes `json.decode(t) as T` a *checked* decode. + * + * Two deliberate, documented limits: a `Bool` field is a WO_K_SCALAR slot + * like every other integer, so it encodes as 0/1 rather than false/true (the + * kind byte does not distinguish them); and a JSON number with a fraction or + * an exponent decodes by truncation to i64, since the language has no float. + * A `json.Value` field (field_class == WOB_FIELD_JSON_RAW) holds the raw JSON + * slice it was decoded from, and encodes back verbatim. + */ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include + +#include "builtin.h" +#include "cont.h" +#include "gc.h" + +/* ---- growable output buffer (encode) --------------------------------- */ + +typedef struct { + char *p; + size_t len, cap; + int oom; +} jbuf; + +static void jb_reserve(jbuf *b, size_t extra) { + if (b->oom) return; + if (b->len + extra <= b->cap) return; + size_t want = b->cap ? b->cap * 2 : 256; + while (want < b->len + extra) want *= 2; + char *np = realloc(b->p, want); + if (!np) { + b->oom = 1; + return; + } + b->p = np; + b->cap = want; +} + +static void jb_put(jbuf *b, const char *s, size_t n) { + jb_reserve(b, n); + if (b->oom) return; + memcpy(b->p + b->len, s, n); + b->len += n; +} + +static void jb_ch(jbuf *b, char c) { jb_put(b, &c, 1); } + +static void jb_int(jbuf *b, int64_t v) { + char tmp[24]; + int n = snprintf(tmp, sizeof tmp, "%lld", (long long)v); + jb_put(b, tmp, (size_t)n); +} + +/* JSON string body: quotes, backslashes and control bytes escaped; every + * other byte passes through, so UTF-8 stays UTF-8. */ +static void jb_text(jbuf *b, const wo_str *s) { + jb_ch(b, '"'); + for (uint32_t i = 0; i < s->len; i++) { + unsigned char c = (unsigned char)s->data[i]; + switch (c) { + case '"': jb_put(b, "\\\"", 2); break; + case '\\': jb_put(b, "\\\\", 2); break; + case '\n': jb_put(b, "\\n", 2); break; + case '\r': jb_put(b, "\\r", 2); break; + case '\t': jb_put(b, "\\t", 2); break; + case '\b': jb_put(b, "\\b", 2); break; + case '\f': jb_put(b, "\\f", 2); break; + default: + if (c < 0x20) { + char esc[7]; + int n = snprintf(esc, sizeof esc, "\\u%04x", c); + jb_put(b, esc, (size_t)n); + } else + jb_ch(b, (char)c); + } + } + jb_ch(b, '"'); +} + +/* ---- encode ---------------------------------------------------------- */ + +static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, uint32_t fclass); + +static void enc_object(jbuf *b, const wo_module *mod, const wo_hdr *o) { + const wo_classdesc *c = &mod->classes[o->class_id]; + const uint64_t *fs = wo_fields((wo_hdr *)(uintptr_t)o); + jb_ch(b, '{'); + int first = 1; + for (uint32_t i = 0; i < c->field_cnt; i++) { + uint32_t nm = c->field_names ? c->field_names[i] : WOB_NONE; + if (nm == WOB_NONE || nm >= mod->const_cnt) continue; /* unnamed: not encodable */ + if (!first) jb_ch(b, ','); + first = 0; + jb_text(b, mod->consts[nm].s); + jb_ch(b, ':'); + enc_value(b, mod, fs[i], c->kinds[i], c->field_class ? c->field_class[i] : WOB_NONE); + } + jb_ch(b, '}'); +} + +static void enc_value(jbuf *b, const wo_module *mod, uint64_t v, uint8_t kind, uint32_t fclass) { + if (!v && kind != WO_K_SCALAR) { + jb_put(b, "null", 4); + return; + } + switch (kind) { + case WO_K_SCALAR: + jb_int(b, (int64_t)v); + return; + case WO_K_TEXT: { + const wo_str *s = (const wo_str *)(uintptr_t)v; + if (fclass == WOB_FIELD_JSON_RAW) jb_put(b, s->data, s->len); /* already JSON */ + else jb_text(b, s); + return; + } + case WO_K_MULTI: { + const wo_multi *m = (const wo_multi *)(uintptr_t)v; + jb_ch(b, '['); + for (uint32_t i = 0; i < m->len; i++) { + if (i) jb_ch(b, ','); + enc_value(b, mod, m->items[i], m->elem_kind, fclass); + } + jb_ch(b, ']'); + return; + } + case WO_K_MAP: { + const wo_map *m = (const wo_map *)(uintptr_t)v; + jb_ch(b, '{'); + for (uint32_t i = 0; i < m->len; i++) { + if (i) jb_ch(b, ','); + if (m->key_kind == WO_K_TEXT && m->keys[i]) jb_text(b, (const wo_str *)(uintptr_t)m->keys[i]); + else { + /* a non-Text key still has to be a JSON string */ + jb_ch(b, '"'); + jb_int(b, (int64_t)m->keys[i]); + jb_ch(b, '"'); + } + jb_ch(b, ':'); + enc_value(b, mod, m->vals[i], m->val_kind, fclass); + } + jb_ch(b, '}'); + return; + } + default: { /* OWNED / GCREF: a class object, or a native one */ + const wo_hdr *o = (const wo_hdr *)(uintptr_t)v; + if (o->class_id == WO_CLS_STR) { + jb_text(b, (const wo_str *)(uintptr_t)o); + return; + } + if (o->class_id == WO_CLS_MULTI) { + enc_value(b, mod, v, WO_K_MULTI, fclass); + return; + } + if (o->class_id == WO_CLS_MAP) { + enc_value(b, mod, v, WO_K_MAP, fclass); + return; + } + if (o->class_id < mod->class_cnt) { + enc_object(b, mod, o); + return; + } + jb_put(b, "null", 4); + return; + } + } +} + +/* ---- decode: parse and bind ------------------------------------------ */ + +typedef struct { + const char *p, *end; + wo_rt *rt; + const wo_module *mod; +} jp; + +static void jskip_ws(jp *j) { + while (j->p < j->end && (*j->p == ' ' || *j->p == '\t' || *j->p == '\n' || *j->p == '\r')) j->p++; +} + +static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, uint64_t *out); + +/* Walks one value without building anything — an unknown object key, or a + * value whose JSON shape does not fit the field it landed on. */ +static int jskip_value(jp *j) { + jskip_ws(j); + if (j->p >= j->end) return -1; + char c = *j->p; + if (c == '{' || c == '[') { + char close = c == '{' ? '}' : ']'; + int depth = 0; + while (j->p < j->end) { + char d = *j->p++; + if (d == '"') { /* strings may contain braces */ + while (j->p < j->end && *j->p != '"') { + if (*j->p == '\\' && j->p + 1 < j->end) j->p++; + j->p++; + } + if (j->p < j->end) j->p++; + continue; + } + if (d == '{' || d == '[') depth++; + else if (d == '}' || d == ']') { + depth--; + if (depth == 0) return 0; + } + } + (void)close; + return -1; + } + if (c == '"') { + j->p++; + while (j->p < j->end && *j->p != '"') { + if (*j->p == '\\' && j->p + 1 < j->end) j->p++; + j->p++; + } + if (j->p >= j->end) return -1; + j->p++; + return 0; + } + while (j->p < j->end && *j->p != ',' && *j->p != '}' && *j->p != ']' && + *j->p != ' ' && *j->p != '\n' && *j->p != '\t' && *j->p != '\r') + j->p++; + return 0; +} + +/* A JSON string into a fresh Text, applying escapes. \uXXXX becomes UTF-8 + * (BMP only: a surrogate pair decodes as two replacement-free code units, + * which is what every byte-oriented consumer here wants). */ +static wo_str *jparse_string(jp *j) { + if (j->p >= j->end || *j->p != '"') return NULL; + const char *start = ++j->p; + size_t worst = (size_t)(j->end - start); + wo_str *s = wo_str_alloc(j->rt, (uint32_t)worst); + if (!s) return NULL; + uint32_t n = 0; + while (j->p < j->end && *j->p != '"') { + char c = *j->p++; + if (c != '\\') { + s->data[n++] = c; + continue; + } + if (j->p >= j->end) break; + char e = *j->p++; + switch (e) { + case 'n': s->data[n++] = '\n'; break; + case 't': s->data[n++] = '\t'; break; + case 'r': s->data[n++] = '\r'; break; + case 'b': s->data[n++] = '\b'; break; + case 'f': s->data[n++] = '\f'; break; + case 'u': { + unsigned cp = 0; + for (int k = 0; k < 4 && j->p < j->end; k++) { + char h = *j->p++; + unsigned d = (unsigned)(h >= '0' && h <= '9' ? h - '0' + : h >= 'a' && h <= 'f' ? h - 'a' + 10 + : h >= 'A' && h <= 'F' ? h - 'A' + 10 + : 0); + cp = cp * 16 + d; + } + if (cp < 0x80) s->data[n++] = (char)cp; + else if (cp < 0x800) { + s->data[n++] = (char)(0xC0 | (cp >> 6)); + s->data[n++] = (char)(0x80 | (cp & 0x3F)); + } else { + s->data[n++] = (char)(0xE0 | (cp >> 12)); + s->data[n++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + s->data[n++] = (char)(0x80 | (cp & 0x3F)); + } + break; + } + default: s->data[n++] = e; /* covers \" \\ \/ */ + } + } + if (j->p >= j->end) { + wo_str_free(j->rt, s); + return NULL; + } + j->p++; /* closing quote */ + s->len = n; + return s; +} + +/* An object into a fresh instance of [class_id]: keys matched against the + * class's field names, values converted to each field's own kind. */ +static int jparse_object(jp *j, uint32_t class_id, uint64_t *out) { + const wo_classdesc *c = &j->mod->classes[class_id]; + wo_hdr *o = wo_obj_new(j->rt, class_id); + if (!o) return -1; + uint64_t *fs = wo_fields(o); + jskip_ws(j); + if (j->p >= j->end || *j->p != '{') { + wo_drop_obj(j->rt, o); + return -1; + } + j->p++; + jskip_ws(j); + if (j->p < j->end && *j->p == '}') { + j->p++; + *out = (uint64_t)(uintptr_t)o; + return 0; + } + for (;;) { + jskip_ws(j); + wo_str *key = jparse_string(j); + if (!key) { + wo_drop_obj(j->rt, o); + return -1; + } + jskip_ws(j); + if (j->p >= j->end || *j->p != ':') { + wo_str_free(j->rt, key); + wo_drop_obj(j->rt, o); + return -1; + } + j->p++; + /* which field is this key? */ + uint32_t idx = c->field_cnt; + for (uint32_t i = 0; i < c->field_cnt; i++) { + uint32_t nm = c->field_names ? c->field_names[i] : WOB_NONE; + if (nm == WOB_NONE || nm >= j->mod->const_cnt) continue; + const wo_str *fname = j->mod->consts[nm].s; + if (fname->len == key->len && !memcmp(fname->data, key->data, key->len)) { + idx = i; + break; + } + } + wo_str_free(j->rt, key); + if (idx == c->field_cnt) { + if (jskip_value(j) != 0) { + wo_drop_obj(j->rt, o); + return -1; + } + } else { + uint64_t val = 0; + if (jparse_value(j, c->kinds[idx], c->field_class ? c->field_class[idx] : WOB_NONE, + c->field_elem ? c->field_elem[idx] : 0, &val) != 0) { + wo_drop_obj(j->rt, o); + return -1; + } + fs[idx] = val; + } + jskip_ws(j); + if (j->p < j->end && *j->p == ',') { + j->p++; + continue; + } + if (j->p < j->end && *j->p == '}') { + j->p++; + *out = (uint64_t)(uintptr_t)o; + return 0; + } + wo_drop_obj(j->rt, o); + return -1; + } +} + +static int jparse_value(jp *j, uint8_t kind, uint32_t fclass, uint32_t felem, uint64_t *out) { + jskip_ws(j); + if (j->p >= j->end) return -1; + /* a `json.Value` field keeps the raw slice, whatever shape it is */ + if (fclass == WOB_FIELD_JSON_RAW) { + const char *start = j->p; + if (jskip_value(j) != 0) return -1; + wo_str *raw = wo_str_new(j->rt, start, (uint32_t)(j->p - start)); + if (!raw) return -1; + *out = (uint64_t)(uintptr_t)raw; + return 0; + } + char c = *j->p; + if (c == 'n') { /* null: the zero word, for every kind */ + return jskip_value(j) == 0 ? (*out = 0, 0) : -1; + } + if (c == '{') { + if ((kind == WO_K_OWNED || kind == WO_K_GCREF) && fclass < j->mod->class_cnt) + return jparse_object(j, fclass, out); + if (kind == WO_K_MAP) { + uint8_t kk = (uint8_t)(felem & 0x0F), vk = (uint8_t)((felem >> 4) & 0x0F); + wo_map *m = wo_map_new(j->rt, kk, vk); + if (!m) return -1; + j->p++; + jskip_ws(j); + if (j->p < j->end && *j->p == '}') { + j->p++; + *out = (uint64_t)(uintptr_t)m; + return 0; + } + for (;;) { + jskip_ws(j); + wo_str *key = jparse_string(j); + if (!key) { + wo_drop_obj(j->rt, &m->h); + return -1; + } + jskip_ws(j); + if (j->p >= j->end || *j->p != ':') { + wo_str_free(j->rt, key); + wo_drop_obj(j->rt, &m->h); + return -1; + } + j->p++; + uint64_t val = 0; + if (jparse_value(j, vk, fclass, 0, &val) != 0) { + wo_str_free(j->rt, key); + wo_drop_obj(j->rt, &m->h); + return -1; + } + uint64_t old = 0; + if (wo_map_set(m, (uint64_t)(uintptr_t)key, val, &old) < 0) { + wo_str_free(j->rt, key); + wo_drop_obj(j->rt, &m->h); + return -1; + } + jskip_ws(j); + if (j->p < j->end && *j->p == ',') { + j->p++; + continue; + } + if (j->p < j->end && *j->p == '}') { + j->p++; + *out = (uint64_t)(uintptr_t)m; + return 0; + } + wo_drop_obj(j->rt, &m->h); + return -1; + } + } + /* an object where the field wants something else: skip it, leave nil */ + *out = 0; + return jskip_value(j); + } + if (c == '[') { + if (kind != WO_K_MULTI) { + *out = 0; + return jskip_value(j); + } + uint8_t ek = (uint8_t)(felem & 0x0F); + wo_multi *m = wo_multi_new(j->rt, ek); + if (!m) return -1; + j->p++; + jskip_ws(j); + if (j->p < j->end && *j->p == ']') { + j->p++; + *out = (uint64_t)(uintptr_t)m; + return 0; + } + for (;;) { + uint64_t item = 0; + if (jparse_value(j, ek, fclass, 0, &item) != 0 || wo_multi_push(m, item) != 0) { + wo_drop_obj(j->rt, &m->h); + return -1; + } + jskip_ws(j); + if (j->p < j->end && *j->p == ',') { + j->p++; + continue; + } + if (j->p < j->end && *j->p == ']') { + j->p++; + *out = (uint64_t)(uintptr_t)m; + return 0; + } + wo_drop_obj(j->rt, &m->h); + return -1; + } + } + if (c == '"') { + wo_str *s = jparse_string(j); + if (!s) return -1; + if (kind == WO_K_TEXT) { + *out = (uint64_t)(uintptr_t)s; + return 0; + } + wo_str_free(j->rt, s); /* a string where a number was declared: nil */ + *out = 0; + return 0; + } + if (c == 't' || c == 'f') { + int truth = c == 't'; + if (jskip_value(j) != 0) return -1; + *out = kind == WO_K_SCALAR ? (uint64_t)truth : 0; + return 0; + } + /* number: i64 by truncation — the language has no float */ + { + int neg = 0; + if (*j->p == '-') { + neg = 1; + j->p++; + } else if (*j->p == '+') + j->p++; + int64_t acc = 0; + int digits = 0; + while (j->p < j->end && *j->p >= '0' && *j->p <= '9') { + acc = acc * 10 + (*j->p++ - '0'); + digits++; + } + if (!digits) return -1; + if (j->p < j->end && (*j->p == '.' || *j->p == 'e' || *j->p == 'E')) { + /* consume the fraction/exponent; the integer part is the value */ + if (*j->p == '.') { + j->p++; + while (j->p < j->end && *j->p >= '0' && *j->p <= '9') j->p++; + } + if (j->p < j->end && (*j->p == 'e' || *j->p == 'E')) { + j->p++; + if (j->p < j->end && (*j->p == '-' || *j->p == '+')) j->p++; + while (j->p < j->end && *j->p >= '0' && *j->p <= '9') j->p++; + } + } + *out = kind == WO_K_SCALAR ? (uint64_t)(neg ? -acc : acc) : 0; + return 0; + } +} + +int wo_builtin_json(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { + wo_rt *rt = &vm->rt; + uint8_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins); + switch (C) { + case WO_B_JSON_ENCODE: { + jbuf b = {NULL, 0, 0, 0}; + uint8_t kind = (uint8_t)R[B + 1]; + enc_value(&b, vm->mod, R[B], kind, WOB_NONE); + if (b.oom) { + free(b.p); + *msg = "out of memory"; + return WO_T_OOM; + } + wo_str *s = wo_str_new(rt, b.p ? b.p : "", (uint32_t)b.len); + free(b.p); + if (!s) { + *msg = "out of memory"; + return WO_T_OOM; + } + R[A] = (uint64_t)(uintptr_t)s; + return 0; + } + case WO_B_JSON_DECODE: { /* malformed input is nil, never a trap */ + if (!R[B]) { + R[A] = 0; + return 0; + } + const wo_str *src = (const wo_str *)(uintptr_t)R[B]; + if (src->h.class_id != WO_CLS_STR) { + *msg = "not a text value"; + return WO_T_BOUNDS; + } + uint64_t cls = R[B + 1]; + if (cls >= vm->mod->class_cnt) { + *msg = "decode target is not a class"; + return WO_T_BOUNDS; + } + jp j = {src->data, src->data + src->len, rt, vm->mod}; + uint64_t out = 0; + if (jparse_object(&j, (uint32_t)cls, &out) != 0) { + R[A] = 0; + return 0; + } + R[A] = out; + return 0; + } + default: + *msg = "unknown json builtin"; + return WO_T_EXPLICIT; + } +} diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 78f8c38..df83fd3 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -59,6 +59,9 @@ static const uint8_t b_arity[WO_B_MAX + 1] = { [WO_B_ENV_GET] = 1, [WO_B_ENV_STOPPING] = 0, [WO_B_NET_LISTEN] = 2, [WO_B_NET_ACCEPT] = 1, [WO_B_NET_READ] = 2, [WO_B_NET_WRITE] = 2, [WO_B_NET_CLOSE] = 1, [WO_B_PROC_RUN] = 3, + /* json (json.c): encode takes the value's static kind, decode the class + id to build */ + [WO_B_JSON_ENCODE] = 2, [WO_B_JSON_DECODE] = 2, }; static int vtab_cmp(const void *a, const void *b) { @@ -137,7 +140,7 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, m->classes = calloc(kcnt, sizeof(wo_classdesc)); if (!m->classes) BAIL("out of memory"); } - size_t pool_len = 0; + size_t pool_len = 0, meta_pool = 0; for (uint32_t i = 0; i < kcnt; i++) { uint32_t name, flags, fcnt; if (rd_u32(&k, &name) || rd_u32(&k, &flags) || rd_u32(&k, &fcnt)) @@ -159,15 +162,47 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, uint32_t pad = (4u - fcnt % 4u) % 4u; if (pad > k.len - k.off) BAIL("class %u: truncated pad", (unsigned)i); k.off += pad; + /* v2: three u32 arrays of per-field metadata (names, referenced + class ids, container element kinds) — wob.h's "class-table field + metadata" note. A name of WOB_NONE means "not recorded", which is + what a hand-built test image writes; any other value must be a + real Text constant, since json.encode renders it as a key. */ + size_t meta_words = (size_t)fcnt * 3u; + if (meta_words * 4u > k.len - k.off) BAIL("class %u: truncated field metadata", (unsigned)i); + uint32_t *mp = realloc(m->metapool, (meta_pool + (meta_words ? meta_words : 1)) * sizeof(uint32_t)); + if (!mp) BAIL("out of memory"); + m->metapool = mp; + for (size_t w = 0; w < meta_words; w++) { + uint32_t val; + if (rd_u32(&k, &val)) BAIL("class %u: truncated field metadata", (unsigned)i); + m->metapool[meta_pool + w] = val; + } + for (uint32_t j = 0; j < fcnt; j++) { + uint32_t nm = m->metapool[meta_pool + j]; + if (nm != WOB_NONE && (nm >= m->const_cnt || m->consts[nm].tag != WOB_K_TEXT)) + BAIL("class %u field %u: bad name constant", (unsigned)i, (unsigned)j); + uint32_t fc = m->metapool[meta_pool + fcnt + j]; + if (fc != WOB_NONE && fc != WOB_FIELD_JSON_RAW && fc >= kcnt) + BAIL("class %u field %u: field class out of range", (unsigned)i, (unsigned)j); + } m->classes[i].name = name; m->classes[i].flags = flags; m->classes[i].field_cnt = fcnt; m->classes[i].kinds = (const uint8_t *)(uintptr_t)pool_len; /* offset */ + /* offsets too; fixed up to pointers once the pool stops moving */ + m->classes[i].field_names = (const uint32_t *)(uintptr_t)meta_pool; + m->classes[i].field_class = (const uint32_t *)(uintptr_t)(meta_pool + fcnt); + m->classes[i].field_elem = (const uint32_t *)(uintptr_t)(meta_pool + 2u * (size_t)fcnt); pool_len += fcnt ? fcnt : 1; + meta_pool += meta_words ? meta_words : 1; m->class_cnt = i + 1; } - for (uint32_t i = 0; i < m->class_cnt; i++) + for (uint32_t i = 0; i < m->class_cnt; i++) { m->classes[i].kinds = m->kindpool + (uintptr_t)m->classes[i].kinds; + m->classes[i].field_names = m->metapool + (uintptr_t)m->classes[i].field_names; + m->classes[i].field_class = m->metapool + (uintptr_t)m->classes[i].field_class; + m->classes[i].field_elem = m->metapool + (uintptr_t)m->classes[i].field_elem; + } /* ---- interfaces + vtable rows (expanded to sorted triples) ---- */ cur_t s = {buf, len, ioff}; @@ -485,6 +520,7 @@ void wo_module_free(wo_module *m) { free(m->consts); free(m->classes); free(m->kindpool); + free(m->metapool); free(m->vtabs); for (uint32_t i = 0; i < m->method_cnt; i++) { free(m->methods[i].code); diff --git a/runtime/src/loader.h b/runtime/src/loader.h index 91e5d9c..36a885f 100644 --- a/runtime/src/loader.h +++ b/runtime/src/loader.h @@ -51,6 +51,9 @@ typedef struct wo_module { wo_classdesc *classes; uint32_t class_cnt; uint8_t *kindpool; /* pooled field-kind bytes the classes point into */ + /* pooled per-field metadata (v2): names, referenced class ids, element + kinds — see wob.h's "class-table field metadata" note */ + uint32_t *metapool; uint32_t slot_cnt; /* total interface slots across all interfaces */ wo_vtabent *vtabs; uint32_t vtab_cnt; diff --git a/runtime/src/wob.h b/runtime/src/wob.h index 904d9da..748426a 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -12,7 +12,7 @@ /* ---- file header (44 bytes, absolute offsets) ---- */ #define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */ -#define WOB_VERSION 1u +#define WOB_VERSION 2u /* v2 adds per-field names/types to the class table */ #define WOB_HDR_SIZE 44u #define WOB_OFF_MAGIC 0u #define WOB_OFF_VERSION 4u @@ -23,6 +23,24 @@ #define WOB_OFF_ENTRY 40u #define WOB_NONE 0xFFFFFFFFu /* "no entry method" / "free fn" class id */ +/* ---- class-table field metadata (v2) ---- + * Every class row carries, after its kind bytes, three u32 arrays — one + * entry per field: the constant index of the field's NAME, the class id the + * field REFERS to, and the element kinds of a container field. They exist + * for one reason: `json.encode`/`json.decode` are runtime services driven by + * class metadata (runtime/src/json.c) instead of per-type generated code, so + * the names a JSON object needs and the shapes a decode must build have to + * be in the image. Absent metadata is WOB_NONE / 0, which every other part + * of the runtime ignores. + * + * field_class[i]: the class id of an OWNED/GCREF field, or of a container + * field's element when that element is a class; WOB_FIELD_JSON_RAW marks a + * `json.Value` field, whose Text holds raw JSON that encode emits verbatim + * and decode captures unparsed; WOB_NONE otherwise. + * field_elem[i]: for a MULTI field, its element kind; for a MAP field, the + * key kind in the low byte and the value kind in the next; 0 otherwise. */ +#define WOB_FIELD_JSON_RAW 0xFFFFFFFEu + /* ---- constant pool tags ---- */ #define WOB_K_INT 0u /* tag byte, then i64 */ #define WOB_K_TEXT 1u /* tag byte, then u32 len + bytes (no NUL) */ @@ -235,8 +253,16 @@ enum { WO_B_NET_WRITE = 54, /* (fd, text) -> 0 */ WO_B_NET_CLOSE = 55, /* (fd) -> 0 */ WO_B_PROC_RUN = 56, /* (cmd, multi Text args, cls) -> Proc {code, out, err} */ + /* ---- json (runtime/src/json.c): metadata-driven, not per-type code. + * encode takes the STATIC kind of its argument, because a register alone + * cannot say whether it holds an i64 or a pointer; everything below the + * top level comes from object headers and the class table. decode takes + * the class id to build, and yields nil (0) on malformed input — never a + * trap, which is what makes `json.decode(t) as T` a checked decode. ---- */ + WO_B_JSON_ENCODE = 57, /* (value, kind) -> Text */ + WO_B_JSON_DECODE = 58, /* (text, cls) -> ?instance of cls */ }; -#define WO_B_MAX 56u +#define WO_B_MAX 58u /* ids at or above this one live in sysio.c, not builtin.c */ #define WO_B_SYS_FIRST WO_B_FS_EXISTS @@ -264,6 +290,12 @@ typedef struct wo_classdesc { uint32_t flags; /* bit0: instances are @gc */ uint32_t field_cnt; const uint8_t *kinds; /* field_cnt kind bytes, declaration order */ + /* v2 per-field metadata, field_cnt entries each — see the + * "class-table field metadata" note above. Both may be NULL for a + * class an image wrote with no metadata at all. */ + const uint32_t *field_names; /* constant index of each field's name */ + const uint32_t *field_class; /* referenced class id / JSON_RAW / NONE */ + const uint32_t *field_elem; /* container element kinds */ } wo_classdesc; #define WO_CLASSF_GC 0x01u diff --git a/runtime/test/wob_build.c b/runtime/test/wob_build.c index 7a86281..c6c9ca2 100644 --- a/runtime/test/wob_build.c +++ b/runtime/test/wob_build.c @@ -59,6 +59,12 @@ uint32_t wb_class(wb_t *b, uint32_t name_const, uint32_t flags, put_u32(&b->classes, field_cnt); put(&b->classes, kinds, field_cnt); for (uint32_t pad = field_cnt; pad % 4; pad++) put_u8(&b->classes, 0); + /* v2 per-field metadata (wob.h): a hand-built image records no field + names and no referenced classes — WOB_NONE reads as "not recorded", + which every consumer but json.encode/decode ignores. */ + for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, WOB_NONE); + for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, WOB_NONE); + for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, 0); return b->class_cnt++; }