6 Commits

Author SHA1 Message Date
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
Oleksandr Kozachuk 9b10723a95 Merge pull request #4 from ok2/fix/web-error-messages
CI / check (push) Has been cancelled
fix(web): surface Forth messages from host-word throws
2026-08-06 20:46:11 +02:00
Oleksandr Kozachuk 15f8005b6d Merge pull request #3 from ok2/fix/release-strip-breaks-proc-macros
fix(build): exempt build scripts and proc-macros from release strip
2026-08-06 20:46:08 +02:00
Oleksandr Kozachuk 4769987b20 fix(web): surface Forth messages from host-word throws
A host word signals failure by throwing across the JS boundary, and the
browser runtime reported that exception with its Debug form, so an
empty-stack RESIZE surfaced as

    call_func(134) failed: JsValue(Error: Stack underflow ...)

with the engine's JS stack trace glued on. The thrown message IS the
Forth message, so take it verbatim: 'Stack underflow', the same text the
native CLI prints. Exceptions without a message keep the call context --
those are genuine runtime faults, not Forth throws.

CATCH is unaffected: it reads the throw code from its own channel rather
than parsing messages. Verified against a fresh VM in Node (initSync +
WaferRepl): host-word underflow, compiled-guard underflow, THROW,
unknown word and ' RESIZE CATCH . all match the native CLI.

Ships as v0.2.4.
2026-08-06 20:45:27 +02:00
Oleksandr Kozachuk d55a27873e fix(build): exempt build scripts and proc-macros from release strip
`wasm-pack build --release` died with "can't find crate" for rustversion,
then thiserror_impl, then every other proc-macro. Cargo strips debuginfo
from release artifacts by default and on macOS that takes the metadata
proc-macro dylibs need to be loadable with it, so rustc could no longer
open them.

Debug builds are unstripped, which is why the whole test suite stayed
green while the browser REPL could not be built for production at all.

Stripping buys nothing for build scripts and proc-macros, so
[profile.release.build-override] exempts them; release binaries stay
stripped.

Also pins wafer-core to 0.2.3 in wafer-web and wafer-cli — both still
asked for 0.2.1. The caret requirement resolved, so nothing broke.
2026-08-06 20:16:41 +02:00
9 changed files with 206 additions and 9 deletions
+64
View File
@@ -5,6 +5,70 @@ 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/), 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). 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
- **Errors from host words in the browser build read like Forth errors
again.** A host word signals failure by throwing across the JS
boundary, and the browser runtime reported the exception with its
`Debug` form, so an empty-stack `RESIZE` came back as
`call_func(134) failed: JsValue(Error: Stack underflow ...)` trailed by
an engine stack trace. The thrown message is the Forth message, so it
is now surfaced verbatim — `Stack underflow`, exactly what the native
CLI prints. Exceptions that carry no message keep the call context,
since those are genuine runtime faults rather than Forth throws.
`CATCH` was never affected: it reads the throw code from its own
channel, not from the message.
## [0.2.3] - 2026-08-06
### Fixed
- **Release builds of `wafer-web` no longer fail on proc-macro loading.**
Cargo strips debuginfo from release artifacts by default, and on macOS
that also strips the metadata proc-macro dylibs need to be loadable, so
`wasm-pack build --release` died with `can't find crate` for
`rustversion`, `thiserror_impl` and every other proc-macro. Build
scripts and proc-macros gain nothing from stripping, so
`[profile.release.build-override]` now exempts them; release binaries
stay stripped. Debug builds were never affected, which is why the test
suite stayed green while the browser REPL could not be built for
production.
- `wafer-web` and `wafer-cli` requested `wafer-core` version `0.2.1`
while the workspace had moved to `0.2.2`. The caret requirement still
resolved, so nothing broke, but the pin is now kept in step.
## [0.2.2] - 2026-08-06 ## [0.2.2] - 2026-08-06
### Added ### Added
Generated
+3 -3
View File
@@ -1589,7 +1589,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "wafer" name = "wafer"
version = "0.2.2" version = "0.2.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -1600,7 +1600,7 @@ dependencies = [
[[package]] [[package]]
name = "wafer-core" name = "wafer-core"
version = "0.2.2" version = "0.2.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"insta", "insta",
@@ -1615,7 +1615,7 @@ dependencies = [
[[package]] [[package]]
name = "wafer-web" name = "wafer-web"
version = "0.2.2" version = "0.2.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"js-sys", "js-sys",
+9 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.2.2" version = "0.2.5"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/ok2/wafer" repository = "https://github.com/ok2/wafer"
@@ -51,3 +51,11 @@ insta = "1"
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
send_wrapper = "0.6" send_wrapper = "0.6"
# Cargo strips debuginfo from release artifacts by default, and on macOS that
# also strips the metadata proc-macro dylibs need to be loadable — release
# builds then fail with "can't find crate" for every proc-macro (rustversion,
# thiserror_impl, ...). Build scripts and proc-macros gain nothing from
# stripping, so exempt them; the release binaries stay stripped.
[profile.release.build-override]
strip = false
+1 -1
View File
@@ -9,7 +9,7 @@ license.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
wafer-core = { path = "../core", version = "0.2.1" } wafer-core = { path = "../core", version = "0.2.5" }
wasmtime = { workspace = true } wasmtime = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
+99 -1
View File
@@ -180,6 +180,10 @@ enum PendingAction {
DeclareLocalEnd, 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 // Control-flow action codes for PendingAction::CompileControl
const CTRL_IF: i32 = 1; const CTRL_IF: i32 = 1;
const CTRL_ELSE: i32 = 2; const CTRL_ELSE: i32 = 2;
@@ -758,6 +762,20 @@ impl<R: Runtime> ForthVM<R> {
self.compile_frames.clear(); self.compile_frames.clear();
self.compiling_source.clear(); self.compiling_source.clear();
self.source_capture_from = None; 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)); return Err(self.describe_uncaught(e));
} }
} }
@@ -4442,6 +4460,20 @@ impl<R: Runtime> ForthVM<R> {
}); });
self.register_host_primitive("_ABORT_Q_", false, func)?; 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. // BYE ( -- ) request REPL/driver exit.
let bye = Arc::clone(&self.bye); let bye = Arc::clone(&self.bye);
let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| { 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); ctx.set_dsp((new_sp as i32) as u32);
Ok(()) Ok(())
} }
Err(_) => { Err(e) => {
// Check if this was a THROW (vs some other trap) // Check if this was a THROW (vs some other trap)
let mut tc = throw_code_for_catch.lock().unwrap(); 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); let code = tc.take().unwrap_or(-1);
drop(tc); drop(tc);
@@ -9340,6 +9379,65 @@ mod tests {
assert!(result.is_err()); 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 // New words: SOURCE
// =================================================================== // ===================================================================
+5
View File
@@ -716,6 +716,11 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
"Read a line of input (unsupported here).", "Read a line of input (unsupported here).",
), ),
("ABORT", "( i*x -- )", "Empty the stacks and abort."), ("ABORT", "( i*x -- )", "Empty the stacks and abort."),
(
"QUIT",
"( -- ) ( R: i*x -- )",
"Empty the return stack, return to the interpreter; data stack kept.",
),
( (
"ABORT\"", "ABORT\"",
"( flag -- )", "( flag -- )",
+5
View File
@@ -468,6 +468,11 @@ fn programs() -> Vec<Program> {
expected: "-1 \n-1 \n42 \n-1 \n", expected: "-1 \n-1 \n42 \n-1 \n",
category: Category::Definitions, 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 -- // -- Strings --
Program { Program {
name: "s-quote-type", name: "s-quote-type",
+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
wafer-core = { path = "../core", version = "0.2.1", default-features = false, features = ["crypto"] } wafer-core = { path = "../core", version = "0.2.5", default-features = false, features = ["crypto"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
js-sys = "0.3" js-sys = "0.3"
send_wrapper = { workspace = true } send_wrapper = { workspace = true }
+19 -2
View File
@@ -38,6 +38,23 @@ impl WebHostAccess {
} }
} }
/// An exception on its way back out of compiled code. Host words rethrow the
/// Forth message (`Stack underflow`, an `ABORT"` text, a `THROW` description),
/// so surface exactly that and nothing else — the JS `Error` carries the whole
/// engine stack in its message, which is noise to a Forth programmer. Anything
/// without a message is a genuine runtime fault and keeps the call context.
fn call_error(fn_index: u32, e: &JsValue) -> anyhow::Error {
match Reflect::get(e, &"message".into())
.ok()
.and_then(|m| m.as_string())
.and_then(|m| m.lines().next().map(str::trim).map(str::to_string))
.filter(|m| !m.is_empty())
{
Some(msg) => anyhow::anyhow!("{msg}"),
None => anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"),
}
}
impl HostAccess for WebHostAccess { impl HostAccess for WebHostAccess {
fn mem_read_i32(&mut self, addr: u32) -> i32 { fn mem_read_i32(&mut self, addr: u32) -> i32 {
let view = js_sys::Int32Array::new(&self.buffer()); let view = js_sys::Int32Array::new(&self.buffer());
@@ -134,7 +151,7 @@ impl HostAccess for WebHostAccess {
.dyn_into() .dyn_into()
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?; .map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?;
func.call0(&JsValue::NULL) func.call0(&JsValue::NULL)
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?; .map_err(|e| call_error(fn_index, &e))?;
Ok(()) Ok(())
} }
} }
@@ -406,7 +423,7 @@ impl Runtime for WebRuntime {
.dyn_into() .dyn_into()
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?; .map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?;
func.call0(&JsValue::NULL) func.call0(&JsValue::NULL)
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?; .map_err(|e| call_error(fn_index, &e))?;
Ok(()) Ok(())
} }