4 Commits

Author SHA1 Message Date
Oleksandr Kozachuk e6c10a6fa1 Merge pull request #6 from ok2/fix/abort-reporting
CI / check (push) Has been cancelled
fix(core): silent ABORT, and name the compile-only condition
2026-08-07 13:11:50 +02:00
Oleksandr Kozachuk e110ca9516 fix(core): silent ABORT, and name the compile-only condition
Two reporting bugs found from the browser shell.

An uncaught ABORT printed 'ABORT (throw -1)'. The standard defines ABORT
as 'empty the data stack and perform the function of QUIT', and QUIT
displays no message; gforth and SwiftForth are both silent. It now takes
the same silent path QUIT got in 0.2.5. CATCH still reports -1 and still
restores the stack depth, and ABORT" still prints its text -- different
word, different code (-2).

Compile-only constructs used in interpretation state claimed to be an
'unknown word', which is misleading for a word the system obviously
knows: ABORT", IF, THEN, LOOP, LITERAL, RECURSE and friends. They now
report 'interpreting a compile-only word: <name> (throw -14)', the
standard condition both reference engines give. The check reuses the
existing INTERPRETER_TOKENS table at the point where interpretation has
already failed, so a genuine typo still reports 'unknown word'.

Ships as v0.2.6.
2026-08-07 13:10:42 +02:00
Oleksandr Kozachuk 8e2fd0d7d4 Merge pull request #5 from ok2/feature/quit
CI / check (push) Has been cancelled
feat(core): QUIT
2026-08-07 12:34:05 +02:00
Oleksandr Kozachuk 69309006a2 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.
2026-08-07 12:28:11 +02:00
8 changed files with 227 additions and 7 deletions
+48
View File
@@ -5,6 +5,54 @@ 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.6] - 2026-08-07
### Fixed
- **An uncaught `ABORT` no longer prints anything.** It used to report
`ABORT (throw -1)`, but the standard defines `ABORT` as "empty the data
stack and perform the function of `QUIT`", and `QUIT` displays no
message. gforth and SwiftForth are both silent here. `CATCH` still
reports -1 as before, and `ABORT"` still prints its text — that is a
different word with a different code (-2).
- **Compile-only words used in interpretation state name the condition.**
`ABORT"`, `IF`, `THEN`, `LOOP`, `LITERAL`, `RECURSE` and the rest of
the compile-time constructs claimed to be an `unknown word`, which is
actively misleading for a word the system obviously knows. They now
report `interpreting a compile-only word: <name> (throw -14)`, the
standard condition both reference engines give. A genuine typo still
reports `unknown word`.
## [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.6"
dependencies = [
"anyhow",
"clap",
@@ -1600,7 +1600,7 @@ dependencies = [
[[package]]
name = "wafer-core"
version = "0.2.4"
version = "0.2.6"
dependencies = [
"anyhow",
"insta",
@@ -1615,7 +1615,7 @@ dependencies = [
[[package]]
name = "wafer-web"
version = "0.2.4"
version = "0.2.6"
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.6"
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.6" }
wasmtime = { workspace = true }
anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] }
+163 -1
View File
@@ -180,6 +180,15 @@ 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;
/// Forth 2012 throw code for ABORT. CATCH sees it like any other exception,
/// but an uncaught one prints nothing: ABORT is specified as "empty the data
/// stack and perform the function of QUIT", and QUIT displays no message.
const ABORT_THROW: i32 = -1;
// Control-flow action codes for PendingAction::CompileControl
const CTRL_IF: i32 = 1;
const CTRL_ELSE: i32 = 2;
@@ -758,6 +767,22 @@ impl<R: Runtime> ForthVM<R> {
self.compile_frames.clear();
self.compiling_source.clear();
self.source_capture_from = None;
// QUIT and ABORT are not errors: the wipe above IS their
// "enter interpretation state", and the standard asks for
// the user input device back and no message at all (ABORT
// is defined as emptying the data stack and then doing
// QUIT; only ABORT" prints, and that is code -2). 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 matches!(*tc, Some(QUIT_THROW | ABORT_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));
}
}
@@ -1271,6 +1296,14 @@ impl<R: Runtime> ForthVM<R> {
return Ok(());
}
// Constructs the outer interpreter only knows how to compile. Forth
// 2012 leaves their interpretation semantics undefined and both gforth
// and SwiftForth name the standard condition, so say what is wrong
// instead of claiming the word does not exist.
if INTERPRETER_TOKENS.contains(&token.to_uppercase().as_str()) {
anyhow::bail!("interpreting a compile-only word: {token} (throw -14)");
}
anyhow::bail!("unknown word: {token}");
}
@@ -4442,6 +4475,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 +4566,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 +9394,114 @@ 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");
}
// ===================================================================
// ABORT reporting — gforth and sf64 both print nothing for an uncaught
// ABORT: it is specified as "empty the data stack and perform the
// function of QUIT", and QUIT displays no message. Only ABORT" prints.
// ===================================================================
#[test]
fn test_abort_is_silent_and_abandons_the_rest() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate("1 2 ABORT 99 .").unwrap();
assert_eq!(vm.take_output(), "");
assert!(vm.data_stack().is_empty(), "ABORT empties the data stack");
}
#[test]
fn test_abort_is_still_catchable() {
// Unlike QUIT: CATCH reports -1 and restores the stack depth.
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate("1 2 ' ABORT CATCH .").unwrap();
assert_eq!(vm.take_output(), "-1 ");
assert_eq!(vm.data_stack(), vec![2, 1]);
}
#[test]
fn test_abort_quote_still_reports_its_text() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate(": T -1 ABORT\" oops\" ; T").unwrap_err();
assert_eq!(err.to_string(), "oops");
}
#[test]
fn test_compile_only_words_say_so_in_interpret_mode() {
for word in ["ABORT\"", "IF", "THEN", "LOOP", "LITERAL", "RECURSE"] {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate(word).unwrap_err().to_string();
assert!(
err.contains("compile-only word"),
"{word}: expected the standard condition, got {err}"
);
}
}
#[test]
fn test_compile_only_check_does_not_swallow_typos() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate("NOSUCHWORD").unwrap_err().to_string();
assert!(err.contains("unknown word"), "{err}");
}
// ===================================================================
// 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.6", default-features = false, features = ["crypto"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
send_wrapper = { workspace = true }