16 Commits

Author SHA1 Message Date
Oleksandr Kozachuk 645c2dadd7 Merge pull request from ok2/perf/typed-calls
CI / check (push) Has been cancelled
0.2.7: typed calling convention, per-region and BEGIN loop promotion,
and a fix for a promoted loop/IF that reordered the stack.
2026-08-09 17:53:00 +02:00
Oleksandr Kozachuk 3bb613ece0 release: 0.2.7
Version bump plus a doc sweep: the benchmark tables in README and
docs/OPTIMIZATIONS.md were still from before the typed calling convention,
OPTIMIZATIONS listed BEGIN loop promotion as not started, and the
subroutine-threading section of docs/WAFER.md described the memory ABI as
the only one.
2026-08-09 17:36:52 +02:00
Oleksandr Kozachuk b8dcc021a2 perf(core): promote per region, promote BEGIN loops, keep loops off the memory stack
Promotion was all-or-nothing per word, so one `.` or one host call put the
whole body -- hot loops included -- on the memory data stack, where a
loop-carried add costs 2.2 ns/iteration instead of 0.31. The stack simulator
now runs over each promotable stretch of a word; BEGIN/UNTIL, BEGIN/AGAIN and
BEGIN/WHILE/REPEAT join DO/LOOP as promotable when the construct is provably
stack-neutral; and the inliner no longer moves a loop-bearing callee into a
caller that can never be promoted.

Fixes a bug the BEGIN work uncovered, present since promotion was introduced
and shipped in 0.2.6: the loop fixup and the IF join copied locals one slot at
a time in index order, so a body that permutes the stack lost a value --
`: C 3 4 2 0 DO SWAP LOOP . . ;` printed `4 4` where gforth prints `4 3`.

Four of five benchmarks now beat sf64: Factorial 0.29x, Collatz 0.30x,
NestedLoops 0.27x, GCD 0.67x. Only Fibonacci is behind, at 1.24x. Also scale
GCD, Factorial and NestedLoops, which ran in 14-51 us where scatter and fixed
costs dominated -- that is what exposed GCD as a loss and pointed at BEGIN.
WS-014, WS-015, WS-016, WS-019.
2026-08-09 17:25:21 +02:00
Oleksandr Kozachuk fc34bd9b24 perf(core): typed calling convention for words with a known stack effect
Such a word now compiles to a fast entry (i32 x p) -> (i32 x q) carrying
its stack items as WASM values, plus the usual ( -- ) wrapper that keeps
the table slot, so EXECUTE / interpreter / host words / CATCH see the
unchanged memory ABI. Fib(25) 1035 -> 366 us, 4.3x slower than sf64 ->
1.2x; the default guards-on config 1740 -> 361 us. WS-006.
2026-08-09 09:20:10 +02:00
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
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
Oleksandr Kozachuk 645b00d6e8 Merge pull request #2 from ok2/feature/swiftforth-number-conversion
feat(core): SwiftForth input number conversion, DPL and NH
2026-08-06 19:57:04 +02:00
Oleksandr Kozachuk 9efb92ddc8 docs(core): correct sign handling note in number conversion docs
The doc comments still claimed a leading + binds as a sign. It does not:
sf64 converts +7 as the double 7 with DPL 1, and the code follows that.
Only a leading - is a sign.
2026-08-06 19:25:48 +02:00
Oleksandr Kozachuk 706c73ce2a feat(core): SwiftForth input number conversion, DPL and NH
Punctuation (`,` `.` `+` `/` `:` and an embedded `-`) after the leftmost
digit now forces double-cell conversion, so `12.34`, `1,234`, `12:30:45`
and `2026-08-06` convert as doubles. Only a trailing `.` worked before,
and `1.5` was an "unknown word" error.

The punctuation is a double-cell marker, not a fractional point, so the
scale has to travel separately: DPL carries the digit count right of the
rightmost punctuation character (negative when there was none), which is
what lets `<# #>` place the point back on output. NH carries the high
cell a single-cell conversion drops, so a token that overflows a cell is
still recoverable as a double.

parse_number and parse_double_number duplicated the prefix and sign
handling and could not share a DPL counter, so they collapse into one
parse_numeric_literal that reports which kind it converted.

Verified token-for-token against sf64. One deliberate divergence: WAFER
keeps accepting a sign before a base prefix (`-$FF`), which sf64 rejects.
2026-08-06 19:19:41 +02:00
Oleksandr Kozachuk a89d7ca704 chore(ci): dprint-format changelog; dedupe sha crates; deny skips for wasmtime 47
CI / check (push) Has been cancelled
2026-08-06 16:15:02 +02:00
22 changed files with 2296 additions and 358 deletions
+237
View File
@@ -5,6 +5,242 @@ 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.7] - 2026-08-09
### Added
- **A typed calling convention for words with a known stack effect.** Such a
word now compiles to two entry points: a fast one whose signature is
`(i32 x p) -> (i32 x q)`, carrying its stack items as WASM values, and the
usual `( -- )` wrapper that moves those items on and off the memory data
stack. The wrapper keeps the function-table slot, so `EXECUTE`, the outer
interpreter, host words and `CATCH` see exactly the ABI they saw before;
only direct calls inside a module take the fast entry.
This is what the SwiftForth gap was made of. sf64 keeps TOS in `RBX` and
the stack pointer in `RBP`, and both survive a `CALL` untouched, so its
`FIB` is 16 instructions and ~7 memory touches per node. WAFER kept the
whole stack in linear memory and flushed its cached `$dsp` to an imported
global before every call: ~36 memory touches per node. The stack simulator
that already promoted loop and `IF` bodies into WASM locals refused any
body containing a call or an `EXIT` -- exactly the words where the
convention cost the most. It now handles both.
Fibonacci(25) goes from 1035 to 366 µs, 4.3x slower than `sf64` to 1.2x.
Loop-heavy benchmarks are unchanged by this entry — see the region
promotion below for those. Words that keep the memory convention: anything
using `SP@`, `DEPTH`, `EXECUTE`, `>R`/`R>`, floats or locals; anything
calling a word that is itself untyped, which in the JIT path means every
call except `RECURSE`; mutually recursive words; and words whose effect is
not static -- branches that disagree on depth, `EXIT` at the wrong depth,
a non-neutral loop body, or a recursion that grows the stack per level.
`CONSOLIDATE` extends this across words, since it puts them all in one
module: the effects are solved to a fixpoint from the leaves outward, and
105 of 187 words in a booted dictionary end up typed.
Stack guards get cheap as a side effect -- they hang off the memory-stack
push/pop choke points, and a typed word barely has any. The default
guards-on configuration that the REPL and the web build use went from 1631
to 365 µs on the same benchmark.
`WAFER_TYPED_CALLS=0` falls back to the memory-stack convention.
- **Promotion is now per region, not per word.** Stack-to-local promotion
used to be all-or-nothing: a single `.`, `CR`, `>R` or host call
anywhere in a definition put the _entire_ body on the memory data
stack, hot loops included. The stack simulator now runs over each
stretch of a word that can live in WASM locals, loading what the
region reads and writing back what it leaves, with the rest of the
word unchanged around it.
The cliff this removes was steep. The same loop, same build:
| `: L1 0 5000000 0 DO 1+ LOOP DROP ;` reached as | µs | ns/iter |
| ----------------------------------------------- | ----- | ------- |
| its own word | 1571 | 0.31 |
| inlined into a caller with a `.` in it (before) | 11100 | 2.22 |
| the same, after this change | 1572 | 0.31 |
7x, for one `i32.add`: on the memory path the accumulator is stored to
linear memory and reloaded next iteration, so the loop-carried
dependency runs through store-to-load forwarding instead of a
register.
A region may only use `I` / `J` when the DO loops naming them are
inside the region, since the simulator resolves them against its own
loop stack. Straight-line regions have to be at least three operations
to be worth the load and store either side; a loop always is.
- **The inliner no longer drags a loop onto the memory stack.** It
inlined any callee of eight IR operations or fewer, so a small
loop-bearing word inlined into a caller that can never be promoted
lost its registers -- an optimisation pass applying the 7x
pessimisation above. Loop-bearing callees now stay put in that case:
one call is far cheaper than a loop's worth of memory traffic.
Straight-line words still inline everywhere.
- **`BEGIN` loops promote as well.** `BEGIN..UNTIL`, `BEGIN..AGAIN` and
`BEGIN..WHILE..REPEAT` were rejected outright by the eligibility check,
so any word built on the idiomatic Forth loop kept the memory data
stack no matter how hot it was. They are promoted now when the
construct is stack-neutral: `UNTIL` consumes exactly the flag its body
leaves, `AGAIN`'s body is neutral, and for `WHILE..REPEAT` the test and
the body balance separately -- `WHILE` leaves the loop between the two,
so a net that only added up over the pair would give the two exits
different stack shapes. Bodies containing an `EXIT` stay out, the same
rule `DO`/`LOOP` follows. `BEGIN..WHILE..WHILE..REPEAT` is still
excluded.
GCD 994 -> 540 µs, Collatz 428 -> 185.
Together these four entries put four of the five cross-engine
benchmarks past SwiftForth `sf64`: Factorial 0.29x, Collatz 0.30x,
NestedLoops 0.27x, GCD 0.67x. Fibonacci stays at 1.24x, being pure
call overhead with no loop to promote.
### Fixed
- **A promoted loop or `IF` whose branch permutes the stack lost a value.**
At the bottom of a promoted loop the body's results are copied back into
the loop-top locals, and the join after a promoted `IF` copies one
branch's locals into the other's. Both did it one slot at a time in index
order, which is wrong as soon as a destination is also a later source:
`: C 3 4 2 0 DO SWAP LOOP . . ;` printed `4 4` where gforth and
SwiftForth print `4 3`, and `2 0 DO ROT LOOP` over three cells printed
`3 2 3` instead of `2 1 3`. The copies are now ordered so every source is
read before it is overwritten, with one scratch local to break a cycle.
Present since stack-to-local promotion was introduced; reachable from
any `DO` loop or `IF` whose body reorders cells it did not create.
- The Forth 2012 Core suite now also runs against consolidated code
(`compliance_core_after_consolidate`). `CONSOLIDATE` had no correctness
test at all before -- only benchmarks.
### Changed
- **Three cross-engine benchmarks were too small to be measured.** GCD ran
in 14 µs, Factorial in 49 and NestedLoops in 51, where per-run scatter is
a good fraction of the total and fixed per-invocation costs in the other
engines dominate. Scaled to Factorial x100K, GCD-bench(20K) and
NestedLoops(50)x1K, all now around 0.5-1 ms.
This changed a result rather than just steadying it: GCD looked like a
win at 0.42x of `sf64` and was in fact a loss at 1.17x. That is what
pointed at `BEGIN` loops as the remaining gap -- GCD is the one benchmark
whose loop is a `BEGIN ... WHILE ... REPEAT` -- and with those promoted it
now reads 0.67x. The regression limits, which had drifted to 3-6x looser
than the measurements they guard, were retightened to ~45% above the
current ratios.
## [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
### Fixed
@@ -104,6 +340,7 @@ compliance suite, `CONSOLIDATE` whole-program recompilation, `wafer build`
AOT export (WASM / native / JS loader), browser REPL, SHA-1/256/512 words,
and cross-engine benchmark lanes against gforth and SwiftForth.
[0.2.7]: https://github.com/ok2/wafer/compare/v0.2.6...v0.2.7
[0.2.1]: https://github.com/ok2/wafer/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/ok2/wafer/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/ok2/wafer/releases/tag/v0.1.0
+2 -2
View File
@@ -2,7 +2,7 @@
## What is WAFER?
WAFER (WebAssembly Forth Engine in Rust) is an optimizing Forth 2012 compiler targeting WebAssembly. Currently a working Forth system with 200+ words, JIT compilation, 12 word sets at 100% compliance, and a full optimization pipeline (peephole, constant folding, inlining, strength reduction, DCE, tail calls, stack-to-local promotion with loop/IF support, self-recursive direct calls, consolidation). Beats gforth on all benchmarks in release mode. Includes a browser-based REPL via wasm-pack.
WAFER (WebAssembly Forth Engine in Rust) is an optimizing Forth 2012 compiler targeting WebAssembly. Currently a working Forth system with 200+ words, JIT compilation, 12 word sets at 100% compliance, and a full optimization pipeline (peephole, constant folding, inlining, strength reduction, DCE, tail calls, per-region stack-to-local promotion with DO/BEGIN loop and IF support, self-recursive direct calls, a typed calling convention for words with a known stack effect, consolidation). Beats gforth on all benchmarks in release mode, and SwiftForth `sf64` on four of five. Includes a browser-based REPL via wasm-pack.
## Architecture
@@ -79,7 +79,7 @@ Handle in `interpret_token_immediate()` or `compile_token()` as a special case.
## 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 601 unit + 1 benchmark + 12 compliance + 9 comparison + 5 crypto)
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
- 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`
Generated
+23 -87
View File
@@ -132,15 +132,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -182,9 +173,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.5"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
@@ -192,9 +183,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.5"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
@@ -255,12 +246,6 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpp_demangle"
version = "0.5.1"
@@ -279,15 +264,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "cranelift-assembler-x64"
version = "0.134.3"
@@ -352,7 +328,7 @@ dependencies = [
"rustc-hash",
"serde",
"serde_derive",
"sha2 0.10.9",
"sha2",
"smallvec",
"target-lexicon",
"wasmtime-internal-core",
@@ -478,15 +454,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "debugid"
version = "0.8.0"
@@ -502,19 +469,8 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"block-buffer",
"crypto-common",
]
[[package]]
@@ -795,15 +751,6 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "id-arena"
version = "2.3.0"
@@ -1400,13 +1347,13 @@ dependencies = [
[[package]]
name = "sha1"
version = "0.11.0"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -1416,19 +1363,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -1653,7 +1589,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wafer"
version = "0.2.1"
version = "0.2.7"
dependencies = [
"anyhow",
"clap",
@@ -1664,13 +1600,13 @@ dependencies = [
[[package]]
name = "wafer-core"
version = "0.2.1"
version = "0.2.7"
dependencies = [
"anyhow",
"insta",
"proptest",
"sha1",
"sha2 0.11.0",
"sha2",
"thiserror 2.0.19",
"wasm-encoder 0.255.0",
"wasmparser 0.255.0",
@@ -1679,7 +1615,7 @@ dependencies = [
[[package]]
name = "wafer-web"
version = "0.2.1"
version = "0.2.7"
dependencies = [
"anyhow",
"js-sys",
@@ -1966,7 +1902,7 @@ dependencies = [
"semver",
"serde",
"serde_derive",
"sha2 0.10.9",
"sha2",
"smallvec",
"target-lexicon",
"wasm-encoder 0.252.0",
@@ -1989,7 +1925,7 @@ dependencies = [
"rustix",
"serde",
"serde_derive",
"sha2 0.10.9",
"sha2",
"toml",
"wasmtime-environ",
"windows-sys",
@@ -2239,18 +2175,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.55"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.55"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
+11 -3
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.2.1"
version = "0.2.7"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/ok2/wafer"
@@ -48,6 +48,14 @@ anyhow = "1"
thiserror = "2"
proptest = "1"
insta = "1"
sha1 = "0.11"
sha2 = "0.11"
sha1 = "0.10"
sha2 = "0.10"
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
+24 -10
View File
@@ -7,10 +7,11 @@ An optimizing Forth 2012 compiler targeting WebAssembly. WAFER JIT-compiles each
## Highlights
- **200+ words** across 12 Forth 2012 word sets, all at **100% compliance**
- **Optimizing compiler** with 6 IR passes + stack-to-local promotion (loops + IF) + consolidation
- **Optimizing compiler** with 6 IR passes + stack-to-local promotion (per region, so a hot loop keeps its registers even inside a word that does I/O; `DO` and `BEGIN` loops alike) + consolidation
- **Faster than gforth** on all benchmarks in release mode (2-10x faster)
- **JIT compilation** — each `:` definition compiles to its own WASM module
- **Self-recursive direct calls** — RECURSE compiles to native `call` instead of `call_indirect`
- **Typed calling convention** — a word with a statically known stack effect passes its stack items as WASM values, so a call keeps them in registers instead of round-tripping through memory
- **Consolidation mode** — recompile all words into a single optimized WASM module
- **Interactive REPL** with line editing (rustyline)
- **Browser REPL** — runs entirely in the browser via wasm-pack + js-sys
@@ -79,23 +80,36 @@ git submodule update --init
## Performance
WAFER beats gforth (the GNU Forth reference implementation) on all benchmarks in release mode:
WAFER beats gforth (the GNU Forth reference implementation) on all benchmarks in release mode, and is within
reach of SwiftForth `sf64`, which compiles to native code:
```
Benchmark WAFER CONSOL gforth WAFER/gf
Fibonacci(25) 1629 1535 3422 0.45x
Factorial(12)x10K 340 339 638 0.53x
GCD-bench(500) 18 15 30 0.50x
NestedLoops(50) 84 73 720 0.10x
Collatz(2K) 1212 1202 3914 0.31x
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
Fibonacci(25) 356 361 3389 287 0.11x 1.24x
Factorial(12)x100K 479 495 6249 1650 0.08x 0.29x
GCD-bench(20K) 540 559 1801 801 0.30x 0.67x
NestedLoops(50)x1K 509 501 7023 1887 0.07x 0.27x
Collatz(2K) 185 213 3873 610 0.05x 0.30x
```
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CONSOLIDATE`.
Two caveats on the `sf64` column. The SwiftForth build here is x86-64 running under Rosetta 2
while WAFER and gforth are native arm64, so it is a native-vs-emulated comparison; and sf64
uses 64-bit cells to WAFER's 32-bit. WAFER is ahead on the four loop-heavy benchmarks and
behind on Fibonacci, which is one call per node with no loop to promote.
A word whose stack effect is statically known gets a **typed entry point**: its stack items travel in and out
as WASM values instead of through the memory data stack, so cranelift keeps them in registers across a call
the way a native Forth keeps TOS in one. The word also keeps a `( -- )` wrapper, which is what the function
table, `EXECUTE` and the outer interpreter reach, so nothing about the memory ABI changes from the outside.
Call-heavy code is what this pays for -- Fibonacci went from 4.3x slower than `sf64` to 1.2x. Set
`WAFER_TYPED_CALLS=0` to fall back to the memory-stack convention.
## Testing
```bash
# All tests (~570 currently passing)
# All tests (~628 currently passing)
cargo test --workspace
# Forth 2012 compliance suite
@@ -128,7 +142,7 @@ Forth Source -> Outer Interpreter -> IR -> [Optimize] -> WASM Codegen (wasm-enco
- `WebRuntime` — browser WebAssembly API via js-sys, for the browser REPL
- **Subroutine threading** via WASM function tables (`call_indirect` for cross-word, direct `call` for self-recursion)
- **JIT mode**: each new word compiles to a separate WASM module linked to shared memory/globals/table
- **IR-based pipeline** with 6 optimization passes (peephole, constant folding, strength reduction, DCE, tail call detection, inlining) plus stack-to-local promotion (with loop and IF/ELSE support), DO/LOOP index locals, and consolidation
- **IR-based pipeline** with 6 optimization passes (peephole, constant folding, strength reduction, DCE, tail call detection, inlining) plus per-region stack-to-local promotion (DO and BEGIN loops, IF/ELSE), DO/LOOP index locals, typed entry points for words with a known stack effect, and consolidation
- **Dictionary**: linked-list word headers in simulated linear memory
## Project Structure
+1 -1
View File
@@ -9,7 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
wafer-core = { path = "../core", version = "0.2.1" }
wafer-core = { path = "../core", version = "0.2.7" }
wasmtime = { workspace = true }
anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] }
+3 -1
View File
@@ -263,7 +263,8 @@ fn cmd_run(file: &str) -> anyhow::Result<()> {
}
/// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides
/// the per-command default (REPL/file execution on, build off).
/// the per-command default (REPL/file execution on, build off);
/// `WAFER_TYPED_CALLS=0` falls back to the memory-stack calling convention.
fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
let mut cfg = wafer_core::config::WaferConfig::all();
cfg.codegen.stack_guards = match std::env::var("WAFER_STACK_GUARDS").ok().as_deref() {
@@ -271,6 +272,7 @@ fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
Some(_) => true,
None => default_guards,
};
cfg.codegen.typed_calls = std::env::var("WAFER_TYPED_CALLS").ok().as_deref() != Some("0");
cfg
}
+1147 -107
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -12,6 +12,11 @@ pub struct CodegenOpts {
/// corrupting stack pointers. On by default; benchmarks and
/// exported production modules turn it off.
pub stack_guards: bool,
/// Compile words with a statically known stack effect to a typed entry
/// point that carries stack items in WASM values, so a call keeps them
/// in registers instead of round-tripping through the memory stack.
/// On by default; `WAFER_TYPED_CALLS=0` turns it off.
pub typed_calls: bool,
}
/// Master configuration for all WAFER optimizations.
@@ -38,6 +43,7 @@ impl WaferConfig {
codegen: CodegenOpts {
stack_to_local_promotion: true,
stack_guards: true,
typed_calls: true,
},
}
}
@@ -56,6 +62,7 @@ impl WaferConfig {
codegen: CodegenOpts {
stack_to_local_promotion: false,
stack_guards: false,
typed_calls: false,
},
}
}
+9 -9
View File
@@ -21,7 +21,7 @@ mod tests {
// Empty word list should produce nothing (but we guard against this at call site)
let words = vec![];
let map = HashMap::new();
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
// Empty is valid -- should produce a valid module with no functions
assert!(result.is_ok());
}
@@ -31,7 +31,7 @@ mod tests {
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
let mut map = HashMap::new();
map.insert(WordId(1), 1u32); // function index 1 (after emit import)
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
@@ -49,7 +49,7 @@ mod tests {
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
map.insert(WordId(3), 3u32);
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
@@ -59,7 +59,7 @@ mod tests {
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
let mut map = HashMap::new();
map.insert(WordId(3), 1u32);
let result = compile_consolidated_module(&words, &map, 256, None);
let result = compile_consolidated_module(&words, &map, 256, None, true);
assert!(result.is_ok());
}
@@ -72,7 +72,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
@@ -95,7 +95,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
@@ -120,7 +120,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
@@ -141,7 +141,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
@@ -163,7 +163,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16, None);
let result = compile_consolidated_module(&words, &map, 16, None, true);
assert!(result.is_ok());
}
}
+1
View File
@@ -126,6 +126,7 @@ pub fn export_module(
table_size,
&export_sections,
vm.stack_guard_param(),
vm.typed_calls(),
)
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
+20
View File
@@ -108,6 +108,23 @@ pub const SYSVAR_HLD: u32 = SYSVAR_BASE + 28;
pub const SYSVAR_LEAVE_FLAG: u32 = SYSVAR_BASE + 32;
/// Throw code left by a compiled stack-guard fault for `_STACK_FAULT_`.
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)]
mod tests {
@@ -149,6 +166,9 @@ mod tests {
SYSVAR_NUM_TIB,
SYSVAR_HLD,
SYSVAR_LEAVE_FLAG,
SYSVAR_FAULT_CODE,
SYSVAR_DPL,
SYSVAR_NH,
];
for offset in all_offsets {
assert!(offset + CELL_SIZE <= SYSVAR_BASE + SYSVAR_SIZE);
+64 -3
View File
@@ -53,7 +53,12 @@ pub fn optimize(
// Phase 2: inline then simplify again
if config.inline {
ir = inline(ir, bodies, 8);
// A caller that can never leave the memory data stack would drag an
// inlined loop down with it, so leave those callees where they are:
// as their own word the loop keeps its registers, and one call is far
// cheaper than a loop's worth of memory traffic.
let keep_loops_out = !crate::codegen::promotable_modulo_calls(&ir);
ir = inline(ir, bodies, 8, keep_loops_out);
}
if config.peephole {
ir = peephole(ir);
@@ -496,7 +501,12 @@ fn dce(ops: Vec<IrOp>) -> Vec<IrOp> {
/// Inline small word bodies: replaces `Call(id)` with the word's IR body
/// if the body is small enough and not recursive.
fn inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>, max_size: usize) -> Vec<IrOp> {
fn inline(
ops: Vec<IrOp>,
bodies: &HashMap<WordId, Vec<IrOp>>,
max_size: usize,
keep_loops_out: bool,
) -> Vec<IrOp> {
let mut out = Vec::new();
for op in ops {
match &op {
@@ -505,6 +515,7 @@ fn inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>, max_size: usize)
&& body.len() <= max_size
&& !contains_call_to(body, *id)
&& !contains_exit(body)
&& !(keep_loops_out && crate::codegen::contains_loop(body))
{
// Inline the body, recursively converting TailCall back to Call
// (tail position in the callee is not tail position in the caller).
@@ -517,7 +528,7 @@ fn inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>, max_size: usize)
}
_ => {
out.push(apply_to_bodies(op, &|inner| {
inline(inner, bodies, max_size)
inline(inner, bodies, max_size, keep_loops_out)
}));
}
}
@@ -1012,4 +1023,54 @@ mod tests {
let result = optimize(vec![IrOp::Call(WordId(5))], &config, &bodies);
assert_eq!(result, vec![IrOp::Call(WordId(5))]);
}
#[test]
fn keeps_a_loop_out_of_a_caller_stuck_on_the_memory_stack() {
// The caller has a `.`, so it can never leave the memory data stack.
// Inlining the loop would drag it down too; as its own word the loop
// keeps its registers and the caller just pays one call.
let mut bodies = HashMap::new();
bodies.insert(
WordId(5),
vec![IrOp::DoLoop {
body: vec![IrOp::PushI32(1), IrOp::Add],
is_plus_loop: false,
}],
);
let result = opt_with_inline(vec![IrOp::Call(WordId(5)), IrOp::Dot], &bodies);
assert!(
matches!(result.first(), Some(IrOp::Call(WordId(5)))),
"loop should not have been inlined, got {result:?}"
);
}
#[test]
fn still_inlines_a_loop_into_a_caller_that_can_be_promoted() {
let mut bodies = HashMap::new();
bodies.insert(
WordId(5),
vec![IrOp::DoLoop {
body: vec![IrOp::PushI32(1), IrOp::Add],
is_plus_loop: false,
}],
);
let result = opt_with_inline(vec![IrOp::Call(WordId(5)), IrOp::Dup], &bodies);
assert!(
!result.iter().any(|op| matches!(op, IrOp::Call(_))),
"loop should have been inlined, got {result:?}"
);
}
#[test]
fn still_inlines_straight_line_words_anywhere() {
// Only loops are held back; a small straight-line word is still
// better off inlined even into an unpromotable caller.
let mut bodies = HashMap::new();
bodies.insert(WordId(5), vec![IrOp::Dup, IrOp::Mul]);
let result = opt_with_inline(vec![IrOp::Call(WordId(5)), IrOp::Dot], &bodies);
assert!(
!result.iter().any(|op| matches!(op, IrOp::Call(_))),
"straight-line word should still inline, got {result:?}"
);
}
}
+593 -81
View File
@@ -23,12 +23,64 @@ use crate::ir::IrOp;
#[cfg(feature = "crypto")]
use crate::memory::HASH_SCRATCH_BASE;
use crate::memory::{
CELL_SIZE, DATA_STACK_TOP, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP, INPUT_BUFFER_BASE,
INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_FAULT_CODE, SYSVAR_HERE,
SYSVAR_LEAVE_FLAG, SYSVAR_NUM_TIB, SYSVAR_STATE, SYSVAR_TO_IN,
CELL_SIZE, DATA_STACK_TOP, DPL_INIT, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP,
INPUT_BUFFER_BASE, INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_DPL,
SYSVAR_FAULT_CODE, SYSVAR_HERE, SYSVAR_LEAVE_FLAG, SYSVAR_NH, SYSVAR_NUM_TIB, SYSVAR_STATE,
SYSVAR_TO_IN,
};
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
// ---------------------------------------------------------------------------
@@ -128,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;
@@ -706,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));
}
}
@@ -1194,22 +1271,18 @@ impl<R: Runtime> ForthVM<R> {
return Ok(());
}
// Try to parse as double-number (trailing dot)
if let Some((lo, hi)) = self.parse_double_number(token) {
self.push_data_stack(lo)?;
self.push_data_stack(hi)?;
// Try to convert as a number; punctuation makes it double-cell
if let Some(lit) = self.parse_numeric_literal(token) {
self.record_number_conversion(lit);
self.push_data_stack(lit.lo())?;
if self.recording_toplevel && self.state == 0 {
self.toplevel_ir.push(IrOp::PushI32(lo));
self.toplevel_ir.push(IrOp::PushI32(hi));
self.toplevel_ir.push(IrOp::PushI32(lit.lo()));
}
return Ok(());
}
// Try to parse as number
if let Some(n) = self.parse_number(token) {
self.push_data_stack(n)?;
if lit.is_double {
self.push_data_stack(lit.hi())?;
if self.recording_toplevel && self.state == 0 {
self.toplevel_ir.push(IrOp::PushI32(n));
self.toplevel_ir.push(IrOp::PushI32(lit.hi()));
}
}
return Ok(());
}
@@ -1223,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}");
}
@@ -1571,16 +1652,13 @@ impl<R: Runtime> ForthVM<R> {
return Ok(());
}
// Try to parse as double-number (trailing dot)
if let Some((lo, hi)) = self.parse_double_number(token) {
self.push_ir(IrOp::PushI32(lo));
self.push_ir(IrOp::PushI32(hi));
return Ok(());
// Try to convert as a number; punctuation makes it double-cell
if let Some(lit) = self.parse_numeric_literal(token) {
self.record_number_conversion(lit);
self.push_ir(IrOp::PushI32(lit.lo()));
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(());
}
@@ -2566,6 +2644,7 @@ impl<R: Runtime> ForthVM<R> {
&local_fn_map,
table_size,
self.stack_guard_param(),
self.config.codegen.typed_calls,
)
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
@@ -2596,6 +2675,7 @@ impl<R: Runtime> ForthVM<R> {
&local_fn_map,
table_size,
self.stack_guard_param(),
self.config.codegen.typed_calls,
)
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
@@ -2731,85 +2811,111 @@ impl<R: Runtime> ForthVM<R> {
// Number parsing
// -----------------------------------------------------------------------
/// Try to parse a token as a number.
fn parse_number(&self, token: &str) -> Option<i32> {
/// Try to convert a token to a number, following `SwiftForth`'s input
/// 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();
if token.is_empty() {
return None;
}
// Check for negative prefix
let (negative, rest) = if let Some(stripped) = token.strip_prefix('-') {
(true, stripped)
} else {
(false, token)
};
// A leading sign binds to the number; it is not double punctuation.
let (neg_outer, rest) = strip_sign(token);
if rest.is_empty() {
return None;
}
// Parse based on prefix
let result = if let Some(hex) = rest.strip_prefix('$') {
i64::from_str_radix(hex, 16).ok()
} else if let Some(dec) = rest.strip_prefix('#') {
dec.parse::<i64>().ok()
} else if let Some(bin) = rest.strip_prefix('%') {
i64::from_str_radix(bin, 2).ok()
} else if rest.len() == 3 && rest.as_bytes()[0] == b'\'' && rest.as_bytes()[2] == b'\'' {
// Character literal: 'x' → ASCII value of x
Some(rest.as_bytes()[1] as i64)
} else {
i64::from_str_radix(rest, self.base).ok()
};
result.map(|n| if negative { -(n as i32) } else { n as i32 })
// Character literal: 'x' → ASCII value of x. No digits, so DPL stays
// at its seed and the result is always single-cell.
if rest.len() == 3 && rest.as_bytes()[0] == b'\'' && rest.as_bytes()[2] == b'\'' {
let value = i64::from(rest.as_bytes()[1]);
return Some(NumberLiteral {
value: if neg_outer { -value } else { value },
dpl: DPL_INIT,
is_double: false,
});
}
/// Try to parse a token as a double-number (token ends with `.`).
/// Returns (lo, hi) where the double-cell value is (hi << 32) | lo.
fn parse_double_number(&self, token: &str) -> Option<(i32, i32)> {
let token = token.trim();
if token.is_empty() {
// 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),
};
// 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;
}
// Check for trailing dot (double-number indicator)
let without_dot = token.strip_suffix('.')?;
if without_dot.is_empty() {
// 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;
}
// Check for negative prefix
let (negative, rest) = if let Some(stripped) = without_dot.strip_prefix('-') {
(true, stripped)
// 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 {
(false, without_dot)
magnitude as i64
};
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)
Some(NumberLiteral {
value,
dpl,
is_double,
})
}
/// Publish the outcome of a conversion in `DPL` and `NH`.
///
/// `NH` only carries meaning after a single-cell conversion, where it holds
/// the high-order cell that the stack result dropped.
fn record_number_conversion(&mut self, lit: NumberLiteral) {
self.rt.mem_write_i32(SYSVAR_DPL, lit.dpl);
if !lit.is_double {
self.rt.mem_write_i32(SYSVAR_NH, lit.hi());
}
}
/// Try to parse a token as a single-cell number, ignoring `DPL`/`NH`.
/// Used where only a plain cell value is meaningful.
fn parse_number(&self, token: &str) -> Option<i32> {
self.parse_numeric_literal(token)
.filter(|lit| !lit.is_double)
.map(NumberLiteral::lo)
}
// -----------------------------------------------------------------------
// Float literal parsing
// -----------------------------------------------------------------------
@@ -2862,6 +2968,11 @@ impl<R: Runtime> ForthVM<R> {
.then_some(self.stack_fault_id)
}
/// Whether words with a known stack effect get a typed entry point.
pub(crate) fn typed_calls(&self) -> bool {
self.config.codegen.typed_calls
}
/// Codegen configuration for compiling one word.
fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig {
CodegenConfig {
@@ -2869,6 +2980,7 @@ impl<R: Runtime> ForthVM<R> {
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
stack_guards: self.stack_guard_param(),
typed_calls: self.config.codegen.typed_calls,
}
}
@@ -3092,6 +3204,7 @@ impl<R: Runtime> ForthVM<R> {
self.register_to_in()?;
self.register_state_var()?;
self.register_base_var()?;
self.register_number_conversion_vars()?;
// Double-cell arithmetic
self.register_m_star()?;
@@ -4370,6 +4483,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| {
@@ -4447,9 +4574,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);
@@ -5151,6 +5285,22 @@ impl<R: Runtime> ForthVM<R> {
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.
fn register_m_star(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
@@ -8038,6 +8188,155 @@ mod tests {
use super::*;
use crate::runtime_native::NativeRuntime;
// -- Typed calling convention -------------------------------------
#[test]
fn test_typed_word_recursion_matches_the_memory_convention() {
// FIB is the shape the typed entry exists for: self-recursive with
// an early EXIT, so it is compiled as WASM values in and out.
let (stack, _) = eval(
": FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ; \
0 FIB 1 FIB 2 FIB 10 FIB 25 FIB",
);
assert_eq!(stack, vec![75025, 55, 1, 1, 0]);
}
#[test]
fn test_typed_word_keeps_the_items_below_its_arguments() {
// The wrapper may only move the cells the word declared; anything
// deeper has to still be there afterwards.
let (stack, _) = eval(": SQ DUP * ; 7 8 9 SQ");
assert_eq!(stack, vec![81, 8, 7]);
}
#[test]
fn test_typed_word_is_reachable_through_execute() {
// EXECUTE goes through the table, which holds the `( -- )` wrapper.
let (stack, _) = eval(": SQ DUP * ; 6 ' SQ EXECUTE");
assert_eq!(stack, vec![36]);
}
#[test]
fn test_catch_sees_a_typed_word_underflow() {
// A typed word's stack guards live in its wrapper, where the
// arguments come off the memory stack. They still THROW -4 rather
// than corrupting the stack pointer, and CATCH still reports it.
let (stack, _) = eval(": SQ DUP * ; : CHK ['] SQ CATCH ; CHK");
assert_eq!(stack, vec![-4]);
}
#[test]
fn test_catch_restores_the_stack_around_a_caller_of_typed_code() {
// T contains THROW, a host word, so T itself keeps the memory
// convention and reaches SQ through its wrapper. CATCH restores the
// depth it saved across that boundary -- not the contents, so the 3
// that SQ squared stays squared. gforth agrees: `1 2 9 5 4`.
let (stack, _) = eval(": SQ DUP * ; : T SQ 5 THROW ; : CHK 1 2 3 ['] T CATCH DEPTH ; CHK");
assert_eq!(stack, vec![4, 5, 9, 2, 1]);
}
#[test]
fn test_typed_word_underflow_still_throws() {
// The stack guards for a typed word live in its wrapper, where the
// arguments are taken off the memory stack.
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": SQ DUP * ;").unwrap();
vm.take_output();
let err = vm.evaluate("SQ");
assert!(err.is_err(), "empty-stack SQ should throw, got {err:?}");
}
#[test]
fn test_deep_typed_recursion_unwinds_cleanly() {
// 10k levels of a typed self-call: results come back in WASM values,
// so nothing is left on the memory stack afterwards.
let (stack, _) =
eval(": COUNT-DOWN DUP 0= IF EXIT THEN 1- RECURSE ; 10000 COUNT-DOWN DEPTH");
assert_eq!(stack, vec![1, 0]);
}
// -- Region promotion (a hot loop inside an unpromotable word) -----
#[test]
fn test_loop_in_an_unpromotable_word_still_computes() {
// `.` keeps MIXED off the register path as a whole, but the loop
// inside it is promoted as its own region. Values checked against
// gforth 0.7.3.
assert_eq!(
eval_output(": MIXED 0 1000 0 DO 1+ LOOP . ; MIXED"),
"1000 "
);
}
#[test]
fn test_j_in_a_promoted_region_reads_the_right_loop() {
// A region may only use `I` / `J` when the DO loops they name are
// inside the region itself -- otherwise the simulator resolves them
// against its own empty loop stack. gforth prints 9.
assert_eq!(
eval_output(": JT 0 3 0 DO 3 0 DO J + LOOP LOOP . ; JT"),
"9 "
);
assert_eq!(eval_output(": IT 0 5 0 DO I + LOOP . ; IT"), "10 ");
}
#[test]
fn test_promoted_loop_body_that_permutes_the_stack() {
// The values a loop body leaves have to reach the loop-top locals all
// at once. Copying them in index order writes the top into the second
// slot and then reads that slot back, so both come out equal -- this
// printed "4 4" and "3 2 3" before. gforth: "4 3" and "2 1 3".
assert_eq!(eval_output(": C 3 4 2 0 DO SWAP LOOP . . ; C"), "4 3 ");
assert_eq!(eval_output(": D 1 2 3 2 0 DO ROT LOOP . . . ; D"), "2 1 3 ");
}
#[test]
fn test_promoted_begin_loops() {
// BEGIN loops promote too, so these run entirely in locals.
assert_eq!(
eval_output(": GCD BEGIN DUP WHILE TUCK MOD REPEAT DROP . ; 1071 462 GCD"),
"21 "
);
assert_eq!(
eval_output(": CD BEGIN 1 - DUP 0= UNTIL DROP 42 . ; 5 CD"),
"42 "
);
// A WHILE test that permutes: the loop is left between test and body,
// so that exit needs the loop-top locals straightened out as well.
assert_eq!(
eval_output(": W BEGIN SWAP DUP WHILE 1 - SWAP REPEAT . . ; 9 3 W"),
"0 3 "
);
}
#[test]
fn test_begin_loop_with_an_unbalanced_body_is_not_promoted() {
// `BEGIN DUP 1+ SWAP DUP 5 > UNTIL` leaves one extra cell per pass, so
// there is no fixed promoted stack shape. It has to keep working.
assert_eq!(
eval_output(": U 0 BEGIN 1 + DUP DUP 3 > UNTIL DROP . . . . ; U"),
"4 3 2 1 "
);
}
#[test]
fn test_several_regions_in_one_word() {
// Two loops separated by a `.`: each is its own region, and the
// stack has to survive the hand-off through memory between them.
assert_eq!(
eval_output(": M2 0 10 0 DO I + LOOP DUP . 5 0 DO 1+ LOOP . ; M2"),
"45 50 "
);
}
#[test]
fn test_region_hands_results_back_to_the_memory_stack() {
// The region computes in locals; what it leaves has to be visible to
// the interpreter afterwards.
let (stack, _) = eval(": R 7 4 0 DO 1+ LOOP ; 100 R");
assert_eq!(stack, vec![11, 100]);
}
fn eval(input: &str) -> (Vec<i32>, String) {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(input).unwrap();
@@ -9252,6 +9551,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
// ===================================================================
@@ -10828,6 +11235,111 @@ mod tests {
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]
fn optimizer_doesnt_break_basic_arithmetic() {
assert_eq!(eval_stack("5 3 +"), vec![8]);
+15
View File
@@ -350,6 +350,16 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
"Convert digits, accumulating into ud.",
),
("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."),
("DECIMAL", "( -- )", "Set BASE to ten."),
// -- Core: strings --
@@ -706,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 -- )",
+17 -12
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",
@@ -741,37 +746,37 @@ fn perf_benchmarks() -> Vec<PerfBenchmark> {
verify: "25 FIB",
expected: 75025,
samples: 5,
max_ratio: 0.65,
max_ratio: 0.17,
},
PerfBenchmark {
name: "Factorial(12)x10K",
name: "Factorial(12)x100K",
define: ": FACT 1 SWAP 1+ 1 ?DO I * LOOP ; \
: FACT-BENCH 10000 0 DO 12 FACT DROP LOOP ;",
: FACT-BENCH 100000 0 DO 12 FACT DROP LOOP ;",
run_code: "FACT-BENCH",
verify: "12 FACT",
expected: 479001600,
samples: 5,
max_ratio: 0.75,
max_ratio: 0.12,
},
PerfBenchmark {
name: "GCD-bench(500)",
name: "GCD-bench(20K)",
define: ": GCD BEGIN DUP WHILE TUCK MOD REPEAT DROP ; \
: GCD-BENCH 0 DO 10000 I 1+ GCD DROP LOOP ;",
run_code: "500 GCD-BENCH",
run_code: "20000 GCD-BENCH",
verify: "48 36 GCD",
expected: 12,
samples: 5,
max_ratio: 0.70,
max_ratio: 0.45,
},
PerfBenchmark {
name: "NestedLoops(50)",
name: "NestedLoops(50)x1K",
define: ": NESTED 0 SWAP 0 DO I 0 ?DO I J + DROP LOOP LOOP ; \
: NESTED-BENCH 100 0 DO 50 NESTED DROP LOOP ;",
: NESTED-BENCH 1000 0 DO 50 NESTED DROP LOOP ;",
run_code: "NESTED-BENCH",
verify: "5 NESTED",
expected: 0,
samples: 3,
max_ratio: 0.20,
samples: 5,
max_ratio: 0.11,
},
PerfBenchmark {
name: "Collatz(2K)",
@@ -783,7 +788,7 @@ fn perf_benchmarks() -> Vec<PerfBenchmark> {
verify: "27 COLLATZ",
expected: 111,
samples: 3,
max_ratio: 0.45,
max_ratio: 0.08,
},
]
}
+29
View File
@@ -344,3 +344,32 @@ fn compliance_tools() {
let errors = run_suite(&mut vm, "toolstest.fth");
assert_eq!(errors, 0, "Programming-Tools: {errors} test failures");
}
/// The Forth 2012 Core suite against consolidated code.
///
/// `CONSOLIDATE` recompiles the whole dictionary into one WASM module, which
/// is where cross-word typed calls live: a word with a known stack effect
/// gets a fast entry taking and returning its stack items as WASM values,
/// and its `() -> ()` wrapper keeps the table slot. Nothing else covers that
/// path for correctness, so run the suite on top of it.
#[test]
fn compliance_core_after_consolidate() {
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
let tester_path = format!("{SUITE_DIR}/tester.fr");
let f1 = load_file(&mut vm, &tester_path);
assert_load_fails_within_baseline(&tester_path, f1);
vm.evaluate("CONSOLIDATE").expect("CONSOLIDATE failed");
vm.take_output();
let core_path = format!("{SUITE_DIR}/core.fr");
let f2 = load_file(&mut vm, &core_path);
assert_load_fails_within_baseline(&core_path, f2);
let _ = vm.evaluate("DECIMAL #ERRORS @");
let errors = vm.data_stack().first().copied().unwrap_or(-1);
assert_eq!(
errors, 0,
"Core word set after CONSOLIDATE: {errors} failures"
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
crate-type = ["cdylib", "rlib"]
[dependencies]
wafer-core = { path = "../core", version = "0.2.1", default-features = false, features = ["crypto"] }
wafer-core = { path = "../core", version = "0.2.7", default-features = false, features = ["crypto"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
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 {
fn mem_read_i32(&mut self, addr: u32) -> i32 {
let view = js_sys::Int32Array::new(&self.buffer());
@@ -134,7 +151,7 @@ impl HostAccess for WebHostAccess {
.dyn_into()
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?;
func.call0(&JsValue::NULL)
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?;
.map_err(|e| call_error(fn_index, &e))?;
Ok(())
}
}
@@ -406,7 +423,7 @@ impl Runtime for WebRuntime {
.dyn_into()
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?;
func.call0(&JsValue::NULL)
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?;
.map_err(|e| call_error(fn_index, &e))?;
Ok(())
}
+2 -2
View File
@@ -18,11 +18,11 @@ confidence-threshold = 0.8
[bans]
multiple-versions = "deny"
wildcards = "deny"
# Transitive duplicates from wasmtime v31 -- will resolve when upgrading
# Transitive duplicates from wasmtime v47 dependencies
skip = [
"getrandom",
"syn",
"hashbrown",
"r-efi",
"thiserror",
"thiserror-impl",
"wasm-encoder",
+45 -13
View File
@@ -14,7 +14,7 @@ This document describes every optimization that makes sense for WAFER, why it ma
| # | Optimization | Level | Status | Impact |
| -- | -------------------------- | ------------ | ----------- | ------- |
| 1 | Stack-to-Local Promotion | Codegen | Phase 2 | Highest |
| 1 | Stack-to-Local Promotion | Codegen | Phase 4 | Highest |
| 2 | Peephole Optimization | IR pass | Done | High |
| 3 | Constant Folding | IR pass | Done | High |
| 4 | Inlining | IR pass | Done | High |
@@ -29,12 +29,18 @@ This document describes every optimization that makes sense for WAFER, why it ma
| 13 | Startup Batching | Architecture | Done | Low |
| 14 | Self-Recursive Direct Call | Codegen | Done | High |
| 15 | Float / Double-Cell | Codegen | Not started | Future |
| 16 | Typed Calling Convention | Codegen | Done | Highest |
## 1. Stack-to-Local Promotion
**Status: Phase 2 done.** Words with straight-line code, DO/LOOP, and IF/ELSE use WASM locals instead of memory stack. Stack manipulation ops (Swap, Rot, Nip, Tuck, Dup, Drop) emit zero WASM instructions. Loop index/limit kept in WASM locals (zero return stack traffic). Switchable via `WaferConfig::codegen.stack_to_local_promotion`.
**Status: Phase 4 done.** Straight-line code, DO/LOOP, IF/ELSE and the BEGIN loop family use WASM locals instead of the memory stack, per region rather than per word. Stack manipulation ops (Swap, Rot, Nip, Tuck, Dup, Drop) emit zero WASM instructions. Loop index/limit stay in WASM locals (zero return stack traffic). Switchable via `WaferConfig::codegen.stack_to_local_promotion`.
Phase 1 covered straight-line code only. Phase 2 extends to DO/LOOP (with stack-neutrality check) and IF/ELSE/THEN (with equal-branch-effect check). BEGIN loops and BeginDoubleWhileRepeat are not yet promoted.
- **Phase 1** straight-line code.
- **Phase 2** — DO/LOOP (stack-neutrality check) and IF/ELSE/THEN (equal-branch-effect check).
- **Phase 3**_per region instead of per word_. Promotion used to be all-or-nothing: one `.`, `CR`, `>R` or host call anywhere in a definition put the entire body on the memory stack, hot loops included, which costs 2.2 ns per loop-carried add instead of 0.31 — the accumulator round-trips through store-to-load forwarding rather than staying in a register. `emit_body` now partitions a body into maximal promotable stretches and runs the simulator over each, loading what a region reads and writing back what it leaves. A region may only use `I` / `J` when the DO loops naming them are inside it, and a straight-line region needs at least three operations to pay for its prologue and epilogue; a loop always does.
- **Phase 4**`BEGIN..UNTIL`, `BEGIN..AGAIN` and `BEGIN..WHILE..REPEAT`, when the construct is provably stack-neutral: UNTIL's body nets +1 (the flag it consumes), AGAIN's nets 0, and for WHILE..REPEAT the test and the body must balance _separately_, because WHILE leaves the loop between them and a net that only added up over the pair would give the two exits different stack shapes. Bodies containing an `EXIT` stay out, the same rule DO/LOOP follows.
Still not promoted: `BeginDoubleWhileRepeat`, `>R`/`R>`, floats, `{: :}` locals, `SP@`/`DEPTH`/`EXECUTE`, and the flat forward-block IR ops.
### The Problem
@@ -452,33 +458,59 @@ Fibonacci(25) with ~243K recursive calls:
The optimization is implemented in `emit_op` for `IrOp::Call`: when `ctx.self_word_id == Some(word_id)`, emit `call WORD_FUNC` (function index 1 in the word's own module). The `self_word_id` is derived from `CodegenConfig::base_fn_index`.
The numbers above are the state before section 16: they measure the call instruction, and what dominated turned out to be the calling _convention_ around it. A self-recursive word that is also typed now calls its own fast entry instead, and Fibonacci(25) is 356 microseconds rather than 1.6 ms.
## 15. Float and Double-Cell Stack
**Status: Not started.** `PushI64` and `PushF64` exist as IR ops but are stubs in codegen. Float stack operations are currently all host functions.
The float stack lives in its own memory region (0x2540--0x2D40). Float operations will have the same memory-based overhead as integer operations, but worse: `f64` values are 8 bytes, doubling the memory traffic per push/pop. Stack-to-local promotion (section 1) is even more impactful for floats because WASM has native `f64` locals and operand stack support.
## 16. Typed Calling Convention
**Status: Done.** A word whose stack effect is statically known compiles to two entry points: a fast one with signature `(i32 x p) -> (i32 x q)`, carrying its stack items as WASM values, and the usual `( -- )` wrapper that moves those items on and off the memory data stack. The wrapper keeps the function-table slot, so `EXECUTE`, the outer interpreter, host words and `CATCH` see exactly the ABI they saw before; only direct calls inside a module take the fast entry. `WAFER_TYPED_CALLS=0` falls back.
### The Problem
This is what the SwiftForth gap was made of. sf64 keeps TOS in `RBX` and the stack pointer in `RBP`, and both survive a `CALL` untouched, so its `FIB` is 16 instructions and about 7 memory touches per node. WAFER kept the whole stack in linear memory and flushed its cached `$dsp` to an imported global before every call: about 36 touches. Section 1's simulator, which already promoted loop and `IF` bodies into locals, refused any body containing a call or an `EXIT` -- exactly the words where the convention cost the most.
### The Effect Fixpoint
Self-recursion makes the stack-effect equation circular (`d = k + m*d`), so the effect is solved by iterating a guess until it reproduces itself: `FIB` settles on `(1,1)` in two rounds, while `: F 1 RECURSE ;` never settles and stays untyped. `CONSOLIDATE` extends this across words, since it puts them all in one module: the effects are solved from the leaves outward, and 105 of 187 words in a booted dictionary end up typed.
### Impact
Fibonacci(25) went from 1035 to 366 microseconds, 4.3x slower than `sf64` to 1.2x. Stack guards became nearly free as a side effect -- they hang off the memory-stack push/pop choke points, and a typed word barely has any -- so the default guards-on configuration that the REPL and the web build use went from 1631 to 365 microseconds on the same benchmark.
Untyped by design: anything using `SP@`, `DEPTH`, `EXECUTE`, `>R`/`R>`, floats or locals; anything calling a word that is itself untyped, which in the JIT path means every call except `RECURSE`; mutually recursive words; and words whose effect is not static -- branches that disagree on depth, `EXIT` at the wrong depth, a non-neutral loop body, or a recursion that grows the stack per level.
## Current Performance vs Gforth
All optimizations enabled, release mode, measured with UTIME:
```
Benchmark WAFER CONSOL gforth WAFER/gf
Fibonacci(25) 1629 1535 3422 0.45x
Factorial(12)x10K 340 339 638 0.53x
GCD-bench(500) 18 15 30 0.50x
NestedLoops(50) 84 73 720 0.10x
Collatz(2K) 1212 1202 3914 0.31x
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
Fibonacci(25) 356 361 3389 287 0.11x 1.24x
Factorial(12)x100K 479 495 6249 1650 0.08x 0.29x
GCD-bench(20K) 540 559 1801 801 0.30x 0.67x
NestedLoops(50)x1K 509 501 7023 1887 0.07x 0.27x
Collatz(2K) 185 213 3873 610 0.05x 0.30x
```
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster.
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. `sf64` is SwiftForth,
which compiles to native code; two caveats on that column. The install here is an
x86-64 binary under Rosetta 2 while WAFER and gforth are native arm64, so it is a
native-vs-emulated comparison and a native SwiftForth would be faster than these
numbers; and sf64 uses 64-bit cells to WAFER's 32-bit. WAFER is ahead on the four
loop-heavy benchmarks and behind on Fibonacci, which is one call per node with no
loop to promote.
## Remaining Opportunities
| Optimization | Status | Potential Impact |
| -------------------------------- | ------------------- | ----------------------------------------------------- |
| BEGIN loop promotion | Not started | Would speed up GCD-style tight loops further |
| BeginDoubleWhileRepeat promotion | Not started | Rare pattern, low priority |
| -------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Bounded self-inlining | Not started | Measured 1.33x on Fibonacci, the last benchmark behind sf64. Blocked on `EXIT`: the inliner refuses any body containing one, and a recursive Forth word is `... IF EXIT THEN ... RECURSE`. Needs either a scoped exit (compile an inlined `EXIT` as a branch to the end of a block) or guard-only expansion |
| BeginDoubleWhileRepeat promotion | Not started | Rare pattern, low priority. Its promoted emitter exists but has no loop fixup and is unverified |
| LEAVE as IR primitive | Not started | Would enable fast-path for loops with LEAVE |
| Float stack-to-local | Not started | Eliminate float stack memory traffic |
| WASM tail calls proposal | Waiting on wasmtime | Would eliminate stack growth for tail-recursive words |
+4 -2
View File
@@ -282,11 +282,13 @@ When the compiler encounters a word reference during compilation, it emits:
(call_indirect (type $void) (table 0)) ;; indirect call through the table
```
**Self-recursive optimization**: When a word calls itself (RECURSE), the codegen detects this and emits a direct `call` instead of `call_indirect`, eliminating the table lookup and signature check (~3x faster for recursive words like Fibonacci).
**Self-recursive optimization**: When a word calls itself (RECURSE), the codegen detects this and emits a direct `call` instead of `call_indirect`, eliminating the table lookup and signature check (~3x faster for recursive words like Fibonacci). When the word is also typed, that direct call goes to its fast entry -- see below.
**After CONSOLIDATE**: All `call_indirect` between words in the consolidated module are replaced with direct `call` instructions, giving similar benefits for cross-word calls.
At runtime, wasmtime resolves the table entry and calls the target function. Because all functions share the same memory, globals, and table, state passes between words through the data stack in linear memory. There are no function parameters or return values at the WASM level -- everything goes through the stack.
At runtime, wasmtime resolves the table entry and calls the target function. Because all functions share the same memory, globals, and table, state passes between words through the data stack in linear memory.
**Typed entry points**: that last sentence is the default, not the whole story. A word whose stack effect is statically known also gets a _fast_ entry with signature `(i32 x p) -> (i32 x q)`, which takes its arguments as WASM values and returns its results the same way, so they stay in registers across the call instead of round-tripping through linear memory. The `( -- )` function above is then a wrapper around it, and it is the wrapper that keeps the table slot -- so `EXECUTE`, the outer interpreter, host words and `CATCH` see the memory ABI unchanged. Only a direct call inside the same module takes the fast entry: `RECURSE` in the JIT path, and every resolvable call after `CONSOLIDATE`. See [OPTIMIZATIONS.md](OPTIMIZATIONS.md) section 16.
This is subroutine threading: each word is a subroutine, and calling a word is an indirect function call.