Compare commits
11 Commits
a89d7ca704
...
v0.2.6
| Author | SHA1 | Date | |
|---|---|---|---|
| e6c10a6fa1 | |||
| e110ca9516 | |||
| 8e2fd0d7d4 | |||
| 69309006a2 | |||
| 9b10723a95 | |||
| 15f8005b6d | |||
| 4769987b20 | |||
| d55a27873e | |||
| 645b00d6e8 | |||
| 9efb92ddc8 | |||
| 706c73ce2a |
+108
@@ -5,6 +5,114 @@ 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.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
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **SwiftForth-style input number conversion.** Punctuation (`,` `.` `+`
|
||||||
|
`/` `:` and an embedded `-`) anywhere after the leftmost digit now forces
|
||||||
|
double-cell conversion, so `12.34`, `1,234`, `12:30:45` and `2026-08-06`
|
||||||
|
all convert as doubles without a custom parser. Previously only a
|
||||||
|
trailing `.` worked and `1.5` was an "unknown word" error. The
|
||||||
|
punctuation is a double-cell marker, not a fractional point: every
|
||||||
|
spelling of `1234` (`1234.`, `123.4`, `.1234`) yields the same value.
|
||||||
|
- **`DPL`** ( -- addr ): digits to the right of the rightmost punctuation
|
||||||
|
character in the last converted number, negative when the token carried
|
||||||
|
none. Seeded at -1024 and bumped once per digit, matching `sf64`.
|
||||||
|
Together with `<# #>` this is how fixed-point input is scaled.
|
||||||
|
- **`NH`** ( -- addr ): the high-order cell dropped by a single-cell
|
||||||
|
conversion, so a token that overflows a cell can be recovered as a
|
||||||
|
double (`4000000000 NH @ D.`).
|
||||||
|
|
||||||
|
Verified token-for-token against SwiftForth `sf64`: DPL values, double
|
||||||
|
promotion and sign handling agree on every probed form. One deliberate
|
||||||
|
divergence — WAFER also accepts a sign before a base prefix (`-$FF`), which
|
||||||
|
`sf64` rejects; the Forth 2012 spelling `$-FF` works in both. A leading `+`
|
||||||
|
is punctuation rather than a sign in both engines, so `+7` is the double 7
|
||||||
|
with `DPL` = 1.
|
||||||
|
|
||||||
## [0.2.1] - 2026-08-06
|
## [0.2.1] - 2026-08-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Handle in `interpret_token_immediate()` or `compile_token()` as a special case.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Run `cargo test --workspace` before committing (currently 542 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto)
|
- Run `cargo test --workspace` before committing (currently 562 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto)
|
||||||
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
|
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
|
||||||
- Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison`
|
- Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison`
|
||||||
- Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
|
- Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
|
||||||
|
|||||||
Generated
+3
-3
@@ -1589,7 +1589,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wafer"
|
name = "wafer"
|
||||||
version = "0.2.1"
|
version = "0.2.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1600,7 +1600,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wafer-core"
|
name = "wafer-core"
|
||||||
version = "0.2.1"
|
version = "0.2.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"insta",
|
"insta",
|
||||||
@@ -1615,7 +1615,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wafer-web"
|
name = "wafer-web"
|
||||||
version = "0.2.1"
|
version = "0.2.6"
|
||||||
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.1"
|
version = "0.2.6"
|
||||||
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.6" }
|
||||||
wasmtime = { workspace = true }
|
wasmtime = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|||||||
@@ -108,6 +108,23 @@ pub const SYSVAR_HLD: u32 = SYSVAR_BASE + 28;
|
|||||||
pub const SYSVAR_LEAVE_FLAG: u32 = SYSVAR_BASE + 32;
|
pub const SYSVAR_LEAVE_FLAG: u32 = SYSVAR_BASE + 32;
|
||||||
/// Throw code left by a compiled stack-guard fault for `_STACK_FAULT_`.
|
/// Throw code left by a compiled stack-guard fault for `_STACK_FAULT_`.
|
||||||
pub const SYSVAR_FAULT_CODE: u32 = SYSVAR_BASE + 36;
|
pub const SYSVAR_FAULT_CODE: u32 = SYSVAR_BASE + 36;
|
||||||
|
/// DPL: digits right of the rightmost punctuation in the last converted
|
||||||
|
/// number; negative when the token carried no punctuation.
|
||||||
|
pub const SYSVAR_DPL: u32 = SYSVAR_BASE + 40;
|
||||||
|
/// NH: high-order cell of the last single-cell conversion, so an
|
||||||
|
/// out-of-range token can be recovered as a double.
|
||||||
|
pub const SYSVAR_NH: u32 = SYSVAR_BASE + 44;
|
||||||
|
|
||||||
|
/// Seed for [`SYSVAR_DPL`] before conversion starts.
|
||||||
|
///
|
||||||
|
/// `SwiftForth` seeds DPL with a negative value and bumps it once per digit,
|
||||||
|
/// so an unpunctuated token still ends up negative. Punctuation resets the
|
||||||
|
/// counter to zero, which makes the final value the digit count right of the
|
||||||
|
/// rightmost punctuation character.
|
||||||
|
///
|
||||||
|
/// The exact seed is observable: `sf64` reports DPL as -1020 after `1234`
|
||||||
|
/// and -1023 after `-1`, both of which pin it to -1024.
|
||||||
|
pub const DPL_INIT: i32 = -1024;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -149,6 +166,9 @@ mod tests {
|
|||||||
SYSVAR_NUM_TIB,
|
SYSVAR_NUM_TIB,
|
||||||
SYSVAR_HLD,
|
SYSVAR_HLD,
|
||||||
SYSVAR_LEAVE_FLAG,
|
SYSVAR_LEAVE_FLAG,
|
||||||
|
SYSVAR_FAULT_CODE,
|
||||||
|
SYSVAR_DPL,
|
||||||
|
SYSVAR_NH,
|
||||||
];
|
];
|
||||||
for offset in all_offsets {
|
for offset in all_offsets {
|
||||||
assert!(offset + CELL_SIZE <= SYSVAR_BASE + SYSVAR_SIZE);
|
assert!(offset + CELL_SIZE <= SYSVAR_BASE + SYSVAR_SIZE);
|
||||||
|
|||||||
+444
-89
@@ -23,12 +23,64 @@ use crate::ir::IrOp;
|
|||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
use crate::memory::HASH_SCRATCH_BASE;
|
use crate::memory::HASH_SCRATCH_BASE;
|
||||||
use crate::memory::{
|
use crate::memory::{
|
||||||
CELL_SIZE, DATA_STACK_TOP, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP, INPUT_BUFFER_BASE,
|
CELL_SIZE, DATA_STACK_TOP, DPL_INIT, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP,
|
||||||
INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_FAULT_CODE, SYSVAR_HERE,
|
INPUT_BUFFER_BASE, INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_DPL,
|
||||||
SYSVAR_LEAVE_FLAG, SYSVAR_NUM_TIB, SYSVAR_STATE, SYSVAR_TO_IN,
|
SYSVAR_FAULT_CODE, SYSVAR_HERE, SYSVAR_LEAVE_FLAG, SYSVAR_NH, SYSVAR_NUM_TIB, SYSVAR_STATE,
|
||||||
|
SYSVAR_TO_IN,
|
||||||
};
|
};
|
||||||
use crate::optimizer::optimize;
|
use crate::optimizer::optimize;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Number conversion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Characters that force double-cell conversion, following `SwiftForth`'s
|
||||||
|
/// input number conversion rules.
|
||||||
|
///
|
||||||
|
/// A leading `-` is the one exception: it binds as a sign, which keeps `-1`
|
||||||
|
/// a single-cell number while `1-2` converts as a double.
|
||||||
|
const DOUBLE_PUNCTUATION: [u8; 6] = *b",.+-/:";
|
||||||
|
|
||||||
|
/// Split a leading minus off a token, returning whether it was negative.
|
||||||
|
///
|
||||||
|
/// Only `-` is a sign. A leading `+` stays punctuation, matching `sf64`,
|
||||||
|
/// where `+7` converts as the double 7 with `DPL` = 1.
|
||||||
|
///
|
||||||
|
/// The sign may sit between a base-override prefix and the digits (`$-FF`,
|
||||||
|
/// the Forth 2012 spelling) or, as a WAFER extension, before the prefix
|
||||||
|
/// (`-$FF`), so this runs at both positions.
|
||||||
|
fn strip_sign(s: &str) -> (bool, &str) {
|
||||||
|
match s.as_bytes().first() {
|
||||||
|
Some(b'-') => (true, &s[1..]),
|
||||||
|
_ => (false, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A numeric token that converted successfully.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct NumberLiteral {
|
||||||
|
/// The accumulated 64-bit value, sign applied.
|
||||||
|
value: i64,
|
||||||
|
/// Digits right of the rightmost punctuation character. Negative when the
|
||||||
|
/// token carried no punctuation, which is how `DPL` reports "single-cell".
|
||||||
|
dpl: i32,
|
||||||
|
/// Whether punctuation forced double-cell conversion.
|
||||||
|
is_double: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NumberLiteral {
|
||||||
|
/// Low-order cell — the value a single-cell conversion leaves on the stack.
|
||||||
|
fn lo(self) -> i32 {
|
||||||
|
self.value as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-order cell. For a single-cell conversion this is what `NH` holds,
|
||||||
|
/// letting an out-of-range token be recovered as a double.
|
||||||
|
fn hi(self) -> i32 {
|
||||||
|
(self.value >> 32) as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Control-flow compilation state
|
// Control-flow compilation state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -128,6 +180,15 @@ 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;
|
||||||
|
|
||||||
|
/// 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
|
// 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;
|
||||||
@@ -706,6 +767,22 @@ 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 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));
|
return Err(self.describe_uncaught(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1194,22 +1271,18 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as double-number (trailing dot)
|
// Try to convert as a number; punctuation makes it double-cell
|
||||||
if let Some((lo, hi)) = self.parse_double_number(token) {
|
if let Some(lit) = self.parse_numeric_literal(token) {
|
||||||
self.push_data_stack(lo)?;
|
self.record_number_conversion(lit);
|
||||||
self.push_data_stack(hi)?;
|
self.push_data_stack(lit.lo())?;
|
||||||
if self.recording_toplevel && self.state == 0 {
|
if self.recording_toplevel && self.state == 0 {
|
||||||
self.toplevel_ir.push(IrOp::PushI32(lo));
|
self.toplevel_ir.push(IrOp::PushI32(lit.lo()));
|
||||||
self.toplevel_ir.push(IrOp::PushI32(hi));
|
|
||||||
}
|
}
|
||||||
return Ok(());
|
if lit.is_double {
|
||||||
}
|
self.push_data_stack(lit.hi())?;
|
||||||
|
if self.recording_toplevel && self.state == 0 {
|
||||||
// Try to parse as number
|
self.toplevel_ir.push(IrOp::PushI32(lit.hi()));
|
||||||
if let Some(n) = self.parse_number(token) {
|
}
|
||||||
self.push_data_stack(n)?;
|
|
||||||
if self.recording_toplevel && self.state == 0 {
|
|
||||||
self.toplevel_ir.push(IrOp::PushI32(n));
|
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1223,6 +1296,14 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
return Ok(());
|
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}");
|
anyhow::bail!("unknown word: {token}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1571,16 +1652,13 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as double-number (trailing dot)
|
// Try to convert as a number; punctuation makes it double-cell
|
||||||
if let Some((lo, hi)) = self.parse_double_number(token) {
|
if let Some(lit) = self.parse_numeric_literal(token) {
|
||||||
self.push_ir(IrOp::PushI32(lo));
|
self.record_number_conversion(lit);
|
||||||
self.push_ir(IrOp::PushI32(hi));
|
self.push_ir(IrOp::PushI32(lit.lo()));
|
||||||
return Ok(());
|
if lit.is_double {
|
||||||
}
|
self.push_ir(IrOp::PushI32(lit.hi()));
|
||||||
|
}
|
||||||
// Try to parse as number
|
|
||||||
if let Some(n) = self.parse_number(token) {
|
|
||||||
self.push_ir(IrOp::PushI32(n));
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2731,83 +2809,109 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
// Number parsing
|
// Number parsing
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Try to parse a token as a number.
|
/// Try to convert a token to a number, following `SwiftForth`'s input
|
||||||
fn parse_number(&self, token: &str) -> Option<i32> {
|
/// number conversion rules.
|
||||||
|
///
|
||||||
|
/// Punctuation (`,` `.` `+` `-` `/` `:`) forces double-cell conversion, so
|
||||||
|
/// `12.34`, `1,234`, `12:30:45` and `2026-08-06` all convert as doubles.
|
||||||
|
/// Only a leading `-` escapes this and binds as a sign, which keeps `-1`
|
||||||
|
/// single-cell; a leading `+` stays punctuation, so `+7` is the double 7.
|
||||||
|
///
|
||||||
|
/// `DPL` counts up once per digit from [`DPL_INIT`] and resets to zero at
|
||||||
|
/// every punctuation character, so it ends up holding the digit count right
|
||||||
|
/// of the rightmost punctuation, and stays negative for unpunctuated tokens.
|
||||||
|
fn parse_numeric_literal(&self, token: &str) -> Option<NumberLiteral> {
|
||||||
let token = token.trim();
|
let token = token.trim();
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for negative prefix
|
// A leading sign binds to the number; it is not double punctuation.
|
||||||
let (negative, rest) = if let Some(stripped) = token.strip_prefix('-') {
|
let (neg_outer, rest) = strip_sign(token);
|
||||||
(true, stripped)
|
|
||||||
} else {
|
|
||||||
(false, token)
|
|
||||||
};
|
|
||||||
|
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse based on prefix
|
// Character literal: 'x' → ASCII value of x. No digits, so DPL stays
|
||||||
let result = if let Some(hex) = rest.strip_prefix('$') {
|
// at its seed and the result is always single-cell.
|
||||||
i64::from_str_radix(hex, 16).ok()
|
if rest.len() == 3 && rest.as_bytes()[0] == b'\'' && rest.as_bytes()[2] == b'\'' {
|
||||||
} else if let Some(dec) = rest.strip_prefix('#') {
|
let value = i64::from(rest.as_bytes()[1]);
|
||||||
dec.parse::<i64>().ok()
|
return Some(NumberLiteral {
|
||||||
} else if let Some(bin) = rest.strip_prefix('%') {
|
value: if neg_outer { -value } else { value },
|
||||||
i64::from_str_radix(bin, 2).ok()
|
dpl: DPL_INIT,
|
||||||
} else if rest.len() == 3 && rest.as_bytes()[0] == b'\'' && rest.as_bytes()[2] == b'\'' {
|
is_double: false,
|
||||||
// Character literal: 'x' → ASCII value of x
|
});
|
||||||
Some(rest.as_bytes()[1] as i64)
|
}
|
||||||
} else {
|
|
||||||
i64::from_str_radix(rest, self.base).ok()
|
// A base-override prefix sits before the leftmost digit.
|
||||||
|
let (radix, after_prefix) = match rest.as_bytes()[0] {
|
||||||
|
b'$' => (16, &rest[1..]),
|
||||||
|
b'#' => (10, &rest[1..]),
|
||||||
|
b'%' => (2, &rest[1..]),
|
||||||
|
_ => (self.base, rest),
|
||||||
};
|
};
|
||||||
|
|
||||||
result.map(|n| if negative { -(n as i32) } else { n as i32 })
|
// Forth 2012 spells a signed based number `#-1289`, so the sign can
|
||||||
|
// also follow the prefix. Either way it precedes the leftmost digit
|
||||||
|
// and so is a sign rather than double punctuation.
|
||||||
|
let (neg_inner, digits) = strip_sign(after_prefix);
|
||||||
|
let negative = neg_outer ^ neg_inner;
|
||||||
|
|
||||||
|
if digits.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the digit string, stripping punctuation and tracking DPL.
|
||||||
|
let mut buf = String::with_capacity(digits.len());
|
||||||
|
let mut dpl = DPL_INIT;
|
||||||
|
let mut is_double = false;
|
||||||
|
for &b in digits.as_bytes() {
|
||||||
|
if DOUBLE_PUNCTUATION.contains(&b) {
|
||||||
|
is_double = true;
|
||||||
|
dpl = 0;
|
||||||
|
} else {
|
||||||
|
buf.push(char::from(b));
|
||||||
|
dpl += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// i128 accumulation so the full u64 range survives conversion.
|
||||||
|
let magnitude = i128::from_str_radix(&buf, radix).ok()?;
|
||||||
|
let value = if negative {
|
||||||
|
-(magnitude as i64)
|
||||||
|
} else {
|
||||||
|
magnitude as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(NumberLiteral {
|
||||||
|
value,
|
||||||
|
dpl,
|
||||||
|
is_double,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to parse a token as a double-number (token ends with `.`).
|
/// Publish the outcome of a conversion in `DPL` and `NH`.
|
||||||
/// Returns (lo, hi) where the double-cell value is (hi << 32) | lo.
|
///
|
||||||
fn parse_double_number(&self, token: &str) -> Option<(i32, i32)> {
|
/// `NH` only carries meaning after a single-cell conversion, where it holds
|
||||||
let token = token.trim();
|
/// the high-order cell that the stack result dropped.
|
||||||
if token.is_empty() {
|
fn record_number_conversion(&mut self, lit: NumberLiteral) {
|
||||||
return None;
|
self.rt.mem_write_i32(SYSVAR_DPL, lit.dpl);
|
||||||
|
if !lit.is_double {
|
||||||
|
self.rt.mem_write_i32(SYSVAR_NH, lit.hi());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for trailing dot (double-number indicator)
|
/// Try to parse a token as a single-cell number, ignoring `DPL`/`NH`.
|
||||||
let without_dot = token.strip_suffix('.')?;
|
/// Used where only a plain cell value is meaningful.
|
||||||
if without_dot.is_empty() {
|
fn parse_number(&self, token: &str) -> Option<i32> {
|
||||||
return None;
|
self.parse_numeric_literal(token)
|
||||||
}
|
.filter(|lit| !lit.is_double)
|
||||||
|
.map(NumberLiteral::lo)
|
||||||
// Check for negative prefix
|
|
||||||
let (negative, rest) = if let Some(stripped) = without_dot.strip_prefix('-') {
|
|
||||||
(true, stripped)
|
|
||||||
} else {
|
|
||||||
(false, without_dot)
|
|
||||||
};
|
|
||||||
|
|
||||||
if rest.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse based on prefix -- use i128 to handle the full u64 range
|
|
||||||
let result: Option<i128> = if let Some(hex) = rest.strip_prefix('$') {
|
|
||||||
i128::from_str_radix(hex, 16).ok()
|
|
||||||
} else if let Some(dec) = rest.strip_prefix('#') {
|
|
||||||
dec.parse::<i128>().ok()
|
|
||||||
} else if let Some(bin) = rest.strip_prefix('%') {
|
|
||||||
i128::from_str_radix(bin, 2).ok()
|
|
||||||
} else {
|
|
||||||
i128::from_str_radix(rest, self.base).ok()
|
|
||||||
};
|
|
||||||
|
|
||||||
result.map(|n| {
|
|
||||||
let val: i64 = if negative { -(n as i64) } else { n as i64 };
|
|
||||||
let lo = val as i32;
|
|
||||||
let hi = (val >> 32) as i32;
|
|
||||||
(lo, hi)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -3092,6 +3196,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
self.register_to_in()?;
|
self.register_to_in()?;
|
||||||
self.register_state_var()?;
|
self.register_state_var()?;
|
||||||
self.register_base_var()?;
|
self.register_base_var()?;
|
||||||
|
self.register_number_conversion_vars()?;
|
||||||
|
|
||||||
// Double-cell arithmetic
|
// Double-cell arithmetic
|
||||||
self.register_m_star()?;
|
self.register_m_star()?;
|
||||||
@@ -4370,6 +4475,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| {
|
||||||
@@ -4447,9 +4566,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);
|
||||||
|
|
||||||
@@ -5151,6 +5277,22 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DPL ( -- addr ) and NH ( -- addr ): input number conversion results.
|
||||||
|
///
|
||||||
|
/// `DPL` holds the digit count right of the rightmost punctuation
|
||||||
|
/// character in the last converted number, or a negative value when the
|
||||||
|
/// token carried none. `NH` holds the high-order cell dropped by a
|
||||||
|
/// single-cell conversion, so an out-of-range token can be recovered as a
|
||||||
|
/// double.
|
||||||
|
fn register_number_conversion_vars(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.rt.mem_write_i32(SYSVAR_DPL, DPL_INIT);
|
||||||
|
self.rt.mem_write_i32(SYSVAR_NH, 0);
|
||||||
|
|
||||||
|
self.register_primitive("DPL", false, vec![IrOp::PushI32(SYSVAR_DPL as i32)])?;
|
||||||
|
self.register_primitive("NH", false, vec![IrOp::PushI32(SYSVAR_NH as i32)])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// M* ( n1 n2 -- d ) signed multiply producing double-cell result.
|
/// M* ( n1 n2 -- d ) signed multiply producing double-cell result.
|
||||||
fn register_m_star(&mut self) -> anyhow::Result<()> {
|
fn register_m_star(&mut self) -> anyhow::Result<()> {
|
||||||
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
||||||
@@ -9252,6 +9394,114 @@ 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 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
|
// New words: SOURCE
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -10828,6 +11078,111 @@ mod tests {
|
|||||||
assert_eq!(eval_stack("1E 2.5E 1E F~"), vec![0]); // |1-2.5| = 1.5 >= 1
|
assert_eq!(eval_stack("1E 2.5E 1E F~"), vec![0]); // |1-2.5| = 1.5 >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn punctuation_anywhere_converts_as_double() {
|
||||||
|
// The punctuation is a double-cell marker, not a fractional point:
|
||||||
|
// every form below carries the same digits, so the value is the same.
|
||||||
|
// eval_stack reports top-first, so a double reads as [hi, lo].
|
||||||
|
for token in ["1234.", "123.4", "12.34", "1.234", ".1234"] {
|
||||||
|
assert_eq!(eval_stack(token), vec![0, 1234], "token {token}");
|
||||||
|
}
|
||||||
|
// SwiftForth accepts comma, colon, slash, plus and dash too, which is
|
||||||
|
// what makes dates and times convert without a custom parser.
|
||||||
|
assert_eq!(eval_stack("1,234"), vec![0, 1234]);
|
||||||
|
assert_eq!(eval_stack("12:30:45"), vec![0, 123045]);
|
||||||
|
assert_eq!(eval_stack("2026-08-06"), vec![0, 20260806]);
|
||||||
|
assert_eq!(eval_stack("12/34"), vec![0, 1234]);
|
||||||
|
assert_eq!(eval_stack("1+234"), vec![0, 1234]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dpl_counts_digits_right_of_last_punctuation() {
|
||||||
|
for (token, dpl) in [("1234.", 0), ("123.4", 1), ("12.34", 2), (".1234", 4)] {
|
||||||
|
assert_eq!(eval_stack(&format!("{token} 2DROP DPL @")), vec![dpl]);
|
||||||
|
}
|
||||||
|
// Only the rightmost punctuation counts.
|
||||||
|
assert_eq!(eval_stack("12:30:45 2DROP DPL @"), vec![2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dpl_stays_negative_for_unpunctuated_numbers() {
|
||||||
|
// A leading minus is a sign, not punctuation, so these stay single-cell.
|
||||||
|
for token in ["1234", "-1", "$FF"] {
|
||||||
|
let dpl = eval_stack(&format!("{token} DROP DPL @"))[0];
|
||||||
|
assert!(dpl < 0, "token {token} left DPL = {dpl}");
|
||||||
|
}
|
||||||
|
assert_eq!(eval_stack("-1"), vec![-1]);
|
||||||
|
// DPL counts up from its seed once per digit.
|
||||||
|
assert_eq!(eval_stack("1234 DROP DPL @"), vec![DPL_INIT + 4]);
|
||||||
|
assert_eq!(eval_stack("-1 DROP DPL @"), vec![DPL_INIT + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leading_plus_is_punctuation_not_a_sign() {
|
||||||
|
// sf64 converts `+7` as the double 7 with DPL = 1: unlike `-`, a
|
||||||
|
// leading `+` does not bind to the number.
|
||||||
|
assert_eq!(eval_stack("+7"), vec![0, 7]);
|
||||||
|
assert_eq!(eval_stack("+7 2DROP DPL @"), vec![1]);
|
||||||
|
// Same after a base prefix.
|
||||||
|
assert_eq!(eval_stack("#+7"), vec![0, 7]);
|
||||||
|
assert_eq!(eval_stack("$+F"), vec![0, 15]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_punctuation_only_counts_from_the_last_one() {
|
||||||
|
// sf64: `12..34` is 1234 with DPL 2, `1-2-3` is 123 with DPL 1.
|
||||||
|
assert_eq!(eval_stack("12..34"), vec![0, 1234]);
|
||||||
|
assert_eq!(eval_stack("12..34 2DROP DPL @"), vec![2]);
|
||||||
|
assert_eq!(eval_stack("1-2-3"), vec![0, 123]);
|
||||||
|
assert_eq!(eval_stack("1-2-3 2DROP DPL @"), vec![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nh_recovers_an_out_of_range_single_number() {
|
||||||
|
// 4000000000 overflows a signed cell, so the stack value is truncated.
|
||||||
|
assert_eq!(eval_stack("4000000000"), vec![-294967296]);
|
||||||
|
// NH carries the high cell, making the true value recoverable.
|
||||||
|
assert_eq!(eval_stack("4000000000 NH @"), vec![0, -294967296]);
|
||||||
|
assert_eq!(eval_output("4000000000 NH @ D."), "4000000000 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_literals_still_win_over_double_punctuation() {
|
||||||
|
// `1.5E0` has an embedded dot, but "15E0" is not a decimal number,
|
||||||
|
// so conversion falls through to the float parser.
|
||||||
|
assert_eq!(eval_output("1.5E0 F."), "1.500000 ");
|
||||||
|
assert_eq!(eval_output("-3.25E0 F."), "-3.250000 ");
|
||||||
|
assert_eq!(eval_output("1E-3 F."), "0.001000 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_punctuation_respects_base_prefixes() {
|
||||||
|
assert_eq!(eval_stack("$FF."), vec![0, 255]);
|
||||||
|
assert_eq!(eval_stack("$F.F"), vec![0, 255]);
|
||||||
|
assert_eq!(eval_stack("%1010."), vec![0, 10]);
|
||||||
|
assert_eq!(eval_stack("#12.34"), vec![0, 1234]);
|
||||||
|
assert_eq!(eval_stack("-$FF."), vec![-1, -255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_after_a_base_prefix_is_a_sign_not_punctuation() {
|
||||||
|
// Forth 2012 spells signed based numbers with the sign after the
|
||||||
|
// prefix. The dash precedes the leftmost digit, so it must not
|
||||||
|
// trigger double-cell conversion.
|
||||||
|
assert_eq!(eval_stack("#-1289"), vec![-1289]);
|
||||||
|
assert_eq!(eval_stack("$-12eF"), vec![-4847]);
|
||||||
|
assert_eq!(eval_stack("%-10010110"), vec![-150]);
|
||||||
|
// The sign may also precede the prefix, and both spellings cancel.
|
||||||
|
assert_eq!(eval_stack("-$FF"), vec![-255]);
|
||||||
|
assert_eq!(eval_stack("-$-FF"), vec![255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn punctuated_numbers_compile_into_definitions() {
|
||||||
|
assert_eq!(eval_stack(": D1 12.34 ; D1"), vec![0, 1234]);
|
||||||
|
assert_eq!(eval_output(": STAMP 2026-08-06 D. ; STAMP"), "20260806 ");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn optimizer_doesnt_break_basic_arithmetic() {
|
fn optimizer_doesnt_break_basic_arithmetic() {
|
||||||
assert_eq!(eval_stack("5 3 +"), vec![8]);
|
assert_eq!(eval_stack("5 3 +"), vec![8]);
|
||||||
|
|||||||
@@ -350,6 +350,16 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
|
|||||||
"Convert digits, accumulating into ud.",
|
"Convert digits, accumulating into ud.",
|
||||||
),
|
),
|
||||||
("BASE", "( -- addr )", "Variable holding the number base."),
|
("BASE", "( -- addr )", "Variable holding the number base."),
|
||||||
|
(
|
||||||
|
"DPL",
|
||||||
|
"( -- addr )",
|
||||||
|
"Variable: digits right of the last punctuation; negative if none.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NH",
|
||||||
|
"( -- addr )",
|
||||||
|
"Variable: high cell dropped by the last single-cell conversion.",
|
||||||
|
),
|
||||||
("HEX", "( -- )", "Set BASE to sixteen."),
|
("HEX", "( -- )", "Set BASE to sixteen."),
|
||||||
("DECIMAL", "( -- )", "Set BASE to ten."),
|
("DECIMAL", "( -- )", "Set BASE to ten."),
|
||||||
// -- Core: strings --
|
// -- Core: strings --
|
||||||
@@ -706,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.6", 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