diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4b09d..9ff40c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to WAFER are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.5] - 2026-08-06 + +### Added + +- **`QUIT`** ( -- ) ( R: i\*x -- ), the CORE word that was missing: empty + the return stack, enter interpretation state, hand the input source + back to the user input device and return to the interpreter without a + message. The data stack is deliberately left alone — that is the whole + difference to `ABORT`, which the standard defines as "empty the data + stack, then `QUIT`". It unwinds through nested `EVALUATE` and + `INCLUDE`, abandoning them, and `SOURCE-ID` is restored to 0. + + `CATCH` does **not** report it: `QUIT` rides throw code -56, which the + interpreter treats as a return to the prompt rather than an exception. + Both behaviours were checked against gforth 0.7.3 and SwiftForth + `sf64`, which agree — `1 2 ' QUIT CATCH .` prints nothing and leaves + `1 2` on the stack in all three engines. + + The gap had gone unnoticed because the Forth 2012 test suite skips it + by its own admission ("I HAVEN'T FIGURED OUT HOW TO TEST KEY, QUIT, + ABORT, OR ABORT\""), and because `HELP`'s coverage lint compares the + dictionary against the docs — a word absent from both looks complete. + `docs/wafer-anki.txt` had been documenting `QUIT` as if it existed. + + Note that `ABORT` was already correct: executing it while a definition + is open does clear both stacks and return to interpretation state. + Typing `ABORT` (or `QUIT`) into an unfinished definition compiles it + rather than running it, exactly as in every other Forth; `[` is the + word that gets you out. + ## [0.2.4] - 2026-08-06 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e1e08d7..28041b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,7 +1589,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wafer" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "clap", @@ -1600,7 +1600,7 @@ dependencies = [ [[package]] name = "wafer-core" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "insta", @@ -1615,7 +1615,7 @@ dependencies = [ [[package]] name = "wafer-web" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 53bc3f4..cfe514c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.2.4" +version = "0.2.5" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/ok2/wafer" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index a34c4e4..7efc328 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -9,7 +9,7 @@ license.workspace = true workspace = true [dependencies] -wafer-core = { path = "../core", version = "0.2.4" } +wafer-core = { path = "../core", version = "0.2.5" } wasmtime = { workspace = true } anyhow = { workspace = true } clap = { version = "4", features = ["derive"] } diff --git a/crates/core/src/outer.rs b/crates/core/src/outer.rs index a550429..1dbebbb 100644 --- a/crates/core/src/outer.rs +++ b/crates/core/src/outer.rs @@ -180,6 +180,10 @@ enum PendingAction { DeclareLocalEnd, } +/// Forth 2012 throw code for QUIT (table 9.1). Unlike every other code it is +/// not an exception: CATCH lets it through and the interpreter reports nothing. +const QUIT_THROW: i32 = -56; + // Control-flow action codes for PendingAction::CompileControl const CTRL_IF: i32 = 1; const CTRL_ELSE: i32 = 2; @@ -758,6 +762,20 @@ impl ForthVM { self.compile_frames.clear(); self.compiling_source.clear(); self.source_capture_from = None; + // QUIT is not an error: the wipe above IS its "enter + // interpretation state", and the standard asks for the + // user input device back and no message at all. The rest + // of this input -- and any EVALUATE / INCLUDE frame it + // unwound through -- is abandoned by returning here. + let mut tc = self.throw_code.lock().unwrap(); + if *tc == Some(QUIT_THROW) { + *tc = None; + drop(tc); + self.rt.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, 0); + self.include_frames.clear(); + return Ok(()); + } + drop(tc); return Err(self.describe_uncaught(e)); } } @@ -4442,6 +4460,20 @@ impl ForthVM { }); self.register_host_primitive("_ABORT_Q_", false, func)?; + // QUIT ( -- ) ( R: i*x -- ) empty the return stack and return to the + // interpreter. The data stack is deliberately untouched -- that is the + // whole difference to ABORT, which is specified as "empty the data + // stack, then QUIT". Unwinding rides the throw plumbing so nested + // EVALUATE / INCLUDE frames are abandoned on the way out; the standard + // code -56 tells `evaluate` and CATCH what this is. + let throw_code = Arc::clone(&self.throw_code); + let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| { + ctx.set_rsp((RETURN_STACK_TOP as i32) as u32); + *throw_code.lock().unwrap() = Some(QUIT_THROW); + Err(anyhow::anyhow!("forth-throw")) + }); + self.register_host_primitive("QUIT", false, func)?; + // BYE ( -- ) request REPL/driver exit. let bye = Arc::clone(&self.bye); let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| { @@ -4519,9 +4551,16 @@ impl ForthVM { ctx.set_dsp((new_sp as i32) as u32); Ok(()) } - Err(_) => { + Err(e) => { // Check if this was a THROW (vs some other trap) let mut tc = throw_code_for_catch.lock().unwrap(); + // QUIT is not an exception: it unwinds past CATCH straight + // to the interpreter, leaving both stacks as it found them + // (verified against gforth and SwiftForth). + if *tc == Some(QUIT_THROW) { + drop(tc); + return Err(e); + } let code = tc.take().unwrap_or(-1); drop(tc); @@ -9340,6 +9379,65 @@ mod tests { assert!(result.is_err()); } + // =================================================================== + // QUIT — Forth 2012 6.1.2050. Semantics checked against gforth 0.7.3 + // and SwiftForth sf64: the data stack survives, nothing is printed, + // the rest of the input is abandoned, and CATCH does not see it. + // =================================================================== + + #[test] + fn test_quit_keeps_data_stack_and_abandons_the_rest() { + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate("1 2 QUIT 99 .").unwrap(); // not an error, and 99 never runs + assert_eq!(vm.take_output(), ""); + assert_eq!(vm.data_stack(), vec![2, 1]); + } + + #[test] + fn test_quit_from_inside_a_definition() { + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate(": T 7 QUIT 8 . ; 5 T 6 .").unwrap(); + assert_eq!(vm.take_output(), ""); + assert_eq!(vm.data_stack(), vec![7, 5]); + } + + #[test] + fn test_quit_empties_the_return_stack() { + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate(": T 1 >R 2 >R QUIT ; T").unwrap(); + vm.evaluate("RDEPTH .").unwrap(); + assert_eq!(vm.take_output(), "0 "); + } + + #[test] + fn test_quit_is_not_caught_by_catch() { + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate("1 2 ' QUIT CATCH .").unwrap(); + assert_eq!(vm.take_output(), "", "CATCH must not report QUIT"); + assert_eq!(vm.data_stack(), vec![2, 1]); + } + + #[test] + fn test_quit_leaves_compile_mode_when_it_executes() { + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate(": BOOM QUIT ; IMMEDIATE").unwrap(); + vm.evaluate("9 : FOO 1 2 BOOM").unwrap(); + assert!(!vm.is_compiling(), "QUIT enters interpretation state"); + assert_eq!(vm.data_stack(), vec![9], "the data stack is left alone"); + vm.evaluate(": SQ DUP * ; 9 SQ .").unwrap(); // the VM is usable again + assert_eq!(vm.take_output(), "81 "); + } + + #[test] + fn test_quit_inside_evaluate_restores_user_input_source() { + let mut vm = ForthVM::::new().unwrap(); + vm.evaluate("S\" 5 QUIT 6 .\" EVALUATE 7 .").unwrap(); + assert_eq!(vm.take_output(), "", "both the string and the line stop"); + assert_eq!(vm.data_stack(), vec![5]); + vm.evaluate("SOURCE-ID .").unwrap(); + assert_eq!(vm.take_output(), "0 ", "back to the user input device"); + } + // =================================================================== // New words: SOURCE // =================================================================== diff --git a/crates/core/src/wordhelp.rs b/crates/core/src/wordhelp.rs index 69c07df..0affc05 100644 --- a/crates/core/src/wordhelp.rs +++ b/crates/core/src/wordhelp.rs @@ -716,6 +716,11 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[ "Read a line of input (unsupported here).", ), ("ABORT", "( i*x -- )", "Empty the stacks and abort."), + ( + "QUIT", + "( -- ) ( R: i*x -- )", + "Empty the return stack, return to the interpreter; data stack kept.", + ), ( "ABORT\"", "( flag -- )", diff --git a/crates/core/tests/comparison.rs b/crates/core/tests/comparison.rs index 8c8919b..baa1470 100644 --- a/crates/core/tests/comparison.rs +++ b/crates/core/tests/comparison.rs @@ -468,6 +468,11 @@ fn programs() -> Vec { expected: "-1 \n-1 \n42 \n-1 \n", category: Category::Definitions, }, + // QUIT is deliberately absent from this corpus: what it abandons is + // "the input source", and each engine here is fed differently (wafer + // line by line, gforth from a file, sf64 from a prompting stdin), so + // a comparison would measure the harness. Its semantics are pinned by + // the QUIT tests in outer.rs, checked by hand against both engines. // -- Strings -- Program { name: "s-quote-type", diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index ffde45e..64bf6f8 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -12,7 +12,7 @@ workspace = true crate-type = ["cdylib", "rlib"] [dependencies] -wafer-core = { path = "../core", version = "0.2.4", default-features = false, features = ["crypto"] } +wafer-core = { path = "../core", version = "0.2.5", default-features = false, features = ["crypto"] } wasm-bindgen = "0.2" js-sys = "0.3" send_wrapper = { workspace = true }