feat(core): QUIT

The CORE word was missing. QUIT empties the return stack, enters
interpretation state, restores SOURCE-ID to the user input device and
returns to the interpreter without a message, leaving the data stack
untouched -- that last part is the whole difference to ABORT, which the
standard defines as 'empty the data stack, then QUIT'.

Implemented on the throw plumbing with the standard code -56, so nested
EVALUATE / INCLUDE frames unwind and are abandoned on the way out. Two
places treat -56 specially: CATCH lets it through (QUIT is a return to
the prompt, not an exception) and evaluate() turns it into a silent Ok
after the compile-state wipe it already performs.

Semantics checked against gforth 0.7.3 and SwiftForth sf64, which agree:
the data stack survives, nothing is printed, the rest of the input is
abandoned, and '1 2 ' QUIT CATCH .' prints nothing while leaving 1 2.
Six tests in outer.rs pin it. Deliberately NOT added to the cross-engine
corpus: what QUIT abandons is the input source, and the three engines are
fed differently there, so a comparison would measure the harness.

The gap survived because the Forth 2012 suite skips QUIT by its own
admission, and HELP's coverage lint compares dictionary against docs --
a word missing from both looks complete. docs/wafer-anki.txt had been
documenting QUIT as if it existed.

ABORT itself was already correct: executed while a definition is open it
clears both stacks and returns to interpretation state.

Ships as v0.2.5.
This commit is contained in:
Oleksandr Kozachuk
2026-08-07 12:28:11 +02:00
parent 9b10723a95
commit 69309006a2
8 changed files with 145 additions and 7 deletions
+30
View File
@@ -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
Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"] }
+99 -1
View File
@@ -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<R: Runtime> ForthVM<R> {
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<R: Runtime> ForthVM<R> {
});
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<R: Runtime> ForthVM<R> {
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::<NativeRuntime>::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::<NativeRuntime>::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::<NativeRuntime>::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::<NativeRuntime>::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::<NativeRuntime>::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::<NativeRuntime>::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
// ===================================================================
+5
View File
@@ -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 -- )",
+5
View File
@@ -468,6 +468,11 @@ fn programs() -> Vec<Program> {
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",
+1 -1
View File
@@ -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 }