feat(core): INCLUDE, error overhaul, sf64 lane, WORDS ALL, .RS

WS-012 -- INCLUDE/INCLUDED:
- Injected source loader (core stays IO-free: CLI installs a
  filesystem reader, web leaves it unset -> defined error). Recursive
  include_file feeds files line-by-line through evaluate, so compile
  state and SEE capture span lines for free. Cycle detection, depth
  cap 16, paths relative to the including file, SOURCE-ID per nesting
  level, parent input restored on success/error/BYE.
- CLI file mode now runs through the include machinery: `wafer x.fth`
  gets file:line error context and a base dir for nested INCLUDEs.
- Unlocks the REMEMBER+INCLUDE reload loop.

WS-008 -- error reporting remainder:
- Errors inside included files carry `file.fth:12:` context
  (anyhow context chain; CLI prints {e:#}).
- describe_uncaught now returns typed WaferError::UncaughtThrow
  { code, message } -- display text unchanged, THROW code reachable
  via downcast for CLI/web consumers.
- compile_word emits a WASM name section; wasmtime trap backtraces
  name the faulting word and runtime_native prefixes "in <WORD>:".
  Batch/consolidated modules stay unnamed (no name plumbing there;
  boot primitives rarely trap).

WS-003 -- SwiftForth correctness lane:
- compare_all_programs_sf64 runs the program corpus with sf64 as
  oracle; whitespace-token comparison (sf64 prints numbers
  space-prefixed and echoes piped lines). 34/35 parity; dot-quote
  skipped (interpret-mode ." is a SwiftForth no-op). #[ignore]d like
  the gforth lane; `just compare-correctness` runs both.

WS-011 leftovers:
- WORDS ALL: grouped full view -- one section per wordlist (search
  order first), then internal words, each with counts. Backed by
  Dictionary::visible_entries (name, wid, internal); visible_words
  now derives from it.
- .RS / RDEPTH: return-stack introspection in boot.fth over a new
  RP@ primitive (IrOp::RpFetch); BEGIN/WHILE walk so the walk never
  touches the stack it prints. SPACES clamped per 6.1.2230.

549 unit + 11 compliance + 9(+2) comparison + 5 crypto + 1 bench
green; fmt/clippy clean; --no-default-features and wasm32 web checks
pass.
This commit is contained in:
Oleksandr Kozachuk
2026-08-06 12:03:29 +02:00
parent dc6e0d45e1
commit 9b1cc0cace
14 changed files with 678 additions and 63 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ Handle in `interpret_token_immediate()` or `compile_token()` as a special case.
## Testing ## Testing
- Run `cargo test --workspace` before committing (currently 524 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto) - Run `cargo test --workspace` before committing (currently 542 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto)
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance` - Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
- Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison` - Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison`
- Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored` - Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
+4
View File
@@ -47,6 +47,10 @@ bench-opts:
bench-compare: bench-compare:
CARGO_PROFILE_RELEASE_STRIP=none cargo test -p wafer-core --release --test comparison -- --nocapture --ignored performance_report CARGO_PROFILE_RELEASE_STRIP=none cargo test -p wafer-core --release --test comparison -- --nocapture --ignored performance_report
# Cross-engine correctness lanes: program corpus vs gforth + sf64 oracles
compare-correctness:
cargo test -p wafer-core --test comparison -- --nocapture --ignored compare_all_programs
# Check dependency licenses and advisories # Check dependency licenses and advisories
deny: deny:
cargo deny check cargo deny check
+2 -2
View File
@@ -95,7 +95,7 @@ Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CON
## Testing ## Testing
```bash ```bash
# All tests (~550 currently passing) # All tests (~570 currently passing)
cargo test --workspace cargo test --workspace
# Forth 2012 compliance suite # Forth 2012 compliance suite
@@ -185,7 +185,7 @@ Over 200 words are implemented across the following categories:
| Strings | `COMPARE SEARCH SLITERAL REPLACES SUBSTITUTE UNESCAPE` | | Strings | `COMPARE SEARCH SLITERAL REPLACES SUBSTITUTE UNESCAPE` |
| Floating-Pt | `F+ F- F* F/ FABS FNEGATE FSQRT FSIN FCOS FTAN FEXP FLOG FMIN FMAX` and 55+ more | | Floating-Pt | `F+ F- F* F/ FABS FNEGATE FSQRT FSIN FCOS FTAN FEXP FLOG FMIN FMAX` and 55+ more |
| Case | `CASE OF ENDOF ENDCASE` | | Case | `CASE OF ENDOF ENDCASE` |
| Tools | `WORDS SEE SEE-IR HELP .S F.S ? DUMP MARKER REMEMBER EMPTY GILD BYE` | | Tools | `WORDS SEE SEE-IR HELP INCLUDE INCLUDED .S F.S ? DUMP MARKER REMEMBER EMPTY GILD BYE` |
## Web REPL ## Web REPL
+13 -4
View File
@@ -139,6 +139,7 @@ fn cmd_build(
// Exported modules are production artifacts: no stack guards by default // Exported modules are production artifacts: no stack guards by default
let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(false))?; let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(false))?;
vm.set_source_loader(fs_loader());
vm.set_recording(true); vm.set_recording(true);
vm.evaluate(&source)?; vm.evaluate(&source)?;
@@ -273,18 +274,26 @@ fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
cfg cfg
} }
/// Filesystem source loader for INCLUDE/INCLUDED.
fn fs_loader() -> Box<dyn Fn(&str) -> anyhow::Result<String> + Send + Sync> {
Box::new(|path| Ok(std::fs::read_to_string(path)?))
}
/// `wafer` (REPL) or `wafer program.fth` (evaluate and exit) /// `wafer` (REPL) or `wafer program.fth` (evaluate and exit)
fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> { fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(true))?; let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(true))?;
vm.set_source_loader(fs_loader());
match file { match file {
Some(file) => { Some(file) => {
let source = std::fs::read_to_string(file)?; // Through the include machinery: file:line error context and a
vm.evaluate(&source)?; // base directory for nested INCLUDEs.
let result = vm.include(file);
let output = vm.take_output(); let output = vm.take_output();
if !output.is_empty() { if !output.is_empty() {
print!("{output}"); print!("{output}");
} }
result?;
} }
None => { None => {
if !stdin_is_tty() { if !stdin_is_tty() {
@@ -303,7 +312,7 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
} }
} }
Err(e) => { Err(e) => {
eprintln!("Error: {e}"); eprintln!("Error: {e:#}");
} }
} }
} }
@@ -459,7 +468,7 @@ fn run_repl(vm: &mut ForthVM<NativeRuntime>) -> anyhow::Result<()> {
} }
} }
Err(e) => { Err(e) => {
eprintln!("Error: {e}"); eprintln!("Error: {e:#}");
} }
} }
// New definitions may have appeared: refresh completion // New definitions may have appeared: refresh completion
+17 -2
View File
@@ -197,8 +197,8 @@
\ TYPE ( c-addr u -- ) output u characters \ TYPE ( c-addr u -- ) output u characters
: TYPE 0 ?DO DUP C@ EMIT 1+ LOOP DROP ; : TYPE 0 ?DO DUP C@ EMIT 1+ LOOP DROP ;
\ SPACES ( n -- ) output n spaces \ SPACES ( n -- ) output n spaces (nothing for n <= 0, per 6.1.2230)
: SPACES 0 ?DO SPACE LOOP ; : SPACES 0 MAX 0 ?DO SPACE LOOP ;
\ Pictured numeric output constants \ Pictured numeric output constants
\ PICT_BUF_TOP = 0x05C0 = 1472, SYSVAR_HLD = 28 \ PICT_BUF_TOP = 0x05C0 = 1472, SYSVAR_HLD = 28
@@ -258,6 +258,21 @@
\ D.R ( d width -- ) print right-justified signed double \ D.R ( d width -- ) print right-justified signed double
: D.R >R SWAP OVER DABS <# #S ROT SIGN #> R> OVER - SPACES TYPE ; : D.R >R SWAP OVER DABS <# #S ROT SIGN #> R> OVER - SPACES TYPE ;
\ ---------------------------------------------------------------
\ Return-stack introspection (debug aids)
\ ---------------------------------------------------------------
\ RDEPTH ( -- n ) number of cells on the return stack
\ RETURN_STACK_TOP = 9728 (0x2600). Only >R temps and loop params
\ live there; return addresses are on the WASM call stack.
: RDEPTH 9728 RP@ - 2 RSHIFT ;
\ .RS ( -- ) print the return stack bottom-to-top, like .S
\ Walks with BEGIN/WHILE (not DO) so the walk itself never pushes
\ onto the return stack it is printing.
: .RS ." R:<" RDEPTH 0 .R ." > "
9728 BEGIN DUP RP@ > WHILE 4 - DUP @ . REPEAT DROP ;
\ --------------------------------------------------------------- \ ---------------------------------------------------------------
\ Phase 6: DEFER support \ Phase 6: DEFER support
\ --------------------------------------------------------------- \ ---------------------------------------------------------------
+26 -2
View File
@@ -899,6 +899,15 @@ fn emit_op(f: &mut Function, op: &IrOp, ctx: &mut EmitCtx) {
.instruction(&Instruction::I32Store(MEM4)); .instruction(&Instruction::I32Store(MEM4));
} }
IrOp::RpFetch => {
// Push the current return-stack pointer onto the data stack.
// `$rsp` lives in a global (not cached), so no writeback needed.
dsp_dec(f);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Store(MEM4));
}
// -- Compound operations ----------------------------------------------- // -- Compound operations -----------------------------------------------
IrOp::TwoDup => { IrOp::TwoDup => {
// ( a b -- a b a b ) // ( a b -- a b a b )
@@ -1242,7 +1251,9 @@ fn is_promotable(ops: &[IrOp]) -> bool {
fn is_promotable_body(ops: &[IrOp]) -> bool { fn is_promotable_body(ops: &[IrOp]) -> bool {
for op in ops { for op in ops {
match op { match op {
IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute | IrOp::SpFetch => return false, IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute | IrOp::SpFetch | IrOp::RpFetch => {
return false;
}
IrOp::ToR | IrOp::FromR | IrOp::Exit => return false, IrOp::ToR | IrOp::FromR | IrOp::Exit => return false,
IrOp::ForthLocalGet(_) | IrOp::ForthLocalSet(_) => return false, IrOp::ForthLocalGet(_) | IrOp::ForthLocalSet(_) => return false,
IrOp::ForthFLocalGet(_) | IrOp::ForthFLocalSet(_) => return false, IrOp::ForthFLocalGet(_) | IrOp::ForthFLocalSet(_) => return false,
@@ -2306,6 +2317,9 @@ fn body_needs_return_stack(ops: &[IrOp]) -> bool {
match op { match op {
IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute => return true, IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute => return true,
IrOp::ToR | IrOp::FromR => return true, IrOp::ToR | IrOp::FromR => return true,
// RP@ observes the return stack, so loop params must be there
// (otherwise inlined RDEPTH/.RS would report an empty stack).
IrOp::RpFetch => return true,
// RFetch (I) is handled by loop locals in the fast path — not a problem. // RFetch (I) is handled by loop locals in the fast path — not a problem.
// LoopJ is also handled by loop locals. // LoopJ is also handled by loop locals.
// Only explicit >R / R> / calls force the slow path. // Only explicit >R / R> / calls force the slow path.
@@ -2518,7 +2532,7 @@ fn count_forth_f_locals(ops: &[IrOp]) -> u32 {
/// This is the JIT path: each word gets its own module that imports /// This is the JIT path: each word gets its own module that imports
/// shared memory, globals, and function table from the host. /// shared memory, globals, and function table from the host.
pub fn compile_word( pub fn compile_word(
_name: &str, name: &str,
body: &[IrOp], body: &[IrOp],
config: &CodegenConfig, config: &CodegenConfig,
) -> WaferResult<CompiledModule> { ) -> WaferResult<CompiledModule> {
@@ -2685,6 +2699,16 @@ pub fn compile_word(
code.function(&func); code.function(&func);
module.section(&code); module.section(&code);
// -- Name section: carries the Forth word name into wasmtime trap
// backtraces (best-effort symbolication, WS-008).
let mut names = wasm_encoder::NameSection::new();
names.module(name);
let mut fn_names = wasm_encoder::NameMap::new();
fn_names.append(0, "emit");
fn_names.append(WORD_FUNC, name);
names.functions(&fn_names);
module.section(&names);
let bytes = module.finish(); let bytes = module.finish();
// Validate // Validate
+21 -6
View File
@@ -420,18 +420,33 @@ impl Dictionary {
/// Return names of all visible (non-hidden) words, newest first. /// Return names of all visible (non-hidden) words, newest first.
/// With `include_internal` false, words flagged INTERNAL are skipped. /// With `include_internal` false, words flagged INTERNAL are skipped.
pub fn visible_words(&self, include_internal: bool) -> Vec<String> { pub fn visible_words(&self, include_internal: bool) -> Vec<String> {
let mut names = Vec::new(); self.visible_entries()
.into_iter()
.filter(|(_, _, internal)| include_internal || !internal)
.map(|(name, _, _)| name)
.collect()
}
/// All visible (non-hidden) entries, newest first:
/// (name, wordlist id, INTERNAL flag). The wid comes from the hash
/// index (entries themselves store no wid); words missing from the
/// index default to wid 1 (FORTH).
pub fn visible_entries(&self) -> Vec<(String, u32, bool)> {
let mut entries = Vec::new();
let mut addr = self.latest; let mut addr = self.latest;
while addr != 0 { while addr != 0 {
let flags_byte = self.memory[(addr + 4) as usize]; let flags_byte = self.memory[(addr + 4) as usize];
let skip = flags_byte & flags::HIDDEN != 0 if flags_byte & flags::HIDDEN == 0 {
|| (!include_internal && flags_byte & flags::INTERNAL != 0);
if !skip {
let name_len = (flags_byte & flags::LENGTH_MASK) as usize; let name_len = (flags_byte & flags::LENGTH_MASK) as usize;
let name_start = (addr + 5) as usize; let name_start = (addr + 5) as usize;
let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len]) let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len])
.to_string(); .to_string();
names.push(name); let wid = self
.index
.get(&name)
.and_then(|es| es.iter().find(|e| e.1 == addr))
.map_or(1, |e| e.0);
entries.push((name, wid, flags_byte & flags::INTERNAL != 0));
} }
let link = self.read_u32_unchecked(addr); let link = self.read_u32_unchecked(addr);
if link == addr { if link == addr {
@@ -439,7 +454,7 @@ impl Dictionary {
} }
addr = link; addr = link;
} }
names entries
} }
/// Get a reference to the raw memory buffer. /// Get a reference to the raw memory buffer.
+7
View File
@@ -61,6 +61,13 @@ pub enum WaferError {
#[error("{0}")] #[error("{0}")]
Abort(String), Abort(String),
/// An uncaught Forth THROW as reported to the user. `message` is the
/// full display text (standard message or ABORT" payload); `code`
/// carries the THROW code for typed consumers (CLI exit paths, web
/// REPL styling) via `Error::downcast_ref`.
#[error("{message}")]
UncaughtThrow { code: i32, message: String },
} }
/// Result type alias for WAFER operations. /// Result type alias for WAFER operations.
+2
View File
@@ -159,6 +159,8 @@ pub enum IrOp {
Execute, Execute,
/// Push the current data-stack pointer: ( -- addr ) /// Push the current data-stack pointer: ( -- addr )
SpFetch, SpFetch,
/// Push the current return-stack pointer: ( -- addr )
RpFetch,
// -- Float stack manipulation -- // -- Float stack manipulation --
/// Float duplicate: ( F: r -- r r ) /// Float duplicate: ( F: r -- r r )
+473 -43
View File
@@ -251,6 +251,25 @@ pub(crate) const INTERPRETER_TOKENS: &[&str] = &[
"POSTPONE", "POSTPONE",
]; ];
/// Source loader injected for INCLUDE/INCLUDED: resolved path -> text.
pub type SourceLoader = Box<dyn Fn(&str) -> anyhow::Result<String> + Send + Sync>;
/// Append names space-separated, wrapped at 78 columns, ending with a
/// newline (also when the list is empty -- WORDS' historic shape).
fn push_wrapped(out: &mut String, names: &[&str]) {
let mut col = 0usize;
for name in names {
if col + name.len() + 1 > 78 && col > 0 {
out.push('\n');
col = 0;
}
out.push_str(name);
out.push(' ');
col += name.len() + 1;
}
out.push('\n');
}
/// Saved VM state for a MARKER word. /// Saved VM state for a MARKER word.
#[derive(Clone)] #[derive(Clone)]
struct MarkerState { struct MarkerState {
@@ -303,6 +322,12 @@ pub struct ForthVM<R: Runtime> {
source_capture_from: Option<usize>, source_capture_from: Option<usize>,
last_token_start: usize, last_token_start: usize,
word_sources: HashMap<WordId, String>, word_sources: HashMap<WordId, String>,
// INCLUDE machinery: injected source loader (CLI: filesystem; web:
// virtual or absent) and the stack of files being included --
// (resolved path, current 1-based line) -- for cycle/depth checks,
// relative-path resolution, and file:line error context.
source_loader: Option<SourceLoader>,
include_frames: Vec<(String, usize)>,
// Output buffer // Output buffer
output: Arc<Mutex<String>>, output: Arc<Mutex<String>>,
// Next table index (mirrors dictionary.next_fn_index conceptually, // Next table index (mirrors dictionary.next_fn_index conceptually,
@@ -333,7 +358,8 @@ pub struct ForthVM<R: Runtime> {
// 5 = WORD, 6 = FIND, 7 = PARSE, 8 = PARSE-NAME, 9 = 2CONSTANT, // 5 = WORD, 6 = FIND, 7 = PARSE, 8 = PARSE-NAME, 9 = 2CONSTANT,
// 10 = 2VARIABLE, 11 = DEFER, 12 = IMMEDIATE, 20 = GET-CURRENT, // 10 = 2VARIABLE, 11 = DEFER, 12 = IMMEDIATE, 20 = GET-CURRENT,
// 21 = SET-CURRENT, 25 = SEARCH-WORDLIST, 33 = DEFINITIONS, // 21 = SET-CURRENT, 25 = SEARCH-WORDLIST, 33 = DEFINITIONS,
// 40 = WORDS, 41 = SEE, 42 = SEE-IR, 43 = HELP // 40 = WORDS, 41 = SEE, 42 = SEE-IR, 43 = HELP, 44 = INCLUDED,
// 45 = INCLUDE
pending_define: Arc<Mutex<Vec<i32>>>, pending_define: Arc<Mutex<Vec<i32>>>,
/// Pending actions from host functions (COMPILE,, CS-PICK, CS-ROLL, POSTPONE of control words). /// Pending actions from host functions (COMPILE,, CS-PICK, CS-ROLL, POSTPONE of control words).
pending_actions: Arc<Mutex<Vec<PendingAction>>>, pending_actions: Arc<Mutex<Vec<PendingAction>>>,
@@ -547,6 +573,8 @@ impl<R: Runtime> ForthVM<R> {
source_capture_from: None, source_capture_from: None,
last_token_start: 0, last_token_start: 0,
word_sources: HashMap::new(), word_sources: HashMap::new(),
source_loader: None,
include_frames: Vec::new(),
output, output,
next_table_index: 0, next_table_index: 0,
host_word_names: HashMap::new(), host_word_names: HashMap::new(),
@@ -698,14 +726,17 @@ impl<R: Runtime> ForthVM<R> {
fn describe_uncaught(&mut self, e: anyhow::Error) -> anyhow::Error { fn describe_uncaught(&mut self, e: anyhow::Error) -> anyhow::Error {
let code = self.throw_code.lock().unwrap().take(); let code = self.throw_code.lock().unwrap().take();
let text = self.abort_message.lock().unwrap().take(); let text = self.abort_message.lock().unwrap().take();
match (code, text) { let (code, message) = match (code, text) {
(Some(-2), Some(t)) => anyhow::anyhow!("{t}"), (Some(-2), Some(t)) => (-2, t),
(Some(code), _) => match throw_message(code) { (Some(code), _) => match throw_message(code) {
Some(m) => anyhow::anyhow!("{m} (throw {code})"), Some(m) => (code, format!("{m} (throw {code})")),
None => anyhow::anyhow!("Catch = {code}"), None => (code, format!("Catch = {code}")),
}, },
(None, _) => e, (None, _) => return e,
} };
// Typed carrier: display text unchanged, THROW code reachable via
// downcast_ref::<WaferError>() for CLI/web consumers.
anyhow::Error::new(crate::error::WaferError::UncaughtThrow { code, message })
} }
/// Get and clear the output buffer. /// Get and clear the output buffer.
@@ -3002,6 +3033,8 @@ impl<R: Runtime> ForthVM<R> {
// -- Priority 6: System/compiler -- // -- Priority 6: System/compiler --
self.register_primitive("EXECUTE", false, vec![IrOp::Execute])?; self.register_primitive("EXECUTE", false, vec![IrOp::Execute])?;
self.register_primitive("SP@", false, vec![IrOp::SpFetch])?; self.register_primitive("SP@", false, vec![IrOp::SpFetch])?;
self.register_primitive("RP@", false, vec![IrOp::RpFetch])?;
// RDEPTH, .RS: defined in boot.fth (use RP@ IR op)
self.register_immediate_word()?; self.register_immediate_word()?;
self.register_decimal()?; self.register_decimal()?;
self.register_hex()?; self.register_hex()?;
@@ -4437,13 +4470,7 @@ impl<R: Runtime> ForthVM<R> {
self.rt.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, -1); self.rt.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, -1);
// Sync input buffer, >IN, and #TIB to WASM (for SOURCE and WORD) // Sync input buffer, >IN, and #TIB to WASM (for SOURCE and WORD)
{ self.sync_full_input_to_wasm();
let bytes = self.input_buffer.as_bytes();
let len = bytes.len().min(INPUT_BUFFER_SIZE as usize);
self.rt.mem_write_slice(INPUT_BUFFER_BASE, &bytes[..len]);
self.rt.mem_write_i32(SYSVAR_TO_IN, 0);
self.rt.mem_write_i32(SYSVAR_NUM_TIB, len as i32);
}
// Interpret with >IN sync (supports >IN manipulation) // Interpret with >IN sync (supports >IN manipulation)
while let Some(token) = self.next_token() { while let Some(token) = self.next_token() {
@@ -4477,19 +4504,120 @@ impl<R: Runtime> ForthVM<R> {
if capture_open { if capture_open {
self.source_capture_from = Some(self.input_pos); self.source_capture_from = Some(self.input_pos);
} }
{ self.sync_full_input_to_wasm();
let bytes = self.input_buffer.as_bytes(); self.rt
let len = bytes.len().min(INPUT_BUFFER_SIZE as usize); .mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, saved_source_id);
self.rt.mem_write_slice(INPUT_BUFFER_BASE, &bytes[..len]);
self.rt.mem_write_i32(SYSVAR_TO_IN, self.input_pos as i32);
self.rt.mem_write_i32(SYSVAR_NUM_TIB, len as i32);
self.rt
.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, saved_source_id);
}
Ok(()) Ok(())
} }
/// Write the current input buffer, >IN, and #TIB to WASM memory
/// (for SOURCE, WORD, and >IN manipulation from Forth code).
fn sync_full_input_to_wasm(&mut self) {
let bytes = self.input_buffer.as_bytes();
let len = bytes.len().min(INPUT_BUFFER_SIZE as usize);
self.rt.mem_write_slice(INPUT_BUFFER_BASE, &bytes[..len]);
self.rt.mem_write_i32(SYSVAR_TO_IN, self.input_pos as i32);
self.rt.mem_write_i32(SYSVAR_NUM_TIB, len as i32);
}
/// Register the loader that INCLUDE/INCLUDED use to read source files.
/// The CLI installs a filesystem reader; the web REPL leaves it unset,
/// which makes INCLUDE a defined error.
pub fn set_source_loader(&mut self, loader: SourceLoader) {
self.source_loader = Some(loader);
}
/// Run a source file through the include machinery (also used by the
/// CLI file mode, so `wafer prog.fth` gets `file:line` error context and
/// a base directory for nested INCLUDEs).
pub fn include(&mut self, path: &str) -> anyhow::Result<()> {
self.include_file(path)
}
/// INCLUDED's engine: resolve the path, load, and feed the file
/// line-by-line through `evaluate` with the parent input saved around
/// it. Compile state and SEE source capture already span `evaluate`
/// calls, so multi-line definitions inside files just work.
fn include_file(&mut self, path: &str) -> anyhow::Result<()> {
const MAX_INCLUDE_DEPTH: usize = 16;
// Relative paths resolve against the including file's directory.
let resolved = match self.include_frames.last() {
Some((parent, _)) if std::path::Path::new(path).is_relative() => {
match std::path::Path::new(parent).parent() {
Some(dir) if dir != std::path::Path::new("") => {
dir.join(path).to_string_lossy().into_owned()
}
_ => path.to_string(),
}
}
_ => path.to_string(),
};
if self.include_frames.iter().any(|(p, _)| *p == resolved) {
anyhow::bail!("INCLUDE: cycle: {resolved}");
}
if self.include_frames.len() >= MAX_INCLUDE_DEPTH {
anyhow::bail!("INCLUDE: nesting deeper than {MAX_INCLUDE_DEPTH}");
}
let Some(loader) = self.source_loader.as_ref() else {
anyhow::bail!("INCLUDE: no source loader registered");
};
let text = loader(&resolved).map_err(|e| anyhow::anyhow!("INCLUDE: {resolved}: {e}"))?;
// Save the parent input source; SOURCE-ID becomes a synthetic
// positive id per nesting level (0 = terminal, -1 = string).
let saved_buffer = std::mem::take(&mut self.input_buffer);
let saved_pos = self.input_pos;
let saved_source_id = self.rt.mem_read_i32(crate::memory::SYSVAR_SOURCE_ID);
self.rt.mem_write_i32(
crate::memory::SYSVAR_SOURCE_ID,
self.include_frames.len() as i32 + 1,
);
self.include_frames.push((resolved, 0));
let mut result = Ok(());
for (i, line) in text.lines().enumerate() {
if let Some(frame) = self.include_frames.last_mut() {
frame.1 = i + 1;
}
if let Err(e) = self.evaluate(line) {
let (p, n) = self.include_frames.last().cloned().unwrap_or_default();
result = Err(e.context(format!("{p}:{n}")));
break;
}
if self.bye.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
}
// Restore the parent input on every path (success, error, BYE).
self.include_frames.pop();
self.input_buffer = saved_buffer;
self.input_pos = saved_pos;
// Re-anchor an open definition's capture at the parent resume point.
if self.state != 0 && self.source_capture_from.is_some() {
self.source_capture_from = Some(self.input_pos);
}
self.sync_full_input_to_wasm();
self.rt
.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, saved_source_id);
result
}
/// INCLUDED ( c-addr u -- ) -- include the named source file.
fn do_included(&mut self) -> anyhow::Result<()> {
let len = self.pop_data_stack()? as u32;
let addr = self.pop_data_stack()? as u32;
let path = String::from_utf8_lossy(&self.rt.mem_read_slice(addr, len as usize)).to_string();
self.include_file(&path)
}
/// INCLUDE <name> -- parsing form of INCLUDED.
fn do_include(&mut self) -> anyhow::Result<()> {
let path = self.parse_name_arg("INCLUDE")?;
self.include_file(&path)
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// WORD -- parse delimited word from input // WORD -- parse delimited word from input
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -5460,6 +5588,8 @@ impl<R: Runtime> ForthVM<R> {
41 => self.do_see()?, 41 => self.do_see()?,
42 => self.do_see_ir()?, 42 => self.do_see_ir()?,
43 => self.do_help()?, 43 => self.do_help()?,
44 => self.do_included()?,
45 => self.do_include()?,
_ => {} _ => {}
} }
} }
@@ -6176,31 +6306,71 @@ impl<R: Runtime> ForthVM<R> {
/// (case-insensitive). Internal (underscore-prefixed) words are /// (case-insensitive). Internal (underscore-prefixed) words are
/// skipped. Output wraps at 78 columns and ends with a count. /// skipped. Output wraps at 78 columns and ends with a count.
fn do_words(&mut self) { fn do_words(&mut self) {
// In interpret mode an optional token on the same line is a filter. // In interpret mode an optional token on the same line is a filter;
// the special filter ALL switches to the grouped full view.
let filter = if self.state == 0 { let filter = if self.state == 0 {
self.next_token().map(|t| t.to_ascii_uppercase()) self.next_token().map(|t| t.to_ascii_uppercase())
} else { } else {
None None
}; };
let names = self.dictionary.visible_words(false); if filter.as_deref() == Some("ALL") {
let mut out = self.output.lock().unwrap(); self.do_words_all();
let mut shown = 0usize; return;
let mut col = 0usize; }
for name in names.iter().filter(|n| { let names = self.dictionary.visible_words(false);
filter let shown: Vec<&str> = names
.as_ref() .iter()
.is_none_or(|f| n.to_ascii_uppercase().contains(f)) .filter(|n| {
}) { filter
if col + name.len() + 1 > 78 && col > 0 { .as_ref()
out.push('\n'); .is_none_or(|f| n.to_ascii_uppercase().contains(f))
col = 0; })
} .map(String::as_str)
out.push_str(name); .collect();
out.push(' '); let mut out = self.output.lock().unwrap();
col += name.len() + 1; push_wrapped(&mut out, &shown);
shown += 1; out.push_str(&format!("{} words\n", shown.len()));
}
/// `WORDS ALL` -- grouped full view: one section per wordlist (search
/// order first, then any other populated wids), then internal words.
fn do_words_all(&mut self) {
let entries = self.dictionary.visible_entries();
let mut wids: Vec<u32> = self.search_order.lock().unwrap().clone();
for (_, wid, _) in &entries {
if !wids.contains(wid) {
wids.push(*wid);
}
}
let wid_name = |wid: u32| {
if wid == 1 {
"FORTH".to_string()
} else {
format!("wid#{wid}")
}
};
let mut out = self.output.lock().unwrap();
for wid in wids {
let names: Vec<&str> = entries
.iter()
.filter(|(_, w, internal)| *w == wid && !internal)
.map(|(n, _, _)| n.as_str())
.collect();
if names.is_empty() {
continue;
}
out.push_str(&format!("-- {} ({} words)\n", wid_name(wid), names.len()));
push_wrapped(&mut out, &names);
}
let internals: Vec<&str> = entries
.iter()
.filter(|(_, _, internal)| *internal)
.map(|(n, _, _)| n.as_str())
.collect();
if !internals.is_empty() {
out.push_str(&format!("-- internal ({} words)\n", internals.len()));
push_wrapped(&mut out, &internals);
} }
out.push_str(&format!("\n{shown} words\n"));
} }
/// Map function-table index -> word name via a dictionary walk. /// Map function-table index -> word name via a dictionary walk.
@@ -6610,7 +6780,14 @@ impl<R: Runtime> ForthVM<R> {
/// Each runs Rust-side via `pending_define` so it can parse arguments /// Each runs Rust-side via `pending_define` so it can parse arguments
/// with `next_token()` and write to `self.output`. /// with `next_token()` and write to `self.output`.
fn register_words(&mut self) -> anyhow::Result<()> { fn register_words(&mut self) -> anyhow::Result<()> {
for (name, code) in [("WORDS", 40), ("SEE", 41), ("SEE-IR", 42), ("HELP", 43)] { for (name, code) in [
("WORDS", 40),
("SEE", 41),
("SEE-IR", 42),
("HELP", 43),
("INCLUDED", 44),
("INCLUDE", 45),
] {
let pending = Arc::clone(&self.pending_define); let pending = Arc::clone(&self.pending_define);
let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| { let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| {
pending.lock().unwrap().push(code); pending.lock().unwrap().push(code);
@@ -9344,6 +9521,27 @@ mod tests {
assert!(output.contains(" words\n")); assert!(output.contains(" words\n"));
} }
#[test]
fn test_words_all_grouped() {
let output = eval_output("WORDS ALL");
assert!(output.contains("-- FORTH ("), "{output}");
assert!(output.contains("-- internal ("), "{output}");
// Internals are visible in the ALL view.
assert!(output.contains("_STACK_FAULT_"), "{output}");
}
#[test]
fn test_words_all_groups_other_wordlists() {
let output =
eval_output("WORDLIST SET-CURRENT : INSIDE 1 ; FORTH-WORDLIST SET-CURRENT WORDS ALL");
assert!(output.contains("-- wid#2 (1 words)"), "{output}");
let wid2_section = output.split("-- wid#2").nth(1).unwrap();
assert!(
wid2_section.lines().nth(1).unwrap().contains("INSIDE"),
"{output}"
);
}
#[test] #[test]
fn test_words_hides_internal() { fn test_words_hides_internal() {
let output = eval_output("WORDS"); let output = eval_output("WORDS");
@@ -9351,6 +9549,186 @@ mod tests {
assert!(!output.contains("__CTRL__")); assert!(!output.contains("__CTRL__"));
} }
// -- Error reporting (WS-008) --
#[test]
fn test_uncaught_throw_is_typed() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate("-4 THROW").unwrap_err();
assert_eq!(err.to_string(), "Stack underflow (throw -4)");
match err.downcast_ref::<crate::error::WaferError>() {
Some(crate::error::WaferError::UncaughtThrow { code, .. }) => assert_eq!(*code, -4),
other => panic!("expected UncaughtThrow, got {other:?}"),
}
}
#[test]
fn test_uncaught_abort_quote_is_typed() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate(": F ABORT\" bad input\" ; -1 F").unwrap_err();
assert_eq!(err.to_string(), "bad input");
match err.downcast_ref::<crate::error::WaferError>() {
Some(crate::error::WaferError::UncaughtThrow { code, .. }) => assert_eq!(*code, -2),
other => panic!("expected UncaughtThrow, got {other:?}"),
}
}
#[test]
fn test_trap_names_faulting_word() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
// Out-of-bounds fetch traps inside the compiled word; the name
// section + backtrace naming must identify CRASHER.
vm.evaluate(": CRASHER 999999999 @ ;").unwrap();
let err = vm.evaluate("CRASHER").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("CRASHER"), "{msg}");
assert!(msg.contains("out of bounds"), "{msg}");
}
// -- INCLUDE / INCLUDED --
/// VM with a virtual source loader over the given (path, text) pairs.
fn vm_with_files(files: &[(&str, &str)]) -> ForthVM<NativeRuntime> {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let map: HashMap<String, String> = files
.iter()
.map(|(p, t)| (p.to_string(), t.to_string()))
.collect();
vm.set_source_loader(Box::new(move |p| {
map.get(p)
.cloned()
.ok_or_else(|| anyhow::anyhow!("file not found"))
}));
vm
}
#[test]
fn test_include_defines_words_and_resumes_parent_line() {
let mut vm = vm_with_files(&[("lib.fth", ": DOUBLE 2 * ;\n: TRIPLE 3 * ;\n")]);
// The 5 after INCLUDE proves the parent input buffer resumes.
vm.evaluate("INCLUDE lib.fth 5 DOUBLE .").unwrap();
assert_eq!(vm.take_output(), "10 ");
}
#[test]
fn test_included_string_form() {
let mut vm = vm_with_files(&[("lib.fth", ": Q 7 ;")]);
vm.evaluate("S\" lib.fth\" INCLUDED Q .").unwrap();
assert_eq!(vm.take_output(), "7 ");
}
#[test]
fn test_include_multiline_definition_and_see() {
let mut vm = vm_with_files(&[("lib.fth", ": TRI\n DUP DUP ;\n")]);
vm.evaluate("INCLUDE lib.fth SEE TRI").unwrap();
assert_eq!(vm.take_output(), ": TRI\n DUP DUP ;\n");
}
#[test]
fn test_include_nested_relative_path() {
let mut vm = vm_with_files(&[
("dir/a.fth", "INCLUDE b.fth : A B 1 + ;"),
("dir/b.fth", ": B 41 ;"),
]);
vm.evaluate("INCLUDE dir/a.fth A .").unwrap();
assert_eq!(vm.take_output(), "42 ");
}
#[test]
fn test_include_cycle_detected() {
let mut vm = vm_with_files(&[("a.fth", "INCLUDE b.fth"), ("b.fth", "INCLUDE a.fth")]);
let err = vm.evaluate("INCLUDE a.fth").unwrap_err();
assert!(format!("{err:#}").contains("cycle"), "{err:#}");
}
#[test]
fn test_include_depth_bounded() {
let files: Vec<(String, String)> = (0..20)
.map(|i| (format!("f{i}.fth"), format!("INCLUDE f{}.fth", i + 1)))
.collect();
let refs: Vec<(&str, &str)> = files
.iter()
.map(|(p, t)| (p.as_str(), t.as_str()))
.collect();
let mut vm = vm_with_files(&refs);
let err = vm.evaluate("INCLUDE f0.fth").unwrap_err();
assert!(format!("{err:#}").contains("nesting deeper"), "{err:#}");
}
#[test]
fn test_include_missing_file_and_no_loader() {
let mut vm = vm_with_files(&[]);
let err = vm.evaluate("INCLUDE nosuch.fth").unwrap_err();
assert!(
format!("{err:#}").contains("INCLUDE: nosuch.fth"),
"{err:#}"
);
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate("INCLUDE x.fth").unwrap_err();
assert!(err.to_string().contains("no source loader"), "{err}");
}
#[test]
fn test_include_error_carries_file_and_line() {
let mut vm = vm_with_files(&[("lib.fth", "1 2 +\nNOSUCHWORD\n3 4 +")]);
let err = vm.evaluate("INCLUDE lib.fth").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("lib.fth:2"), "{msg}");
assert!(msg.contains("NOSUCHWORD"), "{msg}");
// Parent VM stays usable, compile state clean.
vm.evaluate(": OK 1 ; OK .").unwrap();
assert_eq!(vm.take_output(), "1 ");
}
#[test]
fn test_include_nested_error_context_chains() {
let mut vm = vm_with_files(&[("outer.fth", "INCLUDE inner.fth"), ("inner.fth", "BOOM")]);
let err = vm.evaluate("INCLUDE outer.fth").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("outer.fth:1"), "{msg}");
assert!(msg.contains("inner.fth:1"), "{msg}");
}
#[test]
fn test_include_throw_propagates_vm_usable() {
let mut vm = vm_with_files(&[("t.fth", ": BAD -4 THROW ;\nBAD")]);
let err = vm.evaluate("INCLUDE t.fth").unwrap_err();
assert!(format!("{err:#}").contains("Stack underflow"), "{err:#}");
vm.evaluate("6 7 * .").unwrap();
assert_eq!(vm.take_output(), "42 ");
}
#[test]
fn test_include_remember_reload_loop() {
let mut vm = vm_with_files(&[("app.fth", "REMEMBER -WORK\n: APP 1 ;")]);
vm.evaluate("INCLUDE app.fth").unwrap();
vm.evaluate("-WORK INCLUDE app.fth APP .").unwrap();
assert_eq!(vm.take_output(), "1 ");
// Only one APP in the dictionary after the reload.
let names = vm.word_names();
assert_eq!(names.iter().filter(|n| *n == "APP").count(), 1);
}
#[test]
fn test_include_bye_stops_file() {
let mut vm = vm_with_files(&[("b.fth", "1 .\nBYE\n2 .")]);
vm.evaluate("INCLUDE b.fth").unwrap();
assert_eq!(vm.take_output(), "1 ");
assert!(vm.bye_requested());
}
#[test]
fn test_include_source_id_nested_and_restored() {
let mut vm = vm_with_files(&[
("a.fth", "SOURCE-ID N1 ! INCLUDE b.fth"),
("b.fth", "SOURCE-ID N2 !"),
]);
vm.evaluate("VARIABLE N1 VARIABLE N2").unwrap();
vm.evaluate("INCLUDE a.fth N1 @ . N2 @ . SOURCE-ID .")
.unwrap();
assert_eq!(vm.take_output(), "1 2 0 ");
}
// -- HELP -- // -- HELP --
#[test] #[test]
@@ -9651,6 +10029,58 @@ mod tests {
assert!(output.starts_with("F:<2> 1.5 2.5")); assert!(output.starts_with("F:<2> 1.5 2.5"));
} }
#[test]
fn test_spaces_negative_prints_nothing() {
assert_eq!(eval_output("-1 SPACES"), "");
// width smaller than the number: no padding at all
assert_eq!(eval_output("123 0 .R"), "123");
}
#[test]
fn test_rdepth_empty() {
assert_eq!(eval_stack("RDEPTH"), vec![0]);
}
#[test]
fn test_rdepth_counts_r_items() {
assert_eq!(
eval_stack(": TRD 10 >R 20 >R RDEPTH 2R> 2DROP ; TRD"),
vec![2]
);
}
#[test]
fn test_dot_rs_empty() {
assert_eq!(eval_output(".RS"), "R:<0> ");
}
#[test]
fn test_dot_rs_bottom_to_top() {
assert_eq!(
eval_output(": TRS 10 >R 20 >R .RS 2R> 2DROP ; TRS"),
"R:<2> 10 20 "
);
}
#[test]
fn test_dot_rs_honors_base() {
assert_eq!(
eval_output("HEX : TRS16 FF >R .RS R> DROP ; TRS16"),
"R:<1> FF "
);
}
#[test]
fn test_rdepth_sees_loop_params() {
// Inside DO/LOOP the return stack holds the loop parameters,
// so RDEPTH must be > 0 there and 0 again after the loop.
// eval_stack returns top-first: [after-loop RDEPTH, inside-loop RDEPTH]
let stack = eval_stack(": TRL 0 3 1 DO DROP RDEPTH LOOP RDEPTH ; TRL");
assert_eq!(stack.len(), 2);
assert!(stack[1] > 0, "inside loop: expected non-empty return stack");
assert_eq!(stack[0], 0, "after loop: return stack must be empty");
}
#[test] #[test]
fn test_bye_sets_flag_and_stops_line() { fn test_bye_sets_flag_and_stops_line() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap(); let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
+21 -2
View File
@@ -98,11 +98,29 @@ impl HostAccess for CallerHostAccess<'_, '_> {
let func = *func_ref let func = *func_ref
.unwrap_func() .unwrap_func()
.ok_or_else(|| anyhow::anyhow!("call_func: null funcref {fn_index}"))?; .ok_or_else(|| anyhow::anyhow!("call_func: null funcref {fn_index}"))?;
func.call(&mut *self.caller, &[], &mut [])?; func.call(&mut *self.caller, &[], &mut [])
.map_err(name_trap_frame)?;
Ok(()) Ok(())
} }
} }
/// Prefix a wasmtime trap error with the innermost named WASM frame.
/// Compiled words carry their Forth name in the module name section, so a
/// genuine trap reads "in <WORD>: wasm trap: ...". THROW-driven unwinds
/// also pass through here, but CATCH and `describe_uncaught` key on the
/// shared `throw_code` cell, never on the message, so the wrap is inert
/// for them.
fn name_trap_frame(e: wasmtime::Error) -> wasmtime::Error {
let name = e
.downcast_ref::<wasmtime::WasmBacktrace>()
.and_then(|bt| bt.frames().iter().find_map(|f| f.func_name()))
.map(str::to_string);
match name {
Some(n) => e.context(format!("in {n}")),
None => e,
}
}
/// Wasmtime-based native runtime. /// Wasmtime-based native runtime.
pub struct NativeRuntime { pub struct NativeRuntime {
engine: Engine, engine: Engine,
@@ -293,7 +311,8 @@ impl Runtime for NativeRuntime {
let func = *r let func = *r
.unwrap_func() .unwrap_func()
.ok_or_else(|| anyhow::anyhow!("word {fn_index} is null funcref"))?; .ok_or_else(|| anyhow::anyhow!("word {fn_index} is null funcref"))?;
func.call(&mut self.store, &[], &mut [])?; func.call(&mut self.store, &[], &mut [])
.map_err(name_trap_frame)?;
Ok(()) Ok(())
} }
+2
View File
@@ -194,6 +194,7 @@ fn write_op(out: &mut String, op: &IrOp, depth: usize, resolve: &dyn Fn(WordId)
// -- System -- // -- System --
IrOp::Execute => "execute".into(), IrOp::Execute => "execute".into(),
IrOp::SpFetch => "sp@".into(), IrOp::SpFetch => "sp@".into(),
IrOp::RpFetch => "rp@".into(),
// -- Float stack -- // -- Float stack --
IrOp::FDup => "fdup".into(), IrOp::FDup => "fdup".into(),
@@ -342,6 +343,7 @@ mod tests {
IrOp::Type, IrOp::Type,
IrOp::Execute, IrOp::Execute,
IrOp::SpFetch, IrOp::SpFetch,
IrOp::RpFetch,
IrOp::FDup, IrOp::FDup,
IrOp::FDrop, IrOp::FDrop,
IrOp::FSwap, IrOp::FSwap,
+14 -1
View File
@@ -124,6 +124,8 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
"Move back items stored by N>R.", "Move back items stored by N>R.",
), ),
("SP@", "( -- addr )", "Current data-stack pointer."), ("SP@", "( -- addr )", "Current data-stack pointer."),
("RP@", "( -- addr )", "Current return-stack pointer."),
("RDEPTH", "( -- n )", "Number of cells on the return stack."),
// -- Core: arithmetic -- // -- Core: arithmetic --
("+", "( n1 n2 -- n3 )", "Add: n3 = n1 + n2."), ("+", "( n1 n2 -- n3 )", "Add: n3 = n1 + n2."),
("-", "( n1 n2 -- n3 )", "Subtract: n3 = n1 - n2."), ("-", "( n1 n2 -- n3 )", "Subtract: n3 = n1 - n2."),
@@ -1003,7 +1005,7 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
( (
"WORDS", "WORDS",
"( \"filter\"? -- )", "( \"filter\"? -- )",
"List visible words; optional substring filter.", "List words; optional substring filter; ALL = grouped view.",
), ),
( (
"SEE", "SEE",
@@ -1022,6 +1024,7 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
), ),
(".S", "( -- )", "Print the data stack, respecting BASE."), (".S", "( -- )", "Print the data stack, respecting BASE."),
("F.S", "( -- )", "Print the float stack."), ("F.S", "( -- )", "Print the float stack."),
(".RS", "( -- )", "Print the return stack, respecting BASE."),
("?", "( addr -- )", "Fetch and print the cell at addr."), ("?", "( addr -- )", "Fetch and print the cell at addr."),
( (
"DUMP", "DUMP",
@@ -1048,6 +1051,16 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
"( -- )", "( -- )",
"Make the current state the EMPTY baseline.", "Make the current state the EMPTY baseline.",
), ),
(
"INCLUDED",
"( c-addr u -- )",
"Interpret the named source file (nestable).",
),
(
"INCLUDE",
"( \"name\" -- )",
"Interpret the source file named in the input.",
),
// -- WAFER-specific -- // -- WAFER-specific --
( (
"CONSOLIDATE", "CONSOLIDATE",
+75
View File
@@ -624,6 +624,81 @@ fn compare_all_programs() {
); );
} }
// -----------------------------------------------------------------------
// Cross-engine behavioral comparison (requires SwiftForth sf64) -- WS-003
// -----------------------------------------------------------------------
/// Run Forth code through `SwiftForth`. Piped sf64 is quiet (no banner, no
/// `ok` echo), truncates input lines at ~256 chars, and exits 243 after an
/// error, so statements are fed one per line with a final `bye`.
fn run_sf64_code(sf64: &str, code: &str) -> Option<EngineResult> {
let mut input = String::new();
for line in code.lines() {
let t = line.trim();
if !t.is_empty() {
input.push_str(t);
input.push('\n');
}
}
input.push_str("bye\n");
let out = run_via_stdin(sf64, &input)?;
Some(EngineResult {
output: String::from_utf8_lossy(&out.stdout).to_string(),
success: out.status.success(),
})
}
/// Correctness lane against `SwiftForth`: the same program corpus as the
/// gforth comparison, sf64 as the oracle. Skips gracefully when sf64 is
/// not installed (CI/linux). Programs listed in `SF64_SKIP` use words or
/// output conventions `SwiftForth` does not share.
#[test]
#[ignore = "requires SwiftForth sf64 (run with -- --ignored)"]
fn compare_all_programs_sf64() {
// dot-quote: `."` outside a definition is a no-op in SwiftForth
// (compile-only); WAFER supports the interpret-mode extension.
const SF64_SKIP: &[&str] = &["dot-quote"];
let Some(sf64) = find_sf64() else {
eprintln!("SKIP: sf64 not found");
return;
};
let progs = programs();
let mut passed = 0;
let mut skipped = 0;
for prog in &progs {
if SF64_SKIP.contains(&prog.name) {
skipped += 1;
continue;
}
let wafer = run_wafer(prog.code);
assert!(wafer.success, "{}: WAFER execution failed", prog.name);
let Some(sf) = run_sf64_code(sf64, prog.code) else {
skipped += 1;
continue;
};
if !sf.success {
eprintln!(" WARN {}: sf64 execution failed, skipping", prog.name);
skipped += 1;
continue;
}
// SwiftForth prints numbers space-prefixed and echoes piped input
// lines, so byte-exact comparison is meaningless; compare the
// whitespace-token stream (the printed values and strings).
let wafer_tokens: Vec<&str> = wafer.output.split_whitespace().collect();
let sf_tokens: Vec<&str> = sf.output.split_whitespace().collect();
assert_eq!(
wafer_tokens, sf_tokens,
"{}: output differs\n WAFER: {:?}\n sf64: {:?}",
prog.name, wafer.output, sf.output
);
passed += 1;
}
eprintln!(
"\nsf64 behavioral comparison: {passed} passed, {skipped} skipped (of {})",
progs.len()
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Performance comparison (requires gforth) // Performance comparison (requires gforth)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------