23 Commits

Author SHA1 Message Date
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
Oleksandr Kozachuk 20b8754e27 chore(release): v0.2.1 — changelog + version bump
CI / check (push) Has been cancelled
2026-08-06 16:05:02 +02:00
Oleksandr Kozachuk 17852ed459 fix(core): search order is authoritative; host words validate stack args
- Dictionary::find no longer falls back to the newest entry across all
  wordlists when the search order has no match (Forth 2012 16.3.3;
  gforth and SwiftForth agree). Cross-engine corpus program guards it.
- ~40 argument-taking host words (RND-SEED, ACCEPT, RESIZE, ALLOCATE,
  SEARCH, SUBSTITUTE, ROLL, M*, UM/MOD, SF@/SF!/DF@/DF!, F./FE./FS./F~,
  2R@, ...) popped or read stack cells with no underflow check; on an
  empty stack the pointer silently drifted past its base. New host_need/
  host_fneed/host_fpop checked helpers; class-wide regression test
  drives every word on an empty stack.
2026-08-06 16:04:58 +02:00
Oleksandr Kozachuk e6eabb098d Merge pull request #1 from ok2/usability
CI / check (push) Has been cancelled
v0.2.0 — usability release: guards, SEE/HELP, INCLUDE, error overhaul
2026-08-06 12:48:00 +02:00
Oleksandr Kozachuk f8da87187f chore(release): v0.2.0 — changelog, version bump, dependency upgrades 2026-08-06 12:44:37 +02:00
Oleksandr Kozachuk 0645734d94 chore(tools): sync bat syntax with current word set
Alternations diffed against live WORDS output (304 words) plus
outer-interpreter tokens. Adds float transcendentals, double-cell
ops, pictured numeric, conditional compilation, string ops,
SEE/SEE-IR/DUMP/BYE/HELP, RP@/RDEPTH/.RS, REMEMBER/EMPTY/GILD,
DECIMAL/HEX, INCLUDE/INCLUDED, WITHIN, DEFER!/DEFER@, C,.
2026-08-06 12:44:36 +02:00
Oleksandr Kozachuk f83c8f25e4 build: add just install recipe
Installs bat Forth syntax, then cargo install --locked the CLI.
CARGO_PROFILE_RELEASE_STRIP=none because Cargo's release default
(strip = "debuginfo") emits dylibs macOS 27 dyld rejects with
"mis-aligned LINKEDIT string pool"; proc macros then fail to load
during the build.
2026-08-06 12:44:34 +02:00
Oleksandr Kozachuk 9b1cc0cace feat(core): INCLUDE, error overhaul, sf64 lane, WORDS ALL, .RS
WS-012 -- INCLUDE/INCLUDED:
- Injected source loader (core stays IO-free: CLI installs a
  filesystem reader, web leaves it unset -> defined error). Recursive
  include_file feeds files line-by-line through evaluate, so compile
  state and SEE capture span lines for free. Cycle detection, depth
  cap 16, paths relative to the including file, SOURCE-ID per nesting
  level, parent input restored on success/error/BYE.
- CLI file mode now runs through the include machinery: `wafer x.fth`
  gets file:line error context and a base dir for nested INCLUDEs.
- Unlocks the REMEMBER+INCLUDE reload loop.

WS-008 -- error reporting remainder:
- Errors inside included files carry `file.fth:12:` context
  (anyhow context chain; CLI prints {e:#}).
- describe_uncaught now returns typed WaferError::UncaughtThrow
  { code, message } -- display text unchanged, THROW code reachable
  via downcast for CLI/web consumers.
- compile_word emits a WASM name section; wasmtime trap backtraces
  name the faulting word and runtime_native prefixes "in <WORD>:".
  Batch/consolidated modules stay unnamed (no name plumbing there;
  boot primitives rarely trap).

WS-003 -- SwiftForth correctness lane:
- compare_all_programs_sf64 runs the program corpus with sf64 as
  oracle; whitespace-token comparison (sf64 prints numbers
  space-prefixed and echoes piped lines). 34/35 parity; dot-quote
  skipped (interpret-mode ." is a SwiftForth no-op). #[ignore]d like
  the gforth lane; `just compare-correctness` runs both.

WS-011 leftovers:
- WORDS ALL: grouped full view -- one section per wordlist (search
  order first), then internal words, each with counts. Backed by
  Dictionary::visible_entries (name, wid, internal); visible_words
  now derives from it.
- .RS / RDEPTH: return-stack introspection in boot.fth over a new
  RP@ primitive (IrOp::RpFetch); BEGIN/WHILE walk so the walk never
  touches the stack it prints. SPACES clamped per 6.1.2230.

549 unit + 11 compliance + 9(+2) comparison + 5 crypto + 1 bench
green; fmt/clippy clean; --no-default-features and wasm32 web checks
pass.
2026-08-06 12:44:33 +02:00
Oleksandr Kozachuk dc6e0d45e1 feat(core): SEE, SEE-IR, HELP introspection trio (WS-010)
Implements plans/01-see-introspection.md, all phases.

- see.rs: feature-free IR pretty-printer (format_ir/format_ir_with),
  exhaustive over IrOp -- a new variant fails the build, not the output.
- SEE-IR <name>: post-optimization IR view with resolved callee names,
  immediate/does> annotations; host-word and interpreter-token stubs.
- SEE <name>: verbatim source capture for colon words (multi-line,
  comments preserved, EVALUATE-nesting safe, error-path wiped, MARKER/
  REMEMBER/EMPTY roll word sources back too). Data definers (VARIABLE/
  CONSTANT/CREATE/BUFFER:/2*/F*/SYNONYM) record synthesized one-liners
  at definition time; VALUE/2VALUE/FVALUE/DEFER synthesize at SEE time
  so current values and IS targets show. Fallback chain ends at IR dump
  or host-word stub -- SEE never dead-ends on a defined word.
- HELP [<name>]: wordhelp.rs doc table with stack effect + one-line
  description for EVERY word in a fresh VM (300+ dictionary words plus
  all outer-interpreter tokens); a coverage test fails the build if a
  word is ever added undocumented. User words echo their leading
  ( ... -- ... ) comment. SEE/SEE-IR prepend the HELP line as a
  \ comment. Bare HELP prints usage.
- boot.fth colon definitions get real sources for free (they flow
  through evaluate); INTERPRETER_TOKENS gained the missing ?DO.

524 unit + 11 compliance + 9 comparison + 5 crypto + 1 bench green;
fmt/clippy clean; core still builds --no-default-features; web
wasm-pack build unchanged.
2026-08-06 12:44:32 +02:00
Oleksandr Kozachuk cda296aab5 feat(codegen): stack under/overflow guards in compiled words (WS-007)
Compiled code could silently move dsp/rsp/fsp out of their stack
regions (e.g. DROP on an empty stack), corrupting later pushes with
no diagnostic -- the addresses stay inside valid linear memory, so
nothing could trap. Host-side checks cannot catch it.

- Guards are emitted at the sp-adjustment choke points (dsp_inc/
  dsp_dec, fsp_inc/fsp_dec, rpush/rpop/rpeek, peek, TwoDup/TwoDrop,
  promoted prologue/epilogue -- DROP never loads its value, so
  guarding pop() alone is not enough). On fault: write the code to
  SYSVAR_FAULT_CODE, call _STACK_FAULT_, which THROWs it -- so
  guards are CATCHable and print standard messages (-3/-4/-5/-6/
  -44/-45).
- The batch/consolidated compile path (all boot primitives) and the
  export path are wired too; a thread-local carries the fault index
  into the shared emission helpers.
- Config: codegen.stack_guards, default ON. `wafer build` output
  defaults OFF (production artifact); WAFER_STACK_GUARDS=0|1
  overrides either. Perf comparison lanes run unguarded.
- Measured overhead in release loops: within noise (never-taken
  branches).
- toolstest.fth baseline 37 -> 38: line 368's bare interpreted `R>`
  used to underflow silently and count as passing; the guard now
  correctly reports -6.
2026-08-05 17:00:22 +02:00
Oleksandr Kozachuk e31407ab58 feat(core): REMEMBER + EMPTY/GILD; marker rollback covers namespace state
- REMEMBER <name>: SwiftForth-style re-runnable marker -- restores
  to just AFTER its own definition and survives execution. The
  edit-reload-test loop: REMEMBER fresh ... fresh ... fresh.
- EMPTY rolls back to the boot dictionary; GILD re-baselines EMPTY
  to the current state. Baseline captured at VM construction.
- MarkerState now also snapshots search order, wid allocation,
  compilation wordlist, REPLACES table, and ABORT" texts; restore
  discards marker entries newer than the snapshot (was: newer than
  the executing marker id only).
- Shared snapshot_marker_state/apply_marker_state used by MARKER,
  REMEMBER, EMPTY, and GILD.

Known ambiguity (standard-conformant): a DEFER defined before a
marker but retargeted at a word defined after it dangles after
rollback; stale function-table slots are name-unreachable and get
overwritten by later definitions.
2026-08-05 16:22:47 +02:00
Oleksandr Kozachuk 2910884b83 fix(repl): multi-line output starts on its own line, inline ok only for single-line 2026-08-05 16:07:10 +02:00
Oleksandr Kozachuk f584066a0a feat(repl): usability batch - errors, WORDS, .S, BYE, DUMP, history
Core:
- Uncaught THROW prints its standard message ("Stack underflow
  (throw -4)"; unknown codes as "Catch = <n>") instead of the
  "forth-throw" sentinel. ABORT" text is carried as a structured
  payload and shown only when the -2 throw goes uncaught -- CATCH
  stays silent and the payload cannot go stale. ABORT throws -1
  through the same path.
- WORDS: optional same-line substring filter (WORDS FDEPTH), skips
  internal words (new INTERNAL header flag, set at create for
  underscore-prefixed names), wraps at 78 columns, prints a count.
  ORDER names wids (FORTH / wid#N) instead of Rust debug output.
- .S honors BASE. New: F.S (float stack), DUMP (hex+ASCII,
  bounds-checked, 4K cap), ? (fetch-and-print, boot.fth). BYE is a
  real word now: sets a VM flag the driver honors (exits REPL,
  stops rest of line/file).

CLI:
- Persistent history (~/.local/state/wafer/history, 0600 perms,
  $WAFER_HISTORY override), Tab completion over the live dictionary
  (snapshot refreshed after each line), Up/Down do prefix history
  search, Ctrl-C clears the line instead of exiting.

Web:
- History survives reloads (localStorage, cap 200, dedup, init-code
  runs excluded), User Words palette populated via new words()
  export, stack bar annotates non-decimal BASE, base() reads the
  real BASE sysvar instead of returning a hardcoded 10.
2026-08-05 14:57:12 +02:00
Oleksandr Kozachuk 4980648982 feat(bench): SwiftForth sf64 lane in cross-engine performance report
CI / check (push) Has been cancelled
sf64 discovery + stdin runner (no -e flag; input lines truncate at
~256 chars, so one statement per line), ucounter-based µs timing —
same wrapper shape as gforth utime. New sf64 + WAFER/sf columns,
informational only (no regression limit). Justfile: bench-compare
target; CARGO_PROFILE_RELEASE_STRIP=none for Darwin 27 dlopen bug.
2026-08-04 17:07:47 +02:00
Oleksandr Kozachuk 31dc6c6397 fix(core): no SystemTime on wasm32 — fixed boot seed + UTIME Forth error
CI / check (push) Has been cancelled
2026-07-29 16:56:31 +02:00
Oleksandr Kozachuk 35b78193fd feat(boot): add -ROT <= >= gforth extensions
CI / check (push) Has been cancelled
Non-standard but ubiquitous words; absence aborted otherwise-valid
gforth programs with unknown-word errors.
2026-07-18 15:40:39 +02:00
31 changed files with 5045 additions and 1231 deletions
+3
View File
@@ -3,3 +3,6 @@
*.swp *.swp
.DS_Store .DS_Store
*.bk *.bk
# Local planning notes — never tracked
/plans/
+169
View File
@@ -0,0 +1,169 @@
# Changelog
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.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
- **The search order is now authoritative** (Forth 2012 §16.3.3): a word
whose wordlist is not in the search order is no longer findable.
Previously lookup fell back to the newest entry across all wordlists,
making word hiding impossible. Verified against gforth and SwiftForth,
and guarded by a cross-engine corpus program.
- **Host words validate their stack arguments.** Around 40 host-implemented
words (`RND-SEED`, `ACCEPT`, `RESIZE`, `ALLOCATE`, `FREE`, `SEARCH`,
`SUBSTITUTE`, `ROLL`, `M*`, `UM/MOD`, `SF@ SF! DF@ DF!`, `F. FE. FS. F~`,
`2R@`, and friends) performed raw stack-pointer arithmetic with no
underflow check — calling them on an empty stack silently corrupted the
stack pointer (the compiled-code guards from 0.2.0 do not cover host
words). All argument-taking host words now fail with a clean, CATCHable
underflow error, enforced by a class-wide regression test.
## [0.2.0] - 2026-08-06
The usability release: introspection, source files, honest errors, and a
safety net under every compiled word.
### Added
- **Stack guards in compiled code**: under/overflow checks at the
stack-pointer choke points of generated WASM. Faults THROW standard codes
(`-3`..`-6`, `-44`, `-45`), are CATCHable, and print standard messages
instead of silently corrupting memory. Default on; `wafer build` output
stays unguarded; `WAFER_STACK_GUARDS=0|1` overrides.
- **`SEE`**: source-level decompiler. Colon words (including everything in
`boot.fth`) show their captured verbatim source; data words show
synthesized definitions with current values (`9 VALUE X`,
`DEFER D ( IS DUP )`); primitives fall back to a readable IR dump —
`SEE` never dead-ends on a defined word.
- **`SEE-IR`**: post-optimization IR view with resolved callee names and
indented control flow — shows what the optimizer actually did.
- **`HELP`**: stack effect + one-line description for **every** word in a
fresh VM (dictionary words and outer-interpreter tokens alike); coverage
is enforced by a unit test, so an undocumented new word fails the build.
User words echo their leading `( n -- n )` comment.
- **`INCLUDE` / `INCLUDED`**: nestable source-file loading with cycle
detection, depth bound, paths relative to the including file, and
per-level `SOURCE-ID`. The loader is injected (CLI: filesystem; web:
defined error), so the core stays IO-free. `wafer prog.fth` now runs
through the same machinery.
- **`MARKER` extensions**: `REMEMBER` (re-runnable marker), `EMPTY` and
`GILD` (boot-state rollback and re-baselining). Marker rollback now also
restores search order, wordlists, `REPLACES` substitutions, `ABORT"`
texts, and captured word sources — enabling the `REMEMBER` + `INCLUDE`
edit-reload loop.
- **`WORDS`**: optional substring filter (`WORDS FLOAT`), word count, and
`WORDS ALL` — a grouped full view by wordlist plus internal words.
- **Return-stack introspection**: `.RS`, `RDEPTH`, `RP@`.
- **Tools**: `.S` honors `BASE`, `F.S`, `?`, bounds-checked `DUMP`, real
`BYE`, named `ORDER` output.
- **CLI REPL**: persistent history (XDG state dir, `0600`), dictionary-backed
tab completion, prefix history search on Up/Down, Ctrl-C clears the line.
- **Web REPL**: history persisted to localStorage, User Words palette,
`BASE` indicator in the stack bar.
- **Error reporting**: uncaught `THROW` codes map to standard messages;
`ABORT"` text prints only when uncaught; errors inside included files
carry `file.fth:line:` context; uncaught throws are typed
(`WaferError::UncaughtThrow`) for embedding consumers; compiled words
carry WASM name sections, so genuine traps name the faulting word
(`in CRASHER: wasm trap: out of bounds memory access`).
- **SwiftForth correctness lane**: the cross-engine program corpus can run
against sf64 as an oracle (`just compare-correctness`), alongside the
existing gforth lane and the sf64 performance lane.
### Fixed
- Multi-line command output in the CLI REPL starts on its own line
(inline `ok` echo only for single-line output).
- `.S` printed in decimal regardless of `BASE`.
- A bare interpreted `R>` underflowed silently (exposed by the new stack
guards; compliance baseline updated).
- `SPACES` with a negative count now outputs nothing, per Forth 2012
6.1.2230.
### Changed
- `wafer prog.fth` reports errors with `file:line` context and resolves
nested `INCLUDE`s relative to the file.
- Internal words (`_`-prefixed) are flagged in the dictionary and hidden
from `WORDS` and completion (`WORDS ALL` shows them).
- Dependencies upgraded across the board: wasmtime 43 → 47,
wasm-encoder/wasmparser 0.246 → 0.255, plus all semver-compatible
updates.
## [0.1.0] - 2026-08-04
Initial development line (untagged): Forth 2012 core with IR optimizer and
WASM codegen via wasm-encoder/wasmtime, ~300 words across Core, Double,
Float, String, Search-Order, Exception, and Tools word sets, Forth 2012
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.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
+1 -1
View File
@@ -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 431 unit + 1 benchmark + 11 compliance + 9 comparison) - 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
+308 -631
View File
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.2.4"
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"
@@ -41,13 +41,21 @@ needless_collect = "warn"
or_fun_call = "warn" or_fun_call = "warn"
[workspace.dependencies] [workspace.dependencies]
wasm-encoder = "0.246" wasm-encoder = "0.255"
wasmparser = "0.246" wasmparser = "0.255"
wasmtime = "43" wasmtime = "47"
anyhow = "1" anyhow = "1"
thiserror = "2" thiserror = "2"
proptest = "1" proptest = "1"
insta = "1" insta = "1"
sha1 = "0.11" sha1 = "0.10"
sha2 = "0.11" 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
+15
View File
@@ -43,6 +43,14 @@ bench:
bench-opts: bench-opts:
cargo test -p wafer-core --test benchmark_report -- --nocapture --ignored cargo test -p wafer-core --test benchmark_report -- --nocapture --ignored
# Cross-engine performance report: WAFER vs gforth vs SwiftForth (sf64)
bench-compare:
CARGO_PROFILE_RELEASE_STRIP=none cargo test -p wafer-core --release --test comparison -- --nocapture --ignored performance_report
# Cross-engine correctness lanes: program corpus vs gforth + sf64 oracles
compare-correctness:
cargo test -p wafer-core --test comparison -- --nocapture --ignored compare_all_programs
# Check dependency licenses and advisories # Check dependency licenses and advisories
deny: deny:
cargo deny check cargo deny check
@@ -58,6 +66,13 @@ ci: fmt clippy deny test
check: check:
cargo check --workspace cargo check --workspace
# Install the wafer CLI (release build) and bat syntax highlighting.
# STRIP=none: Cargo's release default (strip = "debuginfo") emits dylibs that
# macOS 27's dyld rejects ("mis-aligned LINKEDIT string pool"), so proc macros
# fail to load during the build itself.
install: install-syntax
CARGO_PROFILE_RELEASE_STRIP=none cargo install --path crates/cli --locked
# Install bat syntax highlighting for WAFER / Forth # Install bat syntax highlighting for WAFER / Forth
install-syntax: install-syntax:
mkdir -p ~/.config/bat/syntaxes mkdir -p ~/.config/bat/syntaxes
+2 -1
View File
@@ -95,7 +95,7 @@ Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CON
## Testing ## Testing
```bash ```bash
# All tests (~450 currently passing) # All tests (~570 currently passing)
cargo test --workspace cargo test --workspace
# Forth 2012 compliance suite # Forth 2012 compliance suite
@@ -185,6 +185,7 @@ Over 200 words are implemented across the following categories:
| Strings | `COMPARE SEARCH SLITERAL REPLACES SUBSTITUTE UNESCAPE` | | Strings | `COMPARE SEARCH SLITERAL REPLACES SUBSTITUTE UNESCAPE` |
| Floating-Pt | `F+ F- F* F/ FABS FNEGATE FSQRT FSIN FCOS FTAN FEXP FLOG FMIN FMAX` and 55+ more | | Floating-Pt | `F+ F- F* F/ FABS FNEGATE FSQRT FSIN FCOS FTAN FEXP FLOG FMIN FMAX` and 55+ more |
| Case | `CASE OF ENDOF ENDCASE` | | Case | `CASE OF ENDOF ENDCASE` |
| Tools | `WORDS SEE SEE-IR HELP INCLUDE INCLUDED .S F.S ? DUMP MARKER REMEMBER EMPTY GILD BYE` |
## Web REPL ## Web REPL
+1 -1
View File
@@ -9,7 +9,7 @@ license.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
wafer-core = { path = "../core", version = "0.1.0" } wafer-core = { path = "../core", version = "0.2.4" }
wasmtime = { workspace = true } wasmtime = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
+190 -58
View File
@@ -137,7 +137,9 @@ fn cmd_build(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let source = std::fs::read_to_string(file)?; let source = std::fs::read_to_string(file)?;
let mut vm = ForthVM::<NativeRuntime>::new()?; // Exported modules are production artifacts: no stack guards by default
let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(false))?;
vm.set_source_loader(fs_loader());
vm.set_recording(true); vm.set_recording(true);
vm.evaluate(&source)?; vm.evaluate(&source)?;
@@ -260,18 +262,38 @@ fn cmd_run(file: &str) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides
/// the per-command default (REPL/file execution on, build off).
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() {
Some("0") => false,
Some(_) => true,
None => default_guards,
};
cfg
}
/// Filesystem source loader for INCLUDE/INCLUDED.
fn fs_loader() -> Box<dyn Fn(&str) -> anyhow::Result<String> + Send + Sync> {
Box::new(|path| Ok(std::fs::read_to_string(path)?))
}
/// `wafer` (REPL) or `wafer program.fth` (evaluate and exit) /// `wafer` (REPL) or `wafer program.fth` (evaluate and exit)
fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> { fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
let mut vm = ForthVM::<NativeRuntime>::new()?; let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(true))?;
vm.set_source_loader(fs_loader());
match file { match file {
Some(file) => { Some(file) => {
let source = std::fs::read_to_string(file)?; // Through the include machinery: file:line error context and a
vm.evaluate(&source)?; // base directory for nested INCLUDEs.
let result = vm.include(file);
let output = vm.take_output(); let output = vm.take_output();
if !output.is_empty() { if !output.is_empty() {
print!("{output}"); print!("{output}");
} }
result?;
} }
None => { None => {
if !stdin_is_tty() { if !stdin_is_tty() {
@@ -285,66 +307,17 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
if !output.is_empty() { if !output.is_empty() {
print!("{output}"); print!("{output}");
} }
if vm.bye_requested() {
break;
}
} }
Err(e) => { Err(e) => {
eprintln!("Error: {e}"); eprintln!("Error: {e:#}");
} }
} }
} }
} else { } else {
// Interactive REPL run_repl(&mut vm)?;
println!(
"WAFER v{} - WebAssembly Forth Engine in Rust",
env!("CARGO_PKG_VERSION")
);
println!("Type BYE to exit.");
let mut rl = rustyline::DefaultEditor::new()?;
loop {
let prompt = if vm.is_compiling() { " ] " } else { "> " };
match rl.readline(prompt) {
Ok(line) => {
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case("BYE") {
break;
}
let _ = rl.add_history_entry(&line);
match vm.evaluate(&line) {
Ok(()) => {
let output = vm.take_output();
// PAGE (form feed) clears the terminal
if output.contains('\x0C') {
print!("\x1b[2J\x1b[H");
}
let output = output.replace('\x0C', "");
if !vm.is_compiling() {
// Move cursor back up to end of input line so
// output appears inline, like traditional Forth:
// > 2 2 + . 4 ok
let col = prompt.len() + line.len() + 1;
print!("\x1b[A\x1b[{col}G {output} ok");
println!();
} else if !output.is_empty() {
print!("{output}");
}
}
Err(e) => {
eprintln!("Error: {e}");
}
}
}
Err(
rustyline::error::ReadlineError::Interrupted
| rustyline::error::ReadlineError::Eof,
) => {
break;
}
Err(e) => {
eprintln!("Readline error: {e}");
break;
}
}
}
} }
} }
} }
@@ -357,3 +330,162 @@ fn stdin_is_tty() -> bool {
use std::io::IsTerminal; use std::io::IsTerminal;
std::io::stdin().is_terminal() std::io::stdin().is_terminal()
} }
/// Completes the token under the cursor against the live dictionary.
struct WaferHelper {
words: Vec<String>,
}
impl rustyline::completion::Completer for WaferHelper {
type Candidate = String;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<String>)> {
let start = line[..pos]
.rfind(|c: char| c.is_whitespace())
.map_or(0, |i| i + 1);
let prefix = line[start..pos].to_ascii_uppercase();
let mut matches: Vec<String> = self
.words
.iter()
.filter(|w| w.to_ascii_uppercase().starts_with(&prefix))
.cloned()
.collect();
matches.sort();
matches.dedup();
Ok((start, matches))
}
}
impl rustyline::hint::Hinter for WaferHelper {
type Hint = String;
}
impl rustyline::highlight::Highlighter for WaferHelper {}
impl rustyline::validate::Validator for WaferHelper {}
impl rustyline::Helper for WaferHelper {}
/// History file: `$WAFER_HISTORY`, else `$XDG_STATE_HOME/wafer/history`,
/// else `~/.local/state/wafer/history`.
fn history_path() -> Option<std::path::PathBuf> {
if let Some(p) = std::env::var_os("WAFER_HISTORY") {
return Some(p.into());
}
let base = std::env::var_os("XDG_STATE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/state"))
})?;
Some(base.join("wafer/history"))
}
/// Interactive REPL: line editing, persistent history with prefix search
/// on Up/Down, and Tab completion over the live dictionary.
fn run_repl(vm: &mut ForthVM<NativeRuntime>) -> anyhow::Result<()> {
use rustyline::{Cmd, Editor, EventHandler, KeyCode, KeyEvent, Modifiers};
println!(
"WAFER v{} - WebAssembly Forth Engine in Rust",
env!("CARGO_PKG_VERSION")
);
println!("Type BYE to exit.");
let config = rustyline::Config::builder()
.completion_type(rustyline::CompletionType::List)
.history_ignore_dups(true)?
.build();
let mut rl: Editor<WaferHelper, rustyline::history::DefaultHistory> =
Editor::with_config(config)?;
rl.set_helper(Some(WaferHelper {
words: vm.word_names(),
}));
// Up/Down recall only entries starting with the typed prefix
rl.bind_sequence(
KeyEvent(KeyCode::Up, Modifiers::NONE),
EventHandler::Simple(Cmd::HistorySearchBackward),
);
rl.bind_sequence(
KeyEvent(KeyCode::Down, Modifiers::NONE),
EventHandler::Simple(Cmd::HistorySearchForward),
);
let history = history_path();
if let Some(path) = &history {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = rl.load_history(path);
}
let save_history = |rl: &mut Editor<WaferHelper, rustyline::history::DefaultHistory>| {
if let Some(path) = &history {
let _ = rl.save_history(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
};
loop {
let prompt = if vm.is_compiling() { " ] " } else { "> " };
match rl.readline(prompt) {
Ok(line) => {
let _ = rl.add_history_entry(&line);
match vm.evaluate(&line) {
Ok(()) => {
let output = vm.take_output();
if vm.bye_requested() {
break;
}
// PAGE (form feed) clears the terminal
if output.contains('\x0C') {
print!("\x1b[2J\x1b[H");
}
let output = output.replace('\x0C', "");
if !vm.is_compiling() {
if output.contains('\n') {
// Multi-line output (DUMP, WORDS, ...):
// print as a block, then ok on its own line
print!("{output}");
if !output.ends_with('\n') {
println!();
}
println!(" ok");
} else {
// Move cursor back up to end of input line so
// output appears inline, like traditional Forth:
// > 2 2 + . 4 ok
let col = prompt.len() + line.len() + 1;
print!("\x1b[A\x1b[{col}G {output} ok");
println!();
}
} else if !output.is_empty() {
print!("{output}");
}
}
Err(e) => {
eprintln!("Error: {e:#}");
}
}
// New definitions may have appeared: refresh completion
if let Some(h) = rl.helper_mut() {
h.words = vm.word_names();
}
save_history(&mut rl);
}
// Ctrl-C abandons the current line, Ctrl-D exits
Err(rustyline::error::ReadlineError::Interrupted) => {}
Err(rustyline::error::ReadlineError::Eof) => break,
Err(e) => {
eprintln!("Readline error: {e}");
break;
}
}
}
save_history(&mut rl);
Ok(())
}
+33 -2
View File
@@ -72,6 +72,19 @@
1- 1-
REPEAT ; REPEAT ;
\ ---------------------------------------------------------------
\ Common extensions (not in Forth 2012, gforth-compatible)
\ ---------------------------------------------------------------
\ -ROT ( x1 x2 x3 -- x3 x1 x2 ) rotate top item to third place
: -ROT ROT ROT ;
\ <= ( n1 n2 -- flag ) true if n1 <= n2 (signed)
: <= > 0= ;
\ >= ( n1 n2 -- flag ) true if n1 >= n2 (signed)
: >= < 0= ;
\ --------------------------------------------------------------- \ ---------------------------------------------------------------
\ Phase 2: Double-cell arithmetic \ Phase 2: Double-cell arithmetic
\ --------------------------------------------------------------- \ ---------------------------------------------------------------
@@ -184,8 +197,8 @@
\ TYPE ( c-addr u -- ) output u characters \ TYPE ( c-addr u -- ) output u characters
: TYPE 0 ?DO DUP C@ EMIT 1+ LOOP DROP ; : TYPE 0 ?DO DUP C@ EMIT 1+ LOOP DROP ;
\ SPACES ( n -- ) output n spaces \ SPACES ( n -- ) output n spaces (nothing for n <= 0, per 6.1.2230)
: SPACES 0 ?DO SPACE LOOP ; : SPACES 0 MAX 0 ?DO SPACE LOOP ;
\ Pictured numeric output constants \ Pictured numeric output constants
\ PICT_BUF_TOP = 0x05C0 = 1472, SYSVAR_HLD = 28 \ PICT_BUF_TOP = 0x05C0 = 1472, SYSVAR_HLD = 28
@@ -230,6 +243,9 @@
\ U. ( u -- ) print unsigned number and space \ U. ( u -- ) print unsigned number and space
: U. 0 <# #S #> TYPE SPACE ; : U. 0 <# #S #> TYPE SPACE ;
\ ? ( a-addr -- ) fetch and print
: ? @ . ;
\ .R ( n width -- ) print right-justified signed number \ .R ( n width -- ) print right-justified signed number
: .R >R DUP ABS 0 <# #S ROT SIGN #> R> OVER - SPACES TYPE ; : .R >R DUP ABS 0 <# #S ROT SIGN #> R> OVER - SPACES TYPE ;
@@ -242,6 +258,21 @@
\ D.R ( d width -- ) print right-justified signed double \ D.R ( d width -- ) print right-justified signed double
: D.R >R SWAP OVER DABS <# #S ROT SIGN #> R> OVER - SPACES TYPE ; : D.R >R SWAP OVER DABS <# #S ROT SIGN #> R> OVER - SPACES TYPE ;
\ ---------------------------------------------------------------
\ Return-stack introspection (debug aids)
\ ---------------------------------------------------------------
\ RDEPTH ( -- n ) number of cells on the return stack
\ RETURN_STACK_TOP = 9728 (0x2600). Only >R temps and loop params
\ live there; return addresses are on the WASM call stack.
: RDEPTH 9728 RP@ - 2 RSHIFT ;
\ .RS ( -- ) print the return stack bottom-to-top, like .S
\ Walks with BEGIN/WHILE (not DO) so the walk itself never pushes
\ onto the return stack it is printing.
: .RS ." R:<" RDEPTH 0 .R ." > "
9728 BEGIN DUP RP@ > WHILE 4 - DUP @ . REPEAT DROP ;
\ --------------------------------------------------------------- \ ---------------------------------------------------------------
\ Phase 6: DEFER support \ Phase 6: DEFER support
\ --------------------------------------------------------------- \ ---------------------------------------------------------------
+162 -17
View File
@@ -19,7 +19,10 @@ use wasm_encoder::{
use crate::dictionary::WordId; use crate::dictionary::WordId;
use crate::error::{WaferError, WaferResult}; use crate::error::{WaferError, WaferResult};
use crate::ir::IrOp; use crate::ir::IrOp;
use crate::memory::{CELL_SIZE, SYSVAR_LEAVE_FLAG}; use crate::memory::{
CELL_SIZE, DATA_STACK_BASE, DATA_STACK_TOP, FLOAT_STACK_BASE, FLOAT_STACK_TOP,
RETURN_STACK_BASE, RETURN_STACK_TOP, SYSVAR_FAULT_CODE, SYSVAR_LEAVE_FLAG,
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Import indices (order matters: imports numbered sequentially by kind) // Import indices (order matters: imports numbered sequentially by kind)
@@ -94,6 +97,9 @@ pub struct CodegenConfig {
pub table_size: u32, pub table_size: u32,
/// Enable stack-to-local promotion for straight-line words. /// Enable stack-to-local promotion for straight-line words.
pub stack_to_local_promotion: bool, pub stack_to_local_promotion: bool,
/// Table index of the `_STACK_FAULT_` host word; `Some` enables
/// stack under/overflow guards in the emitted code.
pub stack_guards: Option<u32>,
} }
/// Result of compiling a word to WASM. /// Result of compiling a word to WASM.
@@ -109,16 +115,73 @@ pub struct CompiledModule {
// Instruction-level helpers (free functions that take &mut Function) // Instruction-level helpers (free functions that take &mut Function)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Decrement the cached `$dsp` local by `CELL_SIZE`. // Stack-guard emission. The fault word's table index is stashed in a
// thread-local by `compile_word` (None = guards off) so the low-level
// push/pop helpers can stay plain `&mut Function` free functions
// without threading config through every emitter.
thread_local! {
static GUARD_FAULT: std::cell::Cell<Option<u32>> = const { std::cell::Cell::new(None) };
}
/// With guards on: emit `if <cond> { mem[SYSVAR_FAULT_CODE] = code;
/// call _STACK_FAULT_ }`. `cond` must leave an i32 boolean on the
/// operand stack. The fault host word throws, so the `if` never falls
/// through on the failure path.
fn emit_guard(f: &mut Function, code: i32, cond: impl FnOnce(&mut Function)) {
let Some(fault_idx) = GUARD_FAULT.get() else {
return;
};
cond(f);
f.instruction(&Instruction::If(BlockType::Empty))
.instruction(&Instruction::I32Const(SYSVAR_FAULT_CODE as i32))
.instruction(&Instruction::I32Const(code))
.instruction(&Instruction::I32Store(MEM4))
.instruction(&Instruction::I32Const(fault_idx as i32))
.instruction(&Instruction::CallIndirect {
type_index: TYPE_VOID,
table_index: TABLE,
})
.instruction(&Instruction::End);
}
/// Guard: data stack has at least `n` cells (else throw -4).
fn guard_dsp_underflow(f: &mut Function, n: u32) {
emit_guard(f, -4, |f| {
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const((n * CELL_SIZE) as i32))
.instruction(&Instruction::I32Add)
.instruction(&Instruction::I32Const(DATA_STACK_TOP as i32))
.instruction(&Instruction::I32GtU);
});
}
/// Guard: data stack has room for `n` more cells (else throw -3).
fn guard_dsp_overflow(f: &mut Function, n: u32) {
emit_guard(f, -3, |f| {
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const(
(DATA_STACK_BASE + n * CELL_SIZE) as i32,
))
.instruction(&Instruction::I32LtU);
});
}
/// Decrement the cached `$dsp` local by `CELL_SIZE` (allocate one cell).
/// This is the single choke point for data-stack pushes, so the
/// overflow guard lives here.
fn dsp_dec(f: &mut Function) { fn dsp_dec(f: &mut Function) {
guard_dsp_overflow(f, 1);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL)) f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const(CELL_SIZE as i32)) .instruction(&Instruction::I32Const(CELL_SIZE as i32))
.instruction(&Instruction::I32Sub) .instruction(&Instruction::I32Sub)
.instruction(&Instruction::LocalSet(CACHED_DSP_LOCAL)); .instruction(&Instruction::LocalSet(CACHED_DSP_LOCAL));
} }
/// Increment the cached `$dsp` local by `CELL_SIZE`. /// Increment the cached `$dsp` local by `CELL_SIZE` (free one cell).
/// Single choke point for data-stack pops (`DROP` never loads the
/// value, so the underflow guard must sit here, not in `pop`).
fn dsp_inc(f: &mut Function) { fn dsp_inc(f: &mut Function) {
guard_dsp_underflow(f, 1);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL)) f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const(CELL_SIZE as i32)) .instruction(&Instruction::I32Const(CELL_SIZE as i32))
.instruction(&Instruction::I32Add) .instruction(&Instruction::I32Add)
@@ -160,6 +223,7 @@ fn pop_to(f: &mut Function, local: u32) {
/// Read the top of the data stack without popping (value on operand stack). /// Read the top of the data stack without popping (value on operand stack).
fn peek(f: &mut Function) { fn peek(f: &mut Function) {
guard_dsp_underflow(f, 1);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL)) f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Load(MEM4)); .instruction(&Instruction::I32Load(MEM4));
} }
@@ -182,6 +246,13 @@ fn dsp_reload(f: &mut Function) {
/// Push a value from the WASM operand stack onto the return stack via `tmp`. /// Push a value from the WASM operand stack onto the return stack via `tmp`.
fn rpush_via_local(f: &mut Function, tmp: u32) { fn rpush_via_local(f: &mut Function, tmp: u32) {
emit_guard(f, -5, |f| {
f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Const(
(RETURN_STACK_BASE + CELL_SIZE) as i32,
))
.instruction(&Instruction::I32LtU);
});
f.instruction(&Instruction::LocalSet(tmp)); f.instruction(&Instruction::LocalSet(tmp));
// rsp -= CELL_SIZE // rsp -= CELL_SIZE
f.instruction(&Instruction::GlobalGet(RSP)) f.instruction(&Instruction::GlobalGet(RSP))
@@ -194,8 +265,18 @@ fn rpush_via_local(f: &mut Function, tmp: u32) {
.instruction(&Instruction::I32Store(MEM4)); .instruction(&Instruction::I32Store(MEM4));
} }
/// Guard: return stack is non-empty (else throw -6).
fn guard_rsp_underflow(f: &mut Function) {
emit_guard(f, -6, |f| {
f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Const(RETURN_STACK_TOP as i32))
.instruction(&Instruction::I32GeU);
});
}
/// Pop the return stack onto the WASM operand stack. /// Pop the return stack onto the WASM operand stack.
fn rpop(f: &mut Function) { fn rpop(f: &mut Function) {
guard_rsp_underflow(f);
f.instruction(&Instruction::GlobalGet(RSP)) f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Load(MEM4)); .instruction(&Instruction::I32Load(MEM4));
// rsp += CELL_SIZE // rsp += CELL_SIZE
@@ -207,6 +288,7 @@ fn rpop(f: &mut Function) {
/// Peek at the top of the return stack (no pop). /// Peek at the top of the return stack (no pop).
fn rpeek(f: &mut Function) { fn rpeek(f: &mut Function) {
guard_rsp_underflow(f);
f.instruction(&Instruction::GlobalGet(RSP)) f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Load(MEM4)); .instruction(&Instruction::I32Load(MEM4));
} }
@@ -253,7 +335,9 @@ struct EmitCtx {
} }
/// Decrement the FSP global by 8 (allocate space for one f64). /// Decrement the FSP global by 8 (allocate space for one f64).
/// Single choke point for float pushes: overflow guard lives here.
fn fsp_dec(f: &mut Function) { fn fsp_dec(f: &mut Function) {
guard_fsp_overflow(f);
f.instruction(&Instruction::GlobalGet(FSP)) f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const(8)) .instruction(&Instruction::I32Const(8))
.instruction(&Instruction::I32Sub) .instruction(&Instruction::I32Sub)
@@ -261,7 +345,10 @@ fn fsp_dec(f: &mut Function) {
} }
/// Increment the FSP global by 8 (free space for one f64). /// Increment the FSP global by 8 (free space for one f64).
/// Single choke point for float pops (`FDROP` never loads the value):
/// underflow guard lives here.
fn fsp_inc(f: &mut Function) { fn fsp_inc(f: &mut Function) {
guard_fsp_underflow(f);
f.instruction(&Instruction::GlobalGet(FSP)) f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const(8)) .instruction(&Instruction::I32Const(8))
.instruction(&Instruction::I32Add) .instruction(&Instruction::I32Add)
@@ -278,6 +365,24 @@ fn fpush_via_local(f: &mut Function, tmp: u32) {
.instruction(&Instruction::F64Store(MEM8)); .instruction(&Instruction::F64Store(MEM8));
} }
/// Guard: float stack has room for one more f64 (else throw -44).
fn guard_fsp_overflow(f: &mut Function) {
emit_guard(f, -44, |f| {
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const((FLOAT_STACK_BASE + 8) as i32))
.instruction(&Instruction::I32LtU);
});
}
/// Guard: float stack is non-empty (else throw -45).
fn guard_fsp_underflow(f: &mut Function) {
emit_guard(f, -45, |f| {
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const(FLOAT_STACK_TOP as i32))
.instruction(&Instruction::I32GeU);
});
}
/// Decrement FSP, then store the f64 from local `src` at [FSP]. /// Decrement FSP, then store the f64 from local `src` at [FSP].
fn fpush_from_local(f: &mut Function, src: u32) { fn fpush_from_local(f: &mut Function, src: u32) {
fsp_dec(f); fsp_dec(f);
@@ -295,6 +400,7 @@ fn fpop(f: &mut Function) {
/// Load f64 from [FSP] onto the WASM operand stack without popping. /// Load f64 from [FSP] onto the WASM operand stack without popping.
fn fpeek(f: &mut Function) { fn fpeek(f: &mut Function) {
guard_fsp_underflow(f);
f.instruction(&Instruction::GlobalGet(FSP)) f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::F64Load(MEM8)); .instruction(&Instruction::F64Load(MEM8));
} }
@@ -793,9 +899,20 @@ fn emit_op(f: &mut Function, op: &IrOp, ctx: &mut EmitCtx) {
.instruction(&Instruction::I32Store(MEM4)); .instruction(&Instruction::I32Store(MEM4));
} }
IrOp::RpFetch => {
// Push the current return-stack pointer onto the data stack.
// `$rsp` lives in a global (not cached), so no writeback needed.
dsp_dec(f);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Store(MEM4));
}
// -- Compound operations ----------------------------------------------- // -- Compound operations -----------------------------------------------
IrOp::TwoDup => { IrOp::TwoDup => {
// ( a b -- a b a b ) // ( a b -- a b a b )
guard_dsp_underflow(f, 2);
guard_dsp_overflow(f, 2);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL)) f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Load(MEM4)); // b .instruction(&Instruction::I32Load(MEM4)); // b
f.instruction(&Instruction::LocalSet(SCRATCH_BASE)); f.instruction(&Instruction::LocalSet(SCRATCH_BASE));
@@ -822,6 +939,7 @@ fn emit_op(f: &mut Function, op: &IrOp, ctx: &mut EmitCtx) {
IrOp::TwoDrop => { IrOp::TwoDrop => {
// ( a b -- ) // ( a b -- )
guard_dsp_underflow(f, 2);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL)) f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const((CELL_SIZE * 2) as i32)) .instruction(&Instruction::I32Const((CELL_SIZE * 2) as i32))
.instruction(&Instruction::I32Add) .instruction(&Instruction::I32Add)
@@ -1088,12 +1206,10 @@ fn emit_do_loop(f: &mut Function, body: &[IrOp], is_plus_loop: bool, ctx: &mut E
.instruction(&Instruction::End); .instruction(&Instruction::End);
} }
// Forth 2012: LOOP exits when the index crosses the boundary between // if index >= limit, exit
// limit-1 and limit. With step +1 that is exactly new_index == limit
// in wraparound arithmetic — start >= limit must wrap, not exit early.
f.instruction(&Instruction::LocalGet(index_local)) f.instruction(&Instruction::LocalGet(index_local))
.instruction(&Instruction::LocalGet(limit_local)) .instruction(&Instruction::LocalGet(limit_local))
.instruction(&Instruction::I32Eq) .instruction(&Instruction::I32GeS)
.instruction(&Instruction::BrIf(1)) // break to $exit .instruction(&Instruction::BrIf(1)) // break to $exit
.instruction(&Instruction::Br(0)) // continue loop .instruction(&Instruction::Br(0)) // continue loop
.instruction(&Instruction::End) // end loop .instruction(&Instruction::End) // end loop
@@ -1135,7 +1251,9 @@ fn is_promotable(ops: &[IrOp]) -> bool {
fn is_promotable_body(ops: &[IrOp]) -> bool { fn is_promotable_body(ops: &[IrOp]) -> bool {
for op in ops { for op in ops {
match op { match op {
IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute | IrOp::SpFetch => return false, IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute | IrOp::SpFetch | IrOp::RpFetch => {
return false;
}
IrOp::ToR | IrOp::FromR | IrOp::Exit => return false, IrOp::ToR | IrOp::FromR | IrOp::Exit => return false,
IrOp::ForthLocalGet(_) | IrOp::ForthLocalSet(_) => return false, IrOp::ForthLocalGet(_) | IrOp::ForthLocalSet(_) => return false,
IrOp::ForthFLocalGet(_) | IrOp::ForthFLocalSet(_) => return false, IrOp::ForthFLocalGet(_) | IrOp::ForthFLocalSet(_) => return false,
@@ -1547,6 +1665,11 @@ impl StackSim {
/// Emit the promoted prologue: load `preload` items from the memory stack /// Emit the promoted prologue: load `preload` items from the memory stack
/// into WASM locals. /// into WASM locals.
fn emit_promoted_prologue(f: &mut Function, preload: u32, sim: &mut StackSim) { fn emit_promoted_prologue(f: &mut Function, preload: u32, sim: &mut StackSim) {
// One entry check covers the whole promoted word: the caller must
// have at least `preload` cells on the data stack.
if preload > 0 {
guard_dsp_underflow(f, preload);
}
// Load items: mem[dsp] = top of stack, mem[dsp+4] = second, etc. // Load items: mem[dsp] = top of stack, mem[dsp+4] = second, etc.
// We load them top-first, then reverse the sim stack so that // We load them top-first, then reverse the sim stack so that
// sim.stack[0] = deepest loaded, sim.stack[last] = top. // sim.stack[0] = deepest loaded, sim.stack[last] = top.
@@ -1577,6 +1700,7 @@ fn emit_promoted_prologue(f: &mut Function, preload: u32, sim: &mut StackSim) {
fn emit_promoted_epilogue(f: &mut Function, sim: &mut StackSim) { fn emit_promoted_epilogue(f: &mut Function, sim: &mut StackSim) {
let remaining = sim.stack.len() as u32; let remaining = sim.stack.len() as u32;
if remaining > 0 { if remaining > 0 {
guard_dsp_overflow(f, remaining);
// Decrement cached DSP for the items we're pushing back // Decrement cached DSP for the items we're pushing back
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL)); f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL));
f.instruction(&Instruction::I32Const((remaining * CELL_SIZE) as i32)); f.instruction(&Instruction::I32Const((remaining * CELL_SIZE) as i32));
@@ -1899,8 +2023,7 @@ fn emit_promoted_op(f: &mut Function, op: &IrOp, sim: &mut StackSim) {
// Fix up stack for next iteration (LOOP body is stack-neutral) // Fix up stack for next iteration (LOOP body is stack-neutral)
emit_promoted_loop_fixup(f, sim, &loop_top_stack); emit_promoted_loop_fixup(f, sim, &loop_top_stack);
// LOOP: increment by 1, exit when new_index == limit // LOOP: increment by 1, check >= limit
// (Forth 2012 boundary crossing; start >= limit wraps around)
f.instruction(&Instruction::LocalGet(index_local)); f.instruction(&Instruction::LocalGet(index_local));
f.instruction(&Instruction::I32Const(1)); f.instruction(&Instruction::I32Const(1));
f.instruction(&Instruction::I32Add); f.instruction(&Instruction::I32Add);
@@ -1908,7 +2031,7 @@ fn emit_promoted_op(f: &mut Function, op: &IrOp, sim: &mut StackSim) {
f.instruction(&Instruction::LocalGet(index_local)); f.instruction(&Instruction::LocalGet(index_local));
f.instruction(&Instruction::LocalGet(limit_local)); f.instruction(&Instruction::LocalGet(limit_local));
f.instruction(&Instruction::I32Eq); f.instruction(&Instruction::I32GeS);
f.instruction(&Instruction::BrIf(1)); // break to $exit f.instruction(&Instruction::BrIf(1)); // break to $exit
} }
@@ -2194,6 +2317,9 @@ fn body_needs_return_stack(ops: &[IrOp]) -> bool {
match op { match op {
IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute => return true, IrOp::Call(_) | IrOp::TailCall(_) | IrOp::Execute => return true,
IrOp::ToR | IrOp::FromR => return true, IrOp::ToR | IrOp::FromR => return true,
// RP@ observes the return stack, so loop params must be there
// (otherwise inlined RDEPTH/.RS would report an empty stack).
IrOp::RpFetch => return true,
// RFetch (I) is handled by loop locals in the fast path — not a problem. // RFetch (I) is handled by loop locals in the fast path — not a problem.
// LoopJ is also handled by loop locals. // LoopJ is also handled by loop locals.
// Only explicit >R / R> / calls force the slow path. // Only explicit >R / R> / calls force the slow path.
@@ -2406,10 +2532,13 @@ fn count_forth_f_locals(ops: &[IrOp]) -> u32 {
/// This is the JIT path: each word gets its own module that imports /// This is the JIT path: each word gets its own module that imports
/// shared memory, globals, and function table from the host. /// shared memory, globals, and function table from the host.
pub fn compile_word( pub fn compile_word(
_name: &str, name: &str,
body: &[IrOp], body: &[IrOp],
config: &CodegenConfig, config: &CodegenConfig,
) -> WaferResult<CompiledModule> { ) -> WaferResult<CompiledModule> {
// Arm (or disarm) stack-guard emission for this compilation.
GUARD_FAULT.set(config.stack_guards);
let mut module = Module::new(); let mut module = Module::new();
// -- Type section -- // -- Type section --
@@ -2570,6 +2699,16 @@ pub fn compile_word(
code.function(&func); code.function(&func);
module.section(&code); module.section(&code);
// -- Name section: carries the Forth word name into wasmtime trap
// backtraces (best-effort symbolication, WS-008).
let mut names = wasm_encoder::NameSection::new();
names.module(name);
let mut fn_names = wasm_encoder::NameMap::new();
fn_names.append(0, "emit");
fn_names.append(WORD_FUNC, name);
names.functions(&fn_names);
module.section(&names);
let bytes = module.finish(); let bytes = module.finish();
// Validate // Validate
@@ -2832,11 +2971,9 @@ fn emit_consolidated_do_loop(
.instruction(&Instruction::End); .instruction(&Instruction::End);
} }
// Forth 2012 boundary crossing: exit when new_index == limit
// (start >= limit wraps around instead of exiting early).
f.instruction(&Instruction::LocalGet(index_local)) f.instruction(&Instruction::LocalGet(index_local))
.instruction(&Instruction::LocalGet(limit_local)) .instruction(&Instruction::LocalGet(limit_local))
.instruction(&Instruction::I32Eq) .instruction(&Instruction::I32GeS)
.instruction(&Instruction::BrIf(1)) .instruction(&Instruction::BrIf(1))
.instruction(&Instruction::Br(0)) .instruction(&Instruction::Br(0))
.instruction(&Instruction::End) .instruction(&Instruction::End)
@@ -2876,8 +3013,9 @@ pub fn compile_consolidated_module(
words: &[(WordId, Vec<IrOp>)], words: &[(WordId, Vec<IrOp>)],
local_fn_map: &HashMap<WordId, u32>, local_fn_map: &HashMap<WordId, u32>,
table_size: u32, table_size: u32,
stack_guards: Option<u32>,
) -> WaferResult<Vec<u8>> { ) -> WaferResult<Vec<u8>> {
compile_multi_word_module(words, local_fn_map, table_size, None) compile_multi_word_module(words, local_fn_map, table_size, None, stack_guards)
} }
/// Compile an exportable WASM module with embedded memory and metadata. /// Compile an exportable WASM module with embedded memory and metadata.
@@ -2890,8 +3028,9 @@ pub fn compile_exportable_module(
local_fn_map: &HashMap<WordId, u32>, local_fn_map: &HashMap<WordId, u32>,
table_size: u32, table_size: u32,
export: &ExportSections<'_>, export: &ExportSections<'_>,
stack_guards: Option<u32>,
) -> WaferResult<Vec<u8>> { ) -> WaferResult<Vec<u8>> {
compile_multi_word_module(words, local_fn_map, table_size, Some(export)) compile_multi_word_module(words, local_fn_map, table_size, Some(export), stack_guards)
} }
/// Internal: build a multi-word WASM module. When `export` is `Some`, adds /// Internal: build a multi-word WASM module. When `export` is `Some`, adds
@@ -2901,7 +3040,11 @@ fn compile_multi_word_module(
local_fn_map: &HashMap<WordId, u32>, local_fn_map: &HashMap<WordId, u32>,
table_size: u32, table_size: u32,
export: Option<&ExportSections<'_>>, export: Option<&ExportSections<'_>>,
stack_guards: Option<u32>,
) -> WaferResult<Vec<u8>> { ) -> WaferResult<Vec<u8>> {
// Arm (or disarm) stack-guard emission for this module.
GUARD_FAULT.set(stack_guards);
let has_data = export.is_some_and(|e| !e.memory_snapshot.is_empty()); let has_data = export.is_some_and(|e| !e.memory_snapshot.is_empty());
let mut module = Module::new(); let mut module = Module::new();
@@ -3130,6 +3273,7 @@ mod tests {
base_fn_index: 0, base_fn_index: 0,
table_size: 16, table_size: 16,
stack_to_local_promotion: true, stack_to_local_promotion: true,
stack_guards: None,
} }
} }
@@ -3354,6 +3498,7 @@ mod tests {
base_fn_index: 7, base_fn_index: 7,
table_size: 16, table_size: 16,
stack_to_local_promotion: true, stack_to_local_promotion: true,
stack_guards: None,
}; };
let m = compile_word("t", &[IrOp::PushI32(1)], &cfg).unwrap(); let m = compile_word("t", &[IrOp::PushI32(1)], &cfg).unwrap();
assert_eq!(m.fn_index, 7); assert_eq!(m.fn_index, 7);
+7
View File
@@ -7,6 +7,11 @@ use crate::optimizer::OptConfig;
pub struct CodegenOpts { pub struct CodegenOpts {
/// Enable stack-to-local promotion for straight-line words. /// Enable stack-to-local promotion for straight-line words.
pub stack_to_local_promotion: bool, pub stack_to_local_promotion: bool,
/// Emit stack under/overflow guards in compiled words. Faults throw
/// standard codes (-3/-4/-5/-6/-44/-45) instead of silently
/// corrupting stack pointers. On by default; benchmarks and
/// exported production modules turn it off.
pub stack_guards: bool,
} }
/// Master configuration for all WAFER optimizations. /// Master configuration for all WAFER optimizations.
@@ -32,6 +37,7 @@ impl WaferConfig {
}, },
codegen: CodegenOpts { codegen: CodegenOpts {
stack_to_local_promotion: true, stack_to_local_promotion: true,
stack_guards: true,
}, },
} }
} }
@@ -49,6 +55,7 @@ impl WaferConfig {
}, },
codegen: CodegenOpts { codegen: CodegenOpts {
stack_to_local_promotion: false, stack_to_local_promotion: false,
stack_guards: 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) // Empty word list should produce nothing (but we guard against this at call site)
let words = vec![]; let words = vec![];
let map = HashMap::new(); let map = HashMap::new();
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
// Empty is valid -- should produce a valid module with no functions // Empty is valid -- should produce a valid module with no functions
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -31,7 +31,7 @@ mod tests {
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])]; let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); // function index 1 (after emit import) map.insert(WordId(1), 1u32); // function index 1 (after emit import)
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -49,7 +49,7 @@ mod tests {
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
map.insert(WordId(3), 3u32); map.insert(WordId(3), 3u32);
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -59,7 +59,7 @@ mod tests {
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])]; let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(3), 1u32); map.insert(WordId(3), 1u32);
let result = compile_consolidated_module(&words, &map, 256); let result = compile_consolidated_module(&words, &map, 256, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -72,7 +72,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -95,7 +95,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -120,7 +120,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -141,7 +141,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -163,7 +163,7 @@ mod tests {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(WordId(1), 1u32); map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32); map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16); let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }
+34 -9
View File
@@ -18,6 +18,8 @@ pub mod flags {
pub const IMMEDIATE: u8 = 0x80; pub const IMMEDIATE: u8 = 0x80;
/// Word is hidden (being compiled, not yet findable). /// Word is hidden (being compiled, not yet findable).
pub const HIDDEN: u8 = 0x40; pub const HIDDEN: u8 = 0x40;
/// Word is an implementation detail: findable, but skipped by WORDS.
pub const INTERNAL: u8 = 0x20;
/// Mask for the name length (lower 5 bits). /// Mask for the name length (lower 5 bits).
pub const LENGTH_MASK: u8 = 0x1F; pub const LENGTH_MASK: u8 = 0x1F;
/// Maximum word name length. /// Maximum word name length.
@@ -95,11 +97,17 @@ impl Dictionary {
// Write link field (points to previous LATEST) // Write link field (points to previous LATEST)
self.write_u32_unchecked(entry_start, self.latest); self.write_u32_unchecked(entry_start, self.latest);
// Write flags byte: HIDDEN | length, optionally IMMEDIATE // Write flags byte: HIDDEN | length, optionally IMMEDIATE.
// Underscore-prefixed names are implementation details by repo
// convention (see tools/editor-support): flag them INTERNAL so
// WORDS and completion skip them while FIND still works.
let mut flag_byte = flags::HIDDEN | (name_len as u8 & flags::LENGTH_MASK); let mut flag_byte = flags::HIDDEN | (name_len as u8 & flags::LENGTH_MASK);
if immediate { if immediate {
flag_byte |= flags::IMMEDIATE; flag_byte |= flags::IMMEDIATE;
} }
if name_bytes.first() == Some(&b'_') {
flag_byte |= flags::INTERNAL;
}
self.memory[(entry_start + 4) as usize] = flag_byte; self.memory[(entry_start + 4) as usize] = flag_byte;
// Write name bytes // Write name bytes
@@ -184,10 +192,9 @@ impl Dictionary {
} }
} }
} }
// Fallback: return newest entry across all wordlists // In no wordlist of the search order: not findable
if let Some(&(_wid, word_addr, fn_index, is_immediate)) = entries.last() { // (Forth 2012 §16.3.3 — the order is authoritative).
return Some((word_addr, WordId(fn_index), is_immediate)); return None;
}
} }
// Fallback: linked-list walk (for words not yet in the index) // Fallback: linked-list walk (for words not yet in the index)
@@ -410,8 +417,21 @@ impl Dictionary {
} }
/// Return names of all visible (non-hidden) words, newest first. /// Return names of all visible (non-hidden) words, newest first.
pub fn visible_words(&self) -> Vec<String> { /// With `include_internal` false, words flagged INTERNAL are skipped.
let mut names = Vec::new(); pub fn visible_words(&self, include_internal: bool) -> Vec<String> {
self.visible_entries()
.into_iter()
.filter(|(_, _, internal)| include_internal || !internal)
.map(|(name, _, _)| name)
.collect()
}
/// All visible (non-hidden) entries, newest first:
/// (name, wordlist id, INTERNAL flag). The wid comes from the hash
/// index (entries themselves store no wid); words missing from the
/// index default to wid 1 (FORTH).
pub fn visible_entries(&self) -> Vec<(String, u32, bool)> {
let mut entries = Vec::new();
let mut addr = self.latest; let mut addr = self.latest;
while addr != 0 { while addr != 0 {
let flags_byte = self.memory[(addr + 4) as usize]; let flags_byte = self.memory[(addr + 4) as usize];
@@ -420,7 +440,12 @@ impl Dictionary {
let name_start = (addr + 5) as usize; let name_start = (addr + 5) as usize;
let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len]) let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len])
.to_string(); .to_string();
names.push(name); let wid = self
.index
.get(&name)
.and_then(|es| es.iter().find(|e| e.1 == addr))
.map_or(1, |e| e.0);
entries.push((name, wid, flags_byte & flags::INTERNAL != 0));
} }
let link = self.read_u32_unchecked(addr); let link = self.read_u32_unchecked(addr);
if link == addr { if link == addr {
@@ -428,7 +453,7 @@ impl Dictionary {
} }
addr = link; addr = link;
} }
names entries
} }
/// Get a reference to the raw memory buffer. /// Get a reference to the raw memory buffer.
+7
View File
@@ -61,6 +61,13 @@ pub enum WaferError {
#[error("{0}")] #[error("{0}")]
Abort(String), Abort(String),
/// An uncaught Forth THROW as reported to the user. `message` is the
/// full display text (standard message or ABORT" payload); `code`
/// carries the THROW code for typed consumers (CLI exit paths, web
/// REPL styling) via `Error::downcast_ref`.
#[error("{message}")]
UncaughtThrow { code: i32, message: String },
} }
/// Result type alias for WAFER operations. /// Result type alias for WAFER operations.
+8 -2
View File
@@ -120,8 +120,14 @@ pub fn export_module(
metadata_json: metadata_json.as_bytes(), metadata_json: metadata_json.as_bytes(),
}; };
let wasm_bytes = compile_exportable_module(&words, &local_fn_map, table_size, &export_sections) let wasm_bytes = compile_exportable_module(
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?; &words,
&local_fn_map,
table_size,
&export_sections,
vm.stack_guard_param(),
)
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
Ok((wasm_bytes, metadata)) Ok((wasm_bytes, metadata))
} }
+2
View File
@@ -159,6 +159,8 @@ pub enum IrOp {
Execute, Execute,
/// Push the current data-stack pointer: ( -- addr ) /// Push the current data-stack pointer: ( -- addr )
SpFetch, SpFetch,
/// Push the current return-stack pointer: ( -- addr )
RpFetch,
// -- Float stack manipulation -- // -- Float stack manipulation --
/// Float duplicate: ( F: r -- r r ) /// Float duplicate: ( F: r -- r r )
+2
View File
@@ -24,6 +24,8 @@ pub mod ir;
pub mod memory; pub mod memory;
pub mod optimizer; pub mod optimizer;
pub mod runtime; pub mod runtime;
pub mod see;
pub mod wordhelp;
// Outer interpreter: runtime-agnostic, works with any Runtime impl // Outer interpreter: runtime-agnostic, works with any Runtime impl
#[allow(trivial_numeric_casts, clippy::unnecessary_cast)] #[allow(trivial_numeric_casts, clippy::unnecessary_cast)]
+22
View File
@@ -106,6 +106,25 @@ pub const SYSVAR_NUM_TIB: u32 = SYSVAR_BASE + 24;
pub const SYSVAR_HLD: u32 = SYSVAR_BASE + 28; pub const SYSVAR_HLD: u32 = SYSVAR_BASE + 28;
/// LEAVE flag: nonzero when LEAVE has been called inside a DO loop. /// LEAVE flag: nonzero when LEAVE has been called inside a DO loop.
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_`.
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 {
@@ -147,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);
+2201 -394
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -98,11 +98,29 @@ impl HostAccess for CallerHostAccess<'_, '_> {
let func = *func_ref let func = *func_ref
.unwrap_func() .unwrap_func()
.ok_or_else(|| anyhow::anyhow!("call_func: null funcref {fn_index}"))?; .ok_or_else(|| anyhow::anyhow!("call_func: null funcref {fn_index}"))?;
func.call(&mut *self.caller, &[], &mut [])?; func.call(&mut *self.caller, &[], &mut [])
.map_err(name_trap_frame)?;
Ok(()) Ok(())
} }
} }
/// Prefix a wasmtime trap error with the innermost named WASM frame.
/// Compiled words carry their Forth name in the module name section, so a
/// genuine trap reads "in <WORD>: wasm trap: ...". THROW-driven unwinds
/// also pass through here, but CATCH and `describe_uncaught` key on the
/// shared `throw_code` cell, never on the message, so the wrap is inert
/// for them.
fn name_trap_frame(e: wasmtime::Error) -> wasmtime::Error {
let name = e
.downcast_ref::<wasmtime::WasmBacktrace>()
.and_then(|bt| bt.frames().iter().find_map(|f| f.func_name()))
.map(str::to_string);
match name {
Some(n) => e.context(format!("in {n}")),
None => e,
}
}
/// Wasmtime-based native runtime. /// Wasmtime-based native runtime.
pub struct NativeRuntime { pub struct NativeRuntime {
engine: Engine, engine: Engine,
@@ -293,7 +311,8 @@ impl Runtime for NativeRuntime {
let func = *r let func = *r
.unwrap_func() .unwrap_func()
.ok_or_else(|| anyhow::anyhow!("word {fn_index} is null funcref"))?; .ok_or_else(|| anyhow::anyhow!("word {fn_index} is null funcref"))?;
func.call(&mut self.store, &[], &mut [])?; func.call(&mut self.store, &[], &mut [])
.map_err(name_trap_frame)?;
Ok(()) Ok(())
} }
+376
View File
@@ -0,0 +1,376 @@
//! IR pretty-printer for `SEE-IR` and the `SEE` fallback path.
//!
//! Renders a post-optimization IR body as indented, one-op-per-line text.
//! Simple ops print as short lowercase mnemonics (Forth glyphs where they
//! are universally recognizable: `@`, `!`, `0=`, `>r`, ...); structured ops
//! print as Forth control words with 2-space indented bodies. Calls resolve
//! `WordId`s to names through an optional resolver so the formatter itself
//! stays independent of the VM.
use crate::dictionary::WordId;
use crate::ir::IrOp;
/// Format an IR body as indented, one-op-per-line text.
pub fn format_ir(ops: &[IrOp]) -> String {
format_ir_with(ops, &|_| None)
}
/// Like [`format_ir`], resolving `Call`/`TailCall`/`Execute` targets to word
/// names via `resolve`; unresolved ids print as `#N`.
pub fn format_ir_with(ops: &[IrOp], resolve: &dyn Fn(WordId) -> Option<String>) -> String {
let mut out = String::new();
write_ops(&mut out, ops, 0, resolve);
out
}
fn line(out: &mut String, depth: usize, text: &str) {
for _ in 0..depth {
out.push_str(" ");
}
out.push_str(text);
out.push('\n');
}
fn callee(id: WordId, resolve: &dyn Fn(WordId) -> Option<String>) -> String {
resolve(id).unwrap_or_else(|| format!("#{}", id.0))
}
fn write_ops(
out: &mut String,
ops: &[IrOp],
depth: usize,
resolve: &dyn Fn(WordId) -> Option<String>,
) {
for op in ops {
write_op(out, op, depth, resolve);
}
}
fn write_op(out: &mut String, op: &IrOp, depth: usize, resolve: &dyn Fn(WordId) -> Option<String>) {
// Exhaustive on purpose: a new IrOp variant must show up here at
// compile time, not silently render wrong.
let simple: String = match op {
// -- Literals --
IrOp::PushI32(v) => format!("push {v}"),
IrOp::PushI64(v) => format!("push64 {v}"),
IrOp::PushF64(v) => format!("fpush {v}"),
// -- Stack manipulation --
IrOp::Drop => "drop".into(),
IrOp::Dup => "dup".into(),
IrOp::Swap => "swap".into(),
IrOp::Over => "over".into(),
IrOp::Rot => "rot".into(),
IrOp::Nip => "nip".into(),
IrOp::Tuck => "tuck".into(),
IrOp::TwoDup => "2dup".into(),
IrOp::TwoDrop => "2drop".into(),
// -- Arithmetic --
IrOp::Add => "add".into(),
IrOp::Sub => "sub".into(),
IrOp::Mul => "mul".into(),
IrOp::DivMod => "divmod".into(),
IrOp::Negate => "negate".into(),
IrOp::Abs => "abs".into(),
// -- Comparison --
IrOp::Eq => "eq".into(),
IrOp::NotEq => "ne".into(),
IrOp::Lt => "lt".into(),
IrOp::Gt => "gt".into(),
IrOp::LtUnsigned => "u<".into(),
IrOp::ZeroEq => "0=".into(),
IrOp::ZeroLt => "0<".into(),
// -- Logic --
IrOp::And => "and".into(),
IrOp::Or => "or".into(),
IrOp::Xor => "xor".into(),
IrOp::Invert => "invert".into(),
IrOp::Lshift => "lshift".into(),
IrOp::Rshift => "rshift".into(),
IrOp::ArithRshift => "arshift".into(),
// -- Memory --
IrOp::Fetch => "@".into(),
IrOp::Store => "!".into(),
IrOp::CFetch => "c@".into(),
IrOp::CStore => "c!".into(),
IrOp::PlusStore => "+!".into(),
// -- Calls --
IrOp::Call(id) => format!("call {}", callee(*id, resolve)),
IrOp::TailCall(id) => format!("tail-call {}", callee(*id, resolve)),
// -- Structured control flow (multi-line) --
IrOp::If {
then_body,
else_body,
} => {
line(out, depth, "if");
write_ops(out, then_body, depth + 1, resolve);
if let Some(eb) = else_body {
line(out, depth, "else");
write_ops(out, eb, depth + 1, resolve);
}
line(out, depth, "then");
return;
}
IrOp::DoLoop { body, is_plus_loop } => {
line(out, depth, "do");
write_ops(out, body, depth + 1, resolve);
line(out, depth, if *is_plus_loop { "+loop" } else { "loop" });
return;
}
IrOp::BeginUntil { body } => {
line(out, depth, "begin");
write_ops(out, body, depth + 1, resolve);
line(out, depth, "until");
return;
}
IrOp::BeginAgain { body } => {
line(out, depth, "begin");
write_ops(out, body, depth + 1, resolve);
line(out, depth, "again");
return;
}
IrOp::BeginWhileRepeat { test, body } => {
line(out, depth, "begin");
write_ops(out, test, depth + 1, resolve);
line(out, depth, "while");
write_ops(out, body, depth + 1, resolve);
line(out, depth, "repeat");
return;
}
IrOp::BeginDoubleWhileRepeat {
outer_test,
inner_test,
body,
after_repeat,
else_body,
} => {
line(out, depth, "begin");
write_ops(out, outer_test, depth + 1, resolve);
line(out, depth, "while");
write_ops(out, inner_test, depth + 1, resolve);
line(out, depth, "while");
write_ops(out, body, depth + 1, resolve);
line(out, depth, "repeat");
write_ops(out, after_repeat, depth + 1, resolve);
if let Some(eb) = else_body {
line(out, depth, "else");
write_ops(out, eb, depth + 1, resolve);
}
line(out, depth, "then");
return;
}
IrOp::Exit => "exit".into(),
IrOp::LoopRestartIfFalse => "loop-restart-if-false".into(),
// -- Flat forward branches --
IrOp::Block(l) => format!("block L{l}"),
IrOp::BranchIfFalse(l) => format!("branch-if-false L{l}"),
IrOp::EndBlock(l) => format!("end-block L{l}"),
// -- Return stack --
IrOp::ToR => ">r".into(),
IrOp::FromR => "r>".into(),
IrOp::RFetch => "r@".into(),
IrOp::LoopJ => "j".into(),
// -- Forth locals --
IrOp::ForthLocalGet(n) => format!("local@ {n}"),
IrOp::ForthLocalSet(n) => format!("local! {n}"),
IrOp::ForthFLocalGet(n) => format!("flocal@ {n}"),
IrOp::ForthFLocalSet(n) => format!("flocal! {n}"),
// -- I/O --
IrOp::Emit => "emit".into(),
IrOp::Dot => ".".into(),
IrOp::Cr => "cr".into(),
IrOp::Type => "type".into(),
// -- System --
IrOp::Execute => "execute".into(),
IrOp::SpFetch => "sp@".into(),
IrOp::RpFetch => "rp@".into(),
// -- Float stack --
IrOp::FDup => "fdup".into(),
IrOp::FDrop => "fdrop".into(),
IrOp::FSwap => "fswap".into(),
IrOp::FOver => "fover".into(),
// -- Float arithmetic --
IrOp::FAdd => "fadd".into(),
IrOp::FSub => "fsub".into(),
IrOp::FMul => "fmul".into(),
IrOp::FDiv => "fdiv".into(),
IrOp::FNegate => "fnegate".into(),
IrOp::FAbs => "fabs".into(),
IrOp::FSqrt => "fsqrt".into(),
IrOp::FMin => "fmin".into(),
IrOp::FMax => "fmax".into(),
IrOp::FFloor => "ffloor".into(),
IrOp::FRound => "fround".into(),
// -- Float comparisons --
IrOp::FZeroEq => "f0=".into(),
IrOp::FZeroLt => "f0<".into(),
IrOp::FEq => "f=".into(),
IrOp::FLt => "f<".into(),
// -- Float memory --
IrOp::FetchFloat => "f@".into(),
IrOp::StoreFloat => "f!".into(),
// -- Conversions --
IrOp::StoF => "s>f".into(),
IrOp::FtoS => "f>s".into(),
};
line(out, depth, &simple);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_ops_one_per_line() {
let out = format_ir(&[IrOp::Dup, IrOp::Mul, IrOp::PushI32(7)]);
assert_eq!(out, "dup\nmul\npush 7\n");
}
#[test]
fn call_resolves_via_resolver() {
let ops = [IrOp::Call(WordId(12)), IrOp::TailCall(WordId(13))];
assert_eq!(format_ir(&ops), "call #12\ntail-call #13\n");
let named = format_ir_with(&ops, &|id| (id.0 == 12).then(|| "SQ".to_string()));
assert_eq!(named, "call SQ\ntail-call #13\n");
}
#[test]
fn nested_if_inside_do_loop_indents() {
let ops = [IrOp::DoLoop {
body: vec![
IrOp::Dup,
IrOp::If {
then_body: vec![IrOp::Dup, IrOp::Mul],
else_body: Some(vec![IrOp::Drop]),
},
],
is_plus_loop: false,
}];
let expected = "do\n dup\n if\n dup\n mul\n else\n drop\n then\nloop\n";
assert_eq!(format_ir(&ops), expected);
}
#[test]
fn while_loops_and_flat_branches() {
let ops = [
IrOp::BeginWhileRepeat {
test: vec![IrOp::Dup],
body: vec![IrOp::PushI32(1), IrOp::Sub],
},
IrOp::Block(3),
IrOp::BranchIfFalse(3),
IrOp::EndBlock(3),
];
let expected = "begin\n dup\nwhile\n push 1\n sub\nrepeat\nblock L3\nbranch-if-false L3\nend-block L3\n";
assert_eq!(format_ir(&ops), expected);
}
#[test]
fn every_simple_variant_renders() {
// One of each non-structured op; count of output lines must match.
let ops = vec![
IrOp::PushI32(1),
IrOp::PushI64(2),
IrOp::PushF64(1.5),
IrOp::Drop,
IrOp::Dup,
IrOp::Swap,
IrOp::Over,
IrOp::Rot,
IrOp::Nip,
IrOp::Tuck,
IrOp::TwoDup,
IrOp::TwoDrop,
IrOp::Add,
IrOp::Sub,
IrOp::Mul,
IrOp::DivMod,
IrOp::Negate,
IrOp::Abs,
IrOp::Eq,
IrOp::NotEq,
IrOp::Lt,
IrOp::Gt,
IrOp::LtUnsigned,
IrOp::ZeroEq,
IrOp::ZeroLt,
IrOp::And,
IrOp::Or,
IrOp::Xor,
IrOp::Invert,
IrOp::Lshift,
IrOp::Rshift,
IrOp::ArithRshift,
IrOp::Fetch,
IrOp::Store,
IrOp::CFetch,
IrOp::CStore,
IrOp::PlusStore,
IrOp::Call(WordId(1)),
IrOp::TailCall(WordId(2)),
IrOp::Exit,
IrOp::LoopRestartIfFalse,
IrOp::Block(1),
IrOp::BranchIfFalse(1),
IrOp::EndBlock(1),
IrOp::ToR,
IrOp::FromR,
IrOp::RFetch,
IrOp::LoopJ,
IrOp::ForthLocalGet(0),
IrOp::ForthLocalSet(0),
IrOp::ForthFLocalGet(0),
IrOp::ForthFLocalSet(0),
IrOp::Emit,
IrOp::Dot,
IrOp::Cr,
IrOp::Type,
IrOp::Execute,
IrOp::SpFetch,
IrOp::RpFetch,
IrOp::FDup,
IrOp::FDrop,
IrOp::FSwap,
IrOp::FOver,
IrOp::FAdd,
IrOp::FSub,
IrOp::FMul,
IrOp::FDiv,
IrOp::FNegate,
IrOp::FAbs,
IrOp::FSqrt,
IrOp::FMin,
IrOp::FMax,
IrOp::FFloor,
IrOp::FRound,
IrOp::FZeroEq,
IrOp::FZeroLt,
IrOp::FEq,
IrOp::FLt,
IrOp::FetchFloat,
IrOp::StoreFloat,
IrOp::StoF,
IrOp::FtoS,
];
let out = format_ir(&ops);
assert_eq!(out.lines().count(), ops.len());
// Every line non-empty, no accidental blank rendering.
assert!(out.lines().all(|l| !l.trim().is_empty()));
}
}
File diff suppressed because it is too large Load Diff
+197 -55
View File
@@ -1,8 +1,10 @@
#![allow(dead_code)] #![allow(dead_code)]
//! Cross-engine comparison tests: WAFER vs gforth. //! Cross-engine comparison tests: WAFER vs gforth (and `SwiftForth` for perf).
//! //!
//! Validates that WAFER produces identical output to gforth for standard //! Validates that WAFER produces identical output to gforth for standard
//! Forth programs, and benchmarks performance of both engines. //! Forth programs, and benchmarks performance of the engines. `SwiftForth`
//! (`sf64`, native-code commercial compiler) joins the performance report
//! as an upper-bound reference when installed.
//! //!
//! WAFER-only correctness: `cargo test -p wafer-core --test comparison` //! WAFER-only correctness: `cargo test -p wafer-core --test comparison`
//! Full comparison + perf: `cargo test -p wafer-core --test comparison -- --nocapture --ignored` //! Full comparison + perf: `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
@@ -63,6 +65,48 @@ fn find_gforth_fast() -> Option<&'static str> {
.as_deref() .as_deref()
} }
// -----------------------------------------------------------------------
// SwiftForth (sf64) discovery (cached)
// -----------------------------------------------------------------------
static SF64_PATH: OnceLock<Option<String>> = OnceLock::new();
/// Probe sf64 by piping `bye` via stdin — sf64 has no `-e` flag; it takes
/// Forth source from stdin or as bare command-line arguments.
fn probe_sf64(candidate: &str) -> bool {
run_via_stdin(candidate, "bye\n").is_some_and(|o| o.status.success())
}
fn find_sf64() -> Option<&'static str> {
SF64_PATH
.get_or_init(|| {
for candidate in &["/Applications/ForthInc/SwiftForth/bin/macos/sf64", "sf64"] {
if probe_sf64(candidate) {
return Some(candidate.to_string());
}
}
None
})
.as_deref()
}
/// Spawn `binary`, write `input` to its stdin, and collect the output.
fn run_via_stdin(binary: &str, input: &str) -> Option<std::process::Output> {
Command::new(binary)
// Perf lanes measure unguarded code (only the wafer binary reads this)
.env("WAFER_STACK_GUARDS", "0")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(input.as_bytes())?;
child.wait_with_output()
})
.ok()
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Engine runners // Engine runners
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -409,6 +453,21 @@ fn programs() -> Vec<Program> {
expected: "99 \n", expected: "99 \n",
category: Category::Definitions, category: Category::Definitions,
}, },
Program {
name: "search-order-hides",
code: "WORDLIST CONSTANT MY-WL\n\
MY-WL SET-CURRENT\n\
: SECRET 42 ;\n\
FORTH-WORDLIST SET-CURRENT\n\
[UNDEFINED] SECRET . CR\n\
GET-ORDER MY-WL SWAP 1+ SET-ORDER\n\
[DEFINED] SECRET . CR\n\
SECRET . CR\n\
-1 SET-ORDER\n\
[UNDEFINED] SECRET . CR",
expected: "-1 \n-1 \n42 \n-1 \n",
category: Category::Definitions,
},
// -- Strings -- // -- Strings --
Program { Program {
name: "s-quote-type", name: "s-quote-type",
@@ -580,6 +639,81 @@ fn compare_all_programs() {
); );
} }
// -----------------------------------------------------------------------
// Cross-engine behavioral comparison (requires SwiftForth sf64) -- WS-003
// -----------------------------------------------------------------------
/// Run Forth code through `SwiftForth`. Piped sf64 is quiet (no banner, no
/// `ok` echo), truncates input lines at ~256 chars, and exits 243 after an
/// error, so statements are fed one per line with a final `bye`.
fn run_sf64_code(sf64: &str, code: &str) -> Option<EngineResult> {
let mut input = String::new();
for line in code.lines() {
let t = line.trim();
if !t.is_empty() {
input.push_str(t);
input.push('\n');
}
}
input.push_str("bye\n");
let out = run_via_stdin(sf64, &input)?;
Some(EngineResult {
output: String::from_utf8_lossy(&out.stdout).to_string(),
success: out.status.success(),
})
}
/// Correctness lane against `SwiftForth`: the same program corpus as the
/// gforth comparison, sf64 as the oracle. Skips gracefully when sf64 is
/// not installed (CI/linux). Programs listed in `SF64_SKIP` use words or
/// output conventions `SwiftForth` does not share.
#[test]
#[ignore = "requires SwiftForth sf64 (run with -- --ignored)"]
fn compare_all_programs_sf64() {
// dot-quote: `."` outside a definition is a no-op in SwiftForth
// (compile-only); WAFER supports the interpret-mode extension.
const SF64_SKIP: &[&str] = &["dot-quote"];
let Some(sf64) = find_sf64() else {
eprintln!("SKIP: sf64 not found");
return;
};
let progs = programs();
let mut passed = 0;
let mut skipped = 0;
for prog in &progs {
if SF64_SKIP.contains(&prog.name) {
skipped += 1;
continue;
}
let wafer = run_wafer(prog.code);
assert!(wafer.success, "{}: WAFER execution failed", prog.name);
let Some(sf) = run_sf64_code(sf64, prog.code) else {
skipped += 1;
continue;
};
if !sf.success {
eprintln!(" WARN {}: sf64 execution failed, skipping", prog.name);
skipped += 1;
continue;
}
// SwiftForth prints numbers space-prefixed and echoes piped input
// lines, so byte-exact comparison is meaningless; compare the
// whitespace-token stream (the printed values and strings).
let wafer_tokens: Vec<&str> = wafer.output.split_whitespace().collect();
let sf_tokens: Vec<&str> = sf.output.split_whitespace().collect();
assert_eq!(
wafer_tokens, sf_tokens,
"{}: output differs\n WAFER: {:?}\n sf64: {:?}",
prog.name, wafer.output, sf.output
);
passed += 1;
}
eprintln!(
"\nsf64 behavioral comparison: {passed} passed, {skipped} skipped (of {})",
progs.len()
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Performance comparison (requires gforth) // Performance comparison (requires gforth)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -698,31 +832,11 @@ fn measure_wafer_release(wafer: &str, bench: &PerfBenchmark) -> Option<u64> {
define = bench.define, define = bench.define,
run = bench.run_code, run = bench.run_code,
); );
let output = Command::new(wafer) let output = run_via_stdin(wafer, &code)?;
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(code.as_bytes())?;
child.wait_with_output()
})
.ok()?;
if !output.status.success() { if !output.status.success() {
return None; return None;
} }
let stdout = String::from_utf8_lossy(&output.stdout); median_printed_time(&output.stdout)
let mut times: Vec<u64> = stdout
.trim()
.lines()
.filter_map(|l| l.trim().parse::<u64>().ok())
.collect();
times.sort();
if times.is_empty() {
return None;
}
Some(times[times.len() / 2])
} }
/// Measure WAFER execution time after CONSOLIDATE (direct calls between all words). /// Measure WAFER execution time after CONSOLIDATE (direct calls between all words).
@@ -734,21 +848,17 @@ fn measure_wafer_consolidated(wafer: &str, bench: &PerfBenchmark) -> Option<u64>
define = bench.define, define = bench.define,
run = bench.run_code, run = bench.run_code,
); );
let output = Command::new(wafer) let output = run_via_stdin(wafer, &code)?;
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(code.as_bytes())?;
child.wait_with_output()
})
.ok()?;
if !output.status.success() { if !output.status.success() {
return None; return None;
} }
let stdout = String::from_utf8_lossy(&output.stdout); median_printed_time(&output.stdout)
}
/// Parse the microsecond values printed by TIMED-BENCH (one per line) and
/// return the median.
fn median_printed_time(stdout: &[u8]) -> Option<u64> {
let stdout = String::from_utf8_lossy(stdout);
let mut times: Vec<u64> = stdout let mut times: Vec<u64> = stdout
.trim() .trim()
.lines() .lines()
@@ -778,18 +888,28 @@ fn measure_gforth(gforth: &str, bench: &PerfBenchmark) -> Option<u64> {
if !output.status.success() { if !output.status.success() {
return None; return None;
} }
let stdout = String::from_utf8_lossy(&output.stdout); median_printed_time(&output.stdout)
// Parse the 3 timing values and take the median }
let mut times: Vec<u64> = stdout
.trim() /// Measure `SwiftForth` (`sf64`) execution time using Forth-level `ucounter`
.lines() /// (double-cell microsecond counter; `2swap d- drop` yields elapsed us —
.filter_map(|l| l.trim().parse::<u64>().ok()) /// the same wrapper shape as gforth's `utime`). Timing excludes startup.
.collect(); /// sf64 has no `-e` flag, so the program is piped via stdin — one statement
times.sort(); /// per line, because sf64 truncates input lines at ~256 chars.
if times.is_empty() { /// Returns microseconds, or None if sf64 is unavailable or fails.
fn measure_sf64(sf64: &str, bench: &PerfBenchmark) -> Option<u64> {
let code = format!(
"{define}\n{run}\n\
: TIMED-BENCH ucounter {run} ucounter 2swap d- drop . cr ;\n\
TIMED-BENCH\nTIMED-BENCH\nTIMED-BENCH\nbye\n",
define = bench.define,
run = bench.run_code,
);
let output = run_via_stdin(sf64, &code)?;
if !output.status.success() {
return None; return None;
} }
Some(times[times.len() / 2]) median_printed_time(&output.stdout)
} }
#[test] #[test]
@@ -830,18 +950,31 @@ fn performance_report() {
); );
} }
let sep = "=".repeat(80); let sf64 = find_sf64();
let thin = "-".repeat(80); if sf64.is_none() {
eprintln!("NOTE: sf64 (SwiftForth) not found — column skipped");
}
let sep = "=".repeat(100);
let thin = "-".repeat(100);
println!("\n{sep}"); println!("\n{sep}");
println!(" WAFER vs Gforth Performance Comparison (release mode)"); println!(" WAFER vs Gforth vs SwiftForth Performance Comparison (release mode)");
println!("{sep}\n"); println!("{sep}\n");
println!( println!(
"{:<22} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}", "{:<22} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
"Benchmark", "WAFER", "CONSOL", "gforth", "gf-fast", "WAFER/gf", "limit" "Benchmark",
"WAFER",
"CONSOL",
"gforth",
"gf-fast",
"sf64",
"WAFER/gf",
"WAFER/sf",
"limit"
); );
println!( println!(
"{:<22} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}", "{:<22} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
"", "(us)", "(us)", "(us)", "(us)", "", "" "", "(us)", "(us)", "(us)", "(us)", "(us)", "", "", ""
); );
println!("{thin}"); println!("{thin}");
@@ -856,9 +989,11 @@ fn performance_report() {
.unwrap_or(0); .unwrap_or(0);
let gf = gforth.and_then(|g| measure_gforth(g, bench)); let gf = gforth.and_then(|g| measure_gforth(g, bench));
let gf_fast = gforth_fast.and_then(|g| measure_gforth(g, bench)); let gf_fast = gforth_fast.and_then(|g| measure_gforth(g, bench));
let sf = sf64.and_then(|s| measure_sf64(s, bench));
let gf_str = gf.map_or_else(|| "-".to_string(), |v| format!("{v}")); let gf_str = gf.map_or_else(|| "-".to_string(), |v| format!("{v}"));
let gf_fast_str = gf_fast.map_or_else(|| "-".to_string(), |v| format!("{v}")); let gf_fast_str = gf_fast.map_or_else(|| "-".to_string(), |v| format!("{v}"));
let sf_str = sf.map_or_else(|| "-".to_string(), |v| format!("{v}"));
let best_wafer = if consol > 0 && consol < wafer { let best_wafer = if consol > 0 && consol < wafer {
consol consol
} else { } else {
@@ -872,11 +1007,15 @@ fn performance_report() {
} }
}); });
let ratio = ratio_val.map_or_else(|| "-".to_string(), |r| format!("{r:.2}x")); let ratio = ratio_val.map_or_else(|| "-".to_string(), |r| format!("{r:.2}x"));
let sf_ratio = sf.filter(|&s| s > 0).map_or_else(
|| "-".to_string(),
|s| format!("{:.2}x", best_wafer as f64 / s as f64),
);
let limit_str = format!("{:.2}x", bench.max_ratio); let limit_str = format!("{:.2}x", bench.max_ratio);
println!( println!(
"{:<22} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}", "{:<22} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}",
bench.name, wafer, consol, gf_str, gf_fast_str, ratio, limit_str bench.name, wafer, consol, gf_str, gf_fast_str, sf_str, ratio, sf_ratio, limit_str
); );
// Check regression limits // Check regression limits
@@ -899,6 +1038,9 @@ fn performance_report() {
println!("{thin}"); println!("{thin}");
println!(" WAFER = all optimizations, CONSOL = after CONSOLIDATE"); println!(" WAFER = all optimizations, CONSOL = after CONSOLIDATE");
println!(" WAFER/gf = best(WAFER,CONSOL) vs gforth, < 1.0 means WAFER faster"); println!(" WAFER/gf = best(WAFER,CONSOL) vs gforth, < 1.0 means WAFER faster");
println!(
" WAFER/sf = best(WAFER,CONSOL) vs SwiftForth sf64 (native code; informational, no limit)"
);
println!("{sep}\n"); println!("{sep}\n");
if !regressions.is_empty() { if !regressions.is_empty() {
+6 -1
View File
@@ -105,8 +105,13 @@ fn expected_load_failures(path: &str) -> u32 {
// TRAVERSE-WORDLIST / NAME>COMPILE / NAME>INTERPRET blocks leak as // TRAVERSE-WORDLIST / NAME>COMPILE / NAME>INTERPRET blocks leak as
// unknown-word errors. Fix the SOURCE/`>IN` interaction with // unknown-word errors. Fix the SOURCE/`>IN` interaction with
// line-mode input and drop this to 0. // line-mode input and drop this to 0.
//
// The 38th: line 368 `R> DROP TRUE` runs interpreted (its enclosing
// definition aborted on the missing NAME?), and the bare `R>` used
// to underflow the return stack silently; stack guards now report
// it as "Return stack underflow (throw -6)".
if path.ends_with("/toolstest.fth") { if path.ends_with("/toolstest.fth") {
return 37; return 38;
} }
0 0
} }
+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
wafer-core = { path = "../core", version = "0.1.0", default-features = false, features = ["crypto"] } wafer-core = { path = "../core", version = "0.2.4", 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 }
+8 -4
View File
@@ -6,8 +6,9 @@ use send_wrapper::SendWrapper;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wafer_core::config::WaferConfig; use wafer_core::config::WaferConfig;
use wafer_core::memory::{CELL_SIZE, PAD_BASE, PAD_SIZE}; use wafer_core::memory::{CELL_SIZE, PAD_BASE, PAD_SIZE, SYSVAR_BASE_VAR};
use wafer_core::outer::ForthVM; use wafer_core::outer::ForthVM;
use wafer_core::runtime::Runtime;
use wafer_core::runtime::{HostAccess, HostFn}; use wafer_core::runtime::{HostAccess, HostFn};
use crate::runtime_web::WebRuntime; use crate::runtime_web::WebRuntime;
@@ -53,9 +54,12 @@ impl WaferRepl {
/// Get the current number base (10 = decimal, 16 = hex). /// Get the current number base (10 = decimal, 16 = hex).
pub fn base(&mut self) -> u32 { pub fn base(&mut self) -> u32 {
// BASE is stored at SYSVAR_BASE_VAR in WASM memory self.vm.runtime_mut().mem_read_i32(SYSVAR_BASE_VAR) as u32
self.vm.take_output(); // no-op side effect; just return base }
10 // TODO: read from memory once we have a getter
/// Names of all user-facing words (visible, non-internal), newest first.
pub fn words(&self) -> Vec<String> {
self.vm.word_names()
} }
/// Reset the VM to initial state. /// Reset the VM to initial state.
+19 -2
View File
@@ -38,6 +38,23 @@ impl WebHostAccess {
} }
} }
/// An exception on its way back out of compiled code. Host words rethrow the
/// Forth message (`Stack underflow`, an `ABORT"` text, a `THROW` description),
/// so surface exactly that and nothing else — the JS `Error` carries the whole
/// engine stack in its message, which is noise to a Forth programmer. Anything
/// without a message is a genuine runtime fault and keeps the call context.
fn call_error(fn_index: u32, e: &JsValue) -> anyhow::Error {
match Reflect::get(e, &"message".into())
.ok()
.and_then(|m| m.as_string())
.and_then(|m| m.lines().next().map(str::trim).map(str::to_string))
.filter(|m| !m.is_empty())
{
Some(msg) => anyhow::anyhow!("{msg}"),
None => anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"),
}
}
impl HostAccess for WebHostAccess { impl HostAccess for WebHostAccess {
fn mem_read_i32(&mut self, addr: u32) -> i32 { fn mem_read_i32(&mut self, addr: u32) -> i32 {
let view = js_sys::Int32Array::new(&self.buffer()); let view = js_sys::Int32Array::new(&self.buffer());
@@ -134,7 +151,7 @@ impl HostAccess for WebHostAccess {
.dyn_into() .dyn_into()
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?; .map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?;
func.call0(&JsValue::NULL) func.call0(&JsValue::NULL)
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?; .map_err(|e| call_error(fn_index, &e))?;
Ok(()) Ok(())
} }
} }
@@ -406,7 +423,7 @@ impl Runtime for WebRuntime {
.dyn_into() .dyn_into()
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?; .map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?;
func.call0(&JsValue::NULL) func.call0(&JsValue::NULL)
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?; .map_err(|e| call_error(fn_index, &e))?;
Ok(()) Ok(())
} }
+42 -23
View File
@@ -1,8 +1,11 @@
import init, { WaferRepl } from './pkg/wafer_web.js'; import init, { WaferRepl } from './pkg/wafer_web.js';
let repl = null; let repl = null;
const history = []; const HISTORY_KEY = 'wafer-history';
let historyIdx = -1; const HISTORY_MAX = 200;
const history = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
let historyIdx = history.length;
let builtinWords = null;
const WORD_CATEGORIES = { const WORD_CATEGORIES = {
'Stack': 'DUP DROP SWAP OVER ROT NIP TUCK 2DUP 2DROP 2SWAP 2OVER PICK ROLL DEPTH .S'.split(' '), 'Stack': 'DUP DROP SWAP OVER ROT NIP TUCK 2DUP 2DROP 2SWAP 2OVER PICK ROLL DEPTH .S'.split(' '),
@@ -39,10 +42,12 @@ function updateStack() {
if (!repl) return; if (!repl) return;
try { try {
const stack = repl.data_stack(); const stack = repl.data_stack();
const base = repl.base();
const suffix = base !== 10 ? ` [base ${base}]` : '';
if (stack.length === 0) { if (stack.length === 0) {
stackBar.textContent = 'Stack: (empty)'; stackBar.textContent = `Stack: (empty)${suffix}`;
} else { } else {
stackBar.textContent = `Stack <${stack.length}> ${stack.join(' ')}`; stackBar.textContent = `Stack <${stack.length}> ${stack.join(' ')}${suffix}`;
} }
} catch { } catch {
stackBar.textContent = 'Stack: (error)'; stackBar.textContent = 'Stack: (error)';
@@ -50,19 +55,25 @@ function updateStack() {
} }
function updateUserWords() { function updateUserWords() {
const cat = document.getElementById('cat-user'); const list = document.getElementById('user-word-list');
if (!cat) return; if (!list || !repl || !builtinWords) return;
// We'll track user words by checking what the REPL evaluates list.innerHTML = '';
// For now, just show the category for (const w of repl.words()) {
if (!builtinWords.has(w)) list.appendChild(wordChip(w));
}
} }
function evaluate(line) { function evaluate(line, record = true) {
if (!repl) return; if (!repl) return;
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) return; if (!trimmed) return;
// Add to history // Add to history (user-typed lines only; skip consecutive duplicates)
history.push(trimmed); if (record && history[history.length - 1] !== trimmed) {
history.push(trimmed);
if (history.length > HISTORY_MAX) history.splice(0, history.length - HISTORY_MAX);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
historyIdx = history.length; historyIdx = history.length;
try { try {
@@ -83,6 +94,7 @@ function evaluate(line) {
updatePrompt(); updatePrompt();
updateStack(); updateStack();
updateUserWords();
} }
// Input handling // Input handling
@@ -116,6 +128,18 @@ document.getElementById('btn-toggle-words').addEventListener('click', () => {
document.getElementById('word-panel').classList.toggle('collapsed'); document.getElementById('word-panel').classList.toggle('collapsed');
}); });
function wordChip(w) {
const chip = document.createElement('span');
chip.className = 'word-chip';
chip.textContent = w;
chip.title = w;
chip.addEventListener('click', () => {
input.value += (input.value.length > 0 ? ' ' : '') + w;
input.focus();
});
return chip;
}
function buildWordPanel() { function buildWordPanel() {
const container = document.getElementById('word-categories'); const container = document.getElementById('word-categories');
container.innerHTML = ''; container.innerHTML = '';
@@ -129,15 +153,7 @@ function buildWordPanel() {
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'word-list'; list.className = 'word-list';
for (const w of words) { for (const w of words) {
const chip = document.createElement('span'); list.appendChild(wordChip(w));
chip.className = 'word-chip';
chip.textContent = w;
chip.title = w;
chip.addEventListener('click', () => {
input.value += (input.value.length > 0 ? ' ' : '') + w;
input.focus();
});
list.appendChild(chip);
} }
cat.appendChild(list); cat.appendChild(list);
container.appendChild(cat); container.appendChild(cat);
@@ -179,7 +195,7 @@ document.getElementById('btn-run-init').addEventListener('click', () => {
if (code.trim()) { if (code.trim()) {
// Run each line separately // Run each line separately
for (const line of code.split('\n')) { for (const line of code.split('\n')) {
if (line.trim()) evaluate(line); if (line.trim()) evaluate(line, false);
} }
} }
localStorage.setItem('wafer-init-code', code); localStorage.setItem('wafer-init-code', code);
@@ -214,6 +230,7 @@ document.getElementById('btn-reset').addEventListener('click', () => {
appendLine('WAFER reset.', 'line-ok'); appendLine('WAFER reset.', 'line-ok');
updatePrompt(); updatePrompt();
updateStack(); updateStack();
updateUserWords();
} catch (e) { } catch (e) {
appendLine(`Reset error: ${e.message}`, 'line-error'); appendLine(`Reset error: ${e.message}`, 'line-error');
} }
@@ -225,6 +242,8 @@ async function boot() {
try { try {
await init(); await init();
repl = new WaferRepl(); repl = new WaferRepl();
// Everything defined at boot is "builtin"; later definitions are user words
builtinWords = new Set(repl.words());
output.innerHTML = ''; output.innerHTML = '';
appendLine('WAFER — WebAssembly Forth Engine in Rust', 'line-output'); appendLine('WAFER — WebAssembly Forth Engine in Rust', 'line-output');
appendLine(`Type Forth at the > prompt. Press ? for help.`, 'line-output'); appendLine(`Type Forth at the > prompt. Press ? for help.`, 'line-output');
@@ -241,7 +260,7 @@ async function boot() {
const initCode = document.getElementById('init-code').value; const initCode = document.getElementById('init-code').value;
if (initCode.trim()) { if (initCode.trim()) {
for (const line of initCode.split('\n')) { for (const line of initCode.split('\n')) {
if (line.trim()) evaluate(line); if (line.trim()) evaluate(line, false);
} }
localStorage.setItem('wafer-init-code', initCode); localStorage.setItem('wafer-init-code', initCode);
} }
@@ -252,7 +271,7 @@ async function boot() {
const code = atob(location.hash.slice(1)); const code = atob(location.hash.slice(1));
document.getElementById('init-code').value = code; document.getElementById('init-code').value = code;
for (const line of code.split('\n')) { for (const line of code.split('\n')) {
if (line.trim()) evaluate(line); if (line.trim()) evaluate(line, false);
} }
} catch { /* ignore bad hash */ } } catch { /* ignore bad hash */ }
} }
+2 -2
View File
@@ -18,11 +18,11 @@ confidence-threshold = 0.8
[bans] [bans]
multiple-versions = "deny" multiple-versions = "deny"
wildcards = "deny" wildcards = "deny"
# Transitive duplicates from wasmtime v31 -- will resolve when upgrading # Transitive duplicates from wasmtime v47 dependencies
skip = [ skip = [
"getrandom", "getrandom",
"syn",
"hashbrown", "hashbrown",
"r-efi",
"thiserror", "thiserror",
"thiserror-impl", "thiserror-impl",
"wasm-encoder", "wasm-encoder",
+32 -10
View File
@@ -33,7 +33,10 @@ contexts:
- include: compare - include: compare
- include: memory - include: memory
- include: io - include: io
- include: pictured
- include: string_ops
- include: float - include: float
- include: tools
- include: dictionary - include: dictionary
- include: exception - include: exception
- include: parsing - include: parsing
@@ -95,27 +98,31 @@ contexts:
# Quotations (Core-Ext 6.2.0455): [: ... ;] compiles an anonymous word. # Quotations (Core-Ext 6.2.0455): [: ... ;] compiles an anonymous word.
- match: '(?i)(?:^|(?<=\s))(\[:|;\]){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(\[:|;\]){{ident_break}}'
scope: keyword.other.definition.forth scope: keyword.other.definition.forth
- match: '(?i)(?:^|(?<=\s))(VARIABLE|2VARIABLE|CONSTANT|2CONSTANT|VALUE|CREATE|DEFER|MARKER|BUFFER:|FCONSTANT|FVARIABLE)(\s+)(\S+)?' - match: '(?i)(?:^|(?<=\s))(VARIABLE|2VARIABLE|CONSTANT|2CONSTANT|VALUE|CREATE|DEFER|MARKER|REMEMBER|BUFFER:|FCONSTANT|FVARIABLE)(\s+)(\S+)?'
captures: captures:
1: keyword.other.defining.forth 1: keyword.other.defining.forth
3: entity.name.constant.forth 3: entity.name.constant.forth
- match: '(?i)(?:^|(?<=\s))(DOES>|IMMEDIATE|RECURSE|POSTPONE|COMPILE,|LITERAL|2LITERAL|FLITERAL|SLITERAL){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(DOES>|IMMEDIATE|RECURSE|POSTPONE|COMPILE,|LITERAL|2LITERAL|FLITERAL|SLITERAL|DEFER!|DEFER@){{ident_break}}'
scope: keyword.other.defining.forth scope: keyword.other.defining.forth
control: control:
- match: '(?i)(?:^|(?<=\s))(IF|THEN|ELSE|BEGIN|UNTIL|WHILE|REPEAT|AGAIN|DO|\?DO|LOOP|\+LOOP|LEAVE|UNLOOP|EXIT|CASE|OF|ENDOF|ENDCASE|QUIT){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(IF|THEN|ELSE|BEGIN|UNTIL|WHILE|REPEAT|AGAIN|DO|\?DO|LOOP|\+LOOP|LEAVE|UNLOOP|EXIT|CASE|OF|ENDOF|ENDCASE|QUIT){{ident_break}}'
scope: keyword.control.forth scope: keyword.control.forth
# Conditional compilation (Tools-ext 15.6.2).
- match: '(?i)(?:^|(?<=\s))(\[IF\]|\[ELSE\]|\[THEN\]|\[DEFINED\]|\[UNDEFINED\]){{ident_break}}'
scope: keyword.control.conditional-compilation.forth
stack_ops: stack_ops:
- match: '(?i)(?:^|(?<=\s))(DUP|\?DUP|DROP|SWAP|OVER|ROT|-ROT|NIP|TUCK|PICK|ROLL|2DUP|2DROP|2SWAP|2OVER|2ROT|DEPTH|SP@){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(DUP|\?DUP|DROP|SWAP|OVER|ROT|-ROT|NIP|TUCK|PICK|ROLL|2DUP|2DROP|2SWAP|2OVER|2ROT|DEPTH|SP@){{ident_break}}'
scope: support.function.stack.forth scope: support.function.stack.forth
return_stack: return_stack:
- match: '(?i)(?:^|(?<=\s))(>R|R>|R@|2>R|2R>|2R@|N>R|NR>|I|J|CS-PICK|CS-ROLL){{ident_break}}' # RP@ / RDEPTH are WAFER extensions (gforth-style return-stack access).
- match: '(?i)(?:^|(?<=\s))(>R|R>|R@|2>R|2R>|2R@|N>R|NR>|I|J|CS-PICK|CS-ROLL|RP@|RDEPTH){{ident_break}}'
scope: support.function.return-stack.forth scope: support.function.return-stack.forth
arithmetic: arithmetic:
- match: '(?i)(?:^|(?<=\s))(\+|-|\*|/|MOD|/MOD|\*/|\*/MOD|NEGATE|ABS|MIN|MAX|1\+|1-|2\*|2/|M\*|M\+|M\*/|UM\*|UM/MOD|FM/MOD|SM/REM|S>D|D>S){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(\+|-|\*|/|MOD|/MOD|\*/|\*/MOD|NEGATE|ABS|MIN|MAX|1\+|1-|2\*|2/|M\*|M\+|M\*/|UM\*|UM/MOD|FM/MOD|SM/REM|S>D|D>S|D\+|D-|DNEGATE|DABS|DMAX|DMIN|D2\*|D2/){{ident_break}}'
scope: keyword.operator.arithmetic.forth scope: keyword.operator.arithmetic.forth
logic: logic:
@@ -123,21 +130,36 @@ contexts:
scope: keyword.operator.logical.forth scope: keyword.operator.logical.forth
compare: compare:
- match: '(?i)(?:^|(?<=\s))(=|<>|<|>|<=|>=|U<|U>|0=|0<>|0<|0>){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(=|<>|<|>|<=|>=|U<|U>|0=|0<>|0<|0>|D<|D=|D0<|D0=|DU<|WITHIN){{ident_break}}'
scope: keyword.operator.comparison.forth scope: keyword.operator.comparison.forth
memory: memory:
- match: '(?i)(?:^|(?<=\s))(@|!|C@|C!|\+!|2@|2!|ALLOT|HERE|ALIGN|ALIGNED|CELL\+|CELLS|CHAR\+|CHARS|UNUSED|MOVE|CMOVE|CMOVE>|FILL|ERASE|BLANK|ALLOCATE|FREE|RESIZE|PAD){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(@|!|C@|C!|\+!|2@|2!|C,|ALLOT|HERE|ALIGN|ALIGNED|CELL\+|CELLS|CHAR\+|CHARS|UNUSED|MOVE|CMOVE|CMOVE>|FILL|ERASE|BLANK|ALLOCATE|FREE|RESIZE|PAD){{ident_break}}'
scope: support.function.memory.forth scope: support.function.memory.forth
io: io:
- match: '(?i)(?:^|(?<=\s))(EMIT|CR|SPACE|SPACES|TYPE|\.|U\.|\.R|U\.R|D\.|D\.R|\?|KEY|KEY\?|PAGE|AT-XY|ACCEPT|EXPECT|\.S){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(EMIT|CR|SPACE|SPACES|TYPE|\.|U\.|\.R|U\.R|D\.|D\.R|\?|KEY|KEY\?|PAGE|AT-XY|ACCEPT|EXPECT|\.S|F\.S|\.RS){{ident_break}}'
scope: support.function.io.forth scope: support.function.io.forth
# Pictured numeric output (6.1: <# # #S #> HOLD SIGN; HOLDS is Core-Ext).
pictured:
- match: '(?i)(?:^|(?<=\s))(<#|#>|#S|#|HOLD|HOLDS|SIGN){{ident_break}}'
scope: support.function.pictured.forth
# String word set (17.6).
string_ops:
- match: '(?i)(?:^|(?<=\s))(COUNT|COMPARE|-TRAILING|/STRING){{ident_break}}'
scope: support.function.string.forth
float: float:
- match: '(?i)(?:^|(?<=\s))(F\+|F-|F\*|F/|FNEGATE|FABS|FMAX|FMIN|FSQRT|FFLOOR|FROUND|FSINCOS|F=|F<|F0=|F0<|F~|FDUP|FDROP|FSWAP|FOVER|FROT|FNIP|FTUCK|FDEPTH|F@|F!|FE\.|FS\.|F\.|F>D|D>F|F>S|S>F|>FLOAT|REPRESENT|PRECISION|SET-PRECISION|FALIGNED|DFALIGNED|SFALIGNED|DF@|DF!|SF@|SF!){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(F\+|F-|F\*\*|F\*|F/|FNEGATE|FABS|FMAX|FMIN|FSQRT|FFLOOR|FROUND|FLOOR|FSINCOS|FSINH|FSIN|FCOSH|FCOS|FTANH|FTAN|FASINH|FASIN|FACOSH|FACOS|FATANH|FATAN2|FATAN|FEXPM1|FEXP|FLNP1|FLN|FLOG|FALOG|F=|F<|F0=|F0<|F~|FDUP|FDROP|FSWAP|FOVER|FROT|FNIP|FTUCK|FDEPTH|F@|F!|FE\.|FS\.|F\.|F>D|D>F|F>S|S>F|>FLOAT|REPRESENT|PRECISION|SET-PRECISION|FALIGN|FALIGNED|DFALIGN|DFALIGNED|SFALIGN|SFALIGNED|FLOAT\+|FLOATS|DFLOAT\+|DFLOATS|SFLOAT\+|SFLOATS|DF@|DF!|SF@|SF!){{ident_break}}'
scope: support.function.float.forth scope: support.function.float.forth
# Interactive/debug tools (Tools word set + WAFER REPL additions).
tools:
- match: '(?i)(?:^|(?<=\s))(SEE-IR|SEE|DUMP|BYE|HELP){{ident_break}}'
scope: support.function.tools.forth
dictionary: dictionary:
- match: "(?i)(?:^|(?<=\\s))('|\\[']|,|>BODY|FIND|WORDS|ONLY|ALSO|PREVIOUS|DEFINITIONS|FORTH|GET-ORDER|SET-ORDER|GET-CURRENT|SET-CURRENT|WORDLIST|SEARCH-WORDLIST|FORTH-WORDLIST|ENVIRONMENT\\?|EXECUTE){{ident_break}}" - match: "(?i)(?:^|(?<=\\s))('|\\[']|,|>BODY|FIND|WORDS|ONLY|ALSO|PREVIOUS|DEFINITIONS|FORTH|GET-ORDER|SET-ORDER|GET-CURRENT|SET-CURRENT|WORDLIST|SEARCH-WORDLIST|FORTH-WORDLIST|ENVIRONMENT\\?|EXECUTE){{ident_break}}"
scope: support.function.dictionary.forth scope: support.function.dictionary.forth
@@ -147,7 +169,7 @@ contexts:
scope: keyword.control.exception.forth scope: keyword.control.exception.forth
parsing: parsing:
- match: '(?i)(?:^|(?<=\s))(PARSE|PARSE-NAME|WORD|REFILL|EVALUATE|SOURCE|SOURCE-ID|>IN|BASE|STATE|>NUMBER|SEARCH|SUBSTITUTE|UNESCAPE|REPLACES|S){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(PARSE|PARSE-NAME|WORD|REFILL|EVALUATE|INCLUDE|INCLUDED|SOURCE|SOURCE-ID|>IN|BASE|DECIMAL|HEX|STATE|>NUMBER|SEARCH|SUBSTITUTE|UNESCAPE|REPLACES|S){{ident_break}}'
scope: support.function.parsing.forth scope: support.function.parsing.forth
literals: literals:
@@ -185,5 +207,5 @@ contexts:
wafer_extras: wafer_extras:
# WAFER-specific extensions beyond the Forth 2012 standard. # WAFER-specific extensions beyond the Forth 2012 standard.
# When the language grows new user-facing non-standard words, add them here. # When the language grows new user-facing non-standard words, add them here.
- match: '(?i)(?:^|(?<=\s))(CONSOLIDATE|RANDOM|RND-SEED|UTIME|READ-PASSWORD){{ident_break}}' - match: '(?i)(?:^|(?<=\s))(CONSOLIDATE|RANDOM|RND-SEED|UTIME|READ-PASSWORD|EMPTY|GILD){{ident_break}}'
scope: support.function.wafer-extra.forth scope: support.function.wafer-extra.forth