Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e2fd0d7d4 | |||
| 69309006a2 | |||
| 9b10723a95 | |||
| 15f8005b6d | |||
| 4769987b20 | |||
| d55a27873e |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -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 -- )",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user