From bb217714ac94d553f34fbc93ce9a69fcb8caafeb Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Sat, 18 Apr 2026 17:12:02 +0200 Subject: [PATCH] =?UTF-8?q?Add=20(LOCAL)=20per=20Forth=202012=20=C2=A713.6?= =?UTF-8?q?.1.0086?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement `(LOCAL)` as a host primitive that defers its effect to the outer-interpreter compile state via two new `PendingAction` variants: - `DeclareLocal(name)` — a non-sentinel `(LOCAL)` call with `u > 0` appends the name to `compiling_locals` as an int local. - `DeclareLocalEnd` — the `0 0 (LOCAL)` sentinel emits reverse-order `ForthLocalSet` IR for the batch declared since the last sentinel, reusing the same IR shape as the `{: ... :}` locals flow. `local_batch_base` tracks where the current batch started; it is saved/restored across nested compile frames and cleared on `finish_colon_def`. Int-only, per spec — float locals remain `{F: :}`. Also fix `\` per §6.2.2535: parse-and-discard must stop at the next `\n`, not at `#TIB`. Under line-wrapped `evaluate` calls (common in test files) the old behaviour consumed the trailing `;` of a multi-line `:` definition, silently leaving state in compile mode. Tighten `compliance.rs`: `load_file` now returns a line-failure count, every prerequisite is asserted against `expected_load_failures(path)`, and a new `load_file_whole` handles multi-line definitions (`DOES>` split across lines in `errorreport.fth`) that the per-line loader cannot stitch. Baselines document known gaps for `core.fr` (nested `:`, SOURCE/>IN via EVALUATE), `coreexttest.fth` (SAVE-INPUT, `.(` inside `[...]`), `exceptiontest.fth` (one garbled parse after CATCH/THROW source stacking), and `toolstest.fth` (37 `\?`-guarded lines where `SOURCE >IN ! DROP` fails to skip under per-line `evaluate`). Each entry is a tech-debt ledger item, not an allowlist. Regression tests: LT32 (the localstest case that silently skipped before `(LOCAL)` existed), the `0 0 (LOCAL)` sentinel-only no-op, a multi-line `:` followed by `VARIABLE` after a `\` comment, and a direct `\` stops-at-newline case. Incidental: clear two `implicit_clone` clippy lints in the RANDOM determinism test (`.to_vec()` → `.clone()`). --- crates/core/src/outer.rs | 218 ++++++++++++++++++++++++++++++-- crates/core/tests/compliance.rs | 204 ++++++++++++++++++++++++++---- 2 files changed, 386 insertions(+), 36 deletions(-) diff --git a/crates/core/src/outer.rs b/crates/core/src/outer.rs index 4028d08..b2c1be3 100644 --- a/crates/core/src/outer.rs +++ b/crates/core/src/outer.rs @@ -119,6 +119,13 @@ enum PendingAction { CsRoll(u32), /// Compile a control-flow operation (from POSTPONE of compile-time keywords). CompileControl(i32), + /// Forth 2012 §13.6.1.0086 `(LOCAL)` non-sentinel: declare a local of the + /// given name. Name is already ASCII-uppercased by the host primitive. + DeclareLocal(String), + /// Forth 2012 §13.6.1.0086 `(LOCAL)` sentinel (`0 0 (LOCAL)`): emit the + /// init code for locals declared since the last sentinel (or start of + /// the current colon definition). + DeclareLocalEnd, } // Control-flow action codes for PendingAction::CompileControl @@ -254,6 +261,11 @@ pub struct ForthVM { compiling_locals: Vec, /// Parallel to `compiling_locals`: kind of each local (Int or Float). compiling_local_kinds: Vec, + /// Forth 2012 §13.6.1.0086 `(LOCAL)` batch base: index into + /// `compiling_locals` where the current `(LOCAL)` batch started. + /// `None` means no pending batch. Set on the first `DeclareLocal` of a + /// batch, cleared on `DeclareLocalEnd`, reset on `finish_colon_def`. + local_batch_base: Option, /// Substitution table for SUBSTITUTE/REPLACES (String word set) substitutions: Arc>>>, /// Search order: list of wordlist IDs (first = top of search order). @@ -283,6 +295,7 @@ struct CompileFrame { saw_create_in_def: bool, compiling_locals: Vec, compiling_local_kinds: Vec, + local_batch_base: Option, state: i32, } @@ -295,6 +308,24 @@ pub enum LocalKind { Float, } +/// Advance past the next `\n` in `buf`, starting at `from`. Returns the +/// byte index of the first character on the next line (or `buf.len()` if +/// there's no more newline). Used by the `\` line-comment handler per +/// Forth 2012 §6.2.2535 to correctly stop at end-of-line instead of +/// end-of-input when the input buffer spans multiple lines. +fn skip_to_end_of_line(buf: &str, from: usize) -> usize { + let bytes = buf.as_bytes(); + let mut i = from; + while i < bytes.len() { + let ch = bytes[i]; + i += 1; + if ch == b'\n' { + break; + } + } + i +} + impl ForthVM { /// Boot a new Forth VM with all primitives registered. pub fn new() -> anyhow::Result { @@ -358,6 +389,7 @@ impl ForthVM { next_block_label: 0, compiling_locals: Vec::new(), compiling_local_kinds: Vec::new(), + local_batch_base: None, substitutions: Arc::new(Mutex::new(HashMap::new())), search_order: Arc::new(Mutex::new(vec![1])), next_wid: Arc::new(Mutex::new(2)), @@ -367,7 +399,11 @@ impl ForthVM { .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0xDEAD_BEEF_CAFE_BABE); - Arc::new(Mutex::new(if seed == 0 { 0xDEAD_BEEF_CAFE_BABE } else { seed })) + Arc::new(Mutex::new(if seed == 0 { + 0xDEAD_BEEF_CAFE_BABE + } else { + seed + })) }, compile_frames: Vec::new(), compiling_word_addr: 0, @@ -399,6 +435,7 @@ impl ForthVM { self.compiling_word_id = None; self.compiling_locals.clear(); self.compiling_local_kinds.clear(); + self.local_batch_base = None; self.compile_frames.clear(); return Err(e); } @@ -750,8 +787,10 @@ impl ForthVM { return Ok(()); } if token_upper == "\\" { - // Line comment -- skip rest of input - self.input_pos = self.input_buffer.len(); + // Forth 2012 §6.2.2535: `\` parses and discards the remainder + // of the *line*, not the remainder of the input buffer. Stop + // at the first `\n`; fall through to end-of-buffer otherwise. + self.input_pos = skip_to_end_of_line(&self.input_buffer, self.input_pos); return Ok(()); } @@ -938,7 +977,8 @@ impl ForthVM { return Ok(()); } if token_upper == "\\" { - self.input_pos = self.input_buffer.len(); + // See interpret-mode branch: `\` ends at `\n`, not at `#TIB`. + self.input_pos = skip_to_end_of_line(&self.input_buffer, self.input_pos); return Ok(()); } @@ -1948,6 +1988,7 @@ impl ForthVM { saw_create_in_def: self.saw_create_in_def, compiling_locals: std::mem::take(&mut self.compiling_locals), compiling_local_kinds: std::mem::take(&mut self.compiling_local_kinds), + local_batch_base: self.local_batch_base.take(), state: self.state, }; self.compile_frames.push(frame); @@ -1993,6 +2034,7 @@ impl ForthVM { self.saw_create_in_def = frame.saw_create_in_def; self.compiling_locals = frame.compiling_locals; self.compiling_local_kinds = frame.compiling_local_kinds; + self.local_batch_base = frame.local_batch_base; self.state = frame.state; if self.state != 0 { @@ -2095,6 +2137,7 @@ impl ForthVM { self.compiling_locals.clear(); self.compiling_local_kinds.clear(); + self.local_batch_base = None; let name = self .compiling_name @@ -2686,6 +2729,9 @@ impl ForthVM { // CS-PICK, CS-ROLL, __CTRL__ for Programming-Tools / POSTPONE of control words self.register_cs_pick_roll()?; + // (LOCAL) for Forth 2012 §13.6.1.0086 lower-level locals primitive + self.register_local_paren()?; + // Runtime DOES> patch for double-DOES> support self.register_does_patch()?; @@ -4229,6 +4275,7 @@ impl ForthVM { let saved_control = std::mem::take(&mut self.control_stack); let saved_locals = std::mem::take(&mut self.compiling_locals); let saved_local_kinds = std::mem::take(&mut self.compiling_local_kinds); + let saved_local_batch_base = self.local_batch_base.take(); self.compiling_ir.clear(); self.compiling_name = Some("_does_action_".to_string()); @@ -4273,6 +4320,7 @@ impl ForthVM { self.control_stack = saved_control; self.compiling_locals = saved_locals; self.compiling_local_kinds = saved_local_kinds; + self.local_batch_base = saved_local_batch_base; // Register the defining word as a "does-defining" word. let has_create = self.saw_create_in_def; @@ -4738,6 +4786,45 @@ impl ForthVM { Ok(()) } + /// Register `(LOCAL)` per Forth 2012 §13.6.1.0086. + /// + /// Compile-time `( c-addr u -- )`. When `u > 0`, declare a local named by + /// the byte slice at `c-addr`/`u`. When `u = 0`, emit the initialization + /// code for all locals declared since the last sentinel (the runtime + /// `ForthLocalSet`s that pop args from the data stack in reverse + /// declaration order). + /// + /// The word is non-immediate: it runs when its containing immediate word + /// (typically user-defined `LOCAL` or `END-LOCALS`) executes during the + /// outer compilation loop. Because `HostAccess` cannot reach into the + /// outer-interpreter compile state directly, the actual mutation is + /// deferred via `PendingAction::DeclareLocal` / `DeclareLocalEnd` and + /// processed in `handle_pending_actions` once the immediate word returns. + fn register_local_paren(&mut self) -> anyhow::Result<()> { + let pending = Arc::clone(&self.pending_actions); + + let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| { + // ( c-addr u -- ) — pop both cells. + let sp = ctx.get_dsp(); + let u = ctx.mem_read_i32(sp) as u32; + let addr = ctx.mem_read_i32(sp + CELL_SIZE) as u32; + ctx.set_dsp(sp + 2 * CELL_SIZE); + + let action = if u == 0 { + PendingAction::DeclareLocalEnd + } else { + let bytes = ctx.mem_read_slice(addr, u as usize); + let name = String::from_utf8_lossy(&bytes).to_ascii_uppercase(); + PendingAction::DeclareLocal(name) + }; + pending.lock().unwrap().push(action); + Ok(()) + }); + + self.register_host_primitive("(LOCAL)", false, func)?; + Ok(()) + } + /// Register `_does_patch_` as a host function for runtime DOES> patching. /// ( `does_action_id` -- ) Signals the outer interpreter to patch the most /// recently `CREATEd` word with a new DOES> action. @@ -5011,6 +5098,39 @@ impl ForthVM { CTRL_AHEAD => self.compile_ahead()?, _ => anyhow::bail!("unknown control code: {code}"), }, + // Forth 2012 §13.6.1.0086 `(LOCAL)`: append the named local + // to the current compile context. Locals declared via + // `(LOCAL)` are int-only per spec (float locals are not + // covered by this word). + PendingAction::DeclareLocal(name) => { + if self.state == 0 { + anyhow::bail!("(LOCAL): only valid during compilation"); + } + if self.local_batch_base.is_none() { + self.local_batch_base = Some(self.compiling_locals.len()); + } + self.compiling_locals.push(name); + self.compiling_local_kinds.push(LocalKind::Int); + } + // Forth 2012 §13.6.1.0086 `(LOCAL)` sentinel: emit init + // code for the batch of locals just declared. Pop the + // runtime args from the data stack in reverse declaration + // order — consistent with `compile_locals_block` at the + // `{: ... :}` flow. + PendingAction::DeclareLocalEnd => { + if let Some(base) = self.local_batch_base.take() { + for slot in (base..self.compiling_locals.len()).rev() { + let kind_idx = self.compiling_local_kinds[0..slot] + .iter() + .filter(|k| **k == LocalKind::Int) + .count() as u32; + self.push_ir(IrOp::ForthLocalSet(kind_idx)); + } + } + // No-op if no batch is pending — spec-permissible for + // a user that calls `0 0 (LOCAL)` at the top of a + // definition before declaring anything. + } } } Ok(()) @@ -5088,11 +5208,24 @@ impl ForthVM { /// Register `\` as an immediate host function that sets >IN to end of input. fn register_backslash(&mut self) -> anyhow::Result<()> { let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| { - // Read #TIB (input buffer length) + // Forth 2012 §6.2.2535 `\`: "Parse and discard the remainder of + // the parse area." The parse area extends to the end of the + // current **line**, not the end of the input buffer. Scan from + // the current `>IN` forward for the first `\n`, and set `>IN` + // to the position after it. If there's no newline, stop at + // `#TIB` (end of buffer), matching the single-line case. let b: [u8; 4] = ctx.mem_read_i32(SYSVAR_NUM_TIB as u32).to_le_bytes(); let num_tib = u32::from_le_bytes(b); - // Set >IN to end of input - ctx.mem_write_i32(SYSVAR_TO_IN as u32, num_tib as i32); + let b: [u8; 4] = ctx.mem_read_i32(SYSVAR_TO_IN as u32).to_le_bytes(); + let mut to_in = u32::from_le_bytes(b); + while to_in < num_tib { + let ch = ctx.mem_read_u8(INPUT_BUFFER_BASE + to_in); + to_in += 1; + if ch == b'\n' { + break; + } + } + ctx.mem_write_i32(SYSVAR_TO_IN as u32, to_in as i32); Ok(()) }); @@ -5300,7 +5433,11 @@ impl ForthVM { let seed = ctx.mem_read_i32(sp as u32) as u32 as u64; ctx.set_dsp(sp + CELL_SIZE); let mut s = state.lock().unwrap(); - *s = if seed == 0 { 0xDEAD_BEEF_CAFE_BABE } else { seed }; + *s = if seed == 0 { + 0xDEAD_BEEF_CAFE_BABE + } else { + seed + }; Ok(()) }); self.register_host_primitive("RND-SEED", false, func)?; @@ -7914,6 +8051,56 @@ mod tests { assert_eq!(vm.data_stack(), vec![7]); } + #[test] + fn test_local_primitive_lt32() { + // Forth 2012 §13.6.1.0086 `(LOCAL)` — replica of LT32 from + // localstest.fth line 118-120 (the test that was silently skipped + // before `(LOCAL)` was implemented). + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate(": LOCAL BL WORD COUNT (LOCAL) ; IMMEDIATE") + .unwrap(); + vm.evaluate(": END-LOCALS 0 0 (LOCAL) ; IMMEDIATE").unwrap(); + vm.evaluate(": LT32 LOCAL A LOCAL B LOCAL C END-LOCALS A B C ;") + .unwrap(); + vm.evaluate("61 62 63 LT32").unwrap(); + assert_eq!(vm.data_stack(), vec![63, 62, 61]); + } + + #[test] + fn test_multiline_colon_then_variable() { + // Regression: combined `:` def across newlines must leave state at + // interpret afterwards. Earlier, WAFER's `\` (backslash comment) + // consumed to `#TIB` instead of the next `\n`, so multi-line chunks + // lost the closing `;` inside a comment and left state in compile + // mode. The symptom was a later `VARIABLE X 0 X !` erroring on + // `unknown word: X`, because the outer `:` never actually closed. + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate(": EMPTY-STACK\n DEPTH ?DUP IF DUP 0< IF NEGATE 0 DO 0 LOOP ELSE 0 DO DROP LOOP THEN THEN ;").unwrap(); + vm.evaluate("VARIABLE #ERRORS 0 #ERRORS !").unwrap(); + vm.evaluate("#ERRORS @").unwrap(); + assert_eq!(vm.data_stack(), vec![0]); + } + + #[test] + fn test_backslash_stops_at_newline() { + // Forth 2012 §6.2.2535 `\`: parse-and-discard ends at end-of-line, + // not end of input buffer. Multi-line input must survive a `\` + // comment on a prior line. + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate("\\ comment line\n42").unwrap(); + assert_eq!(vm.data_stack(), vec![42]); + } + + #[test] + fn test_local_primitive_end_sentinel_only() { + // `0 0 (LOCAL)` with no prior names must be a harmless no-op. + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate(": END-LOCALS 0 0 (LOCAL) ; IMMEDIATE").unwrap(); + vm.evaluate(": T END-LOCALS 42 ;").unwrap(); + vm.evaluate("T").unwrap(); + assert_eq!(vm.data_stack(), vec![42]); + } + // =================================================================== // Quotations: [: ... ;] // =================================================================== @@ -7999,11 +8186,11 @@ mod tests { fn test_random_deterministic_after_seed() { let mut vm = ForthVM::::new().unwrap(); vm.evaluate("42 RND-SEED RANDOM RANDOM RANDOM").unwrap(); - let first = vm.data_stack().to_vec(); + let first = vm.data_stack().clone(); let mut vm2 = ForthVM::::new().unwrap(); vm2.evaluate("42 RND-SEED RANDOM RANDOM RANDOM").unwrap(); - let second = vm2.data_stack().to_vec(); + let second = vm2.data_stack().clone(); assert_eq!(first, second, "same seed must produce same sequence"); assert_eq!(first.len(), 3); @@ -8021,7 +8208,11 @@ mod tests { } // xorshift64's low-32 sequence repeats after a long period; 1000 pulls // should hit at least 900 unique cells. - assert!(seen.len() >= 900, "only {} distinct out of 1000", seen.len()); + assert!( + seen.len() >= 900, + "only {} distinct out of 1000", + seen.len() + ); } #[test] @@ -8030,7 +8221,10 @@ mod tests { let mut vm = ForthVM::::new().unwrap(); vm.evaluate("0 RND-SEED RANDOM RANDOM").unwrap(); let stack = vm.data_stack(); - assert!(stack[0] != 0 || stack[1] != 0, "seed-0 must not freeze the stream"); + assert!( + stack[0] != 0 || stack[1] != 0, + "seed-0 must not freeze the stream" + ); } // =================================================================== diff --git a/crates/core/tests/compliance.rs b/crates/core/tests/compliance.rs index 77dbb9b..6f2f373 100644 --- a/crates/core/tests/compliance.rs +++ b/crates/core/tests/compliance.rs @@ -13,41 +13,165 @@ const SUITE_DIR: &str = concat!( "/../../tests/forth2012-test-suite/src" ); -/// Load a file and evaluate it line by line, ignoring errors on individual lines. -fn load_file(vm: &mut ForthVM, path: &str) { +/// Load a file line-by-line, returning the number of lines that raised an +/// `evaluate` error. Each failing line is printed (visible under +/// `cargo test -- --nocapture`) so failures can be triaged without a +/// debugger. +/// +/// Historically this helper discarded errors silently, which caused tests +/// like LT32 in `localstest.fth` (compile errors from unknown words such +/// as `(LOCAL)` before it was implemented) to vanish — the T{ }T error +/// counter was never incremented because the `:` definition never ran. +/// Returning the count surfaces silent skips as real failures. +/// +/// **Note on multi-line definitions.** WAFER's DOES> handler collects +/// the does-body to `;` via `next_token()` within a *single* `evaluate` +/// call and treats end-of-input as end-of-body. Files with a `DOES>` +/// split across lines (e.g. `errorreport.fth`) therefore cannot be +/// loaded line-by-line; use [`load_file_whole`] for those. +fn load_file(vm: &mut ForthVM, path: &str) -> u32 { let source = std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read {path}")); - for line in source.lines() { - let _ = vm.evaluate(line); + let mut fails = 0u32; + for (lineno, line) in source.lines().enumerate() { + if let Err(e) = vm.evaluate(line) { + fails += 1; + eprintln!("{path}:{}: {e}\n line: {line}", lineno + 1); + } } vm.take_output(); // discard output + fails +} + +/// Load a file as a single `evaluate` call (not line-by-line). Required +/// for files with multi-line definitions that WAFER's per-line handlers +/// can't stitch across calls (notably `: X ... DOES> ... ;` spanning +/// lines — see [`load_file`] note). +/// +/// Returns `1` on any failure, `0` on success, so the caller can apply +/// baselines the same way as [`load_file`]. +fn load_file_whole(vm: &mut ForthVM, path: &str) -> u32 { + let source = std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read {path}")); + let fails = match vm.evaluate(&source) { + Ok(()) => 0, + Err(e) => { + eprintln!("{path}: {e}"); + 1 + } + }; + vm.take_output(); + fails +} + +/// Baseline of *known* line-level failures per prerequisite file. The runner +/// asserts `load_fails == expected_load_failures(path)`, so any regression +/// above (or silently-fixed case below) the baseline is caught. +/// +/// Baselines are not an allowlist to paper over bugs — they are an explicit +/// tech-debt ledger. Each non-zero entry here is a bug that should be fixed +/// and the baseline lowered to zero. See the in-tree follow-up tasks. +fn expected_load_failures(path: &str) -> u32 { + // core.fr exercises two constructs WAFER does not yet support: + // 1. Nested colon definitions (`: NOP : POSTPONE ; ;` at line 751, + // defining NOP, NOP1, NOP2 — four silent lines). + // 2. `SOURCE`/`>IN` round-trip through `EVALUATE` at line 797 + // (GS1 definition) — one line. + // Total: 5. Fix these and drop the baseline to 0. + if path.ends_with("/core.fr") { + return 5; + } + // coreexttest.fth uses two Core-Extension features WAFER lacks: + // 1. SAVE-INPUT / RESTORE-INPUT at line 548 — not implemented. + // 2. `.(` inside `[ ... ]` brackets at line 559 — `.(` isn't + // handled by `compile_token`'s `[ ... ]` interpret-mode path, + // so `First message via .(` tokens leak to the compiler as + // undefined words. + // Total: 2. Fix these and drop the baseline to 0. + if path.ends_with("/coreexttest.fth") { + return 2; + } + // exceptiontest.fth line 95 fails with a garbled parse ("unknown word" + // over non-ASCII bytes): WAFER's parser reads past a prior test's + // scratch region after the preceding `C6` / `T9` frame exercises + // CATCH/THROW source stacking. Root cause not yet diagnosed; baseline + // until fixed. + if path.ends_with("/exceptiontest.fth") { + return 1; + } + // toolstest.fth uses the `\?` conditional-skip idiom defined in + // utilities.fth:37 as `: \? (\?) @ IF EXIT THEN SOURCE >IN ! DROP ; + // IMMEDIATE`. Under WAFER's per-line `evaluate` loader, the + // `SOURCE >IN ! DROP` path does not consume the remainder of the + // current line correctly, so 37 `\?`-guarded lines inside the + // TRAVERSE-WORDLIST / NAME>COMPILE / NAME>INTERPRET blocks leak as + // unknown-word errors. Fix the SOURCE/`>IN` interaction with + // line-mode input and drop this to 0. + if path.ends_with("/toolstest.fth") { + return 37; + } + 0 +} + +/// Assert a file loaded with exactly its baseline number of line-level +/// failures. Used for prerequisites; keeps the runner tight without +/// blocking the whole suite on known gaps. +fn assert_load_fails_within_baseline(path: &str, fails: u32) { + let expected = expected_load_failures(path); + assert_eq!( + fails, expected, + "{path} had {fails} line-level failures (expected baseline: {expected})" + ); } /// Boot a WAFER VM with full prerequisites loaded. +/// +/// Every prerequisite file must load with zero line-level errors. Any +/// regression here points to a missing primitive or a parser bug and must +/// be fixed, not silently tolerated. fn boot_with_prerequisites() -> ForthVM { let mut vm = ForthVM::::new().expect("Failed to create ForthVM"); // Load test framework - load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); + let tester_path = format!("{SUITE_DIR}/tester.fr"); + let f1 = load_file(&mut vm, &tester_path); + assert_load_fails_within_baseline(&tester_path, f1); // Load core tests (prerequisite) - load_file(&mut vm, &format!("{SUITE_DIR}/core.fr")); + let core_path = format!("{SUITE_DIR}/core.fr"); + let f2 = load_file(&mut vm, &core_path); + assert_load_fails_within_baseline(&core_path, f2); // Switch to decimal and load utilities let _ = vm.evaluate("DECIMAL"); vm.take_output(); - load_file(&mut vm, &format!("{SUITE_DIR}/utilities.fth")); + let util_path = format!("{SUITE_DIR}/utilities.fth"); + let f3 = load_file(&mut vm, &util_path); + assert_load_fails_within_baseline(&util_path, f3); + // errorreport.fth defines SET-ERROR-COUNT and the per-wordset counter + // accessors (CORE-ERRORS, STRING-ERRORS, LOCALS-ERRORS, ...). Every + // suite's final `X-ERRORS SET-ERROR-COUNT` line depends on this file, + // and silently errored before the runner was tightened. + let errorreport_path = format!("{SUITE_DIR}/errorreport.fth"); + let f_err = load_file_whole(&mut vm, &errorreport_path); + assert_load_fails_within_baseline(&errorreport_path, f_err); // Load core extensions - load_file(&mut vm, &format!("{SUITE_DIR}/coreexttest.fth")); + let ext_path = format!("{SUITE_DIR}/coreexttest.fth"); + let f4 = load_file(&mut vm, &ext_path); + assert_load_fails_within_baseline(&ext_path, f4); vm } -/// Run a test suite file and return the #ERRORS count. +/// Run a test suite file and return the *total* error count: +/// `#ERRORS` from the Forth test framework plus any lines where +/// `vm.evaluate` itself failed (e.g. unknown word in a `:` definition +/// outside `T{ }T`, which the framework cannot catch). fn run_suite(vm: &mut ForthVM, test_file: &str) -> u32 { // Reset error counter let _ = vm.evaluate("DECIMAL 0 #ERRORS !"); vm.take_output(); // Load the test file - load_file(vm, &format!("{SUITE_DIR}/{test_file}")); + let file_path = format!("{SUITE_DIR}/{test_file}"); + let load_fails = load_file(vm, &file_path); + assert_load_fails_within_baseline(&file_path, load_fails); // Read error count -- try multiple approaches to be robust let _ = vm.evaluate("DECIMAL"); @@ -76,8 +200,12 @@ fn run_suite(vm: &mut ForthVM, test_file: &str) -> u32 { #[test] fn compliance_core() { let mut vm = ForthVM::::new().expect("Failed to create ForthVM"); - load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); - load_file(&mut vm, &format!("{SUITE_DIR}/core.fr")); + let tester_path = format!("{SUITE_DIR}/tester.fr"); + let f1 = load_file(&mut vm, &tester_path); + assert_load_fails_within_baseline(&tester_path, f1); + let core_path = format!("{SUITE_DIR}/core.fr"); + let f2 = load_file(&mut vm, &core_path); + assert_load_fails_within_baseline(&core_path, f2); let _ = vm.evaluate("DECIMAL #ERRORS @"); let errors = vm.data_stack().first().copied().unwrap_or(-1); @@ -96,17 +224,31 @@ fn compliance_core_ext() { // Core Extensions are loaded as part of prerequisites. // Run from scratch to get a clean error count. let mut vm = ForthVM::::new().expect("Failed to create ForthVM"); - load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); - load_file(&mut vm, &format!("{SUITE_DIR}/core.fr")); + let tester_path = format!("{SUITE_DIR}/tester.fr"); + let f1 = load_file(&mut vm, &tester_path); + assert_load_fails_within_baseline(&tester_path, f1); + let core_path = format!("{SUITE_DIR}/core.fr"); + let f2 = load_file(&mut vm, &core_path); + assert_load_fails_within_baseline(&core_path, f2); let _ = vm.evaluate("DECIMAL"); vm.take_output(); - load_file(&mut vm, &format!("{SUITE_DIR}/utilities.fth")); + let util_path = format!("{SUITE_DIR}/utilities.fth"); + let f3 = load_file(&mut vm, &util_path); + assert_load_fails_within_baseline(&util_path, f3); + let errorreport_path = format!("{SUITE_DIR}/errorreport.fth"); + let f_err = load_file_whole(&mut vm, &errorreport_path); + assert_load_fails_within_baseline(&errorreport_path, f_err); let _ = vm.evaluate("DECIMAL 0 #ERRORS !"); vm.take_output(); - load_file(&mut vm, &format!("{SUITE_DIR}/coreexttest.fth")); + let ext_path = format!("{SUITE_DIR}/coreexttest.fth"); + let load_fails = load_file(&mut vm, &ext_path); + assert_load_fails_within_baseline(&ext_path, load_fails); let _ = vm.evaluate("DECIMAL #ERRORS @"); - let errors = vm.data_stack().first().copied().unwrap_or(-1) as u32; - assert_eq!(errors, 0, "Core Extensions: {errors} test failures"); + let framework_errors = vm.data_stack().first().copied().unwrap_or(-1) as u32; + assert_eq!( + framework_errors, 0, + "Core Extensions: {framework_errors} framework test failures" + ); } #[test] @@ -164,17 +306,31 @@ fn compliance_string() { // Run from scratch -- the stringtest includes CoreExt tests that // cascade failures when run on top of an already-loaded CoreExt suite. let mut vm = ForthVM::::new().expect("Failed to create ForthVM"); - load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); - load_file(&mut vm, &format!("{SUITE_DIR}/core.fr")); + let tester_path = format!("{SUITE_DIR}/tester.fr"); + let f1 = load_file(&mut vm, &tester_path); + assert_load_fails_within_baseline(&tester_path, f1); + let core_path = format!("{SUITE_DIR}/core.fr"); + let f2 = load_file(&mut vm, &core_path); + assert_load_fails_within_baseline(&core_path, f2); let _ = vm.evaluate("DECIMAL"); vm.take_output(); - load_file(&mut vm, &format!("{SUITE_DIR}/utilities.fth")); + let util_path = format!("{SUITE_DIR}/utilities.fth"); + let f3 = load_file(&mut vm, &util_path); + assert_load_fails_within_baseline(&util_path, f3); + let errorreport_path = format!("{SUITE_DIR}/errorreport.fth"); + let f_err = load_file_whole(&mut vm, &errorreport_path); + assert_load_fails_within_baseline(&errorreport_path, f_err); let _ = vm.evaluate("DECIMAL 0 #ERRORS !"); vm.take_output(); - load_file(&mut vm, &format!("{SUITE_DIR}/stringtest.fth")); + let str_path = format!("{SUITE_DIR}/stringtest.fth"); + let load_fails = load_file(&mut vm, &str_path); + assert_load_fails_within_baseline(&str_path, load_fails); let _ = vm.evaluate("DECIMAL #ERRORS @"); - let errors = vm.data_stack().first().copied().unwrap_or(-1) as u32; - assert_eq!(errors, 0, "String: {errors} test failures"); + let framework_errors = vm.data_stack().first().copied().unwrap_or(-1) as u32; + assert_eq!( + framework_errors, 0, + "String: {framework_errors} framework test failures" + ); } #[test]