diff --git a/CLAUDE.md b/CLAUDE.md index c51d311..e938fb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Handle in `interpret_token_immediate()` or `compile_token()` as a special case. ## 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` - 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` diff --git a/Justfile b/Justfile index 1845471..3057bcf 100644 --- a/Justfile +++ b/Justfile @@ -47,6 +47,10 @@ bench-opts: bench-compare: 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 deny: cargo deny check diff --git a/README.md b/README.md index 457cad2..0574cf7 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CON ## Testing ```bash -# All tests (~550 currently passing) +# All tests (~570 currently passing) cargo test --workspace # Forth 2012 compliance suite @@ -185,7 +185,7 @@ Over 200 words are implemented across the following categories: | 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 | | 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 diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f2a6ac3..96d6840 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -139,6 +139,7 @@ fn cmd_build( // Exported modules are production artifacts: no stack guards by default let mut vm = ForthVM::::new_with_config(vm_config(false))?; + vm.set_source_loader(fs_loader()); vm.set_recording(true); vm.evaluate(&source)?; @@ -273,18 +274,26 @@ fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig { cfg } +/// Filesystem source loader for INCLUDE/INCLUDED. +fn fs_loader() -> Box anyhow::Result + Send + Sync> { + Box::new(|path| Ok(std::fs::read_to_string(path)?)) +} + /// `wafer` (REPL) or `wafer program.fth` (evaluate and exit) fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> { let mut vm = ForthVM::::new_with_config(vm_config(true))?; + vm.set_source_loader(fs_loader()); match file { Some(file) => { - let source = std::fs::read_to_string(file)?; - vm.evaluate(&source)?; + // Through the include machinery: file:line error context and a + // base directory for nested INCLUDEs. + let result = vm.include(file); let output = vm.take_output(); if !output.is_empty() { print!("{output}"); } + result?; } None => { if !stdin_is_tty() { @@ -303,7 +312,7 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> { } } Err(e) => { - eprintln!("Error: {e}"); + eprintln!("Error: {e:#}"); } } } @@ -459,7 +468,7 @@ fn run_repl(vm: &mut ForthVM) -> anyhow::Result<()> { } } Err(e) => { - eprintln!("Error: {e}"); + eprintln!("Error: {e:#}"); } } // New definitions may have appeared: refresh completion diff --git a/crates/core/boot.fth b/crates/core/boot.fth index cc9d8e0..6a04449 100644 --- a/crates/core/boot.fth +++ b/crates/core/boot.fth @@ -197,8 +197,8 @@ \ TYPE ( c-addr u -- ) output u characters : TYPE 0 ?DO DUP C@ EMIT 1+ LOOP DROP ; -\ SPACES ( n -- ) output n spaces -: SPACES 0 ?DO SPACE LOOP ; +\ SPACES ( n -- ) output n spaces (nothing for n <= 0, per 6.1.2230) +: SPACES 0 MAX 0 ?DO SPACE LOOP ; \ Pictured numeric output constants \ PICT_BUF_TOP = 0x05C0 = 1472, SYSVAR_HLD = 28 @@ -258,6 +258,21 @@ \ D.R ( d width -- ) print right-justified signed double : 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 \ --------------------------------------------------------------- diff --git a/crates/core/src/codegen.rs b/crates/core/src/codegen.rs index 279a392..f9dd20d 100644 --- a/crates/core/src/codegen.rs +++ b/crates/core/src/codegen.rs @@ -899,6 +899,15 @@ fn emit_op(f: &mut Function, op: &IrOp, ctx: &mut EmitCtx) { .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 ----------------------------------------------- IrOp::TwoDup => { // ( a b -- a b a b ) @@ -1242,7 +1251,9 @@ fn is_promotable(ops: &[IrOp]) -> bool { fn is_promotable_body(ops: &[IrOp]) -> bool { for op in ops { 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::ForthLocalGet(_) | IrOp::ForthLocalSet(_) => return false, IrOp::ForthFLocalGet(_) | IrOp::ForthFLocalSet(_) => return false, @@ -2306,6 +2317,9 @@ fn body_needs_return_stack(ops: &[IrOp]) -> bool { match op { IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute => 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. // LoopJ is also handled by loop locals. // 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 /// shared memory, globals, and function table from the host. pub fn compile_word( - _name: &str, + name: &str, body: &[IrOp], config: &CodegenConfig, ) -> WaferResult { @@ -2685,6 +2699,16 @@ pub fn compile_word( code.function(&func); 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(); // Validate diff --git a/crates/core/src/dictionary.rs b/crates/core/src/dictionary.rs index bc079de..fea0054 100644 --- a/crates/core/src/dictionary.rs +++ b/crates/core/src/dictionary.rs @@ -420,18 +420,33 @@ impl Dictionary { /// Return names of all visible (non-hidden) words, newest first. /// With `include_internal` false, words flagged INTERNAL are skipped. pub fn visible_words(&self, include_internal: bool) -> Vec { - 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; while addr != 0 { let flags_byte = self.memory[(addr + 4) as usize]; - let skip = flags_byte & flags::HIDDEN != 0 - || (!include_internal && flags_byte & flags::INTERNAL != 0); - if !skip { + if flags_byte & flags::HIDDEN == 0 { let name_len = (flags_byte & flags::LENGTH_MASK) as usize; let name_start = (addr + 5) as usize; let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len]) .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); if link == addr { @@ -439,7 +454,7 @@ impl Dictionary { } addr = link; } - names + entries } /// Get a reference to the raw memory buffer. diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 7af7187..3227a21 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -61,6 +61,13 @@ pub enum WaferError { #[error("{0}")] 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. diff --git a/crates/core/src/ir.rs b/crates/core/src/ir.rs index 18130a8..47aed57 100644 --- a/crates/core/src/ir.rs +++ b/crates/core/src/ir.rs @@ -159,6 +159,8 @@ pub enum IrOp { Execute, /// Push the current data-stack pointer: ( -- addr ) SpFetch, + /// Push the current return-stack pointer: ( -- addr ) + RpFetch, // -- Float stack manipulation -- /// Float duplicate: ( F: r -- r r ) diff --git a/crates/core/src/outer.rs b/crates/core/src/outer.rs index e260065..9d40a00 100644 --- a/crates/core/src/outer.rs +++ b/crates/core/src/outer.rs @@ -251,6 +251,25 @@ pub(crate) const INTERPRETER_TOKENS: &[&str] = &[ "POSTPONE", ]; +/// Source loader injected for INCLUDE/INCLUDED: resolved path -> text. +pub type SourceLoader = Box anyhow::Result + 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. #[derive(Clone)] struct MarkerState { @@ -303,6 +322,12 @@ pub struct ForthVM { source_capture_from: Option, last_token_start: usize, word_sources: HashMap, + // 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, + include_frames: Vec<(String, usize)>, // Output buffer output: Arc>, // Next table index (mirrors dictionary.next_fn_index conceptually, @@ -333,7 +358,8 @@ pub struct ForthVM { // 5 = WORD, 6 = FIND, 7 = PARSE, 8 = PARSE-NAME, 9 = 2CONSTANT, // 10 = 2VARIABLE, 11 = DEFER, 12 = IMMEDIATE, 20 = GET-CURRENT, // 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>>, /// Pending actions from host functions (COMPILE,, CS-PICK, CS-ROLL, POSTPONE of control words). pending_actions: Arc>>, @@ -547,6 +573,8 @@ impl ForthVM { source_capture_from: None, last_token_start: 0, word_sources: HashMap::new(), + source_loader: None, + include_frames: Vec::new(), output, next_table_index: 0, host_word_names: HashMap::new(), @@ -698,14 +726,17 @@ impl ForthVM { fn describe_uncaught(&mut self, e: anyhow::Error) -> anyhow::Error { let code = self.throw_code.lock().unwrap().take(); let text = self.abort_message.lock().unwrap().take(); - match (code, text) { - (Some(-2), Some(t)) => anyhow::anyhow!("{t}"), + let (code, message) = match (code, text) { + (Some(-2), Some(t)) => (-2, t), (Some(code), _) => match throw_message(code) { - Some(m) => anyhow::anyhow!("{m} (throw {code})"), - None => anyhow::anyhow!("Catch = {code}"), + Some(m) => (code, format!("{m} (throw {code})")), + None => (code, format!("Catch = {code}")), }, - (None, _) => e, - } + (None, _) => return e, + }; + // Typed carrier: display text unchanged, THROW code reachable via + // downcast_ref::() for CLI/web consumers. + anyhow::Error::new(crate::error::WaferError::UncaughtThrow { code, message }) } /// Get and clear the output buffer. @@ -3002,6 +3033,8 @@ impl ForthVM { // -- Priority 6: System/compiler -- self.register_primitive("EXECUTE", false, vec![IrOp::Execute])?; 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_decimal()?; self.register_hex()?; @@ -4437,13 +4470,7 @@ impl ForthVM { self.rt.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, -1); // Sync input buffer, >IN, and #TIB to WASM (for SOURCE and WORD) - { - 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); - } + self.sync_full_input_to_wasm(); // Interpret with >IN sync (supports >IN manipulation) while let Some(token) = self.next_token() { @@ -4477,19 +4504,120 @@ impl ForthVM { if capture_open { self.source_capture_from = Some(self.input_pos); } - { - 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); - self.rt - .mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, saved_source_id); - } + self.sync_full_input_to_wasm(); + self.rt + .mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, saved_source_id); 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 -- 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 // ----------------------------------------------------------------------- @@ -5460,6 +5588,8 @@ impl ForthVM { 41 => self.do_see()?, 42 => self.do_see_ir()?, 43 => self.do_help()?, + 44 => self.do_included()?, + 45 => self.do_include()?, _ => {} } } @@ -6176,31 +6306,71 @@ impl ForthVM { /// (case-insensitive). Internal (underscore-prefixed) words are /// skipped. Output wraps at 78 columns and ends with a count. 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 { self.next_token().map(|t| t.to_ascii_uppercase()) } else { None }; - let names = self.dictionary.visible_words(false); - let mut out = self.output.lock().unwrap(); - let mut shown = 0usize; - let mut col = 0usize; - for name in names.iter().filter(|n| { - filter - .as_ref() - .is_none_or(|f| n.to_ascii_uppercase().contains(f)) - }) { - if col + name.len() + 1 > 78 && col > 0 { - out.push('\n'); - col = 0; - } - out.push_str(name); - out.push(' '); - col += name.len() + 1; - shown += 1; + if filter.as_deref() == Some("ALL") { + self.do_words_all(); + return; + } + let names = self.dictionary.visible_words(false); + let shown: Vec<&str> = names + .iter() + .filter(|n| { + filter + .as_ref() + .is_none_or(|f| n.to_ascii_uppercase().contains(f)) + }) + .map(String::as_str) + .collect(); + let mut out = self.output.lock().unwrap(); + push_wrapped(&mut out, &shown); + 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 = 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. @@ -6610,7 +6780,14 @@ impl ForthVM { /// Each runs Rust-side via `pending_define` so it can parse arguments /// with `next_token()` and write to `self.output`. 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 func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| { pending.lock().unwrap().push(code); @@ -9344,6 +9521,27 @@ mod tests { 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] fn test_words_hides_internal() { let output = eval_output("WORDS"); @@ -9351,6 +9549,186 @@ mod tests { assert!(!output.contains("__CTRL__")); } + // -- Error reporting (WS-008) -- + + #[test] + fn test_uncaught_throw_is_typed() { + let mut vm = ForthVM::::new().unwrap(); + let err = vm.evaluate("-4 THROW").unwrap_err(); + assert_eq!(err.to_string(), "Stack underflow (throw -4)"); + match err.downcast_ref::() { + 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::::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::() { + 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::::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 { + let mut vm = ForthVM::::new().unwrap(); + let map: HashMap = 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::::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 -- #[test] @@ -9651,6 +10029,58 @@ mod tests { 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] fn test_bye_sets_flag_and_stops_line() { let mut vm = ForthVM::::new().unwrap(); diff --git a/crates/core/src/runtime_native.rs b/crates/core/src/runtime_native.rs index 37b250f..de29f3e 100644 --- a/crates/core/src/runtime_native.rs +++ b/crates/core/src/runtime_native.rs @@ -98,11 +98,29 @@ impl HostAccess for CallerHostAccess<'_, '_> { let func = *func_ref .unwrap_func() .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(()) } } +/// 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 : 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::() + .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. pub struct NativeRuntime { engine: Engine, @@ -293,7 +311,8 @@ impl Runtime for NativeRuntime { let func = *r .unwrap_func() .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(()) } diff --git a/crates/core/src/see.rs b/crates/core/src/see.rs index b3754af..30ea2b6 100644 --- a/crates/core/src/see.rs +++ b/crates/core/src/see.rs @@ -194,6 +194,7 @@ fn write_op(out: &mut String, op: &IrOp, depth: usize, resolve: &dyn Fn(WordId) // -- System -- IrOp::Execute => "execute".into(), IrOp::SpFetch => "sp@".into(), + IrOp::RpFetch => "rp@".into(), // -- Float stack -- IrOp::FDup => "fdup".into(), @@ -342,6 +343,7 @@ mod tests { IrOp::Type, IrOp::Execute, IrOp::SpFetch, + IrOp::RpFetch, IrOp::FDup, IrOp::FDrop, IrOp::FSwap, diff --git a/crates/core/src/wordhelp.rs b/crates/core/src/wordhelp.rs index 3760e9d..2cae0cd 100644 --- a/crates/core/src/wordhelp.rs +++ b/crates/core/src/wordhelp.rs @@ -124,6 +124,8 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[ "Move back items stored by N>R.", ), ("SP@", "( -- addr )", "Current data-stack pointer."), + ("RP@", "( -- addr )", "Current return-stack pointer."), + ("RDEPTH", "( -- n )", "Number of cells on the return stack."), // -- Core: arithmetic -- ("+", "( n1 n2 -- n3 )", "Add: n3 = n1 + n2."), ("-", "( n1 n2 -- n3 )", "Subtract: n3 = n1 - n2."), @@ -1003,7 +1005,7 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[ ( "WORDS", "( \"filter\"? -- )", - "List visible words; optional substring filter.", + "List words; optional substring filter; ALL = grouped view.", ), ( "SEE", @@ -1022,6 +1024,7 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[ ), (".S", "( -- )", "Print the data stack, respecting BASE."), ("F.S", "( -- )", "Print the float stack."), + (".RS", "( -- )", "Print the return stack, respecting BASE."), ("?", "( addr -- )", "Fetch and print the cell at addr."), ( "DUMP", @@ -1048,6 +1051,16 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[ "( -- )", "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 -- ( "CONSOLIDATE", diff --git a/crates/core/tests/comparison.rs b/crates/core/tests/comparison.rs index 8501295..2d7ab50 100644 --- a/crates/core/tests/comparison.rs +++ b/crates/core/tests/comparison.rs @@ -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 { + 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) // -----------------------------------------------------------------------