From 1dda7991a8780ec35b98120adf17b39a91effbb6 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:53:28 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20real=20statement=20lowering?= =?UTF-8?q?=20=E2=80=94=20let/if/else/while/indexed=20access=20+=20skip=5F?= =?UTF-8?q?declaration=20bug=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes example 03/04 system bodies from representative stubs to REAL typed loads/stores with pinned access sites, executed correctly in a real engine. - parser: general statement lowerer (tried after the exact reader/ writer shapes; bails to the stub on anything unsupported). Covers `let [mut] x: ty = expr`, assignment, `if`/`else`, `while` (block/ loop/br_if), `return [expr]`, indexed `region.get/set $h[i] .f` (base + i*stride), `cast`, typed expression lowering with precedence (cmp > add > mul > primary) and no implicit promotion. Each region param is copied to a base local ONCE in the prologue so own/&mut discipline holds under per-path use counting regardless of how many accesses follow. - IR: Op gains locals/control-flow/arithmetic/conversion instructions; Func gains `locals` (emitted via Function::new_with_locals_types). - REAL BUG FIX: skip_declaration skipped a balanced `{...}` block and then KEPT SCANNING for a `;` — so example-04's top-level `invariant ... { ... }` blocks swallowed every following declaration: ex04's functions were never parsed at all (its "6/6 corpus round-trip" was regions-only). Now returns after a block-shaped declaration; ex04 parses 4 functions, 3 lower for real. - tests/example04.rs: real-lowering assertions for ex03/ex04 bodies (pinned sites), all-verifier-passes gate (validate + L7/L10 + L2 bounds + L2 access-typing), and a wasmi execution gate proving while/if-else/indexed/cast lowering COMPUTES correct results (sum-abs + f32-scale over a cell array). Workspace: 166 tests, 0 failures. Co-Authored-By: Claude Fable 5 --- crates/typed-wasm-codegen/README.md | 2 +- crates/typed-wasm-codegen/src/lib.rs | 137 +++- crates/typed-wasm-codegen/src/parser.rs | 585 +++++++++++++++++- .../typed-wasm-codegen/tests/access_typing.rs | 6 + crates/typed-wasm-codegen/tests/corpus.rs | 2 + crates/typed-wasm-codegen/tests/errors.rs | 1 + crates/typed-wasm-codegen/tests/example04.rs | 167 +++++ .../typed-wasm-codegen/tests/optimization.rs | 3 + 8 files changed, 891 insertions(+), 12 deletions(-) create mode 100644 crates/typed-wasm-codegen/tests/example04.rs diff --git a/crates/typed-wasm-codegen/README.md b/crates/typed-wasm-codegen/README.md index 6cbc142..139946b 100644 --- a/crates/typed-wasm-codegen/README.md +++ b/crates/typed-wasm-codegen/README.md @@ -52,7 +52,7 @@ cargo test -p typed-wasm-codegen | WAT (text) emission | **emitted** via `--emit wat\|both` (#125) | | `typedwasm.ownership` (L7/L10) | **emitted** for any `own`/`&mut`/`&` source discipline (parser-recorded, incl. Linear returns) and for the multi-module callee (#128) | | Multi-module (linear boundary) | **emitted** — callee export + caller import round-trip through `verify_cross_module` (#128) | -| Function-body lowering | representative type-correct bodies, not full `region.scan`/indexing semantics | +| Function-body lowering | **real statement lowering** — `let`, assignment, `if`/`else`, `while`, indexed `region.get`/`region.set`, `cast<>` (wasmi-executed, `tests/example04.rs`); `region.scan` and `opt` unwraps still stub | ## Where this goes next diff --git a/crates/typed-wasm-codegen/src/lib.rs b/crates/typed-wasm-codegen/src/lib.rs index 3d63552..ab45448 100644 --- a/crates/typed-wasm-codegen/src/lib.rs +++ b/crates/typed-wasm-codegen/src/lib.rs @@ -31,8 +31,10 @@ //! emitted whenever the source declares discipline (`own` / `&mut` / //! `&` qualifiers) — the parser records them into [`Module::ownership`] //! and `emit` writes the carrier (exercised by `tests/example03.rs`). -//! * Full function-body semantics (indexing, `region.scan`, null checks): -//! v0 emits type-correct representative bodies, not the full lowering. +//! * Function bodies lower for real where the statement lowerer covers +//! them (`let`, assignment, `if`/`else`, `while`, indexed access, +//! `cast<>` — see `parser.rs`); `region.scan` closures and `opt` +//! null-checks still fall back to type-correct representative stubs. use typed_wasm_verify::section::{ build_access_sites_section_payload, AccessSiteEntry, ACCESS_SITE_UNPINNED, NO_TARGET_REGION, @@ -47,7 +49,7 @@ use typed_wasm_verify::{ REGION_IMPORTS_SECTION_NAME, }; use wasm_encoder::{ - CodeSection, CustomSection, EntityType, ExportKind, ExportSection, Function, FunctionSection, + BlockType, CodeSection, CustomSection, EntityType, ExportKind, ExportSection, Function, FunctionSection, ImportSection, Instruction, MemArg, MemorySection, MemoryType, Module as WasmModule, NameMap, NameSection, TypeSection, ValType, }; @@ -239,6 +241,61 @@ pub enum Op { Call(u32), /// Drop the top stack value — consumes a value exactly once. Drop, + // --- Locals + control flow + arithmetic (front-end statement + // lowering, ADR-0006: `let`, assignment, `if`/`else`, `while`) --- + LocalSet(u32), + LocalTee(u32), + /// `block (empty)` — the `while` exit target. + Block, + /// `loop (empty)` — the `while` back-edge target. + Loop, + /// `if (empty)` — statement-position conditional (no result value). + If, + Else, + End, + Br(u32), + BrIf(u32), + Return, + I32Eqz, + I32Add, + I32Sub, + I32Mul, + I32DivS, + I32Eq, + I32Ne, + I32LtS, + I32LeS, + I32GtS, + I32GeS, + I64Add, + I64Sub, + I64Mul, + F32Add, + F32Sub, + F32Mul, + F32Div, + F32Eq, + F32Ne, + F32Lt, + F32Le, + F32Gt, + F32Ge, + F64Add, + F64Sub, + F64Mul, + F64Div, + F64Eq, + F64Ne, + F64Lt, + F64Le, + F64Gt, + F64Ge, + /// `cast(f32/f64 expr)` — saturating-free truncation (traps on + /// NaN/overflow, matching wasm core `i32.trunc_f*_s`). + I32TruncF32S, + I32TruncF64S, + F32ConvertI32S, + F64ConvertI32S, } /// Ownership discipline for a function parameter, emitted into the @@ -297,6 +354,11 @@ pub struct Func { pub name: String, pub params: Vec, pub results: Vec, + /// Extra (non-param) locals, indexed after the params. Statement + /// lowering allocates these for `let` bindings, `region.get` + /// destinations, and the per-handle base-pointer copies that keep + /// L7/L10 param-use counts at exactly one. + pub locals: Vec, pub body: Vec, pub accesses: Vec, pub export: bool, @@ -399,6 +461,54 @@ fn op_to_instruction(op: Op) -> Instruction<'static> { Op::F64Store { offset } => Instruction::F64Store(memarg(offset, 3)), Op::Call(i) => Instruction::Call(i), Op::Drop => Instruction::Drop, + Op::LocalSet(i) => Instruction::LocalSet(i), + Op::LocalTee(i) => Instruction::LocalTee(i), + Op::Block => Instruction::Block(BlockType::Empty), + Op::Loop => Instruction::Loop(BlockType::Empty), + Op::If => Instruction::If(BlockType::Empty), + Op::Else => Instruction::Else, + Op::End => Instruction::End, + Op::Br(d) => Instruction::Br(d), + Op::BrIf(d) => Instruction::BrIf(d), + Op::Return => Instruction::Return, + Op::I32Eqz => Instruction::I32Eqz, + Op::I32Add => Instruction::I32Add, + Op::I32Sub => Instruction::I32Sub, + Op::I32Mul => Instruction::I32Mul, + Op::I32DivS => Instruction::I32DivS, + Op::I32Eq => Instruction::I32Eq, + Op::I32Ne => Instruction::I32Ne, + Op::I32LtS => Instruction::I32LtS, + Op::I32LeS => Instruction::I32LeS, + Op::I32GtS => Instruction::I32GtS, + Op::I32GeS => Instruction::I32GeS, + Op::I64Add => Instruction::I64Add, + Op::I64Sub => Instruction::I64Sub, + Op::I64Mul => Instruction::I64Mul, + Op::F32Add => Instruction::F32Add, + Op::F32Sub => Instruction::F32Sub, + Op::F32Mul => Instruction::F32Mul, + Op::F32Div => Instruction::F32Div, + Op::F32Eq => Instruction::F32Eq, + Op::F32Ne => Instruction::F32Ne, + Op::F32Lt => Instruction::F32Lt, + Op::F32Le => Instruction::F32Le, + Op::F32Gt => Instruction::F32Gt, + Op::F32Ge => Instruction::F32Ge, + Op::F64Add => Instruction::F64Add, + Op::F64Sub => Instruction::F64Sub, + Op::F64Mul => Instruction::F64Mul, + Op::F64Div => Instruction::F64Div, + Op::F64Eq => Instruction::F64Eq, + Op::F64Ne => Instruction::F64Ne, + Op::F64Lt => Instruction::F64Lt, + Op::F64Le => Instruction::F64Le, + Op::F64Gt => Instruction::F64Gt, + Op::F64Ge => Instruction::F64Ge, + Op::I32TruncF32S => Instruction::I32TruncF32S, + Op::I32TruncF64S => Instruction::I32TruncF64S, + Op::F32ConvertI32S => Instruction::F32ConvertI32S, + Op::F64ConvertI32S => Instruction::F64ConvertI32S, } } @@ -443,7 +553,8 @@ pub fn emit(module: &Module) -> Vec { let global_idx = import_count + local_i as u32; funcs.function(global_idx); - let mut f = Function::new([]); + let mut f = + Function::new_with_locals_types(func.locals.iter().map(|l| l.to_val_type())); for op in &func.body { f.instruction(&op_to_instruction(*op)); } @@ -606,6 +717,7 @@ pub fn example01() -> Module { name: "get_player_hp".into(), params: vec![Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::I32Load { offset: 0 }], accesses: vec![AccessSite { region: 1, @@ -619,6 +731,7 @@ pub fn example01() -> Module { name: "damage_player".into(), params: vec![Wty::I32, Wty::I32, Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::LocalGet(2), Op::I32Store { offset: 0 }], accesses: vec![AccessSite { region: 1, @@ -633,6 +746,7 @@ pub fn example01() -> Module { name: "get_enemy_target_hp".into(), params: vec![Wty::I32, Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::I32Load { offset: 0 }], accesses: vec![ AccessSite { @@ -653,6 +767,7 @@ pub fn example01() -> Module { name: "count_active_enemies".into(), params: vec![Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::I32Const(0)], accesses: vec![AccessSite { region: 2, @@ -666,6 +781,7 @@ pub fn example01() -> Module { name: "move_player".into(), params: vec![Wty::I32, Wty::I32, Wty::F32, Wty::F32], results: vec![], + locals: vec![], body: vec![ Op::LocalGet(0), Op::LocalGet(2), @@ -746,6 +862,7 @@ pub fn paint_type_tile() -> Module { name: "alloc_tile".into(), params: vec![Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::Drop, Op::I32Const(0)], accesses: vec![], export: true, @@ -756,6 +873,7 @@ pub fn paint_type_tile() -> Module { name: "free_tile".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::Drop], accesses: vec![], export: true, @@ -766,6 +884,7 @@ pub fn paint_type_tile() -> Module { name: "fill_tile".into(), params: vec![Wty::I32, Wty::I32, Wty::I32, Wty::I32, Wty::I32], results: vec![], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -784,6 +903,7 @@ pub fn paint_type_tile() -> Module { name: "read_pixel".into(), params: vec![Wty::I32, Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -800,6 +920,7 @@ pub fn paint_type_tile() -> Module { name: "write_pixel".into(), params: vec![Wty::I32, Wty::I32, Wty::I32, Wty::I32, Wty::I32, Wty::I32, Wty::I32], results: vec![], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -820,6 +941,7 @@ pub fn paint_type_tile() -> Module { name: "blit_tile".into(), params: vec![Wty::I32, Wty::I32], results: vec![], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -893,6 +1015,7 @@ pub fn paint_type_layer() -> Module { name: "stack_new".into(), params: vec![], results: vec![Wty::I32], + locals: vec![], body: vec![Op::I32Const(0)], accesses: vec![], export: true, @@ -902,6 +1025,7 @@ pub fn paint_type_layer() -> Module { name: "stack_free".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::Drop], accesses: vec![], export: true, @@ -911,6 +1035,7 @@ pub fn paint_type_layer() -> Module { name: "push_layer".into(), params: vec![Wty::I32, Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -929,6 +1054,7 @@ pub fn paint_type_layer() -> Module { name: "get_id_at".into(), params: vec![Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -944,6 +1070,7 @@ pub fn paint_type_layer() -> Module { name: "set_opacity".into(), params: vec![Wty::I32, Wty::I32, Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![ Op::LocalGet(0), Op::Drop, Op::LocalGet(1), Op::Drop, @@ -1003,6 +1130,7 @@ pub fn multimodule_callee() -> Module { name: "consume".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::Drop], // uses the Linear param once accesses: vec![], export: true, @@ -1035,6 +1163,7 @@ pub fn multimodule_caller(call_count: u32) -> Module { name: "use_resource".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body, accesses: vec![], export: false, diff --git a/crates/typed-wasm-codegen/src/parser.rs b/crates/typed-wasm-codegen/src/parser.rs index 8e9ad2e..60a695e 100644 --- a/crates/typed-wasm-codegen/src/parser.rs +++ b/crates/typed-wasm-codegen/src/parser.rs @@ -694,14 +694,27 @@ impl<'a> Parser<'a> { // closing `}` on success; on a None it leaves the cursor unspecified, so // we restore `body_start` before the next attempt and before the stub. let body_start = self.pos; - let lowered = self.try_lower_reader(¶m_meta, &results).or_else(|| { - self.pos = body_start; - self.try_lower_writer(¶ms, ¶m_meta, &results) - }); - let (body, accesses) = match lowered { - Some((body, accesses)) => (body, accesses), + let memory_before = self.memory; + let lowered = self + .try_lower_reader(¶m_meta, &results) + .map(|(b, a)| (Vec::new(), b, a)) + .or_else(|| { + self.pos = body_start; + self.try_lower_writer(¶ms, ¶m_meta, &results) + .map(|(b, a)| (Vec::new(), b, a)) + }) + .or_else(|| { + // General statement lowerer (locals, if/else, while, + // indexed access) — bails to the stub on any + // unsupported construct. + self.pos = body_start; + self.try_lower_stmts(¶ms, ¶m_meta, &results) + }); + let (locals, body, accesses) = match lowered { + Some(triple) => triple, None => { self.pos = body_start; + self.memory = memory_before; let mut body = Vec::new(); // Representative stub: drop all params, push a typed zero result. for i in 0..params.len() as u32 { @@ -717,7 +730,7 @@ impl<'a> Parser<'a> { }); } self.skip_to_brace_close(); - (body, Vec::new()) + (Vec::new(), body, Vec::new()) } }; @@ -737,6 +750,7 @@ impl<'a> Parser<'a> { name, params, results, + locals, body, accesses, export: true, // All functions in .twasm are exported by default @@ -1240,6 +1254,11 @@ impl<'a> Parser<'a> { } self.pos += 1; } + // A block-shaped declaration (`invariant name { ... }`) + // ends with its balanced braces. Continuing to scan for + // a `;` here used to swallow every following top-level + // declaration (all of example-04's functions). + return; } else { self.pos += 1; } @@ -1407,6 +1426,558 @@ fn scalar_to_wire_ty(s: &Scalar) -> typed_wasm_verify::WasmTy { } } +// ---------------------------------------------------------------------- +// General statement lowering (ADR-0006 debt: `let`, assignment, +// `if`/`else`, `while`, indexed `region.get`/`region.set`, `return`). +// Tried after the exact single-statement reader/writer shapes; bails to +// the type-correct stub on ANY unsupported construct (`region.scan`, +// `opt` unwraps, calls, unknown idents), leaving the parser cursor +// for the caller to restore. +// ---------------------------------------------------------------------- + +/// Lowering context for one function body. Extra locals hold `let` +/// bindings, `region.get` destinations, and one base-pointer copy per +/// region-typed param — the body reads each region PARAM exactly once +/// (in the prologue) so `own`/`&mut` discipline holds under the +/// verifier's per-path use counting no matter how many accesses follow. +struct Lower { + /// Scalar bindings visible to expressions: name → (local slot, type). + names: HashMap, + /// Region handles: param name → (region index, base-local slot). + handles: HashMap, + /// Extra locals, indexed after the params. + locals: Vec, + n_params: u32, + ops: Vec, + accesses: Vec, + /// Regions touched — memory is synthesised only on successful lowering. + used_regions: Vec, +} + +impl Lower { + fn alloc(&mut self, wty: Wty) -> u32 { + let slot = self.n_params + self.locals.len() as u32; + self.locals.push(wty); + slot + } +} + +impl<'a> Parser<'a> { + /// Whole-body statement lowerer. Cursor sits just inside the body + /// `{`; on success it is past the matching `}` and the return value + /// is `(extra_locals, ops, access_sites)`. None = fall back to stub + /// (caller restores the cursor; no parser state was mutated). + fn try_lower_stmts( + &mut self, + params: &[Wty], + param_meta: &[(String, Option)], + results: &[Wty], + ) -> Option<(Vec, Vec, Vec)> { + let mut cx = Lower { + names: HashMap::new(), + handles: HashMap::new(), + locals: Vec::new(), + n_params: params.len() as u32, + ops: Vec::new(), + accesses: Vec::new(), + used_regions: Vec::new(), + }; + // Prologue: copy each region param into a base local (its single + // use), bind scalar params by name. + for (i, (name, region)) in param_meta.iter().enumerate() { + match region { + Some(r) => { + let slot = cx.alloc(Wty::I32); + cx.ops.push(crate::Op::LocalGet(i as u32)); + cx.ops.push(crate::Op::LocalSet(slot)); + if !name.is_empty() { + cx.handles.insert(name.clone(), (*r, slot)); + } + cx.used_regions.push(*r); + } + None => { + if !name.is_empty() { + cx.names.insert(name.clone(), (i as u32, params[i])); + } + } + } + } + + let ended_with_return = self.lower_block(&mut cx, results)?; + if !results.is_empty() && !ended_with_return { + return None; // fall-off-the-end of a value-returning body + } + for r in cx.used_regions.clone() { + self.ensure_memory_for_region(r); + } + Some((cx.locals, cx.ops, cx.accesses)) + } + + /// Lower statements until the closing `}` (consumed). Returns whether + /// the LAST top-level statement was a `return`. + fn lower_block(&mut self, cx: &mut Lower, results: &[Wty]) -> Option { + let mut last_was_return = false; + loop { + self.skip_whitespace(); + if self.try_char('}') { + return Some(last_was_return); + } + if self.pos >= self.src.len() { + return None; + } + last_was_return = self.lower_stmt(cx, results)?; + } + } + + /// Lower one statement. Returns Some(true) iff it was a `return`. + fn lower_stmt(&mut self, cx: &mut Lower, results: &[Wty]) -> Option { + self.skip_whitespace(); + if self.try_keyword("region") { + if !self.try_char('.') { + return None; + } + if self.try_keyword("get") { + return self.lower_region_get(cx).map(|_| false); + } + if self.try_keyword("set") { + return self.lower_region_set(cx).map(|_| false); + } + return None; // alloc / free / scan / place: unsupported here + } + if self.try_keyword("let") { + self.try_keyword("mut"); + let name = self.parse_ident(); + if name.is_empty() || !self.try_char(':') { + return None; + } + let tyname = self.parse_ident(); + let wty = wty_from_type_name(&tyname)?; + if !self.try_char('=') { + return None; + } + let got = self.lower_expr(cx, Some(wty))?; + if got != wty || !self.try_char(';') { + return None; + } + let slot = cx.alloc(wty); + cx.ops.push(crate::Op::LocalSet(slot)); + cx.names.insert(name, (slot, wty)); + return Some(false); + } + if self.try_keyword("if") { + self.lower_if(cx, results)?; + return Some(false); + } + if self.try_keyword("while") { + // block { loop { eqz br_if 1 ; ; br 0 } } + cx.ops.push(crate::Op::Block); + cx.ops.push(crate::Op::Loop); + if self.lower_expr(cx, None)? != Wty::I32 { + return None; + } + cx.ops.push(crate::Op::I32Eqz); + cx.ops.push(crate::Op::BrIf(1)); + if !self.try_char('{') { + return None; + } + self.lower_block(cx, results)?; + cx.ops.push(crate::Op::Br(0)); + cx.ops.push(crate::Op::End); + cx.ops.push(crate::Op::End); + return Some(false); + } + if self.try_keyword("return") { + if self.try_char(';') { + if !results.is_empty() { + return None; // bare return in a value-returning body + } + cx.ops.push(crate::Op::Return); + return Some(true); + } + let &want = results.first()?; + self.try_char('$'); // optional `$` on a returned binding + if self.lower_expr(cx, Some(want))? != want || !self.try_char(';') { + return None; + } + cx.ops.push(crate::Op::Return); + return Some(true); + } + if self.try_keyword("proof") { + // Inline proof annotation: no runtime ops. + let _name = self.parse_ident(); + if !self.try_char('{') { + return None; + } + self.skip_to_brace_close(); + return Some(false); + } + // Assignment: `name = expr ;` (rejecting `==`). + let save = self.pos; + let name = self.parse_ident(); + if name.is_empty() { + return None; + } + let &(slot, wty) = cx.names.get(&name)?; + self.skip_whitespace(); + if self.src.as_bytes().get(self.pos) != Some(&b'=') + || self.src.as_bytes().get(self.pos + 1) == Some(&b'=') + { + self.pos = save; + return None; + } + self.pos += 1; + if self.lower_expr(cx, Some(wty))? != wty || !self.try_char(';') { + return None; + } + cx.ops.push(crate::Op::LocalSet(slot)); + Some(false) + } + + /// `if { … } [else { … }]` — statement position, no value. + fn lower_if(&mut self, cx: &mut Lower, results: &[Wty]) -> Option<()> { + if self.lower_expr(cx, None)? != Wty::I32 { + return None; + } + if !self.try_char('{') { + return None; + } + cx.ops.push(crate::Op::If); + self.lower_block(cx, results)?; + if self.try_keyword("else") { + cx.ops.push(crate::Op::Else); + if !self.try_char('{') { + return None; + } + self.lower_block(cx, results)?; + } + cx.ops.push(crate::Op::End); + Some(()) + } + + /// Push the address of `$handle` or `$handle[index]`; cursor ends + /// after the optional `]`. Returns the region index. + fn lower_region_addr(&mut self, cx: &mut Lower) -> Option { + if !self.try_char('$') { + return None; + } + let hname = self.parse_ident(); + let &(region, base) = cx.handles.get(&hname)?; + cx.ops.push(crate::Op::LocalGet(base)); + if self.try_char('[') { + if self.lower_expr(cx, Some(Wty::I32))? != Wty::I32 || !self.try_char(']') { + return None; + } + let stride = self.regions.get(region)?.byte_size; + cx.ops.push(crate::Op::I32Const(i32::try_from(stride).ok()?)); + cx.ops.push(crate::Op::I32Mul); + cx.ops.push(crate::Op::I32Add); + } + Some(region) + } + + /// `region.get $h[i]? .field -> name ;` — typed load into a fresh local. + fn lower_region_get(&mut self, cx: &mut Lower) -> Option<()> { + let region = self.lower_region_addr(cx)?; + if !self.try_char('.') { + return None; + } + let field = self.parse_ident(); + let (field_idx, offset, scalar) = self.resolve_field(region, &field)?; + let load_at = cx.ops.len(); + cx.ops.push(scalar_load_op(&scalar, offset as u64)); + cx.accesses.push(crate::AccessSite { + region, + field: field_idx, + instr_index: Some(load_at), + }); + if !self.try_str("->") { + return None; + } + let name = self.parse_ident(); + if name.is_empty() || !self.try_char(';') { + return None; + } + let wty = wty_from_scalar(&scalar); + let slot = cx.alloc(wty); + cx.ops.push(crate::Op::LocalSet(slot)); + cx.names.insert(name, (slot, wty)); + Some(()) + } + + /// `region.set $h[i]? .field , expr ;` — typed store. + fn lower_region_set(&mut self, cx: &mut Lower) -> Option<()> { + let region = self.lower_region_addr(cx)?; + if !self.try_char('.') { + return None; + } + let field = self.parse_ident(); + let (field_idx, offset, scalar) = self.resolve_field(region, &field)?; + if !self.try_char(',') { + return None; + } + let wty = wty_from_scalar(&scalar); + if self.lower_expr(cx, Some(wty))? != wty || !self.try_char(';') { + return None; + } + let store_at = cx.ops.len(); + cx.ops.push(scalar_store_op(&scalar, offset as u64)); + cx.accesses.push(crate::AccessSite { + region, + field: field_idx, + instr_index: Some(store_at), + }); + Some(()) + } + + // --- Typed expression lowering (precedence: cmp > add > mul > primary). + // `expected` seeds literal typing; operands of a binary op must agree + // exactly (no implicit promotion — a mismatch bails the whole body). + + fn lower_expr(&mut self, cx: &mut Lower, expected: Option) -> Option { + let lty = self.lower_add(cx, expected)?; + self.skip_whitespace(); + let cmp = if self.try_str("==") { + "==" + } else if self.try_str("!=") { + "!=" + } else if self.try_str("<=") { + "<=" + } else if self.try_str(">=") { + ">=" + } else if self.src.as_bytes().get(self.pos) == Some(&b'<') { + self.pos += 1; + "<" + } else if self.src.as_bytes().get(self.pos) == Some(&b'>') { + self.pos += 1; + ">" + } else { + return Some(lty); + }; + if self.lower_add(cx, Some(lty))? != lty { + return None; + } + use crate::Op::*; + cx.ops.push(match (lty, cmp) { + (Wty::I32, "==") => I32Eq, + (Wty::I32, "!=") => I32Ne, + (Wty::I32, "<") => I32LtS, + (Wty::I32, "<=") => I32LeS, + (Wty::I32, ">") => I32GtS, + (Wty::I32, ">=") => I32GeS, + (Wty::F32, "==") => F32Eq, + (Wty::F32, "!=") => F32Ne, + (Wty::F32, "<") => F32Lt, + (Wty::F32, "<=") => F32Le, + (Wty::F32, ">") => F32Gt, + (Wty::F32, ">=") => F32Ge, + (Wty::F64, "==") => F64Eq, + (Wty::F64, "!=") => F64Ne, + (Wty::F64, "<") => F64Lt, + (Wty::F64, "<=") => F64Le, + (Wty::F64, ">") => F64Gt, + (Wty::F64, ">=") => F64Ge, + _ => return None, // i64 comparisons: unsupported + }); + Some(Wty::I32) + } + + fn lower_add(&mut self, cx: &mut Lower, expected: Option) -> Option { + let ty = self.lower_mul(cx, expected)?; + loop { + self.skip_whitespace(); + let op = match self.src.as_bytes().get(self.pos) { + Some(&b'+') => crate::Op::I32Add, + Some(&b'-') => crate::Op::I32Sub, + _ => return Some(ty), + }; + let plus = matches!(op, crate::Op::I32Add); + self.pos += 1; + if self.lower_mul(cx, Some(ty))? != ty { + return None; + } + use crate::Op::*; + cx.ops.push(match (ty, plus) { + (Wty::I32, true) => I32Add, + (Wty::I32, false) => I32Sub, + (Wty::I64, true) => I64Add, + (Wty::I64, false) => I64Sub, + (Wty::F32, true) => F32Add, + (Wty::F32, false) => F32Sub, + (Wty::F64, true) => F64Add, + (Wty::F64, false) => F64Sub, + }); + } + } + + fn lower_mul(&mut self, cx: &mut Lower, expected: Option) -> Option { + let ty = self.lower_primary(cx, expected)?; + loop { + self.skip_whitespace(); + let times = match self.src.as_bytes().get(self.pos) { + Some(&b'*') => true, + Some(&b'/') if self.src.as_bytes().get(self.pos + 1) != Some(&b'/') => false, + _ => return Some(ty), + }; + self.pos += 1; + if self.lower_primary(cx, Some(ty))? != ty { + return None; + } + use crate::Op::*; + cx.ops.push(match (ty, times) { + (Wty::I32, true) => I32Mul, + (Wty::I32, false) => I32DivS, + (Wty::I64, true) => I64Mul, + (Wty::I64, false) => return None, // i64 division: unsupported + (Wty::F32, true) => F32Mul, + (Wty::F32, false) => F32Div, + (Wty::F64, true) => F64Mul, + (Wty::F64, false) => F64Div, + }); + } + } + + fn lower_primary(&mut self, cx: &mut Lower, expected: Option) -> Option { + self.skip_whitespace(); + let &b = self.src.as_bytes().get(self.pos)?; + if b == b'(' { + self.pos += 1; + let ty = self.lower_expr(cx, expected)?; + if !self.try_char(')') { + return None; + } + return Some(ty); + } + if b == b'-' || b.is_ascii_digit() { + return self.lower_literal(cx, expected); + } + let save = self.pos; + let ident = self.parse_ident(); + if ident.is_empty() { + return None; + } + match ident.as_str() { + "true" => { + cx.ops.push(crate::Op::I32Const(1)); + return Some(Wty::I32); + } + "false" => { + cx.ops.push(crate::Op::I32Const(0)); + return Some(Wty::I32); + } + "cast" => { + if !self.try_char('<') { + return None; + } + let target = wty_from_type_name(&self.parse_ident())?; + if !self.try_char('>') || !self.try_char('(') { + return None; + } + let src_ty = self.lower_expr(cx, None)?; + if !self.try_char(')') { + return None; + } + use crate::Op::*; + cx.ops.push(match (src_ty, target) { + (Wty::F32, Wty::I32) => I32TruncF32S, + (Wty::F64, Wty::I32) => I32TruncF64S, + (Wty::I32, Wty::F32) => F32ConvertI32S, + (Wty::I32, Wty::F64) => F64ConvertI32S, + _ => return None, + }); + return Some(target); + } + _ => {} + } + if let Some(&(slot, wty)) = cx.names.get(&ident) { + cx.ops.push(crate::Op::LocalGet(slot)); + return Some(wty); + } + self.pos = save; + None // unknown ident: is_null / sizeof / handle-as-value / call + } + + /// Numeric literal, typed by `expected` (default: `.`-bearing → F32, + /// integer → I32). Rejects range overflows — including a finite + /// literal that would round to ±inf as f32 — by bailing. + fn lower_literal(&mut self, cx: &mut Lower, expected: Option) -> Option { + self.skip_whitespace(); + let start = self.pos; + if self.src.as_bytes().get(self.pos) == Some(&b'-') { + self.pos += 1; + } + let mut is_float = false; + while self.pos < self.src.len() { + let c = self.src.as_bytes()[self.pos]; + if c == b'.' { + is_float = true; + self.pos += 1; + } else if c.is_ascii_alphanumeric() || c == b'_' { + self.pos += 1; + } else { + break; + } + } + let tok = self.src.get(start..self.pos)?; + if is_float { + let v: f64 = tok.parse().ok()?; + return match expected { + Some(Wty::F64) => { + cx.ops.push(crate::Op::F64Const(v)); + Some(Wty::F64) + } + Some(Wty::F32) | None => { + let f = v as f32; + if f.is_infinite() && v.is_finite() { + return None; + } + cx.ops.push(crate::Op::F32Const(f)); + Some(Wty::F32) + } + _ => None, + }; + } + let value: i64 = if let Some(hex) = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16).ok()? as i64 + } else { + tok.parse::().ok()? + }; + match expected { + Some(Wty::I64) => { + cx.ops.push(crate::Op::I64Const(value)); + Some(Wty::I64) + } + Some(Wty::F32) => { + cx.ops.push(crate::Op::F32Const(value as f32)); + Some(Wty::F32) + } + Some(Wty::F64) => { + cx.ops.push(crate::Op::F64Const(value as f64)); + Some(Wty::F64) + } + Some(Wty::I32) | None => { + let v = i32::try_from(value) + .or_else(|_| u32::try_from(value).map(|u| u as i32)) + .ok()?; + cx.ops.push(crate::Op::I32Const(v)); + Some(Wty::I32) + } + } + } +} + +/// `.twasm` scalar type NAME (as written in `let x: ty` / `cast`) +/// → wasm value type. +fn wty_from_type_name(name: &str) -> Option { + match name { + "i8" | "i16" | "i32" | "u8" | "u16" | "u32" | "bool" => Some(Wty::I32), + "i64" | "u64" => Some(Wty::I64), + "f32" => Some(Wty::F32), + "f64" => Some(Wty::F64), + _ => None, + } +} + fn scalar_byte_size(s: &Scalar) -> u32 { match s { Scalar::I8 | Scalar::U8 => 1, diff --git a/crates/typed-wasm-codegen/tests/access_typing.rs b/crates/typed-wasm-codegen/tests/access_typing.rs index 83d39bd..629c0af 100644 --- a/crates/typed-wasm-codegen/tests/access_typing.rs +++ b/crates/typed-wasm-codegen/tests/access_typing.rs @@ -122,6 +122,7 @@ fn teeth_wrong_width_is_type_mismatch() { name: "bad".into(), params: vec![Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::I32Load { offset: 4 }], accesses: vec![pinned(0, 1, 1)], // field 1 = b (u8) export: true, @@ -147,6 +148,7 @@ fn teeth_wrong_offset_is_offset_mismatch() { name: "bad".into(), params: vec![Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::I32Load { offset: 99 }], accesses: vec![pinned(0, 0, 1)], // field 0 = a (i32 @0) export: true, @@ -174,6 +176,7 @@ fn teeth_non_memory_op_is_rejected() { name: "bad".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::Drop], accesses: vec![pinned(0, 0, 1)], // index 1 = Drop export: true, @@ -196,6 +199,7 @@ fn teeth_index_past_end_is_out_of_range() { name: "bad".into(), params: vec![Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::I32Load { offset: 0 }], accesses: vec![pinned(0, 0, 9)], // body has 3 ops incl End export: true, @@ -228,6 +232,7 @@ fn teeth_field_past_region_size_is_out_of_region() { name: "bad".into(), params: vec![Wty::I32], results: vec![Wty::I64], + locals: vec![], body: vec![Op::LocalGet(0), Op::I64Load { offset: 0 }], accesses: vec![pinned(0, 0, 1)], export: true, @@ -257,6 +262,7 @@ fn teeth_correct_handbuilt_site_type_verifies() { name: "good".into(), params: vec![Wty::I32], results: vec![Wty::I32], + locals: vec![], body: vec![Op::LocalGet(0), Op::I32Load { offset: 0 }], accesses: vec![pinned(0, 0, 1)], // field 0 = a (i32 @0) export: true, diff --git a/crates/typed-wasm-codegen/tests/corpus.rs b/crates/typed-wasm-codegen/tests/corpus.rs index 44d64ac..bfa60c3 100644 --- a/crates/typed-wasm-codegen/tests/corpus.rs +++ b/crates/typed-wasm-codegen/tests/corpus.rs @@ -139,6 +139,7 @@ fn gen_valid(seed: u64) -> Module { name: format!("func{k}"), params, results, + locals: vec![], body, accesses: vec![], export: true, @@ -615,6 +616,7 @@ fn one_func_module(kind: Ownership, body: Vec) -> Module { name: "subject".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body, accesses: vec![], export: true, diff --git a/crates/typed-wasm-codegen/tests/errors.rs b/crates/typed-wasm-codegen/tests/errors.rs index 95ed1f5..153ffb0 100644 --- a/crates/typed-wasm-codegen/tests/errors.rs +++ b/crates/typed-wasm-codegen/tests/errors.rs @@ -34,6 +34,7 @@ fn double_free_gives_named_actionable_message() { name: "despawn_particle".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::LocalGet(0), Op::Drop, Op::Drop], accesses: vec![], export: true, diff --git a/crates/typed-wasm-codegen/tests/example04.rs b/crates/typed-wasm-codegen/tests/example04.rs new file mode 100644 index 0000000..2919de0 --- /dev/null +++ b/crates/typed-wasm-codegen/tests/example04.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Statement-lowering coverage (ADR-0006 debt: `let`, assignment, +// `if`/`else`, `while`, indexed access, `cast`) — the increment that +// takes examples 03/04 ECS-style system bodies from representative +// stubs to REAL typed loads/stores with pinned access sites, plus a +// wasmi execution gate proving the lowered control flow COMPUTES the +// intended semantics (not merely that it validates). + +use typed_wasm_codegen::{emit, parser}; +use typed_wasm_verify::{ + verify_access_sites_from_module, verify_access_typing_from_module, verify_from_module, +}; +use wasmi::{Engine, Linker, Module as WasmiModule, Store, TypedFunc}; + +const EX04: &str = include_str!("../../../examples/04-ecs-game.twasm"); +const EX03: &str = include_str!("../../../examples/03-ownership-linearity.twasm"); + +/// example-04's ECS system bodies (while + indexed get/set + if/else + +/// cast + compound f32 expressions) must lower for real: pinned access +/// sites, not the stub's empty access list. +#[test] +fn example04_system_bodies_lower_for_real() { + let module = parser::parse_module(EX04).expect("04-ecs-game.twasm must parse"); + + let sites = |name: &str| { + let f = module + .funcs + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("function {name} must be parsed")); + (f.accesses.len(), f.accesses.iter().filter(|a| a.instr_index.is_some()).count()) + }; + + let (total, pinned) = sites("movement_system"); + assert!(total >= 3, "movement_system: expected >=3 access sites, got {total}"); + assert_eq!(total, pinned, "movement_system: every site must be pinned"); + + let (total, pinned) = sites("health_regen_system"); + assert!(total >= 4, "health_regen_system: expected >=4 access sites, got {total}"); + assert_eq!(total, pinned, "health_regen_system: every site must be pinned"); +} + +/// example-03's update_particle (multi-get/set + early return) must +/// also graduate from the stub. +#[test] +fn example03_update_particle_lowers_for_real() { + let module = parser::parse_module(EX03).expect("03-ownership-linearity.twasm must parse"); + let f = module + .funcs + .iter() + .find(|f| f.name == "update_particle") + .expect("update_particle must be parsed"); + assert!( + f.accesses.len() >= 8, + "update_particle reads 5 fields and writes 4: got {} sites", + f.accesses.len() + ); + assert!(f.accesses.iter().all(|a| a.instr_index.is_some())); +} + +/// The full verifier stack accepts the real-lowered examples: structural +/// validation, L7/L10 ownership, L2 bounds AND L2 access-typing (every +/// pinned instruction is the right op at the right offset). +#[test] +fn real_lowered_examples_pass_all_verifier_passes() { + for (name, src) in [("03", EX03), ("04", EX04)] { + let module = parser::parse_module(src).unwrap_or_else(|e| panic!("ex{name}: {e}")); + let bytes = emit(&module); + wasmparser::Validator::new() + .validate_all(&bytes) + .unwrap_or_else(|e| panic!("ex{name} must validate: {e}")); + verify_from_module(&bytes).unwrap_or_else(|e| panic!("ex{name} L7/L10: {e}")); + let bounds = verify_access_sites_from_module(&bytes).unwrap(); + assert!(bounds.is_empty(), "ex{name} L2 bounds: {bounds:?}"); + let typing = verify_access_typing_from_module(&bytes).unwrap(); + assert!( + typing.errors.is_empty(), + "ex{name} L2 access-typing: {:?}", + typing.errors + ); + } +} + +/// Execution gate for the NEW lowering constructs: while-loops, if/else +/// branches, indexed region access, assignment, and cast<> must COMPUTE +/// correctly in a real engine, not merely validate. +const CELLS: &str = r#" + region Cell { + v: i32; + } + memory mem { initial: 1; } + + fn set_v(p: &mut region, i: i32, v: i32) { + region.set $p[i] .v, v; + } + + fn sum_abs(p: ®ion, count: i32) -> i32 { + let mut acc: i32 = 0; + let mut i: i32 = 0; + while i < count { + region.get $p[i] .v -> x; + if x > 0 { + acc = acc + x; + } else { + acc = acc - x; + } + i = i + 1; + } + return acc; + } + + fn scale(p: &mut region, count: i32, k: f32) { + let mut i: i32 = 0; + while i < count { + region.get $p[i] .v -> x; + region.set $p[i] .v, cast(cast(x) * k); + i = i + 1; + } + } +"#; + +#[test] +fn lowered_control_flow_executes_with_correct_semantics() { + let module_ir = parser::parse_module(CELLS).expect("Cells fixture must parse"); + + // The fixture must NOT be stub-lowered — the whole point. + for name in ["set_v", "sum_abs", "scale"] { + let f = module_ir.funcs.iter().find(|f| f.name == name).unwrap(); + assert!( + !f.accesses.is_empty(), + "{name} must lower for real (stub would have no access sites)" + ); + } + + let bytes = emit(&module_ir); + let engine = Engine::default(); + let module = WasmiModule::new(&engine, &bytes[..]).expect("loads into wasmi"); + let mut store = Store::new(&engine, ()); + let linker = >::new(&engine); + let instance = linker + .instantiate_and_start(&mut store, &module) + .expect("instantiate"); + + let set_v: TypedFunc<(i32, i32, i32), ()> = + instance.get_typed_func(&store, "set_v").unwrap(); + let sum_abs: TypedFunc<(i32, i32), i32> = + instance.get_typed_func(&store, "sum_abs").unwrap(); + let scale: TypedFunc<(i32, i32, f32), ()> = + instance.get_typed_func(&store, "scale").unwrap(); + + const BASE: i32 = 0; + // cells = [3, -4, 5] + set_v.call(&mut store, (BASE, 0, 3)).unwrap(); + set_v.call(&mut store, (BASE, 1, -4)).unwrap(); + set_v.call(&mut store, (BASE, 2, 5)).unwrap(); + + // |3| + |-4| + |5| — exercises both if-branches and the loop. + assert_eq!(sum_abs.call(&mut store, (BASE, 3)).unwrap(), 12); + // A count of 0 must skip the loop entirely. + assert_eq!(sum_abs.call(&mut store, (BASE, 0)).unwrap(), 0); + + // scale ×2 via f32 round-trip: [6, -8, 10]. + scale.call(&mut store, (BASE, 3, 2.0)).unwrap(); + assert_eq!(sum_abs.call(&mut store, (BASE, 3)).unwrap(), 24); +} diff --git a/crates/typed-wasm-codegen/tests/optimization.rs b/crates/typed-wasm-codegen/tests/optimization.rs index 67335cc..03230c2 100644 --- a/crates/typed-wasm-codegen/tests/optimization.rs +++ b/crates/typed-wasm-codegen/tests/optimization.rs @@ -47,6 +47,7 @@ fn stripping_carriers_makes_verification_vacuous() { name: "f".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::Drop], accesses: vec![], export: true, @@ -77,6 +78,7 @@ fn ownership_func_idx_is_load_bearing() { name: "a".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::LocalGet(0), Op::Drop, Op::Drop], accesses: vec![], export: true, @@ -85,6 +87,7 @@ fn ownership_func_idx_is_load_bearing() { name: "b".into(), params: vec![Wty::I32], results: vec![], + locals: vec![], body: vec![Op::LocalGet(0), Op::LocalGet(0), Op::Drop, Op::Drop], accesses: vec![], export: true,