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:
Generated
+7
-842
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,6 @@ or_fun_call = "warn"
|
|||||||
wasm-encoder = "0.228"
|
wasm-encoder = "0.228"
|
||||||
wasmparser = "0.228"
|
wasmparser = "0.228"
|
||||||
wasmtime = "31"
|
wasmtime = "31"
|
||||||
wasmtime-wasi = "31"
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[package.metadata.cargo-machete]
|
|
||||||
ignored = ["wasmtime", "wasmtime-wasi"]
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wafer-core = { path = "../core", version = "0.1.0" }
|
wafer-core = { path = "../core", version = "0.1.0" }
|
||||||
wasmtime = { workspace = true }
|
|
||||||
wasmtime-wasi = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rustyline = "15"
|
rustyline = "15"
|
||||||
|
|||||||
+129
-13
@@ -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::outer::ForthVM;
|
||||||
|
use wafer_core::runner::run_wasm_file;
|
||||||
|
|
||||||
/// WAFER: WebAssembly Forth Engine in Rust
|
/// WAFER: WebAssembly Forth Engine in Rust
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "wafer", version, about)]
|
#[command(name = "wafer", version, about)]
|
||||||
struct Cli {
|
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>,
|
file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Compile all words into a single optimized WASM module
|
#[derive(Subcommand, Debug)]
|
||||||
#[arg(long)]
|
enum Commands {
|
||||||
consolidate: bool,
|
/// Compile a Forth source file to a standalone WASM module
|
||||||
|
Build {
|
||||||
|
/// Input Forth source file
|
||||||
|
file: String,
|
||||||
|
|
||||||
/// Output file for consolidated WASM (requires --consolidate)
|
/// Output .wasm file (default: input with .wasm extension)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
output: Option<String>,
|
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<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
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()?;
|
let mut vm = ForthVM::new()?;
|
||||||
|
|
||||||
match cli.file {
|
match file {
|
||||||
Some(ref file) => {
|
Some(file) => {
|
||||||
let source = std::fs::read_to_string(file)?;
|
let source = std::fs::read_to_string(file)?;
|
||||||
vm.evaluate(&source)?;
|
vm.evaluate(&source)?;
|
||||||
let output = vm.take_output();
|
let output = vm.take_output();
|
||||||
@@ -34,12 +152,10 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Check if stdin is a pipe (not a TTY)
|
if !stdin_is_tty() {
|
||||||
if !atty_is_tty() {
|
|
||||||
// Non-interactive: read all of stdin and evaluate
|
// Non-interactive: read all of stdin and evaluate
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut input)?;
|
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() {
|
for line in input.lines() {
|
||||||
match vm.evaluate(line) {
|
match vm.evaluate(line) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -106,7 +222,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if stdin is a terminal (TTY).
|
/// Check if stdin is a terminal (TTY).
|
||||||
fn atty_is_tty() -> bool {
|
fn stdin_is_tty() -> bool {
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
std::io::stdin().is_terminal()
|
std::io::stdin().is_terminal()
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-20
@@ -10,9 +10,10 @@ use std::borrow::Cow;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use wasm_encoder::{
|
use wasm_encoder::{
|
||||||
BlockType, CodeSection, ConstExpr, ElementSection, Elements, EntityType, ExportKind,
|
BlockType, CodeSection, ConstExpr, CustomSection, DataCountSection, DataSection,
|
||||||
ExportSection, Function, FunctionSection, GlobalType, ImportSection, Instruction, MemArg,
|
ElementSection, Elements, EntityType, ExportKind, ExportSection, Function, FunctionSection,
|
||||||
MemoryType, Module, RefType, TableType, TypeSection, ValType,
|
GlobalType, ImportSection, Instruction, MemArg, MemoryType, Module, RefType, TableType,
|
||||||
|
TypeSection, ValType,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::dictionary::WordId;
|
use crate::dictionary::WordId;
|
||||||
@@ -2062,25 +2063,50 @@ fn emit_consolidated_do_loop(
|
|||||||
f.instruction(&Instruction::Drop);
|
f.instruction(&Instruction::Drop);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compile all given words into a single consolidated WASM module.
|
/// Optional extras for exportable modules (data section, entry point, metadata).
|
||||||
|
pub struct ExportSections<'a> {
|
||||||
|
/// Memory snapshot to embed as a WASM data section.
|
||||||
|
pub memory_snapshot: &'a [u8],
|
||||||
|
/// If set, export this function index as `_start`.
|
||||||
|
pub entry_fn_index: Option<u32>,
|
||||||
|
/// JSON metadata to embed as a custom "wafer" section.
|
||||||
|
pub metadata_json: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile multiple IR-based words into a single WASM module with direct calls.
|
||||||
///
|
///
|
||||||
/// Each word becomes a function in the module. Calls between words within the
|
/// Used at runtime by `CONSOLIDATE` and during startup batch compilation.
|
||||||
/// module use direct `call` instructions instead of `call_indirect` through the
|
|
||||||
/// function table, enabling Cranelift to inline and optimize across word
|
|
||||||
/// boundaries.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `words` - Words to consolidate, sorted by `WordId`. Each entry is
|
|
||||||
/// `(WordId, Vec<IrOp>)` containing the word's IR body.
|
|
||||||
/// * `local_fn_map` - Maps each `WordId` in the module to its WASM function
|
|
||||||
/// index (imported functions come first, so defined functions start at 1).
|
|
||||||
/// * `table_size` - Current function table size, used for table import minimum.
|
|
||||||
pub fn compile_consolidated_module(
|
pub fn compile_consolidated_module(
|
||||||
words: &[(WordId, Vec<IrOp>)],
|
words: &[(WordId, Vec<IrOp>)],
|
||||||
local_fn_map: &HashMap<WordId, u32>,
|
local_fn_map: &HashMap<WordId, u32>,
|
||||||
table_size: u32,
|
table_size: u32,
|
||||||
) -> WaferResult<Vec<u8>> {
|
) -> WaferResult<Vec<u8>> {
|
||||||
|
compile_multi_word_module(words, local_fn_map, table_size, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile an exportable WASM module with embedded memory and metadata.
|
||||||
|
///
|
||||||
|
/// Same as [`compile_consolidated_module`] but adds a WASM data section
|
||||||
|
/// (memory snapshot), an optional `_start` entry point export, and a
|
||||||
|
/// custom "wafer" section with JSON metadata.
|
||||||
|
pub fn compile_exportable_module(
|
||||||
|
words: &[(WordId, Vec<IrOp>)],
|
||||||
|
local_fn_map: &HashMap<WordId, u32>,
|
||||||
|
table_size: u32,
|
||||||
|
export: &ExportSections<'_>,
|
||||||
|
) -> WaferResult<Vec<u8>> {
|
||||||
|
compile_multi_word_module(words, local_fn_map, table_size, Some(export))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal: build a multi-word WASM module. When `export` is `Some`, adds
|
||||||
|
/// data section, entry-point export, and custom metadata section.
|
||||||
|
fn compile_multi_word_module(
|
||||||
|
words: &[(WordId, Vec<IrOp>)],
|
||||||
|
local_fn_map: &HashMap<WordId, u32>,
|
||||||
|
table_size: u32,
|
||||||
|
export: Option<&ExportSections<'_>>,
|
||||||
|
) -> WaferResult<Vec<u8>> {
|
||||||
|
let has_data = export.is_some_and(|e| !e.memory_snapshot.is_empty());
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
|
|
||||||
// -- Type section --
|
// -- Type section --
|
||||||
@@ -2157,10 +2183,15 @@ pub fn compile_consolidated_module(
|
|||||||
// +1 because emit is imported function index 0
|
// +1 because emit is imported function index 0
|
||||||
exports.export(&name, ExportKind::Func, (i as u32) + 1);
|
exports.export(&name, ExportKind::Func, (i as u32) + 1);
|
||||||
}
|
}
|
||||||
|
// Optionally export an entry point as "_start"
|
||||||
|
if let Some(e) = export
|
||||||
|
&& let Some(fn_idx) = e.entry_fn_index
|
||||||
|
{
|
||||||
|
exports.export("_start", ExportKind::Func, fn_idx);
|
||||||
|
}
|
||||||
module.section(&exports);
|
module.section(&exports);
|
||||||
|
|
||||||
// -- Element section: place each function in the table at its WordId slot --
|
// -- Element section: place each function in the table at its WordId slot --
|
||||||
// Use a single element section with one active segment per word.
|
|
||||||
let mut elements = ElementSection::new();
|
let mut elements = ElementSection::new();
|
||||||
for (i, (word_id, _)) in words.iter().enumerate() {
|
for (i, (word_id, _)) in words.iter().enumerate() {
|
||||||
let offset = ConstExpr::i32_const(word_id.0 as i32);
|
let offset = ConstExpr::i32_const(word_id.0 as i32);
|
||||||
@@ -2174,6 +2205,11 @@ pub fn compile_consolidated_module(
|
|||||||
}
|
}
|
||||||
module.section(&elements);
|
module.section(&elements);
|
||||||
|
|
||||||
|
// -- DataCount section (required before Code when Data section is present) --
|
||||||
|
if has_data {
|
||||||
|
module.section(&DataCountSection { count: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
// -- Code section: emit each function body --
|
// -- Code section: emit each function body --
|
||||||
let mut code = CodeSection::new();
|
let mut code = CodeSection::new();
|
||||||
for (_word_id, body) in words {
|
for (_word_id, body) in words {
|
||||||
@@ -2206,12 +2242,34 @@ pub fn compile_consolidated_module(
|
|||||||
}
|
}
|
||||||
module.section(&code);
|
module.section(&code);
|
||||||
|
|
||||||
|
// -- Data section (memory snapshot for exportable modules) --
|
||||||
|
if let Some(e) = export
|
||||||
|
&& !e.memory_snapshot.is_empty()
|
||||||
|
{
|
||||||
|
let mut data = DataSection::new();
|
||||||
|
data.active(
|
||||||
|
MEMORY_INDEX,
|
||||||
|
&ConstExpr::i32_const(0),
|
||||||
|
e.memory_snapshot.iter().copied(),
|
||||||
|
);
|
||||||
|
module.section(&data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Custom "wafer" section (metadata for exportable modules) --
|
||||||
|
if let Some(e) = export
|
||||||
|
&& !e.metadata_json.is_empty()
|
||||||
|
{
|
||||||
|
module.section(&CustomSection {
|
||||||
|
name: Cow::Borrowed("wafer"),
|
||||||
|
data: Cow::Borrowed(e.metadata_json),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let bytes = module.finish();
|
let bytes = module.finish();
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
wasmparser::validate(&bytes).map_err(|e| {
|
wasmparser::validate(&bytes)
|
||||||
WaferError::ValidationError(format!("Consolidated WASM failed validation: {e}"))
|
.map_err(|e| WaferError::ValidationError(format!("WASM module failed validation: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
//! WASM module export: compile a Forth session to a standalone `.wasm` file.
|
||||||
|
//!
|
||||||
|
//! Orchestrates the export pipeline: collect IR words, resolve the entry point,
|
||||||
|
//! snapshot WASM memory, build metadata, and call the exportable codegen.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use crate::codegen::{ExportSections, compile_exportable_module};
|
||||||
|
use crate::dictionary::WordId;
|
||||||
|
use crate::ir::IrOp;
|
||||||
|
use crate::outer::ForthVM;
|
||||||
|
|
||||||
|
/// Configuration for `wafer build`.
|
||||||
|
pub struct ExportConfig {
|
||||||
|
/// Explicit entry-point word name (from `--entry` flag).
|
||||||
|
pub entry_word: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata embedded in the "wafer" custom section of exported modules.
|
||||||
|
pub struct ExportMetadata {
|
||||||
|
/// Format version (currently 1).
|
||||||
|
pub version: u32,
|
||||||
|
/// Table index of the entry-point function, if any.
|
||||||
|
pub entry_table_index: Option<u32>,
|
||||||
|
/// Host functions referenced by consolidated code: (`table_index`, name).
|
||||||
|
pub host_functions: Vec<(u32, String)>,
|
||||||
|
/// Number of memory bytes in the data section snapshot.
|
||||||
|
pub memory_size: u32,
|
||||||
|
/// Initial data-stack pointer.
|
||||||
|
pub dsp_init: u32,
|
||||||
|
/// Initial return-stack pointer.
|
||||||
|
pub rsp_init: u32,
|
||||||
|
/// Initial float-stack pointer.
|
||||||
|
pub fsp_init: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the current VM state as a standalone WASM module.
|
||||||
|
///
|
||||||
|
/// Returns the raw `.wasm` bytes ready to write to a file, plus the metadata.
|
||||||
|
pub fn export_module(
|
||||||
|
vm: &mut ForthVM,
|
||||||
|
config: &ExportConfig,
|
||||||
|
) -> anyhow::Result<(Vec<u8>, ExportMetadata)> {
|
||||||
|
let mut words = vm.ir_words();
|
||||||
|
|
||||||
|
// Determine the entry point.
|
||||||
|
// Priority: --entry flag > MAIN word > recorded top-level execution.
|
||||||
|
let toplevel = vm.toplevel_ir();
|
||||||
|
let entry_word_id = if let Some(ref name) = config.entry_word {
|
||||||
|
Some(
|
||||||
|
vm.resolve_word(name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("entry word '{name}' not found"))?,
|
||||||
|
)
|
||||||
|
} else if let Some(main_id) = vm.resolve_word("MAIN") {
|
||||||
|
Some(main_id)
|
||||||
|
} else if !toplevel.is_empty() {
|
||||||
|
// Synthesize a _start word from recorded top-level execution.
|
||||||
|
// Pick a WordId that won't collide (one past the current table size).
|
||||||
|
let start_id = WordId(vm.current_table_size());
|
||||||
|
words.push((start_id, toplevel.to_vec()));
|
||||||
|
Some(start_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if words.is_empty() {
|
||||||
|
anyhow::bail!("nothing to export: no compiled words found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build local_fn_map: WordId -> module-internal function index.
|
||||||
|
// Imported functions occupy index 0 (emit), so defined functions start at 1.
|
||||||
|
let mut local_fn_map = HashMap::new();
|
||||||
|
for (i, (word_id, _)) in words.iter().enumerate() {
|
||||||
|
local_fn_map.insert(*word_id, (i as u32) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve entry function index within the module.
|
||||||
|
let entry_fn_index = entry_word_id.and_then(|id| local_fn_map.get(&id).copied());
|
||||||
|
|
||||||
|
// Snapshot memory (system variables + user data).
|
||||||
|
let memory_snapshot = vm.memory_snapshot();
|
||||||
|
|
||||||
|
// Table size: must accommodate all WordIds including the synthetic _start.
|
||||||
|
let max_word_id = words.iter().map(|(id, _)| id.0).max().unwrap_or(0);
|
||||||
|
let table_size = (max_word_id + 1).max(vm.current_table_size());
|
||||||
|
|
||||||
|
// Find host functions referenced by any consolidated word.
|
||||||
|
let ir_word_ids: HashSet<WordId> = words.iter().map(|(id, _)| *id).collect();
|
||||||
|
let mut referenced_host_ids: HashSet<WordId> = HashSet::new();
|
||||||
|
for (_, body) in &words {
|
||||||
|
collect_external_calls(body, &ir_word_ids, &mut referenced_host_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_names = vm.host_function_names();
|
||||||
|
let mut host_functions: Vec<(u32, String)> = referenced_host_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| host_names.get(id).map(|name| (id.0, name.clone())))
|
||||||
|
.collect();
|
||||||
|
host_functions.sort_by_key(|(idx, _)| *idx);
|
||||||
|
|
||||||
|
let (dsp_init, rsp_init, fsp_init) = vm.stack_pointer_inits();
|
||||||
|
|
||||||
|
let metadata = ExportMetadata {
|
||||||
|
version: 1,
|
||||||
|
entry_table_index: entry_word_id.map(|id| id.0),
|
||||||
|
host_functions,
|
||||||
|
memory_size: memory_snapshot.len() as u32,
|
||||||
|
dsp_init,
|
||||||
|
rsp_init,
|
||||||
|
fsp_init,
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata_json = serialize_metadata(&metadata);
|
||||||
|
|
||||||
|
let export_sections = ExportSections {
|
||||||
|
memory_snapshot: &memory_snapshot,
|
||||||
|
entry_fn_index,
|
||||||
|
metadata_json: metadata_json.as_bytes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let wasm_bytes = compile_exportable_module(&words, &local_fn_map, table_size, &export_sections)
|
||||||
|
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
|
||||||
|
|
||||||
|
Ok((wasm_bytes, metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect `Call`/`TailCall` targets that are NOT in the IR word set
|
||||||
|
/// (i.e., they are host functions that the runner must provide).
|
||||||
|
fn collect_external_calls(ops: &[IrOp], ir_ids: &HashSet<WordId>, host_ids: &mut HashSet<WordId>) {
|
||||||
|
for op in ops {
|
||||||
|
match op {
|
||||||
|
IrOp::Call(id) | IrOp::TailCall(id) => {
|
||||||
|
if !ir_ids.contains(id) {
|
||||||
|
host_ids.insert(*id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IrOp::If {
|
||||||
|
then_body,
|
||||||
|
else_body,
|
||||||
|
} => {
|
||||||
|
collect_external_calls(then_body, ir_ids, host_ids);
|
||||||
|
if let Some(eb) = else_body {
|
||||||
|
collect_external_calls(eb, ir_ids, host_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IrOp::DoLoop { body, .. } | IrOp::BeginUntil { body } | IrOp::BeginAgain { body } => {
|
||||||
|
collect_external_calls(body, ir_ids, host_ids);
|
||||||
|
}
|
||||||
|
IrOp::BeginWhileRepeat { test, body } => {
|
||||||
|
collect_external_calls(test, ir_ids, host_ids);
|
||||||
|
collect_external_calls(body, ir_ids, host_ids);
|
||||||
|
}
|
||||||
|
IrOp::BeginDoubleWhileRepeat {
|
||||||
|
outer_test,
|
||||||
|
inner_test,
|
||||||
|
body,
|
||||||
|
after_repeat,
|
||||||
|
else_body,
|
||||||
|
} => {
|
||||||
|
collect_external_calls(outer_test, ir_ids, host_ids);
|
||||||
|
collect_external_calls(inner_test, ir_ids, host_ids);
|
||||||
|
collect_external_calls(body, ir_ids, host_ids);
|
||||||
|
collect_external_calls(after_repeat, ir_ids, host_ids);
|
||||||
|
if let Some(eb) = else_body {
|
||||||
|
collect_external_calls(eb, ir_ids, host_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize export metadata to JSON (hand-rolled, no serde dependency).
|
||||||
|
fn serialize_metadata(m: &ExportMetadata) -> String {
|
||||||
|
let mut s = String::from("{\n");
|
||||||
|
let _ = writeln!(s, " \"version\": {},", m.version);
|
||||||
|
match m.entry_table_index {
|
||||||
|
Some(idx) => {
|
||||||
|
let _ = writeln!(s, " \"entry_table_index\": {idx},");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = writeln!(s, " \"entry_table_index\": null,");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = writeln!(s, " \"memory_size\": {},", m.memory_size);
|
||||||
|
let _ = writeln!(s, " \"dsp_init\": {},", m.dsp_init);
|
||||||
|
let _ = writeln!(s, " \"rsp_init\": {},", m.rsp_init);
|
||||||
|
let _ = writeln!(s, " \"fsp_init\": {},", m.fsp_init);
|
||||||
|
let _ = write!(s, " \"host_functions\": [");
|
||||||
|
for (i, (idx, name)) in m.host_functions.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
let _ = write!(s, ", ");
|
||||||
|
}
|
||||||
|
// Escape any quotes in the name (unlikely but safe).
|
||||||
|
let escaped: String = name
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| if c == '"' { vec!['\\', '"'] } else { vec![c] })
|
||||||
|
.collect();
|
||||||
|
let _ = write!(s, "{{\"index\": {idx}, \"name\": \"{escaped}\"}}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(s, "]");
|
||||||
|
s.push('}');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize export metadata from JSON (minimal parser for our known format).
|
||||||
|
pub fn deserialize_metadata(json: &str) -> anyhow::Result<ExportMetadata> {
|
||||||
|
// Simple extraction by key -- works for our flat JSON structure.
|
||||||
|
let get_u32 = |key: &str| -> anyhow::Result<u32> {
|
||||||
|
let pat = format!("\"{key}\": ");
|
||||||
|
let start = json
|
||||||
|
.find(&pat)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing key: {key}"))?
|
||||||
|
+ pat.len();
|
||||||
|
let end = json[start..]
|
||||||
|
.find([',', '\n', '}'])
|
||||||
|
.map_or(json.len(), |i| start + i);
|
||||||
|
json[start..end]
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow::anyhow!("bad {key}: {e}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let get_optional_u32 = |key: &str| -> anyhow::Result<Option<u32>> {
|
||||||
|
let pat = format!("\"{key}\": ");
|
||||||
|
let Some(pos) = json.find(&pat) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let start = pos + pat.len();
|
||||||
|
let end = json[start..]
|
||||||
|
.find([',', '\n', '}'])
|
||||||
|
.map_or(json.len(), |i| start + i);
|
||||||
|
let val = json[start..end].trim();
|
||||||
|
if val == "null" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
val.parse()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| anyhow::anyhow!("bad {key}: {e}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse host_functions array
|
||||||
|
let mut host_functions = Vec::new();
|
||||||
|
if let Some(arr_start) = json.find("\"host_functions\": [") {
|
||||||
|
let arr_start = arr_start + "\"host_functions\": [".len();
|
||||||
|
let arr_end = json[arr_start..]
|
||||||
|
.find(']')
|
||||||
|
.map_or(json.len(), |i| arr_start + i);
|
||||||
|
let arr = &json[arr_start..arr_end];
|
||||||
|
|
||||||
|
// Parse each {"index": N, "name": "X"} object
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos < arr.len() {
|
||||||
|
if let Some(obj_start) = arr[pos..].find('{') {
|
||||||
|
let obj_start = pos + obj_start;
|
||||||
|
if let Some(obj_end) = arr[obj_start..].find('}') {
|
||||||
|
let obj = &arr[obj_start..obj_start + obj_end + 1];
|
||||||
|
|
||||||
|
// Extract index
|
||||||
|
if let Some(idx_start) = obj.find("\"index\": ") {
|
||||||
|
let idx_start = idx_start + "\"index\": ".len();
|
||||||
|
let idx_end = obj[idx_start..]
|
||||||
|
.find([',', '}'])
|
||||||
|
.map_or(obj.len(), |i| idx_start + i);
|
||||||
|
let idx: u32 = obj[idx_start..idx_end].trim().parse().unwrap_or(0);
|
||||||
|
|
||||||
|
// Extract name
|
||||||
|
if let Some(name_start) = obj.find("\"name\": \"") {
|
||||||
|
let name_start = name_start + "\"name\": \"".len();
|
||||||
|
if let Some(name_end) = obj[name_start..].find('"') {
|
||||||
|
let name = obj[name_start..name_start + name_end].to_string();
|
||||||
|
host_functions.push((idx, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = obj_start + obj_end + 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ExportMetadata {
|
||||||
|
version: get_u32("version")?,
|
||||||
|
entry_table_index: get_optional_u32("entry_table_index")?,
|
||||||
|
host_functions,
|
||||||
|
memory_size: get_u32("memory_size")?,
|
||||||
|
dsp_init: get_u32("dsp_init")?,
|
||||||
|
rsp_init: get_u32("rsp_init")?,
|
||||||
|
fsp_init: get_u32("fsp_init")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_roundtrip() {
|
||||||
|
let m = ExportMetadata {
|
||||||
|
version: 1,
|
||||||
|
entry_table_index: Some(42),
|
||||||
|
host_functions: vec![(5, ".".to_string()), (12, "TYPE".to_string())],
|
||||||
|
memory_size: 65536,
|
||||||
|
dsp_init: 5440,
|
||||||
|
rsp_init: 9536,
|
||||||
|
fsp_init: 11584,
|
||||||
|
};
|
||||||
|
let json = serialize_metadata(&m);
|
||||||
|
let m2 = deserialize_metadata(&json).unwrap();
|
||||||
|
assert_eq!(m2.version, 1);
|
||||||
|
assert_eq!(m2.entry_table_index, Some(42));
|
||||||
|
assert_eq!(m2.host_functions.len(), 2);
|
||||||
|
assert_eq!(m2.host_functions[0], (5, ".".to_string()));
|
||||||
|
assert_eq!(m2.host_functions[1], (12, "TYPE".to_string()));
|
||||||
|
assert_eq!(m2.memory_size, 65536);
|
||||||
|
assert_eq!(m2.dsp_init, 5440);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_null_entry() {
|
||||||
|
let m = ExportMetadata {
|
||||||
|
version: 1,
|
||||||
|
entry_table_index: None,
|
||||||
|
host_functions: vec![],
|
||||||
|
memory_size: 1024,
|
||||||
|
dsp_init: 5440,
|
||||||
|
rsp_init: 9536,
|
||||||
|
fsp_init: 11584,
|
||||||
|
};
|
||||||
|
let json = serialize_metadata(&m);
|
||||||
|
assert!(json.contains("\"entry_table_index\": null"));
|
||||||
|
let m2 = deserialize_metadata(&json).unwrap();
|
||||||
|
assert_eq!(m2.entry_table_index, None);
|
||||||
|
assert!(m2.host_functions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_calls_finds_host_functions() {
|
||||||
|
let ir_ids: HashSet<WordId> = [WordId(1), WordId(2)].iter().copied().collect();
|
||||||
|
let body = vec![
|
||||||
|
IrOp::Call(WordId(1)), // IR word, not host
|
||||||
|
IrOp::Call(WordId(99)), // host function
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![IrOp::Call(WordId(50))], // host in nested body
|
||||||
|
else_body: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut host = HashSet::new();
|
||||||
|
collect_external_calls(&body, &ir_ids, &mut host);
|
||||||
|
assert!(host.contains(&WordId(99)));
|
||||||
|
assert!(host.contains(&WordId(50)));
|
||||||
|
assert!(!host.contains(&WordId(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: evaluate Forth code, export to WASM, run, and return the output.
|
||||||
|
fn roundtrip(source: &str) -> String {
|
||||||
|
use crate::outer::ForthVM;
|
||||||
|
use crate::runner::run_wasm_bytes;
|
||||||
|
|
||||||
|
let mut vm = ForthVM::new().unwrap();
|
||||||
|
vm.set_recording(true);
|
||||||
|
vm.evaluate(source).unwrap();
|
||||||
|
|
||||||
|
let config = ExportConfig { entry_word: None };
|
||||||
|
let (wasm_bytes, _metadata) = export_module(&mut vm, &config).unwrap();
|
||||||
|
run_wasm_bytes(&wasm_bytes).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_simple_dot() {
|
||||||
|
assert_eq!(roundtrip(": main 42 . ;"), "42 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_multiple_words() {
|
||||||
|
assert_eq!(roundtrip(": double 2 * ; : main 21 double . ;"), "42 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_variable() {
|
||||||
|
assert_eq!(roundtrip("VARIABLE X 99 X ! : main X @ . ;"), "99 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_emit() {
|
||||||
|
assert_eq!(roundtrip(": main 72 EMIT 73 EMIT 10 EMIT ;"), "HI\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_constant() {
|
||||||
|
assert_eq!(roundtrip("42 CONSTANT ANSWER : main ANSWER . ;"), "42 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_toplevel_execution() {
|
||||||
|
// No MAIN: top-level calls become the entry point.
|
||||||
|
assert_eq!(roundtrip(": hello 42 . ; hello"), "42 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_control_flow() {
|
||||||
|
assert_eq!(roundtrip(": main 1 IF 42 ELSE 0 THEN . ;"), "42 ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//! Generate JavaScript and HTML loaders for running exported WASM in the browser.
|
||||||
|
|
||||||
|
use crate::export::ExportMetadata;
|
||||||
|
|
||||||
|
/// Generate a JavaScript loader that instantiates a WAFER `.wasm` module.
|
||||||
|
///
|
||||||
|
/// The loader provides the six required imports (emit, memory, dsp, rsp, fsp,
|
||||||
|
/// table) and host-function stubs, then calls `_start`.
|
||||||
|
pub fn generate_js_loader(wasm_filename: &str, metadata: &ExportMetadata) -> String {
|
||||||
|
let (dsp, rsp, fsp) = (metadata.dsp_init, metadata.rsp_init, metadata.fsp_init);
|
||||||
|
let memory_pages = metadata.memory_size.div_ceil(65536).max(16);
|
||||||
|
|
||||||
|
// Build the host function registration code.
|
||||||
|
let mut host_registrations = String::new();
|
||||||
|
for (idx, name) in &metadata.host_functions {
|
||||||
|
let js_impl = js_host_function(name);
|
||||||
|
host_registrations.push_str(&format!(" table.set({idx}, {js_impl});\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"// WAFER JS Loader - generated by wafer build --js
|
||||||
|
// Loads and runs {wasm_filename} in the browser.
|
||||||
|
|
||||||
|
const WAFER = (() => {{
|
||||||
|
const CELL_SIZE = 4;
|
||||||
|
const DATA_STACK_TOP = 0x1540;
|
||||||
|
const SYSVAR_BASE = 0x0004;
|
||||||
|
let outputCallback = (s) => {{
|
||||||
|
const el = document.getElementById('output');
|
||||||
|
if (el) el.textContent += s;
|
||||||
|
else console.log(s);
|
||||||
|
}};
|
||||||
|
|
||||||
|
async function run(opts) {{
|
||||||
|
if (opts && opts.output) outputCallback = opts.output;
|
||||||
|
|
||||||
|
const memory = new WebAssembly.Memory({{ initial: {memory_pages} }});
|
||||||
|
const dsp = new WebAssembly.Global({{ value: 'i32', mutable: true }}, {dsp});
|
||||||
|
const rsp = new WebAssembly.Global({{ value: 'i32', mutable: true }}, {rsp});
|
||||||
|
const fsp = new WebAssembly.Global({{ value: 'i32', mutable: true }}, {fsp});
|
||||||
|
const table = new WebAssembly.Table({{ element: 'anyfunc', initial: 256 }});
|
||||||
|
|
||||||
|
function emit(code) {{
|
||||||
|
outputCallback(String.fromCharCode(code));
|
||||||
|
}}
|
||||||
|
|
||||||
|
const importObject = {{
|
||||||
|
env: {{ emit, memory, dsp, rsp, fsp, table }}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Register host functions
|
||||||
|
const view = () => new DataView(memory.buffer);
|
||||||
|
const pop = () => {{
|
||||||
|
const sp = dsp.value;
|
||||||
|
const v = view().getInt32(sp, true);
|
||||||
|
dsp.value = sp + CELL_SIZE;
|
||||||
|
return v;
|
||||||
|
}};
|
||||||
|
const push = (v) => {{
|
||||||
|
const sp = dsp.value - CELL_SIZE;
|
||||||
|
view().setInt32(sp, v, true);
|
||||||
|
dsp.value = sp;
|
||||||
|
}};
|
||||||
|
|
||||||
|
{host_registrations}
|
||||||
|
const response = await fetch('{wasm_filename}');
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
const {{ instance }} = await WebAssembly.instantiate(bytes, importObject);
|
||||||
|
|
||||||
|
if (instance.exports._start) {{
|
||||||
|
instance.exports._start();
|
||||||
|
}}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}}
|
||||||
|
|
||||||
|
return {{ run }};
|
||||||
|
}})();
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a minimal HTML page that loads the JS loader.
|
||||||
|
pub fn generate_html_page(wasm_filename: &str, js_filename: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>WAFER - {wasm_filename}</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 2em; }}
|
||||||
|
#output {{ white-space: pre-wrap; font-size: 1.2em; padding: 1em; background: #16213e;
|
||||||
|
border: 1px solid #0f3460; border-radius: 4px; min-height: 4em; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>WAFER Output</h2>
|
||||||
|
<div id="output"></div>
|
||||||
|
<script src="{js_filename}"></script>
|
||||||
|
<script>WAFER.run();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a JS expression that creates a `WebAssembly.Function` for a known
|
||||||
|
/// host word. Falls back to a stub that logs an error.
|
||||||
|
fn js_host_function(name: &str) -> &'static str {
|
||||||
|
match name {
|
||||||
|
"." => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
const n = pop();
|
||||||
|
const base = view().getUint32(SYSVAR_BASE, true);
|
||||||
|
outputCallback((base === 16 ? n.toString(16).toUpperCase() : n.toString()) + ' ');
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
"U." => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
const n = pop() >>> 0;
|
||||||
|
const base = view().getUint32(SYSVAR_BASE, true);
|
||||||
|
outputCallback((base === 16 ? n.toString(16).toUpperCase() : n.toString()) + ' ');
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
"TYPE" => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
const len = pop();
|
||||||
|
const addr = pop();
|
||||||
|
const bytes = new Uint8Array(memory.buffer, addr, len);
|
||||||
|
outputCallback(new TextDecoder().decode(bytes));
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
"SPACES" => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
const n = pop();
|
||||||
|
if (n > 0) outputCallback(' '.repeat(n));
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
".S" => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
const sp = dsp.value;
|
||||||
|
const depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
||||||
|
let s = '<' + depth + '> ';
|
||||||
|
for (let a = DATA_STACK_TOP - CELL_SIZE; a >= sp; a -= CELL_SIZE) {
|
||||||
|
s += view().getInt32(a, true) + ' ';
|
||||||
|
}
|
||||||
|
outputCallback(s);
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
"DEPTH" => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
const depth = (DATA_STACK_TOP - dsp.value) / CELL_SIZE;
|
||||||
|
push(depth);
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
r#"new WebAssembly.Function({parameters:[], results:[]}, () => {
|
||||||
|
console.error('Host function not available in standalone mode');
|
||||||
|
})"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,10 @@ pub mod config;
|
|||||||
pub mod consolidate;
|
pub mod consolidate;
|
||||||
pub mod dictionary;
|
pub mod dictionary;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod export;
|
||||||
pub mod ir;
|
pub mod ir;
|
||||||
|
pub mod js_loader;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod optimizer;
|
pub mod optimizer;
|
||||||
pub mod outer;
|
pub mod outer;
|
||||||
|
pub mod runner;
|
||||||
|
|||||||
+107
-24
@@ -194,9 +194,8 @@ pub struct ForthVM {
|
|||||||
next_table_index: u32,
|
next_table_index: u32,
|
||||||
// The emit function (shared across all instantiated modules)
|
// The emit function (shared across all instantiated modules)
|
||||||
emit_func: Func,
|
emit_func: Func,
|
||||||
// Dot (print number) function -- kept for potential future use
|
// Map from WordId to name for host-function words (for export metadata).
|
||||||
#[allow(dead_code)]
|
host_word_names: HashMap<WordId, String>,
|
||||||
dot_func: Func,
|
|
||||||
// Shared HERE value for host functions (synced with user_here)
|
// Shared HERE value for host functions (synced with user_here)
|
||||||
here_cell: Option<Arc<Mutex<u32>>>,
|
here_cell: Option<Arc<Mutex<u32>>>,
|
||||||
// User data allocation pointer in WASM linear memory.
|
// User data allocation pointer in WASM linear memory.
|
||||||
@@ -241,6 +240,10 @@ pub struct ForthVM {
|
|||||||
batch_mode: bool,
|
batch_mode: bool,
|
||||||
/// IR primitives deferred during `batch_mode` for single-module compilation.
|
/// IR primitives deferred during `batch_mode` for single-module compilation.
|
||||||
deferred_ir: Vec<(WordId, Vec<IrOp>)>,
|
deferred_ir: Vec<(WordId, Vec<IrOp>)>,
|
||||||
|
/// Recorded top-level IR from interpretation mode (for `wafer build`).
|
||||||
|
toplevel_ir: Vec<IrOp>,
|
||||||
|
/// When true, interpretation-mode execution is recorded into `toplevel_ir`.
|
||||||
|
recording_toplevel: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ForthVM {
|
impl ForthVM {
|
||||||
@@ -306,21 +309,6 @@ impl ForthVM {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create dot host function: (i32) -> ()
|
|
||||||
// This is used to implement `.` -- it pops TOS and prints it.
|
|
||||||
// We create a host function that takes i32, converts to string, appends to output.
|
|
||||||
let out_ref2 = Arc::clone(&output);
|
|
||||||
let dot_func = Func::new(
|
|
||||||
&mut store,
|
|
||||||
FuncType::new(&engine, [ValType::I32], []),
|
|
||||||
move |_caller, params, _results| {
|
|
||||||
let n = params[0].unwrap_i32();
|
|
||||||
let s = format!("{n} ");
|
|
||||||
out_ref2.lock().unwrap().push_str(&s);
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let dictionary = Dictionary::new();
|
let dictionary = Dictionary::new();
|
||||||
|
|
||||||
let mut vm = ForthVM {
|
let mut vm = ForthVM {
|
||||||
@@ -343,7 +331,7 @@ impl ForthVM {
|
|||||||
output,
|
output,
|
||||||
next_table_index: 0,
|
next_table_index: 0,
|
||||||
emit_func,
|
emit_func,
|
||||||
dot_func,
|
host_word_names: HashMap::new(),
|
||||||
here_cell: None,
|
here_cell: None,
|
||||||
// User data starts at 64K in WASM memory, well clear of all system regions
|
// User data starts at 64K in WASM memory, well clear of all system regions
|
||||||
user_here: 0x10000,
|
user_here: 0x10000,
|
||||||
@@ -366,6 +354,8 @@ impl ForthVM {
|
|||||||
total_module_bytes: 0,
|
total_module_bytes: 0,
|
||||||
batch_mode: false,
|
batch_mode: false,
|
||||||
deferred_ir: Vec::new(),
|
deferred_ir: Vec::new(),
|
||||||
|
toplevel_ir: Vec::new(),
|
||||||
|
recording_toplevel: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.register_primitives()?;
|
vm.register_primitives()?;
|
||||||
@@ -447,6 +437,69 @@ impl ForthVM {
|
|||||||
self.total_module_bytes
|
self.total_module_bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Export support: public accessors for `wafer build`
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Enable or disable top-level execution recording.
|
||||||
|
///
|
||||||
|
/// When enabled, interpretation-mode word calls and literal pushes are
|
||||||
|
/// captured into an IR body that becomes the `_start` entry point in
|
||||||
|
/// exported WASM modules.
|
||||||
|
pub fn set_recording(&mut self, on: bool) {
|
||||||
|
self.recording_toplevel = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the recorded top-level IR (empty if recording was not enabled).
|
||||||
|
pub fn toplevel_ir(&self) -> &[IrOp] {
|
||||||
|
&self.toplevel_ir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot WASM linear memory from byte 0 through `user_here`.
|
||||||
|
///
|
||||||
|
/// The returned bytes contain system variables, stack regions, and all
|
||||||
|
/// user-allocated data (VARIABLEs, strings, etc.). This becomes the
|
||||||
|
/// WASM data section in exported modules.
|
||||||
|
pub fn memory_snapshot(&mut self) -> Vec<u8> {
|
||||||
|
self.refresh_user_here();
|
||||||
|
let data = self.memory.data(&self.store);
|
||||||
|
let end = self.user_here as usize;
|
||||||
|
data[..end].to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all IR-based word bodies, sorted by `WordId`.
|
||||||
|
pub fn ir_words(&self) -> Vec<(WordId, Vec<IrOp>)> {
|
||||||
|
let mut words: Vec<(WordId, Vec<IrOp>)> = self
|
||||||
|
.ir_bodies
|
||||||
|
.iter()
|
||||||
|
.map(|(&id, body)| (id, body.clone()))
|
||||||
|
.collect();
|
||||||
|
words.sort_by_key(|(id, _)| id.0);
|
||||||
|
words
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map of host-function `WordId`s to their Forth names.
|
||||||
|
pub fn host_function_names(&self) -> &HashMap<WordId, String> {
|
||||||
|
&self.host_word_names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a word name to its `WordId`. Returns `None` if not found.
|
||||||
|
pub fn resolve_word(&self, name: &str) -> Option<WordId> {
|
||||||
|
self.dictionary
|
||||||
|
.find(&name.to_ascii_uppercase())
|
||||||
|
.map(|(_, id, _)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current function table size.
|
||||||
|
pub fn current_table_size(&self) -> u32 {
|
||||||
|
self.table.size(&self.store) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial stack pointer values: (dsp, rsp, fsp).
|
||||||
|
pub fn stack_pointer_inits(&self) -> (u32, u32, u32) {
|
||||||
|
(DATA_STACK_TOP, RETURN_STACK_TOP, FLOAT_STACK_TOP)
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Internal: tokenizer
|
// Internal: tokenizer
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -674,6 +727,9 @@ impl ForthVM {
|
|||||||
return self.execute_does_defining(word_id);
|
return self.execute_does_defining(word_id);
|
||||||
}
|
}
|
||||||
self.execute_word(word_id)?;
|
self.execute_word(word_id)?;
|
||||||
|
if self.recording_toplevel && self.state == 0 {
|
||||||
|
self.toplevel_ir.push(IrOp::Call(word_id));
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,18 +737,28 @@ impl ForthVM {
|
|||||||
if let Some((lo, hi)) = self.parse_double_number(token) {
|
if let Some((lo, hi)) = self.parse_double_number(token) {
|
||||||
self.push_data_stack(lo)?;
|
self.push_data_stack(lo)?;
|
||||||
self.push_data_stack(hi)?;
|
self.push_data_stack(hi)?;
|
||||||
|
if self.recording_toplevel && self.state == 0 {
|
||||||
|
self.toplevel_ir.push(IrOp::PushI32(lo));
|
||||||
|
self.toplevel_ir.push(IrOp::PushI32(hi));
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as number
|
// Try to parse as number
|
||||||
if let Some(n) = self.parse_number(token) {
|
if let Some(n) = self.parse_number(token) {
|
||||||
self.push_data_stack(n)?;
|
self.push_data_stack(n)?;
|
||||||
|
if self.recording_toplevel && self.state == 0 {
|
||||||
|
self.toplevel_ir.push(IrOp::PushI32(n));
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as float literal (contains 'E' or 'e')
|
// Try to parse as float literal (contains 'E' or 'e')
|
||||||
if let Some(f) = self.parse_float_literal(token) {
|
if let Some(f) = self.parse_float_literal(token) {
|
||||||
self.fpush(f)?;
|
self.fpush(f)?;
|
||||||
|
if self.recording_toplevel && self.state == 0 {
|
||||||
|
self.toplevel_ir.push(IrOp::PushF64(f));
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1949,6 +2015,8 @@ impl ForthVM {
|
|||||||
self.dictionary.reveal();
|
self.dictionary.reveal();
|
||||||
self.sync_word_lookup(name, word_id, immediate);
|
self.sync_word_lookup(name, word_id, immediate);
|
||||||
self.next_table_index = self.next_table_index.max(word_id.0 + 1);
|
self.next_table_index = self.next_table_index.max(word_id.0 + 1);
|
||||||
|
self.host_word_names
|
||||||
|
.insert(word_id, name.to_ascii_uppercase());
|
||||||
|
|
||||||
Ok(word_id)
|
Ok(word_id)
|
||||||
}
|
}
|
||||||
@@ -2260,19 +2328,18 @@ impl ForthVM {
|
|||||||
&mut self.store,
|
&mut self.store,
|
||||||
FuncType::new(&self.engine, [], []),
|
FuncType::new(&self.engine, [], []),
|
||||||
move |mut caller, _params, _results| {
|
move |mut caller, _params, _results| {
|
||||||
// Read top of data stack
|
|
||||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||||
|
if sp >= DATA_STACK_TOP {
|
||||||
|
return Err(wasmtime::Error::msg("stack underflow"));
|
||||||
|
}
|
||||||
let data = memory.data(&caller);
|
let data = memory.data(&caller);
|
||||||
let b: [u8; 4] = data[sp as usize..sp as usize + 4].try_into().unwrap();
|
let b: [u8; 4] = data[sp as usize..sp as usize + 4].try_into().unwrap();
|
||||||
let value = i32::from_le_bytes(b);
|
let value = i32::from_le_bytes(b);
|
||||||
// Read BASE from WASM memory
|
|
||||||
let b: [u8; 4] = data[SYSVAR_BASE_VAR as usize..SYSVAR_BASE_VAR as usize + 4]
|
let b: [u8; 4] = data[SYSVAR_BASE_VAR as usize..SYSVAR_BASE_VAR as usize + 4]
|
||||||
.try_into()
|
.try_into()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let base_val = u32::from_le_bytes(b);
|
let base_val = u32::from_le_bytes(b);
|
||||||
// Increment dsp (pop)
|
|
||||||
dsp.set(&mut caller, Val::I32((sp + CELL_SIZE) as i32))?;
|
dsp.set(&mut caller, Val::I32((sp + CELL_SIZE) as i32))?;
|
||||||
// Format number in current base
|
|
||||||
let s = format_signed(value, base_val);
|
let s = format_signed(value, base_val);
|
||||||
output.lock().unwrap().push_str(&s);
|
output.lock().unwrap().push_str(&s);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2294,9 +2361,13 @@ impl ForthVM {
|
|||||||
FuncType::new(&self.engine, [], []),
|
FuncType::new(&self.engine, [], []),
|
||||||
move |mut caller, _params, _results| {
|
move |mut caller, _params, _results| {
|
||||||
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
let sp = dsp.get(&mut caller).unwrap_i32() as u32;
|
||||||
|
let mut out = output.lock().unwrap();
|
||||||
|
if sp >= DATA_STACK_TOP {
|
||||||
|
out.push_str("<0> ");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let data = memory.data(&caller);
|
let data = memory.data(&caller);
|
||||||
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
||||||
let mut out = output.lock().unwrap();
|
|
||||||
out.push_str(&format!("<{depth}> "));
|
out.push_str(&format!("<{depth}> "));
|
||||||
// Print from bottom to top
|
// Print from bottom to top
|
||||||
let mut addr = DATA_STACK_TOP - CELL_SIZE;
|
let mut addr = DATA_STACK_TOP - CELL_SIZE;
|
||||||
@@ -2449,6 +2520,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a tiny word that pushes the variable's address
|
// Compile a tiny word that pushes the variable's address
|
||||||
let ir_body = vec![IrOp::PushI32(var_addr as i32)];
|
let ir_body = vec![IrOp::PushI32(var_addr as i32)];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -2480,6 +2552,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a word that pushes the constant value
|
// Compile a word that pushes the constant value
|
||||||
let ir_body = vec![IrOp::PushI32(value)];
|
let ir_body = vec![IrOp::PushI32(value)];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -2516,6 +2589,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a word that pushes the pfa
|
// Compile a word that pushes the pfa
|
||||||
let ir_body = vec![IrOp::PushI32(pfa as i32)];
|
let ir_body = vec![IrOp::PushI32(pfa as i32)];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -2562,6 +2636,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a word that fetches from the value's address
|
// Compile a word that fetches from the value's address
|
||||||
let ir_body = vec![IrOp::PushI32(val_addr as i32), IrOp::Fetch];
|
let ir_body = vec![IrOp::PushI32(val_addr as i32), IrOp::Fetch];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -2606,6 +2681,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a word that fetches the xt and executes it
|
// Compile a word that fetches the xt and executes it
|
||||||
let ir_body = vec![IrOp::PushI32(defer_addr as i32), IrOp::Fetch, IrOp::Execute];
|
let ir_body = vec![IrOp::PushI32(defer_addr as i32), IrOp::Fetch, IrOp::Execute];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -2644,6 +2720,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a word that pushes the buffer address
|
// Compile a word that pushes the buffer address
|
||||||
let ir_body = vec![IrOp::PushI32(buf_addr as i32)];
|
let ir_body = vec![IrOp::PushI32(buf_addr as i32)];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -2676,6 +2753,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Stub: marker word does nothing when executed
|
// Stub: marker word does nothing when executed
|
||||||
let ir_body = vec![];
|
let ir_body = vec![];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -4146,6 +4224,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Temporarily install a "push PFA" word (will be patched later)
|
// Temporarily install a "push PFA" word (will be patched later)
|
||||||
let ir_body = vec![IrOp::PushI32(pfa as i32)];
|
let ir_body = vec![IrOp::PushI32(pfa as i32)];
|
||||||
|
self.ir_bodies.insert(new_word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: new_word_id.0,
|
base_fn_index: new_word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -6674,6 +6753,7 @@ impl ForthVM {
|
|||||||
self.dictionary.reveal();
|
self.dictionary.reveal();
|
||||||
|
|
||||||
let ir = vec![IrOp::PushI32(lo), IrOp::PushI32(hi)];
|
let ir = vec![IrOp::PushI32(lo), IrOp::PushI32(hi)];
|
||||||
|
self.ir_bodies.insert(word_id, ir.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -6704,6 +6784,7 @@ impl ForthVM {
|
|||||||
self.dictionary.reveal();
|
self.dictionary.reveal();
|
||||||
|
|
||||||
let ir = vec![IrOp::PushI32(addr as i32)];
|
let ir = vec![IrOp::PushI32(addr as i32)];
|
||||||
|
self.ir_bodies.insert(word_id, ir.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -6747,6 +6828,7 @@ impl ForthVM {
|
|||||||
IrOp::PushI32((addr + 4) as i32),
|
IrOp::PushI32((addr + 4) as i32),
|
||||||
IrOp::Fetch,
|
IrOp::Fetch,
|
||||||
];
|
];
|
||||||
|
self.ir_bodies.insert(word_id, ir.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
@@ -8074,6 +8156,7 @@ impl ForthVM {
|
|||||||
|
|
||||||
// Compile a word that pushes the address onto the DATA stack
|
// Compile a word that pushes the address onto the DATA stack
|
||||||
let ir_body = vec![IrOp::PushI32(addr as i32)];
|
let ir_body = vec![IrOp::PushI32(addr as i32)];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
let config = CodegenConfig {
|
let config = CodegenConfig {
|
||||||
base_fn_index: word_id.0,
|
base_fn_index: word_id.0,
|
||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wafer-web"
|
|
||||||
description = "WAFER: WebAssembly Forth Engine in Rust - browser bindings"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[package.metadata.cargo-machete]
|
|
||||||
ignored = ["wafer-core"]
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wafer-core = { path = "../core", version = "0.1.0" }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//! WAFER Web: Browser bindings for WAFER Forth.
|
|
||||||
//!
|
|
||||||
//! This crate will provide wasm-bindgen bindings for running WAFER
|
|
||||||
//! in the browser with a web REPL.
|
|
||||||
|
|
||||||
// TODO: Phase 5 - Browser target implementation
|
|
||||||
Reference in New Issue
Block a user