Add (LOCAL) per Forth 2012 §13.6.1.0086

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()`).
This commit is contained in:
2026-04-18 17:12:02 +02:00
parent b06f9b65c2
commit 7d21506d7b
2 changed files with 386 additions and 36 deletions
+206 -12
View File
@@ -119,6 +119,13 @@ enum PendingAction {
CsRoll(u32), CsRoll(u32),
/// Compile a control-flow operation (from POSTPONE of compile-time keywords). /// Compile a control-flow operation (from POSTPONE of compile-time keywords).
CompileControl(i32), 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 // Control-flow action codes for PendingAction::CompileControl
@@ -254,6 +261,11 @@ pub struct ForthVM<R: Runtime> {
compiling_locals: Vec<String>, compiling_locals: Vec<String>,
/// Parallel to `compiling_locals`: kind of each local (Int or Float). /// Parallel to `compiling_locals`: kind of each local (Int or Float).
compiling_local_kinds: Vec<LocalKind>, compiling_local_kinds: Vec<LocalKind>,
/// 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<usize>,
/// Substitution table for SUBSTITUTE/REPLACES (String word set) /// Substitution table for SUBSTITUTE/REPLACES (String word set)
substitutions: Arc<Mutex<HashMap<String, Vec<u8>>>>, substitutions: Arc<Mutex<HashMap<String, Vec<u8>>>>,
/// Search order: list of wordlist IDs (first = top of search order). /// Search order: list of wordlist IDs (first = top of search order).
@@ -283,6 +295,7 @@ struct CompileFrame {
saw_create_in_def: bool, saw_create_in_def: bool,
compiling_locals: Vec<String>, compiling_locals: Vec<String>,
compiling_local_kinds: Vec<LocalKind>, compiling_local_kinds: Vec<LocalKind>,
local_batch_base: Option<usize>,
state: i32, state: i32,
} }
@@ -295,6 +308,24 @@ pub enum LocalKind {
Float, 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<R: Runtime> ForthVM<R> { impl<R: Runtime> ForthVM<R> {
/// Boot a new Forth VM with all primitives registered. /// Boot a new Forth VM with all primitives registered.
pub fn new() -> anyhow::Result<Self> { pub fn new() -> anyhow::Result<Self> {
@@ -358,6 +389,7 @@ impl<R: Runtime> ForthVM<R> {
next_block_label: 0, next_block_label: 0,
compiling_locals: Vec::new(), compiling_locals: Vec::new(),
compiling_local_kinds: Vec::new(), compiling_local_kinds: Vec::new(),
local_batch_base: None,
substitutions: Arc::new(Mutex::new(HashMap::new())), substitutions: Arc::new(Mutex::new(HashMap::new())),
search_order: Arc::new(Mutex::new(vec![1])), search_order: Arc::new(Mutex::new(vec![1])),
next_wid: Arc::new(Mutex::new(2)), next_wid: Arc::new(Mutex::new(2)),
@@ -367,7 +399,11 @@ impl<R: Runtime> ForthVM<R> {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64) .map(|d| d.as_nanos() as u64)
.unwrap_or(0xDEAD_BEEF_CAFE_BABE); .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(), compile_frames: Vec::new(),
compiling_word_addr: 0, compiling_word_addr: 0,
@@ -399,6 +435,7 @@ impl<R: Runtime> ForthVM<R> {
self.compiling_word_id = None; self.compiling_word_id = None;
self.compiling_locals.clear(); self.compiling_locals.clear();
self.compiling_local_kinds.clear(); self.compiling_local_kinds.clear();
self.local_batch_base = None;
self.compile_frames.clear(); self.compile_frames.clear();
return Err(e); return Err(e);
} }
@@ -750,8 +787,10 @@ impl<R: Runtime> ForthVM<R> {
return Ok(()); return Ok(());
} }
if token_upper == "\\" { if token_upper == "\\" {
// Line comment -- skip rest of input // Forth 2012 §6.2.2535: `\` parses and discards the remainder
self.input_pos = self.input_buffer.len(); // 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(()); return Ok(());
} }
@@ -938,7 +977,8 @@ impl<R: Runtime> ForthVM<R> {
return Ok(()); return Ok(());
} }
if token_upper == "\\" { 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(()); return Ok(());
} }
@@ -1948,6 +1988,7 @@ impl<R: Runtime> ForthVM<R> {
saw_create_in_def: self.saw_create_in_def, saw_create_in_def: self.saw_create_in_def,
compiling_locals: std::mem::take(&mut self.compiling_locals), compiling_locals: std::mem::take(&mut self.compiling_locals),
compiling_local_kinds: std::mem::take(&mut self.compiling_local_kinds), compiling_local_kinds: std::mem::take(&mut self.compiling_local_kinds),
local_batch_base: self.local_batch_base.take(),
state: self.state, state: self.state,
}; };
self.compile_frames.push(frame); self.compile_frames.push(frame);
@@ -1993,6 +2034,7 @@ impl<R: Runtime> ForthVM<R> {
self.saw_create_in_def = frame.saw_create_in_def; self.saw_create_in_def = frame.saw_create_in_def;
self.compiling_locals = frame.compiling_locals; self.compiling_locals = frame.compiling_locals;
self.compiling_local_kinds = frame.compiling_local_kinds; self.compiling_local_kinds = frame.compiling_local_kinds;
self.local_batch_base = frame.local_batch_base;
self.state = frame.state; self.state = frame.state;
if self.state != 0 { if self.state != 0 {
@@ -2095,6 +2137,7 @@ impl<R: Runtime> ForthVM<R> {
self.compiling_locals.clear(); self.compiling_locals.clear();
self.compiling_local_kinds.clear(); self.compiling_local_kinds.clear();
self.local_batch_base = None;
let name = self let name = self
.compiling_name .compiling_name
@@ -2686,6 +2729,9 @@ impl<R: Runtime> ForthVM<R> {
// CS-PICK, CS-ROLL, __CTRL__ for Programming-Tools / POSTPONE of control words // CS-PICK, CS-ROLL, __CTRL__ for Programming-Tools / POSTPONE of control words
self.register_cs_pick_roll()?; 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 // Runtime DOES> patch for double-DOES> support
self.register_does_patch()?; self.register_does_patch()?;
@@ -4229,6 +4275,7 @@ impl<R: Runtime> ForthVM<R> {
let saved_control = std::mem::take(&mut self.control_stack); let saved_control = std::mem::take(&mut self.control_stack);
let saved_locals = std::mem::take(&mut self.compiling_locals); 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_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_ir.clear();
self.compiling_name = Some("_does_action_".to_string()); self.compiling_name = Some("_does_action_".to_string());
@@ -4273,6 +4320,7 @@ impl<R: Runtime> ForthVM<R> {
self.control_stack = saved_control; self.control_stack = saved_control;
self.compiling_locals = saved_locals; self.compiling_locals = saved_locals;
self.compiling_local_kinds = saved_local_kinds; self.compiling_local_kinds = saved_local_kinds;
self.local_batch_base = saved_local_batch_base;
// Register the defining word as a "does-defining" word. // Register the defining word as a "does-defining" word.
let has_create = self.saw_create_in_def; let has_create = self.saw_create_in_def;
@@ -4738,6 +4786,45 @@ impl<R: Runtime> ForthVM<R> {
Ok(()) 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. /// Register `_does_patch_` as a host function for runtime DOES> patching.
/// ( `does_action_id` -- ) Signals the outer interpreter to patch the most /// ( `does_action_id` -- ) Signals the outer interpreter to patch the most
/// recently `CREATEd` word with a new DOES> action. /// recently `CREATEd` word with a new DOES> action.
@@ -5011,6 +5098,39 @@ impl<R: Runtime> ForthVM<R> {
CTRL_AHEAD => self.compile_ahead()?, CTRL_AHEAD => self.compile_ahead()?,
_ => anyhow::bail!("unknown control code: {code}"), _ => 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(()) Ok(())
@@ -5088,11 +5208,24 @@ impl<R: Runtime> ForthVM<R> {
/// Register `\` as an immediate host function that sets >IN to end of input. /// Register `\` as an immediate host function that sets >IN to end of input.
fn register_backslash(&mut self) -> anyhow::Result<()> { fn register_backslash(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| { 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 b: [u8; 4] = ctx.mem_read_i32(SYSVAR_NUM_TIB as u32).to_le_bytes();
let num_tib = u32::from_le_bytes(b); let num_tib = u32::from_le_bytes(b);
// Set >IN to end of input let b: [u8; 4] = ctx.mem_read_i32(SYSVAR_TO_IN as u32).to_le_bytes();
ctx.mem_write_i32(SYSVAR_TO_IN as u32, num_tib as i32); 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(()) Ok(())
}); });
@@ -5300,7 +5433,11 @@ impl<R: Runtime> ForthVM<R> {
let seed = ctx.mem_read_i32(sp as u32) as u32 as u64; let seed = ctx.mem_read_i32(sp as u32) as u32 as u64;
ctx.set_dsp(sp + CELL_SIZE); ctx.set_dsp(sp + CELL_SIZE);
let mut s = state.lock().unwrap(); 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(()) Ok(())
}); });
self.register_host_primitive("RND-SEED", false, func)?; self.register_host_primitive("RND-SEED", false, func)?;
@@ -7914,6 +8051,56 @@ mod tests {
assert_eq!(vm.data_stack(), vec![7]); 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::<NativeRuntime>::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::<NativeRuntime>::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::<NativeRuntime>::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::<NativeRuntime>::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: [: ... ;] // Quotations: [: ... ;]
// =================================================================== // ===================================================================
@@ -7999,11 +8186,11 @@ mod tests {
fn test_random_deterministic_after_seed() { fn test_random_deterministic_after_seed() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap(); let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate("42 RND-SEED RANDOM RANDOM RANDOM").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::<NativeRuntime>::new().unwrap(); let mut vm2 = ForthVM::<NativeRuntime>::new().unwrap();
vm2.evaluate("42 RND-SEED RANDOM RANDOM RANDOM").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, second, "same seed must produce same sequence");
assert_eq!(first.len(), 3); assert_eq!(first.len(), 3);
@@ -8021,7 +8208,11 @@ mod tests {
} }
// xorshift64's low-32 sequence repeats after a long period; 1000 pulls // xorshift64's low-32 sequence repeats after a long period; 1000 pulls
// should hit at least 900 unique cells. // 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] #[test]
@@ -8030,7 +8221,10 @@ mod tests {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap(); let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate("0 RND-SEED RANDOM RANDOM").unwrap(); vm.evaluate("0 RND-SEED RANDOM RANDOM").unwrap();
let stack = vm.data_stack(); 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"
);
} }
// =================================================================== // ===================================================================
+180 -24
View File
@@ -13,41 +13,165 @@ const SUITE_DIR: &str = concat!(
"/../../tests/forth2012-test-suite/src" "/../../tests/forth2012-test-suite/src"
); );
/// Load a file and evaluate it line by line, ignoring errors on individual lines. /// Load a file line-by-line, returning the number of lines that raised an
fn load_file(vm: &mut ForthVM<NativeRuntime>, path: &str) { /// `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<NativeRuntime>, path: &str) -> u32 {
let source = std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read {path}")); let source = std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read {path}"));
for line in source.lines() { let mut fails = 0u32;
let _ = vm.evaluate(line); 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 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<NativeRuntime>, 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. /// 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<NativeRuntime> { fn boot_with_prerequisites() -> ForthVM<NativeRuntime> {
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM"); let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
// Load test framework // 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 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 // Switch to decimal and load utilities
let _ = vm.evaluate("DECIMAL"); let _ = vm.evaluate("DECIMAL");
vm.take_output(); 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 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 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<NativeRuntime>, test_file: &str) -> u32 { fn run_suite(vm: &mut ForthVM<NativeRuntime>, test_file: &str) -> u32 {
// Reset error counter // Reset error counter
let _ = vm.evaluate("DECIMAL 0 #ERRORS !"); let _ = vm.evaluate("DECIMAL 0 #ERRORS !");
vm.take_output(); vm.take_output();
// Load the test file // 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 // Read error count -- try multiple approaches to be robust
let _ = vm.evaluate("DECIMAL"); let _ = vm.evaluate("DECIMAL");
@@ -76,8 +200,12 @@ fn run_suite(vm: &mut ForthVM<NativeRuntime>, test_file: &str) -> u32 {
#[test] #[test]
fn compliance_core() { fn compliance_core() {
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM"); let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); let tester_path = format!("{SUITE_DIR}/tester.fr");
load_file(&mut vm, &format!("{SUITE_DIR}/core.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 _ = vm.evaluate("DECIMAL #ERRORS @");
let errors = vm.data_stack().first().copied().unwrap_or(-1); 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. // Core Extensions are loaded as part of prerequisites.
// Run from scratch to get a clean error count. // Run from scratch to get a clean error count.
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM"); let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); let tester_path = format!("{SUITE_DIR}/tester.fr");
load_file(&mut vm, &format!("{SUITE_DIR}/core.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"); let _ = vm.evaluate("DECIMAL");
vm.take_output(); 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 !"); let _ = vm.evaluate("DECIMAL 0 #ERRORS !");
vm.take_output(); 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 _ = vm.evaluate("DECIMAL #ERRORS @");
let errors = vm.data_stack().first().copied().unwrap_or(-1) as u32; let framework_errors = vm.data_stack().first().copied().unwrap_or(-1) as u32;
assert_eq!(errors, 0, "Core Extensions: {errors} test failures"); assert_eq!(
framework_errors, 0,
"Core Extensions: {framework_errors} framework test failures"
);
} }
#[test] #[test]
@@ -164,17 +306,31 @@ fn compliance_string() {
// Run from scratch -- the stringtest includes CoreExt tests that // Run from scratch -- the stringtest includes CoreExt tests that
// cascade failures when run on top of an already-loaded CoreExt suite. // cascade failures when run on top of an already-loaded CoreExt suite.
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM"); let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
load_file(&mut vm, &format!("{SUITE_DIR}/tester.fr")); let tester_path = format!("{SUITE_DIR}/tester.fr");
load_file(&mut vm, &format!("{SUITE_DIR}/core.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"); let _ = vm.evaluate("DECIMAL");
vm.take_output(); 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 !"); let _ = vm.evaluate("DECIMAL 0 #ERRORS !");
vm.take_output(); 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 _ = vm.evaluate("DECIMAL #ERRORS @");
let errors = vm.data_stack().first().copied().unwrap_or(-1) as u32; let framework_errors = vm.data_stack().first().copied().unwrap_or(-1) as u32;
assert_eq!(errors, 0, "String: {errors} test failures"); assert_eq!(
framework_errors, 0,
"String: {framework_errors} framework test failures"
);
} }
#[test] #[test]