diff --git a/crates/typed-wasm-codegen/README.md b/crates/typed-wasm-codegen/README.md index 139946b..2da32a4 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 | **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 | +| Function-body lowering | **real statement lowering** — `let`, assignment, `if`/`else`, `while`, indexed access, `cast<>`, `region.scan … -> \|e\| { … }` with `where` predicates, `is_null`/`opt` (v0 null = 0) — wasmi-executed (`tests/example04.rs`, `tests/scan_lowering.rs`); remaining stubs: handle-typed locals, embedded-region paths | ## Where this goes next diff --git a/crates/typed-wasm-codegen/src/lib.rs b/crates/typed-wasm-codegen/src/lib.rs index ab45448..315365b 100644 --- a/crates/typed-wasm-codegen/src/lib.rs +++ b/crates/typed-wasm-codegen/src/lib.rs @@ -33,8 +33,10 @@ //! and `emit` writes the carrier (exercised by `tests/example03.rs`). //! * 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. +//! `cast<>`, `region.scan` closures with `where` predicates, +//! `is_null` under the v0 null-is-zero convention — see `parser.rs`); +//! handle-typed locals and embedded-region field paths 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, @@ -257,6 +259,8 @@ pub enum Op { BrIf(u32), Return, I32Eqz, + I32And, + I32Or, I32Add, I32Sub, I32Mul, @@ -472,6 +476,8 @@ fn op_to_instruction(op: Op) -> Instruction<'static> { Op::BrIf(d) => Instruction::BrIf(d), Op::Return => Instruction::Return, Op::I32Eqz => Instruction::I32Eqz, + Op::I32And => Instruction::I32And, + Op::I32Or => Instruction::I32Or, Op::I32Add => Instruction::I32Add, Op::I32Sub => Instruction::I32Sub, Op::I32Mul => Instruction::I32Mul, diff --git a/crates/typed-wasm-codegen/src/parser.rs b/crates/typed-wasm-codegen/src/parser.rs index 60a695e..8450c39 100644 --- a/crates/typed-wasm-codegen/src/parser.rs +++ b/crates/typed-wasm-codegen/src/parser.rs @@ -37,6 +37,13 @@ struct Parser<'a> { funcs: Vec, ownership: Vec<(usize, Vec, crate::Ownership)>, region_imports: Vec, + /// Declared instance count per region (`region Name[N]`; 1 if bare). + /// Parallel to `regions`. Bounds `region.scan` loops and sizes the + /// synthesised memory. + region_instances: Vec, + /// True when the source declared `memory { … }` — a synthesised + /// memory must never override it. + memory_declared: bool, } impl<'a> Parser<'a> { @@ -51,6 +58,8 @@ impl<'a> Parser<'a> { funcs: Vec::new(), ownership: Vec::new(), region_imports: Vec::new(), + region_instances: Vec::new(), + memory_declared: false, } } @@ -177,11 +186,12 @@ impl<'a> Parser<'a> { let name = self.parse_ident(); self.skip_whitespace(); - // Parse optional array specifier: region Name[N]. We don't yet track - // region cardinality, so the parsed size is discarded. + // Parse optional array specifier: region Name[N] — the declared + // instance count (bounds `region.scan`, sizes synthesised memory). + let mut instances = 1u64; if self.peek_char('[') { self.expect("[")?; - let _n: u64 = self.parse_number()?; + instances = self.parse_number()?.max(1); self.expect("]")?; } @@ -273,6 +283,7 @@ impl<'a> Parser<'a> { fields, byte_size, }); + self.region_instances.push(instances); self.skip_whitespace(); Ok(()) @@ -510,6 +521,7 @@ impl<'a> Parser<'a> { } fn parse_memory(&mut self) -> Result<(), String> { + self.memory_declared = true; self.expect("memory")?; let _name = self.parse_ident(); self.skip_whitespace(); @@ -1022,12 +1034,21 @@ impl<'a> Parser<'a> { /// cannot point past the declared memory and trap at runtime. Only fills in /// a memory when the module declared none; a declared memory is the author's. fn ensure_memory_for_region(&mut self, region: usize) { - if self.memory.is_none() { - let bytes = self.regions.get(region).map_or(0, |r| r.byte_size as u64); - self.memory = Some(Memory { - min_pages: bytes.div_ceil(65536).max(1), - max_pages: None, - }); + if self.memory_declared { + return; // never override a source-declared memory + } + let bytes = self.regions.get(region).map_or(0, |r| r.byte_size as u64) + * self.region_instances.get(region).copied().unwrap_or(1); + let pages = bytes.div_ceil(65536).max(1); + match &mut self.memory { + Some(m) if m.min_pages >= pages => {} + Some(m) => m.min_pages = pages, + None => { + self.memory = Some(Memory { + min_pages: pages, + max_pages: None, + }) + } } } @@ -1452,6 +1473,10 @@ struct Lower { accesses: Vec, /// Regions touched — memory is synthesised only on successful lowering. used_regions: Vec, + /// Active `region.scan` contexts, innermost last: (region index, + /// slot of the current-instance address local). Bare idents in scan + /// predicates/bodies resolve to fields of the innermost scan region. + scan_stack: Vec<(usize, u32)>, } impl Lower { @@ -1481,6 +1506,7 @@ impl<'a> Parser<'a> { ops: Vec::new(), accesses: Vec::new(), used_regions: Vec::new(), + scan_stack: Vec::new(), }; // Prologue: copy each region param into a base local (its single // use), bind scalar params by name. @@ -1542,7 +1568,10 @@ impl<'a> Parser<'a> { 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("scan") { + return self.lower_region_scan(cx, results).map(|_| false); + } + return None; // alloc / free / place: unsupported here } if self.try_keyword("let") { self.try_keyword("mut"); @@ -1729,12 +1758,84 @@ impl<'a> Parser<'a> { Some(()) } - // --- Typed expression lowering (precedence: cmp > add > mul > primary). + /// `region.scan $h [where ] -> |e| { body }` — a bounded loop + /// over the region's declared instances. Bare idents in the + /// predicate resolve to fields of the scanned region; `$e` in the + /// body is the current instance's handle. + fn lower_region_scan(&mut self, cx: &mut Lower, results: &[Wty]) -> Option<()> { + if !self.try_char('$') { + return None; + } + let hname = self.parse_ident(); + let &(region, base) = cx.handles.get(&hname)?; + let instances = i32::try_from(*self.region_instances.get(region)?).ok()?; + let stride = i32::try_from(self.regions.get(region)?.byte_size).ok()?; + + let i_slot = cx.alloc(Wty::I32); + let addr_slot = cx.alloc(Wty::I32); + + // i = 0; block { loop { i < N eqz br_if 1; addr = base + i*stride; … + cx.ops.push(crate::Op::I32Const(0)); + cx.ops.push(crate::Op::LocalSet(i_slot)); + cx.ops.push(crate::Op::Block); + cx.ops.push(crate::Op::Loop); + cx.ops.push(crate::Op::LocalGet(i_slot)); + cx.ops.push(crate::Op::I32Const(instances)); + cx.ops.push(crate::Op::I32LtS); + cx.ops.push(crate::Op::I32Eqz); + cx.ops.push(crate::Op::BrIf(1)); + cx.ops.push(crate::Op::LocalGet(base)); + cx.ops.push(crate::Op::LocalGet(i_slot)); + cx.ops.push(crate::Op::I32Const(stride)); + cx.ops.push(crate::Op::I32Mul); + cx.ops.push(crate::Op::I32Add); + cx.ops.push(crate::Op::LocalSet(addr_slot)); + + cx.scan_stack.push((region, addr_slot)); + + let has_pred = self.try_keyword("where"); + if has_pred { + if self.lower_expr(cx, None)? != Wty::I32 { + cx.scan_stack.pop(); + return None; + } + cx.ops.push(crate::Op::If); + } + + let ok = (|| { + if !self.try_str("->") || !self.try_char('|') { + return None; + } + let ename = self.parse_ident(); + if ename.is_empty() || !self.try_char('|') || !self.try_char('{') { + return None; + } + cx.handles.insert(ename, (region, addr_slot)); + self.lower_block(cx, results)?; + Some(()) + })(); + cx.scan_stack.pop(); + ok?; + + if has_pred { + cx.ops.push(crate::Op::End); + } + cx.ops.push(crate::Op::LocalGet(i_slot)); + cx.ops.push(crate::Op::I32Const(1)); + cx.ops.push(crate::Op::I32Add); + cx.ops.push(crate::Op::LocalSet(i_slot)); + cx.ops.push(crate::Op::Br(0)); + cx.ops.push(crate::Op::End); + cx.ops.push(crate::Op::End); + Some(()) + } + + // --- Typed expression lowering (precedence: cmp > band > 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)?; + let lty = self.lower_band(cx, expected)?; self.skip_whitespace(); let cmp = if self.try_str("==") { "==" @@ -1753,7 +1854,7 @@ impl<'a> Parser<'a> { } else { return Some(lty); }; - if self.lower_add(cx, Some(lty))? != lty { + if self.lower_band(cx, Some(lty))? != lty { return None; } use crate::Op::*; @@ -1781,13 +1882,39 @@ impl<'a> Parser<'a> { Some(Wty::I32) } + /// Bitwise tier (`&`, `|`) between comparison and additive — i32 + /// only. The examples parenthesise (`(flags & 1) == 1`), so the + /// C-precedence footgun does not arise. + fn lower_band(&mut self, cx: &mut Lower, expected: Option) -> Option { + let ty = self.lower_add(cx, expected)?; + loop { + self.skip_whitespace(); + let and = match self.src.as_bytes().get(self.pos) { + Some(&b'&') if self.src.as_bytes().get(self.pos + 1) != Some(&b'&') => true, + Some(&b'|') if self.src.as_bytes().get(self.pos + 1) != Some(&b'|') => false, + _ => return Some(ty), + }; + if ty != Wty::I32 { + return None; + } + self.pos += 1; + if self.lower_add(cx, Some(Wty::I32))? != Wty::I32 { + return None; + } + cx.ops.push(if and { crate::Op::I32And } else { crate::Op::I32Or }); + } + } + 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, + // `->` is the scan/get arrow, never subtraction. + Some(&b'-') if self.src.as_bytes().get(self.pos + 1) != Some(&b'>') => { + crate::Op::I32Sub + } _ => return Some(ty), }; let plus = matches!(op, crate::Op::I32Add); @@ -1839,6 +1966,15 @@ impl<'a> Parser<'a> { 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.src.as_bytes().get(self.pos + 1) != Some(&b'=') { + // Logical not on an i32 truth value. + self.pos += 1; + if self.lower_primary(cx, Some(Wty::I32))? != Wty::I32 { + return None; + } + cx.ops.push(crate::Op::I32Eqz); + return Some(Wty::I32); + } if b == b'(' { self.pos += 1; let ty = self.lower_expr(cx, expected)?; @@ -1864,6 +2000,20 @@ impl<'a> Parser<'a> { cx.ops.push(crate::Op::I32Const(0)); return Some(Wty::I32); } + "is_null" => { + // v0 null representation: 0 — matching wasm's null + // pointer convention (`opt` carries `Nullability` in + // the regions carrier; a richer representation is an L4 + // follow-up). `is_null(x)` = `x == 0`. + if !self.try_char('(') { + return None; + } + if self.lower_expr(cx, Some(Wty::I32))? != Wty::I32 || !self.try_char(')') { + return None; + } + cx.ops.push(crate::Op::I32Eqz); + return Some(Wty::I32); + } "cast" => { if !self.try_char('<') { return None; @@ -1892,8 +2042,23 @@ impl<'a> Parser<'a> { cx.ops.push(crate::Op::LocalGet(slot)); return Some(wty); } + // Inside a `region.scan`, a bare ident is a field of the scanned + // region — a typed load off the current-instance address. + if let Some(&(region, addr)) = cx.scan_stack.last() { + if let Some((field_idx, offset, scalar)) = self.resolve_field(region, &ident) { + cx.ops.push(crate::Op::LocalGet(addr)); + 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), + }); + return Some(wty_from_scalar(&scalar)); + } + } self.pos = save; - None // unknown ident: is_null / sizeof / handle-as-value / call + None // unknown ident: sizeof / handle-as-value / call } /// Numeric literal, typed by `expected` (default: `.`-bearing → F32, diff --git a/crates/typed-wasm-codegen/tests/scan_lowering.rs b/crates/typed-wasm-codegen/tests/scan_lowering.rs new file mode 100644 index 0000000..ed75561 --- /dev/null +++ b/crates/typed-wasm-codegen/tests/scan_lowering.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// `region.scan` closure lowering + `is_null` (v0 null = 0) — the last +// statement forms that previously fell back to representative stubs. +// Scans lower to a bounded loop over the region's declared instances +// with the predicate's bare field idents resolving to typed loads off +// the current-instance address; the closure handle `$e` binds that +// address for the body. + +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 EX01: &str = include_str!("../../../examples/01-single-module.twasm"); +const EX02: &str = include_str!("../../../examples/02-multi-module.twasm"); + +/// The scan-shaped bodies across examples 01/02 lower for real. +#[test] +fn scan_bodies_lower_for_real() { + let ex01 = parser::parse_module(EX01).expect("ex01 parses"); + let ex02 = parser::parse_module(EX02).expect("ex02 parses"); + for (module, name, min_sites) in [ + (&ex01, "count_active_enemies", 1), // pred load: is_active + (&ex02, "physics_step", 7), // pred + 6 gets + 3 sets + (&ex02, "collect_visible", 1), // pred load: flags + (&ex02, "ai_decision", 8), // opt unwrap + indexed get/set + ] { + let f = module + .funcs + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("{name} must be parsed")); + assert!( + f.accesses.len() >= min_sites, + "{name}: expected >={min_sites} sites, got {}", + f.accesses.len() + ); + assert!( + f.accesses.iter().all(|a| a.instr_index.is_some()), + "{name}: every site must be pinned" + ); + } +} + +/// Full verifier stack over the scan-lowered examples. +#[test] +fn scan_lowered_examples_pass_all_verifier_passes() { + for (name, src) in [("01", EX01), ("02", EX02)] { + 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 typing: {:?}", typing.errors); + } +} + +/// Execution gate: a where-predicated scan must visit exactly the +/// matching instances, and `is_null` must implement the v0 null = 0 +/// convention. +const CELLS: &str = r#" + region Cell[8] { + flag: u32; + v: i32; + } + memory mem { initial: 1; } + + fn set_cell(p: &mut region, i: i32, flag: u32, v: i32) { + region.set $p[i] .flag, flag; + region.set $p[i] .v, v; + } + + fn sum_flagged(p: ®ion) -> i32 { + let mut acc: i32 = 0; + region.scan $p where (flag & 1) == 1 -> |c| { + region.get $c .v -> x; + acc = acc + x; + } + return acc; + } + + fn non_null(x: i32) -> i32 { + if !is_null(x) { + return 1; + } + return 0; + } +"#; + +#[test] +fn scan_and_is_null_execute_with_correct_semantics() { + let module_ir = parser::parse_module(CELLS).expect("Cells fixture parses"); + for name in ["set_cell", "sum_flagged", "non_null"] { + let f = module_ir.funcs.iter().find(|f| f.name == name).unwrap(); + assert!( + name == "non_null" || !f.accesses.is_empty(), + "{name} must lower for real (stub has 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 instance = >::new(&engine) + .instantiate_and_start(&mut store, &module) + .expect("instantiate"); + + let set_cell: TypedFunc<(i32, i32, i32, i32), ()> = + instance.get_typed_func(&store, "set_cell").unwrap(); + let sum_flagged: TypedFunc<(i32,), i32> = + instance.get_typed_func(&store, "sum_flagged").unwrap(); + let non_null: TypedFunc<(i32,), i32> = + instance.get_typed_func(&store, "non_null").unwrap(); + + const BASE: i32 = 0; + // Instances 0..3 populated; 4..7 stay zero (flag 0 -> skipped). + set_cell.call(&mut store, (BASE, 0, 1, 10)).unwrap(); // flagged + set_cell.call(&mut store, (BASE, 1, 2, 20)).unwrap(); // even flag: skipped + set_cell.call(&mut store, (BASE, 2, 3, 30)).unwrap(); // flagged (3 & 1 == 1) + set_cell.call(&mut store, (BASE, 3, 0, 40)).unwrap(); // unflagged: skipped + + assert_eq!(sum_flagged.call(&mut store, (BASE,)).unwrap(), 40, "10 + 30"); + + assert_eq!(non_null.call(&mut store, (0,)).unwrap(), 0, "0 is null"); + assert_eq!(non_null.call(&mut store, (7,)).unwrap(), 1, "non-zero is not null"); +}