Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/typed-wasm-codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@ cargo test -p typed-wasm-codegen

- **#127** — codegen coverage across all 10 levels × all 6 examples (and the front-end → IR JSON seam).
- **#130** — promote the round-trip tests into the ECHIDNA property corpus.
- **Per-module split** — **done**: `module Name { … }` blocks parse into separate modules (`parse_modules`; importers seeded with the producer's actual schema), `tw build --split` emits one wasm per module, `tw link` certifies the graph (`tests/multimodule_split.rs`, `tests/fixtures/multimodule/game.twasm`).
- **L13 positive-form / region-imports** — **done** (issue #140): `import region … from "…" { … }` parses into `Module::region_imports`, emits the `typedwasm.region-imports` carrier (proposal 0003 `[accepted]` / ADR-0007), and `verify_link_graph` certifies cross-module schema agreement (`tests/example02.rs`).
130 changes: 124 additions & 6 deletions crates/typed-wasm-codegen/src/bin/tw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
//! `tw` — the typed-wasm build CLI (codegen v0).
//!
//! Usage:
//! tw build <file.twasm> [-o <out.wasm>]
//! tw build <file.twasm> [-o <out>] [--emit wasm|wat|both] [--split]
//! tw link <a.wasm> <b.wasm> …
//!
//! v0 supports only the example-01 schema; general `.twasm` front-end →
//! IR lowering is tracked in ADR-0004 and issue #127.
//! `--split` emits one wasm per top-level `module Name { … }` block
//! (`<out>.<module>.wasm`); `link` runs the L13 cross-module link-graph
//! pass (ADR-0007) over already-built modules, naming each by its file
//! stem, and reports certificates / violations.

use std::path::Path;
use std::process::ExitCode;
Expand All @@ -16,6 +19,7 @@ fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("build") => build(&args[2..]),
Some("link") => link(&args[2..]),
Some("--version") | Some("-V") => {
println!("tw {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
Expand All @@ -33,7 +37,8 @@ fn main() -> ExitCode {
}

fn usage() {
eprintln!("usage: tw build <file.twasm> [-o <out>] [--emit wasm|wat|both]");
eprintln!("usage: tw build <file.twasm> [-o <out>] [--emit wasm|wat|both] [--split]");
eprintln!(" tw link <a.wasm> <b.wasm> ...");
}

/// What `tw build` emits.
Expand Down Expand Up @@ -65,6 +70,7 @@ fn build(rest: &[String]) -> ExitCode {
let mut input: Option<String> = None;
let mut output: Option<String> = None;
let mut emit = Emit::Wasm;
let mut split = false;
let mut i = 0;
while i < rest.len() {
let arg = rest[i].as_str();
Expand Down Expand Up @@ -96,6 +102,7 @@ fn build(rest: &[String]) -> ExitCode {
return ExitCode::FAILURE;
}
},
"--split" => split = true,
s if !s.starts_with('-') => input = Some(s.to_string()),
other => {
eprintln!("tw build: unknown option '{other}'");
Expand All @@ -119,8 +126,11 @@ fn build(rest: &[String]) -> ExitCode {
}
};

// Try to parse the .twasm file using the Rust parser (issue #127).
// This parser handles the paint-type schemas and example-01.
if split {
return build_split(&input, &src, output.as_deref(), emit);
}

// Parse via the canonical Rust front-end (ADR-0006).
let bytes = match typed_wasm_codegen::parser::parse_module(&src) {
Ok(module) => {
let bytes = typed_wasm_codegen::emit(&module);
Expand Down Expand Up @@ -171,6 +181,114 @@ fn build(rest: &[String]) -> ExitCode {
ExitCode::SUCCESS
}

/// `tw build --split`: one output per `module Name { … }` block.
fn build_split(input: &str, src: &str, output: Option<&str>, emit: Emit) -> ExitCode {
let modules = match typed_wasm_codegen::parser::parse_modules(src) {
Ok(m) => m,
Err(e) => {
eprintln!("tw build: parse error in '{input}': {e}");
return ExitCode::FAILURE;
}
};
let base = output.unwrap_or(input);
let stem = Path::new(base)
.with_extension("")
.to_string_lossy()
.into_owned();
let mut wrote = Vec::new();
for (name, module) in &modules {
let bytes = typed_wasm_codegen::emit(module);
if let Err(diagnostics) = typed_wasm_codegen::self_verify(module) {
for msg in diagnostics {
eprintln!("tw build: self-verify warning [{name}]: {msg}");
}
}
if emit.wants_wasm() {
let path = format!("{stem}.{name}.wasm");
if let Err(e) = std::fs::write(&path, &bytes) {
eprintln!("tw build: cannot write '{path}': {e}");
return ExitCode::FAILURE;
}
wrote.push(format!("{path} ({} bytes)", bytes.len()));
}
if emit.wants_wat() {
let path = format!("{stem}.{name}.wat");
let text = typed_wasm_codegen::wat(&bytes);
if let Err(e) = std::fs::write(&path, text.as_bytes()) {
eprintln!("tw build: cannot write '{path}': {e}");
return ExitCode::FAILURE;
}
wrote.push(path);
}
}
eprintln!(
"tw build: split {} module(s): {}",
modules.len(),
wrote.join(" + ")
);
eprintln!("tw build: check cross-module schema agreement with `tw link <files…>`.");
ExitCode::SUCCESS
}

/// `tw link`: the L13 positive-form link-graph pass (ADR-0007) over
/// built modules. Each module's wasm-level name is its file stem's last
/// dot-segment (`game.physics.wasm` → `physics`).
fn link(files: &[String]) -> ExitCode {
if files.is_empty() {
eprintln!("tw link: no modules given");
usage();
return ExitCode::FAILURE;
}
let mut named: Vec<(String, Vec<u8>)> = Vec::new();
for f in files {
let bytes = match std::fs::read(f) {
Ok(b) => b,
Err(e) => {
eprintln!("tw link: cannot read '{f}': {e}");
return ExitCode::FAILURE;
}
};
let stem = Path::new(f)
.with_extension("")
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| f.clone());
let name = stem.rsplit('.').next().unwrap_or(&stem).to_string();
named.push((name, bytes));
}
let graph: Vec<(&str, &[u8])> = named
.iter()
.map(|(n, b)| (n.as_str(), b.as_slice()))
.collect();
match typed_wasm_verify::verify_link_graph(&graph) {
Ok(report) => {
for c in &report.certificates {
println!(
"CERTIFIED {} imports {}.{}",
c.consumer, c.producer, c.region_name
);
}
for e in &report.errors {
eprintln!("VIOLATION {e}");
}
if report.errors.is_empty() {
eprintln!(
"tw link: {} module(s), {} certificate(s), no violations",
named.len(),
report.certificates.len()
);
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Err(e) => {
eprintln!("tw link: wasm parse error: {e}");
ExitCode::FAILURE
}
}
}

/// `base` with its extension replaced by `ext`
/// (e.g. `with_extension("foo.twasm", "wasm")` -> `"foo.wasm"`).
fn with_extension(base: &str, ext: &str) -> String {
Expand Down
152 changes: 140 additions & 12 deletions crates/typed-wasm-codegen/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,127 @@
use crate::{Field, FieldTy, Memory, Module, PtrKind, Region, Scalar, Wty};
use std::collections::HashMap;

/// Parse a .twasm source file into a Module IR.
/// Parse a .twasm source file into a single merged Module IR.
/// `module Name [isolated] { … }` blocks are flattened into one module
/// (the historical single-module view the round-trip corpus uses);
/// [`parse_modules`] gives the per-module split.
pub fn parse_module(src: &str) -> Result<Module, String> {
let parser = Parser::new(src);
parser.parse_module()
let fragments = slice_modules(src);
if fragments.len() == 1 {
return Parser::new(src).parse_module();
}
let merged: String = fragments
.into_iter()
.map(|(_, frag)| frag)
.collect::<Vec<_>>()
.join("\n");
Parser::new(&merged).parse_module()
}

/// Parse a multi-module `.twasm` source into one Module per top-level
/// `module Name [isolated] { … }` block (declarations outside any block
/// form the `"main"` module, omitted when empty). Two passes: modules
/// parse independently, then any module importing a region whose
/// producer is in the same file is RE-parsed with the producer's actual
/// region schemas seeded in — so cross-module accesses lower against
/// the producer's real layout, not the expected subset's packing.
pub fn parse_modules(src: &str) -> Result<Vec<(String, Module)>, String> {
let fragments = slice_modules(src);
let mut parsed: Vec<(String, Module, Vec<u64>, String)> = Vec::new();
for (name, frag) in fragments {
let (module, instances) = Parser::new(&frag).parse_with_instances()?;
if name == "main"
&& module.regions.is_empty()
&& module.funcs.is_empty()
&& module.region_imports.is_empty()
{
continue; // no default-module content
}
parsed.push((name, module, instances, frag));
}

// Pass 2: seed importers with in-file producers' actual schemas.
let producer_snapshot: Vec<(String, Module, Vec<u64>)> = parsed
.iter()
.map(|(n, m, i, _)| (n.clone(), m.clone(), i.clone()))
.collect();
for (_, module, _, frag) in parsed.iter_mut() {
if module.region_imports.is_empty() {
continue;
}
let mut seeds: Vec<(Region, u64)> = Vec::new();
for imp in &module.region_imports {
if let Some((_, producer, instances)) = producer_snapshot
.iter()
.find(|(n, _, _)| *n == imp.producer_module)
{
if let Some(idx) =
producer.regions.iter().position(|r| r.name == imp.region_name)
{
seeds.push((
producer.regions[idx].clone(),
instances.get(idx).copied().unwrap_or(1),
));
}
}
}
if !seeds.is_empty() {
let mut p = Parser::new(frag);
for (region, instances) in seeds {
p.region_map.insert(region.name.clone(), p.regions.len());
p.regions.push(region);
p.region_instances.push(instances);
}
*module = p.parse_module()?;
}
}

Ok(parsed.into_iter().map(|(n, m, _, _)| (n, m)).collect())
}

/// Split source at top-level `module Name [isolated] { … }` blocks into
/// `(name, fragment)` pairs; text outside any block accumulates into a
/// `"main"` fragment (kept even when blank so `parse_module` can join).
fn slice_modules(src: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let mut default_frag = String::new();
let mut scanner = Parser::new(src);
let mut plain_start = 0usize;
loop {
scanner.skip_whitespace();
if scanner.pos >= src.len() {
break;
}
if scanner.peek_word("module") {
let block_start = scanner.pos;
scanner.pos += "module".len();
let name = scanner.parse_ident();
scanner.skip_whitespace();
let _ = scanner.try_keyword("isolated");
scanner.skip_whitespace();
if !name.is_empty() && scanner.try_char('{') {
default_frag.push_str(&src[plain_start..block_start]);
let body_start = scanner.pos;
scanner.skip_to_brace_close();
// skip_to_brace_close consumed through the closing `}`.
let body_end = scanner.pos.saturating_sub(1).max(body_start);
out.push((name, src[body_start..body_end].to_string()));
plain_start = scanner.pos;
continue;
}
// `module X;` or malformed: leave for the plain fragment.
scanner.pos = block_start + "module".len();
}
// Advance one declaration: consume through it so we never split
// inside a nested brace (fn bodies, regions, …).
scanner.skip_declaration();
}
default_frag.push_str(&src[plain_start..]);
if out.is_empty() {
return vec![("main".into(), default_frag)];
}
out.insert(0, ("main".into(), default_frag));
out
}

/// A simple hand-written parser for .twasm syntax.
Expand Down Expand Up @@ -63,7 +180,15 @@ impl<'a> Parser<'a> {
}
}

fn parse_module(mut self) -> Result<Module, String> {
fn parse_module(self) -> Result<Module, String> {
self.parse_with_instances().map(|(m, _)| m)
}

/// As [`Self::parse_module`], additionally returning the per-region
/// declared instance counts (needed to seed importers in
/// [`parse_modules`] so their scans iterate the producer's real
/// instance array).
fn parse_with_instances(mut self) -> Result<(Module, Vec<u64>), String> {
while self.pos < self.src.len() {
self.skip_whitespace();
if self.pos >= self.src.len() {
Expand All @@ -88,14 +213,17 @@ impl<'a> Parser<'a> {
}
}

Ok(Module {
regions: self.regions,
memory: self.memory,
imports: self.imports,
funcs: self.funcs,
ownership: self.ownership,
region_imports: self.region_imports,
})
Ok((
Module {
regions: self.regions,
memory: self.memory,
imports: self.imports,
funcs: self.funcs,
ownership: self.ownership,
region_imports: self.region_imports,
},
self.region_instances,
))
}

fn peek_word(&mut self, word: &str) -> bool {
Expand Down
Loading
Loading