Implement WASM export and standalone execution
Add `wafer build` to compile Forth source files to standalone .wasm modules, and `wafer run` to execute them. The same .wasm file works with both the wafer runtime (via wasmtime) and in browsers (via generated JS loader). New CLI subcommands: - `wafer build file.fth -o file.wasm` — compile to standalone WASM - `wafer build file.fth -o file.wasm --js` — also generate JS/HTML loader - `wafer build file.fth --entry WORD` — custom entry point - `wafer run file.wasm` — execute pre-compiled module Entry point resolution: --entry flag > MAIN word > recorded top-level execution. Memory snapshot embedded as WASM data section preserves VARIABLE/CONSTANT state. Metadata in custom "wafer" section enables the runner to provide host functions. New modules: export.rs (orchestration), runner.rs (wasmtime host), js_loader.rs (browser support). Refactored codegen.rs to share logic between consolidation and export via compile_multi_word_module(). Added ir_bodies tracking for VARIABLE, CONSTANT, CREATE, VALUE, DEFER, BUFFER:, MARKER, 2CONSTANT, 2VARIABLE, 2VALUE, FVARIABLE defining words. Removed dead code: dot_func field, unused wafer-web stub crate, wasmtime-wasi dependency from CLI, orphaned --consolidate/--output CLI flags. 425 tests pass (414 original + 11 new including 7 round-trip integration tests).
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
//! WASM runner: execute a pre-compiled `.wasm` module produced by `wafer build`.
|
||||
//!
|
||||
//! Provides the six imports the module expects (emit, memory, dsp, rsp, fsp,
|
||||
//! table) and registers host-function stubs for known Forth words.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use wasmtime::{
|
||||
Engine, Func, FuncType, Global, GlobalType, Memory, MemoryType, Module, Ref, Store, Table,
|
||||
TableType, Val, ValType,
|
||||
};
|
||||
|
||||
use crate::export::deserialize_metadata;
|
||||
use crate::memory::{CELL_SIZE, DATA_STACK_TOP, SYSVAR_BASE_VAR};
|
||||
|
||||
/// Host state for the runner (currently unused by wasmtime `Store` but
|
||||
/// required as the generic parameter).
|
||||
struct RunnerHost {}
|
||||
|
||||
/// Execute a pre-compiled `.wasm` module and return its output.
|
||||
pub fn run_wasm_file(path: &str) -> anyhow::Result<String> {
|
||||
let wasm_bytes = std::fs::read(path)?;
|
||||
run_wasm_bytes(&wasm_bytes)
|
||||
}
|
||||
|
||||
/// Execute WASM bytes directly (used by tests and the CLI).
|
||||
pub fn run_wasm_bytes(wasm_bytes: &[u8]) -> anyhow::Result<String> {
|
||||
// Parse the "wafer" custom section for metadata.
|
||||
let metadata_json = extract_custom_section(wasm_bytes, "wafer")?;
|
||||
let metadata = deserialize_metadata(&metadata_json)?;
|
||||
|
||||
// Set up wasmtime runtime.
|
||||
let mut config = wasmtime::Config::new();
|
||||
config.cranelift_nan_canonicalization(false);
|
||||
let engine = Engine::new(&config)?;
|
||||
|
||||
let output = Arc::new(Mutex::new(String::new()));
|
||||
let mut store = Store::new(&engine, RunnerHost {});
|
||||
|
||||
// Create the 6 imports the module expects.
|
||||
let memory_pages = metadata.memory_size.div_ceil(65536).max(16); // at least 16 pages like the VM
|
||||
let memory = Memory::new(&mut store, MemoryType::new(memory_pages, None))?;
|
||||
|
||||
let dsp = Global::new(
|
||||
&mut store,
|
||||
GlobalType::new(ValType::I32, wasmtime::Mutability::Var),
|
||||
Val::I32(metadata.dsp_init as i32),
|
||||
)?;
|
||||
let rsp = Global::new(
|
||||
&mut store,
|
||||
GlobalType::new(ValType::I32, wasmtime::Mutability::Var),
|
||||
Val::I32(metadata.rsp_init as i32),
|
||||
)?;
|
||||
let fsp = Global::new(
|
||||
&mut store,
|
||||
GlobalType::new(ValType::I32, wasmtime::Mutability::Var),
|
||||
Val::I32(metadata.fsp_init as i32),
|
||||
)?;
|
||||
|
||||
// Determine table size from the module's import.
|
||||
let parsed = wasmparser::Parser::new(0).parse_all(wasm_bytes);
|
||||
let mut table_min: u64 = 256;
|
||||
for payload in parsed {
|
||||
if let wasmparser::Payload::ImportSection(reader) = payload? {
|
||||
for import in reader {
|
||||
let import = import?;
|
||||
if import.name == "table"
|
||||
&& let wasmparser::TypeRef::Table(t) = import.ty
|
||||
{
|
||||
table_min = t.initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let table = Table::new(
|
||||
&mut store,
|
||||
TableType::new(wasmtime::RefType::FUNCREF, table_min as u32, None),
|
||||
Ref::Func(None),
|
||||
)?;
|
||||
|
||||
// Create the emit function.
|
||||
let out_ref = Arc::clone(&output);
|
||||
let emit_func = Func::new(
|
||||
&mut store,
|
||||
FuncType::new(&engine, [ValType::I32], []),
|
||||
move |_caller, params, _results| {
|
||||
let code = params[0].unwrap_i32();
|
||||
if let Some(ch) = char::from_u32(code as u32) {
|
||||
out_ref.lock().unwrap().push(ch);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
// Instantiate the module.
|
||||
let module = Module::new(&engine, wasm_bytes)?;
|
||||
let instance = wasmtime::Instance::new(
|
||||
&mut store,
|
||||
&module,
|
||||
&[
|
||||
emit_func.into(),
|
||||
memory.into(),
|
||||
dsp.into(),
|
||||
rsp.into(),
|
||||
fsp.into(),
|
||||
table.into(),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Register host functions in the table at the metadata-specified indices.
|
||||
for (idx, name) in &metadata.host_functions {
|
||||
let func = create_host_func(&mut store, &engine, memory, dsp, &output, name);
|
||||
table.set(&mut store, *idx as u64, Ref::Func(Some(func)))?;
|
||||
}
|
||||
|
||||
// Call _start if it exists.
|
||||
if let Some(start) = instance.get_func(&mut store, "_start") {
|
||||
start.call(&mut store, &[], &mut [])?;
|
||||
}
|
||||
|
||||
let result = output.lock().unwrap().clone();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Create a host function implementation for a known Forth word.
|
||||
fn create_host_func(
|
||||
store: &mut Store<RunnerHost>,
|
||||
engine: &Engine,
|
||||
memory: Memory,
|
||||
dsp: Global,
|
||||
output: &Arc<Mutex<String>>,
|
||||
name: &str,
|
||||
) -> Func {
|
||||
let void_type = FuncType::new(engine, [], []);
|
||||
|
||||
match name {
|
||||
"." => {
|
||||
// ( n -- ) print number followed by space
|
||||
let out = Arc::clone(output);
|
||||
Func::new(store, void_type, move |mut caller, _params, _results| {
|
||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||
// Read all values from memory before mutable borrow.
|
||||
let (n, base) = {
|
||||
let data = memory.data(&caller);
|
||||
let n =
|
||||
i32::from_le_bytes(data[sp as usize..sp as usize + 4].try_into().unwrap());
|
||||
let base = u32::from_le_bytes(
|
||||
data[SYSVAR_BASE_VAR as usize..SYSVAR_BASE_VAR as usize + 4]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
(n, base)
|
||||
};
|
||||
dsp.set(&mut caller, Val::I32((sp + CELL_SIZE) as i32))?;
|
||||
let s = if base == 16 {
|
||||
format!("{n:X} ")
|
||||
} else {
|
||||
format!("{n} ")
|
||||
};
|
||||
out.lock().unwrap().push_str(&s);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
"U." => {
|
||||
// ( u -- ) print unsigned number followed by space
|
||||
let out = Arc::clone(output);
|
||||
Func::new(store, void_type, move |mut caller, _params, _results| {
|
||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||
let (n, base) = {
|
||||
let data = memory.data(&caller);
|
||||
let n =
|
||||
u32::from_le_bytes(data[sp as usize..sp as usize + 4].try_into().unwrap());
|
||||
let base = u32::from_le_bytes(
|
||||
data[SYSVAR_BASE_VAR as usize..SYSVAR_BASE_VAR as usize + 4]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
(n, base)
|
||||
};
|
||||
dsp.set(&mut caller, Val::I32((sp + CELL_SIZE) as i32))?;
|
||||
let s = if base == 16 {
|
||||
format!("{n:X} ")
|
||||
} else {
|
||||
format!("{n} ")
|
||||
};
|
||||
out.lock().unwrap().push_str(&s);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
"TYPE" => {
|
||||
// ( c-addr u -- ) output u characters from memory at c-addr
|
||||
let out = Arc::clone(output);
|
||||
Func::new(store, void_type, move |mut caller, _params, _results| {
|
||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||
let text = {
|
||||
let data = memory.data(&caller);
|
||||
let len =
|
||||
i32::from_le_bytes(data[sp as usize..sp as usize + 4].try_into().unwrap())
|
||||
as u32;
|
||||
let addr = i32::from_le_bytes(
|
||||
data[sp as usize + 4..sp as usize + 8].try_into().unwrap(),
|
||||
) as u32;
|
||||
let end = (addr + len) as usize;
|
||||
String::from_utf8_lossy(&data[addr as usize..end]).to_string()
|
||||
};
|
||||
dsp.set(&mut caller, Val::I32((sp + 2 * CELL_SIZE) as i32))?;
|
||||
out.lock().unwrap().push_str(&text);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
"SPACES" => {
|
||||
// ( n -- ) output n spaces
|
||||
let out = Arc::clone(output);
|
||||
Func::new(store, void_type, move |mut caller, _params, _results| {
|
||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||
let n = {
|
||||
let data = memory.data(&caller);
|
||||
i32::from_le_bytes(data[sp as usize..sp as usize + 4].try_into().unwrap())
|
||||
};
|
||||
dsp.set(&mut caller, Val::I32((sp + CELL_SIZE) as i32))?;
|
||||
if n > 0 {
|
||||
let spaces: String = std::iter::repeat_n(' ', n as usize).collect();
|
||||
out.lock().unwrap().push_str(&spaces);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
".S" => {
|
||||
// ( -- ) print stack contents non-destructively (no mutable borrow needed)
|
||||
let out = Arc::clone(output);
|
||||
Func::new(store, void_type, move |mut caller, _params, _results| {
|
||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||
let data = memory.data(&caller);
|
||||
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
||||
let mut buf = format!("<{depth}> ");
|
||||
let mut addr = DATA_STACK_TOP - CELL_SIZE;
|
||||
while addr >= sp {
|
||||
let n = i32::from_le_bytes(
|
||||
data[addr as usize..addr as usize + 4].try_into().unwrap(),
|
||||
);
|
||||
buf.push_str(&format!("{n} "));
|
||||
if addr < CELL_SIZE {
|
||||
break;
|
||||
}
|
||||
addr -= CELL_SIZE;
|
||||
}
|
||||
out.lock().unwrap().push_str(&buf);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
"DEPTH" => {
|
||||
// ( -- n ) push current stack depth
|
||||
Func::new(store, void_type, move |mut caller, _params, _results| {
|
||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
||||
let new_sp = sp - CELL_SIZE;
|
||||
memory.data_mut(&mut caller)[new_sp as usize..new_sp as usize + 4]
|
||||
.copy_from_slice(&(depth as i32).to_le_bytes());
|
||||
dsp.set(&mut caller, Val::I32(new_sp as i32))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Unimplemented host function: trap with a clear message.
|
||||
let name_owned = name.to_string();
|
||||
Func::new(store, void_type, move |_caller, _params, _results| {
|
||||
anyhow::bail!("host function '{name_owned}' is not available in standalone mode")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a named custom section from raw WASM bytes.
|
||||
fn extract_custom_section(wasm_bytes: &[u8], section_name: &str) -> anyhow::Result<String> {
|
||||
let parsed = wasmparser::Parser::new(0).parse_all(wasm_bytes);
|
||||
for payload in parsed {
|
||||
if let wasmparser::Payload::CustomSection(reader) = payload?
|
||||
&& reader.name() == section_name
|
||||
{
|
||||
return Ok(String::from_utf8_lossy(reader.data()).to_string());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no '{section_name}' custom section found in WASM module")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_missing_section_is_error() {
|
||||
// Minimal valid WASM module (magic + version only won't validate,
|
||||
// but we can test with a trivial module).
|
||||
let empty_module = wasm_encoder::Module::new().finish();
|
||||
let result = extract_custom_section(&empty_module, "wafer");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user