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:
2026-04-04 11:33:11 +02:00
parent bbc9ae464c
commit 913612d902
12 changed files with 1202 additions and 928 deletions
+131 -15
View File
@@ -1,31 +1,149 @@
//! WAFER CLI: Interactive REPL and AOT compiler for WAFER Forth.
//! WAFER CLI: Interactive REPL, AOT compiler, and WASM runner for WAFER Forth.
use clap::Parser;
use std::path::Path;
use clap::{Parser, Subcommand};
use wafer_core::export::{ExportConfig, export_module};
use wafer_core::outer::ForthVM;
use wafer_core::runner::run_wasm_file;
/// WAFER: WebAssembly Forth Engine in Rust
#[derive(Parser, Debug)]
#[command(name = "wafer", version, about)]
struct Cli {
/// Forth source file to execute
#[command(subcommand)]
command: Option<Commands>,
/// Forth source file to execute (when no subcommand is given)
file: Option<String>,
}
/// Compile all words into a single optimized WASM module
#[arg(long)]
consolidate: bool,
#[derive(Subcommand, Debug)]
enum Commands {
/// Compile a Forth source file to a standalone WASM module
Build {
/// Input Forth source file
file: String,
/// Output file for consolidated WASM (requires --consolidate)
#[arg(short, long)]
output: Option<String>,
/// Output .wasm file (default: input with .wasm extension)
#[arg(short, long)]
output: Option<String>,
/// Entry-point word name (default: MAIN, or top-level execution)
#[arg(long)]
entry: Option<String>,
/// Also generate a JS loader and HTML page for browser execution
#[arg(long)]
js: bool,
},
/// Run a pre-compiled WASM module
Run {
/// .wasm file to execute
file: String,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Build {
file,
output,
entry,
js,
}) => cmd_build(&file, output.as_deref(), entry, js),
Some(Commands::Run { file }) => cmd_run(&file),
None => cmd_eval_or_repl(cli.file.as_deref()),
}
}
/// `wafer build program.fth -o program.wasm`
fn cmd_build(
file: &str,
output: Option<&str>,
entry: Option<String>,
js: bool,
) -> anyhow::Result<()> {
let source = std::fs::read_to_string(file)?;
let mut vm = ForthVM::new()?;
vm.set_recording(true);
vm.evaluate(&source)?;
// Print any side-effect output from evaluation.
let eval_output = vm.take_output();
if !eval_output.is_empty() {
print!("{eval_output}");
}
let config = ExportConfig { entry_word: entry };
let (wasm_bytes, metadata) = export_module(&mut vm, &config)?;
// Determine output path.
let out_path = match output {
Some(p) => p.to_string(),
None => {
let stem = Path::new(file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("out");
format!("{stem}.wasm")
}
};
std::fs::write(&out_path, &wasm_bytes)?;
let word_count = vm.ir_words().len();
let host_count = metadata.host_functions.len();
eprintln!(
"Wrote {out_path} ({} bytes, {word_count} words, {host_count} host functions)",
wasm_bytes.len()
);
if js {
let out = Path::new(&out_path);
let wasm_filename = out
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("out.wasm");
let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or("out");
let dir = out.parent().unwrap_or_else(|| Path::new("."));
let js_path = dir.join(format!("{stem}.js"));
let html_path = dir.join(format!("{stem}.html"));
let js_filename = format!("{stem}.js");
let js_code = wafer_core::js_loader::generate_js_loader(wasm_filename, &metadata);
let html_code = wafer_core::js_loader::generate_html_page(wasm_filename, &js_filename);
std::fs::write(&js_path, &js_code)?;
std::fs::write(&html_path, &html_code)?;
eprintln!("Wrote {} and {}", js_path.display(), html_path.display());
}
Ok(())
}
/// `wafer run program.wasm`
fn cmd_run(file: &str) -> anyhow::Result<()> {
let output = run_wasm_file(file)?;
if !output.is_empty() {
print!("{output}");
}
Ok(())
}
/// `wafer` (REPL) or `wafer program.fth` (evaluate and exit)
fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
let mut vm = ForthVM::new()?;
match cli.file {
Some(ref file) => {
match file {
Some(file) => {
let source = std::fs::read_to_string(file)?;
vm.evaluate(&source)?;
let output = vm.take_output();
@@ -34,12 +152,10 @@ fn main() -> anyhow::Result<()> {
}
}
None => {
// Check if stdin is a pipe (not a TTY)
if !atty_is_tty() {
if !stdin_is_tty() {
// Non-interactive: read all of stdin and evaluate
let mut input = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut input)?;
// Evaluate line-by-line to handle multi-line input
for line in input.lines() {
match vm.evaluate(line) {
Ok(()) => {
@@ -106,7 +222,7 @@ fn main() -> anyhow::Result<()> {
}
/// Check if stdin is a terminal (TTY).
fn atty_is_tty() -> bool {
fn stdin_is_tty() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}