14 Commits

Author SHA1 Message Date
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 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
16 changed files with 1038 additions and 1065 deletions
+3
View File
@@ -3,3 +3,6 @@
*.swp
.DS_Store
*.bk
# Local planning notes — never tracked
/plans/
+153
View File
@@ -0,0 +1,153 @@
# 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.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
- Run `cargo test --workspace` before committing (currently 542 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto)
- Run `cargo test --workspace` before committing (currently 562 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto)
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
- Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison`
- Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
Generated
+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"
[workspace.package]
version = "0.1.0"
version = "0.2.3"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/ok2/wafer"
@@ -41,13 +41,21 @@ needless_collect = "warn"
or_fun_call = "warn"
[workspace.dependencies]
wasm-encoder = "0.246"
wasmparser = "0.246"
wasmtime = "43"
wasm-encoder = "0.255"
wasmparser = "0.255"
wasmtime = "47"
anyhow = "1"
thiserror = "2"
proptest = "1"
insta = "1"
sha1 = "0.11"
sha2 = "0.11"
sha1 = "0.10"
sha2 = "0.10"
send_wrapper = "0.6"
# Cargo strips debuginfo from release artifacts by default, and on macOS that
# also strips the metadata proc-macro dylibs need to be loadable — release
# builds then fail with "can't find crate" for every proc-macro (rustversion,
# thiserror_impl, ...). Build scripts and proc-macros gain nothing from
# stripping, so exempt them; the release binaries stay stripped.
[profile.release.build-override]
strip = false
+7
View File
@@ -66,6 +66,13 @@ ci: fmt clippy deny test
check:
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-syntax:
mkdir -p ~/.config/bat/syntaxes
+1 -1
View File
@@ -9,7 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
wafer-core = { path = "../core", version = "0.1.0" }
wafer-core = { path = "../core", version = "0.2.3" }
wasmtime = { workspace = true }
anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] }
+3 -4
View File
@@ -192,10 +192,9 @@ impl Dictionary {
}
}
}
// Fallback: return newest entry across all wordlists
if let Some(&(_wid, word_addr, fn_index, is_immediate)) = entries.last() {
return Some((word_addr, WordId(fn_index), is_immediate));
}
// In no wordlist of the search order: not findable
// (Forth 2012 §16.3.3 — the order is authoritative).
return None;
}
// Fallback: linked-list walk (for words not yet in the index)
+20
View File
@@ -108,6 +108,23 @@ pub const SYSVAR_HLD: u32 = SYSVAR_BASE + 28;
pub const SYSVAR_LEAVE_FLAG: u32 = SYSVAR_BASE + 32;
/// Throw code left by a compiled stack-guard fault for `_STACK_FAULT_`.
pub const SYSVAR_FAULT_CODE: u32 = SYSVAR_BASE + 36;
/// DPL: digits right of the rightmost punctuation in the last converted
/// number; negative when the token carried no punctuation.
pub const SYSVAR_DPL: u32 = SYSVAR_BASE + 40;
/// NH: high-order cell of the last single-cell conversion, so an
/// out-of-range token can be recovered as a double.
pub const SYSVAR_NH: u32 = SYSVAR_BASE + 44;
/// Seed for [`SYSVAR_DPL`] before conversion starts.
///
/// `SwiftForth` seeds DPL with a negative value and bumps it once per digit,
/// so an unpunctuated token still ends up negative. Punctuation resets the
/// counter to zero, which makes the final value the digit count right of the
/// rightmost punctuation character.
///
/// The exact seed is observable: `sf64` reports DPL as -1020 after `1234`
/// and -1023 after `-1`, both of which pin it to -1024.
pub const DPL_INIT: i32 = -1024;
#[cfg(test)]
mod tests {
@@ -149,6 +166,9 @@ mod tests {
SYSVAR_NUM_TIB,
SYSVAR_HLD,
SYSVAR_LEAVE_FLAG,
SYSVAR_FAULT_CODE,
SYSVAR_DPL,
SYSVAR_NH,
];
for offset in all_offsets {
assert!(offset + CELL_SIZE <= SYSVAR_BASE + SYSVAR_SIZE);
+468 -179
View File
@@ -23,12 +23,64 @@ use crate::ir::IrOp;
#[cfg(feature = "crypto")]
use crate::memory::HASH_SCRATCH_BASE;
use crate::memory::{
CELL_SIZE, DATA_STACK_TOP, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP, INPUT_BUFFER_BASE,
INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_FAULT_CODE, SYSVAR_HERE,
SYSVAR_LEAVE_FLAG, SYSVAR_NUM_TIB, SYSVAR_STATE, SYSVAR_TO_IN,
CELL_SIZE, DATA_STACK_TOP, DPL_INIT, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP,
INPUT_BUFFER_BASE, INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_DPL,
SYSVAR_FAULT_CODE, SYSVAR_HERE, SYSVAR_LEAVE_FLAG, SYSVAR_NH, SYSVAR_NUM_TIB, SYSVAR_STATE,
SYSVAR_TO_IN,
};
use crate::optimizer::optimize;
// ---------------------------------------------------------------------------
// Number conversion
// ---------------------------------------------------------------------------
/// Characters that force double-cell conversion, following `SwiftForth`'s
/// input number conversion rules.
///
/// A leading `-` is the one exception: it binds as a sign, which keeps `-1`
/// a single-cell number while `1-2` converts as a double.
const DOUBLE_PUNCTUATION: [u8; 6] = *b",.+-/:";
/// Split a leading minus off a token, returning whether it was negative.
///
/// Only `-` is a sign. A leading `+` stays punctuation, matching `sf64`,
/// where `+7` converts as the double 7 with `DPL` = 1.
///
/// The sign may sit between a base-override prefix and the digits (`$-FF`,
/// the Forth 2012 spelling) or, as a WAFER extension, before the prefix
/// (`-$FF`), so this runs at both positions.
fn strip_sign(s: &str) -> (bool, &str) {
match s.as_bytes().first() {
Some(b'-') => (true, &s[1..]),
_ => (false, s),
}
}
/// A numeric token that converted successfully.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct NumberLiteral {
/// The accumulated 64-bit value, sign applied.
value: i64,
/// Digits right of the rightmost punctuation character. Negative when the
/// token carried no punctuation, which is how `DPL` reports "single-cell".
dpl: i32,
/// Whether punctuation forced double-cell conversion.
is_double: bool,
}
impl NumberLiteral {
/// Low-order cell — the value a single-cell conversion leaves on the stack.
fn lo(self) -> i32 {
self.value as i32
}
/// High-order cell. For a single-cell conversion this is what `NH` holds,
/// letting an out-of-range token be recovered as a double.
fn hi(self) -> i32 {
(self.value >> 32) as i32
}
}
// ---------------------------------------------------------------------------
// Control-flow compilation state
// ---------------------------------------------------------------------------
@@ -521,6 +573,40 @@ fn host_pop(ctx: &mut dyn HostAccess) -> anyhow::Result<i32> {
Ok(v)
}
/// Ensure the data stack holds at least `n` cells; returns the stack
/// pointer for the caller's reads. Host words must check before raw
/// pointer arithmetic — compiled-code guards do not cover them.
fn host_need(ctx: &mut dyn HostAccess, n: u32) -> anyhow::Result<u32> {
let sp = ctx.get_dsp();
match n.checked_mul(CELL_SIZE).and_then(|b| sp.checked_add(b)) {
Some(end) if end <= DATA_STACK_TOP => Ok(sp),
_ => anyhow::bail!("Stack underflow"),
}
}
/// Ensure the float stack holds at least `n` floats; returns the pointer.
fn host_fneed(ctx: &mut dyn HostAccess, n: u32) -> anyhow::Result<u32> {
let sp = ctx.get_fsp();
match n.checked_mul(FLOAT_SIZE).and_then(|b| sp.checked_add(b)) {
Some(end) if end <= FLOAT_STACK_TOP => Ok(sp),
_ => anyhow::bail!("Float stack underflow"),
}
}
/// Checked float-stack pop for host words.
fn host_fpop(ctx: &mut dyn HostAccess) -> anyhow::Result<f64> {
let sp = ctx.get_fsp();
if sp >= FLOAT_STACK_TOP {
anyhow::bail!("Float stack underflow");
}
let bytes: [u8; 8] = ctx
.mem_read_slice(sp, 8)
.try_into()
.map_err(|_| anyhow::anyhow!("float stack read failed"))?;
ctx.set_fsp(sp + FLOAT_SIZE);
Ok(f64::from_le_bytes(bytes))
}
/// Advance past the next `\n` in `buf`, starting at `from`. Returns the
/// byte index of the first character on the next line (or `buf.len()` if
/// there's no more newline). Used by the `\` line-comment handler per
@@ -1160,22 +1246,18 @@ impl<R: Runtime> ForthVM<R> {
return Ok(());
}
// Try to parse as double-number (trailing dot)
if let Some((lo, hi)) = self.parse_double_number(token) {
self.push_data_stack(lo)?;
self.push_data_stack(hi)?;
// Try to convert as a number; punctuation makes it double-cell
if let Some(lit) = self.parse_numeric_literal(token) {
self.record_number_conversion(lit);
self.push_data_stack(lit.lo())?;
if self.recording_toplevel && self.state == 0 {
self.toplevel_ir.push(IrOp::PushI32(lo));
self.toplevel_ir.push(IrOp::PushI32(hi));
self.toplevel_ir.push(IrOp::PushI32(lit.lo()));
}
return Ok(());
}
// Try to parse as number
if let Some(n) = self.parse_number(token) {
self.push_data_stack(n)?;
if self.recording_toplevel && self.state == 0 {
self.toplevel_ir.push(IrOp::PushI32(n));
if lit.is_double {
self.push_data_stack(lit.hi())?;
if self.recording_toplevel && self.state == 0 {
self.toplevel_ir.push(IrOp::PushI32(lit.hi()));
}
}
return Ok(());
}
@@ -1537,16 +1619,13 @@ impl<R: Runtime> ForthVM<R> {
return Ok(());
}
// Try to parse as double-number (trailing dot)
if let Some((lo, hi)) = self.parse_double_number(token) {
self.push_ir(IrOp::PushI32(lo));
self.push_ir(IrOp::PushI32(hi));
return Ok(());
}
// Try to parse as number
if let Some(n) = self.parse_number(token) {
self.push_ir(IrOp::PushI32(n));
// Try to convert as a number; punctuation makes it double-cell
if let Some(lit) = self.parse_numeric_literal(token) {
self.record_number_conversion(lit);
self.push_ir(IrOp::PushI32(lit.lo()));
if lit.is_double {
self.push_ir(IrOp::PushI32(lit.hi()));
}
return Ok(());
}
@@ -2697,83 +2776,109 @@ impl<R: Runtime> ForthVM<R> {
// Number parsing
// -----------------------------------------------------------------------
/// Try to parse a token as a number.
fn parse_number(&self, token: &str) -> Option<i32> {
/// Try to convert a token to a number, following `SwiftForth`'s input
/// number conversion rules.
///
/// Punctuation (`,` `.` `+` `-` `/` `:`) forces double-cell conversion, so
/// `12.34`, `1,234`, `12:30:45` and `2026-08-06` all convert as doubles.
/// Only a leading `-` escapes this and binds as a sign, which keeps `-1`
/// single-cell; a leading `+` stays punctuation, so `+7` is the double 7.
///
/// `DPL` counts up once per digit from [`DPL_INIT`] and resets to zero at
/// every punctuation character, so it ends up holding the digit count right
/// of the rightmost punctuation, and stays negative for unpunctuated tokens.
fn parse_numeric_literal(&self, token: &str) -> Option<NumberLiteral> {
let token = token.trim();
if token.is_empty() {
return None;
}
// Check for negative prefix
let (negative, rest) = if let Some(stripped) = token.strip_prefix('-') {
(true, stripped)
} else {
(false, token)
};
// A leading sign binds to the number; it is not double punctuation.
let (neg_outer, rest) = strip_sign(token);
if rest.is_empty() {
return None;
}
// Parse based on prefix
let result = if let Some(hex) = rest.strip_prefix('$') {
i64::from_str_radix(hex, 16).ok()
} else if let Some(dec) = rest.strip_prefix('#') {
dec.parse::<i64>().ok()
} else if let Some(bin) = rest.strip_prefix('%') {
i64::from_str_radix(bin, 2).ok()
} else if rest.len() == 3 && rest.as_bytes()[0] == b'\'' && rest.as_bytes()[2] == b'\'' {
// Character literal: 'x' → ASCII value of x
Some(rest.as_bytes()[1] as i64)
} else {
i64::from_str_radix(rest, self.base).ok()
// Character literal: 'x' → ASCII value of x. No digits, so DPL stays
// at its seed and the result is always single-cell.
if rest.len() == 3 && rest.as_bytes()[0] == b'\'' && rest.as_bytes()[2] == b'\'' {
let value = i64::from(rest.as_bytes()[1]);
return Some(NumberLiteral {
value: if neg_outer { -value } else { value },
dpl: DPL_INIT,
is_double: false,
});
}
// A base-override prefix sits before the leftmost digit.
let (radix, after_prefix) = match rest.as_bytes()[0] {
b'$' => (16, &rest[1..]),
b'#' => (10, &rest[1..]),
b'%' => (2, &rest[1..]),
_ => (self.base, rest),
};
result.map(|n| if negative { -(n as i32) } else { n as i32 })
// Forth 2012 spells a signed based number `#-1289`, so the sign can
// also follow the prefix. Either way it precedes the leftmost digit
// and so is a sign rather than double punctuation.
let (neg_inner, digits) = strip_sign(after_prefix);
let negative = neg_outer ^ neg_inner;
if digits.is_empty() {
return None;
}
// Walk the digit string, stripping punctuation and tracking DPL.
let mut buf = String::with_capacity(digits.len());
let mut dpl = DPL_INIT;
let mut is_double = false;
for &b in digits.as_bytes() {
if DOUBLE_PUNCTUATION.contains(&b) {
is_double = true;
dpl = 0;
} else {
buf.push(char::from(b));
dpl += 1;
}
}
if buf.is_empty() {
return None;
}
// i128 accumulation so the full u64 range survives conversion.
let magnitude = i128::from_str_radix(&buf, radix).ok()?;
let value = if negative {
-(magnitude as i64)
} else {
magnitude as i64
};
Some(NumberLiteral {
value,
dpl,
is_double,
})
}
/// Try to parse a token as a double-number (token ends with `.`).
/// Returns (lo, hi) where the double-cell value is (hi << 32) | lo.
fn parse_double_number(&self, token: &str) -> Option<(i32, i32)> {
let token = token.trim();
if token.is_empty() {
return None;
/// Publish the outcome of a conversion in `DPL` and `NH`.
///
/// `NH` only carries meaning after a single-cell conversion, where it holds
/// the high-order cell that the stack result dropped.
fn record_number_conversion(&mut self, lit: NumberLiteral) {
self.rt.mem_write_i32(SYSVAR_DPL, lit.dpl);
if !lit.is_double {
self.rt.mem_write_i32(SYSVAR_NH, lit.hi());
}
}
// Check for trailing dot (double-number indicator)
let without_dot = token.strip_suffix('.')?;
if without_dot.is_empty() {
return None;
}
// Check for negative prefix
let (negative, rest) = if let Some(stripped) = without_dot.strip_prefix('-') {
(true, stripped)
} else {
(false, without_dot)
};
if rest.is_empty() {
return None;
}
// Parse based on prefix -- use i128 to handle the full u64 range
let result: Option<i128> = if let Some(hex) = rest.strip_prefix('$') {
i128::from_str_radix(hex, 16).ok()
} else if let Some(dec) = rest.strip_prefix('#') {
dec.parse::<i128>().ok()
} else if let Some(bin) = rest.strip_prefix('%') {
i128::from_str_radix(bin, 2).ok()
} else {
i128::from_str_radix(rest, self.base).ok()
};
result.map(|n| {
let val: i64 = if negative { -(n as i64) } else { n as i64 };
let lo = val as i32;
let hi = (val >> 32) as i32;
(lo, hi)
})
/// Try to parse a token as a single-cell number, ignoring `DPL`/`NH`.
/// Used where only a plain cell value is meaningful.
fn parse_number(&self, token: &str) -> Option<i32> {
self.parse_numeric_literal(token)
.filter(|lit| !lit.is_double)
.map(NumberLiteral::lo)
}
// -----------------------------------------------------------------------
@@ -3058,6 +3163,7 @@ impl<R: Runtime> ForthVM<R> {
self.register_to_in()?;
self.register_state_var()?;
self.register_base_var()?;
self.register_number_conversion_vars()?;
// Double-cell arithmetic
self.register_m_star()?;
@@ -3325,7 +3431,7 @@ impl<R: Runtime> ForthVM<R> {
let digest_len = algo.digest_len as i32;
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop ( c-addr u )
let dsp = ctx.get_dsp();
let dsp = host_need(ctx, 2)?;
let u = ctx.mem_read_i32(dsp) as u32;
let c_addr = ctx.mem_read_i32(dsp + CELL_SIZE) as u32;
@@ -4077,8 +4183,9 @@ impl<R: Runtime> ForthVM<R> {
fn register_roll(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop u from stack
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let u = ctx.mem_read_i32(sp as u32) as u32;
host_need(ctx, u.saturating_add(2))?;
let sp = sp + CELL_SIZE; // pop u
if u == 0 {
@@ -4263,7 +4370,7 @@ impl<R: Runtime> ForthVM<R> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop xt from data stack
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let xt = ctx.mem_read_i32(sp as u32) as u32;
// Look up PFA for this xt
@@ -4283,7 +4390,7 @@ impl<R: Runtime> ForthVM<R> {
/// ENVIRONMENT? -- ( c-addr u -- false | value true ) query system parameters.
fn register_environment_q(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let u = ctx.mem_read_i32(sp as u32) as u32;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
let addr = u32::from_le_bytes(b);
@@ -5116,10 +5223,26 @@ impl<R: Runtime> ForthVM<R> {
Ok(())
}
/// DPL ( -- addr ) and NH ( -- addr ): input number conversion results.
///
/// `DPL` holds the digit count right of the rightmost punctuation
/// character in the last converted number, or a negative value when the
/// token carried none. `NH` holds the high-order cell dropped by a
/// single-cell conversion, so an out-of-range token can be recovered as a
/// double.
fn register_number_conversion_vars(&mut self) -> anyhow::Result<()> {
self.rt.mem_write_i32(SYSVAR_DPL, DPL_INIT);
self.rt.mem_write_i32(SYSVAR_NH, 0);
self.register_primitive("DPL", false, vec![IrOp::PushI32(SYSVAR_DPL as i32)])?;
self.register_primitive("NH", false, vec![IrOp::PushI32(SYSVAR_NH as i32)])?;
Ok(())
}
/// M* ( n1 n2 -- d ) signed multiply producing double-cell result.
fn register_m_star(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let n2 = ctx.mem_read_i32(sp as u32) as i64;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
let n1 = i32::from_le_bytes(b) as i64;
@@ -5140,7 +5263,7 @@ impl<R: Runtime> ForthVM<R> {
/// UM* ( u1 u2 -- ud ) unsigned multiply producing double-cell result.
fn register_um_star(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let u2 = ctx.mem_read_i32(sp as u32) as u32 as u64;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
let u1 = u32::from_le_bytes(b) as u64;
@@ -5159,7 +5282,7 @@ impl<R: Runtime> ForthVM<R> {
/// UM/MOD ( ud u -- rem quot ) unsigned double-cell divide.
fn register_um_div_mod(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 3)?;
// Pop u (divisor)
let divisor = ctx.mem_read_i32(sp as u32) as u32 as u64;
// Pop ud (double-cell): high at sp+4, low at sp+8
@@ -5247,7 +5370,7 @@ impl<R: Runtime> ForthVM<R> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop xt from data stack
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let xt = ctx.mem_read_i32(sp as u32) as u32;
// Drop top of stack
let new_sp = sp + 4;
@@ -5332,7 +5455,7 @@ impl<R: Runtime> ForthVM<R> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// ( c-addr u -- ) — pop both cells.
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let u = ctx.mem_read_i32(sp) as u32;
let addr = ctx.mem_read_i32(sp + CELL_SIZE) as u32;
ctx.set_dsp(sp + 2 * CELL_SIZE);
@@ -5453,10 +5576,7 @@ impl<R: Runtime> ForthVM<R> {
/// WORD ( char -- c-addr ) reads from the WASM input buffer and updates >IN.
fn register_word_word(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop delimiter from data stack
let sp = ctx.get_dsp();
let delim = ctx.mem_read_i32(sp as u32) as u8;
ctx.set_dsp(((sp + CELL_SIZE) as i32) as u32);
let delim = host_pop(ctx)? as u8;
// Read >IN and #TIB from WASM memory
let b: [u8; 4] = ctx.mem_read_i32(SYSVAR_TO_IN as u32).to_le_bytes();
@@ -5502,8 +5622,8 @@ impl<R: Runtime> ForthVM<R> {
ctx.mem_write_u8((dst_start + i) as u32, byte);
}
// Push c-addr onto data stack
let new_sp = sp; // We already popped delim, now push c-addr
// Push c-addr onto data stack (reuse the popped delim's slot)
let new_sp = ctx.get_dsp() - CELL_SIZE;
ctx.mem_write_i32(new_sp, buf_addr as i32);
ctx.set_dsp(new_sp);
@@ -5856,6 +5976,9 @@ impl<R: Runtime> ForthVM<R> {
fn register_2r_fetch(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let rsp_val = ctx.get_rsp();
if rsp_val + 2 * CELL_SIZE > RETURN_STACK_TOP {
anyhow::bail!("Return stack underflow");
}
let sp = ctx.get_dsp();
// Return stack: x2 at rsp, x1 at rsp+4
let b: [u8; 4] = ctx.mem_read_i32(rsp_val as u32).to_le_bytes();
@@ -5963,9 +6086,7 @@ impl<R: Runtime> ForthVM<R> {
let state = Arc::clone(&self.rng_state);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let seed = ctx.mem_read_i32(sp as u32) as u32 as u64;
ctx.set_dsp(sp + CELL_SIZE);
let seed = host_pop(ctx)? as u32 as u64;
let mut s = state.lock().unwrap();
*s = if seed == 0 {
0xDEAD_BEEF_CAFE_BABE
@@ -5982,7 +6103,7 @@ impl<R: Runtime> ForthVM<R> {
fn register_parse_host(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop delimiter from data stack
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let delim = ctx.mem_read_i32(sp as u32) as u8;
let sp = sp + CELL_SIZE; // pop delimiter
@@ -6100,7 +6221,7 @@ impl<R: Runtime> ForthVM<R> {
// In non-interactive mode, return 0 (no input).
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop +n1 (max count) and c-addr from stack
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let new_sp = sp + CELL_SIZE; // pop +n1
let new_sp = new_sp + CELL_SIZE; // pop c-addr
// Push 0 (no characters received)
@@ -6125,7 +6246,7 @@ impl<R: Runtime> ForthVM<R> {
fn register_memory_alloc(&mut self) -> anyhow::Result<()> {
// ALLOCATE ( u -- a-addr ior )
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let size = ctx.mem_read_i32(sp as u32) as u32;
let mem_len = ctx.mem_len() as u32;
@@ -6184,7 +6305,7 @@ impl<R: Runtime> ForthVM<R> {
// FREE ( a-addr -- ior )
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Simple allocator: FREE is a no-op (arena style), return ior=0
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
// Replace a-addr with ior=0
ctx.mem_write_i32(sp as u32, 0i32 as i32);
Ok(())
@@ -6193,7 +6314,7 @@ impl<R: Runtime> ForthVM<R> {
// RESIZE ( a-addr u -- a-addr2 ior )
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let new_size = ctx.mem_read_i32(sp as u32) as u32;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
let old_addr = u32::from_le_bytes(b);
@@ -6614,8 +6735,14 @@ impl<R: Runtime> ForthVM<R> {
{
let so = Arc::clone(&self.search_order);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let n = ctx.mem_read_i32(sp as u32);
if !(-1..=64).contains(&n) {
anyhow::bail!("SET-ORDER: bad wordlist count: {n}");
}
if n != -1 {
host_need(ctx, 1 + n as u32)?;
}
if n == -1 {
*so.lock().unwrap() = vec![1];
@@ -6718,8 +6845,9 @@ impl<R: Runtime> ForthVM<R> {
fn register_n_to_r(&mut self) -> anyhow::Result<()> {
// N>R ( xn..x1 n -- ; R: -- x1..xn n )
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let n = ctx.mem_read_i32(sp as u32) as u32;
host_need(ctx, n.saturating_add(1))?;
let mut rsp_val = ctx.get_rsp();
@@ -6803,7 +6931,7 @@ impl<R: Runtime> ForthVM<R> {
// UNESCAPE ( c-addr1 u1 c-addr2 -- c-addr2 u2 )
// Copy string escaping each % as %%
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 3)?;
let dest = ctx.mem_read_i32(sp as u32) as u32;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
let u1 = u32::from_le_bytes(b);
@@ -6841,7 +6969,7 @@ impl<R: Runtime> ForthVM<R> {
// Define substitution: name (c-addr2 u2) → replacement (c-addr1 u1)
let subs = Arc::clone(&self.substitutions);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 4)?;
// Stack: u2(sp), c-addr2(sp+4), u1(sp+8), c-addr1(sp+12)
let u2 = ctx.mem_read_i32(sp as u32) as u32;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
@@ -6869,7 +6997,7 @@ impl<R: Runtime> ForthVM<R> {
// Replace %name% patterns, %% → %
let subs = Arc::clone(&self.substitutions);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 4)?;
// Stack: u2/capacity(sp), c-addr2/dest(sp+4), u1(sp+8), c-addr1(sp+12)
let capacity = ctx.mem_read_i32(sp as u32) as u32 as usize;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
@@ -6957,7 +7085,7 @@ impl<R: Runtime> ForthVM<R> {
/// M*/ ( d n1 n2 -- d ) multiply d by n1, divide by n2.
fn register_m_star_slash(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 4)?;
// Stack: n2(sp), n1(sp+4), d-hi(sp+8), d-lo(sp+12)
let n2 = ctx.mem_read_i32(sp as u32) as i128;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
@@ -7093,7 +7221,7 @@ impl<R: Runtime> ForthVM<R> {
/// SEARCH ( c-addr1 u1 c-addr2 u2 -- c-addr3 u3 flag ) search for substring.
fn register_search(&mut self) -> anyhow::Result<()> {
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 4)?;
// Stack: u2(sp), c-addr2(sp+4), u1(sp+8), c-addr1(sp+12)
let u2 = ctx.mem_read_i32(sp as u32) as usize;
let b: [u8; 4] = ctx.mem_read_i32((sp + 4) as u32).to_le_bytes();
@@ -7244,7 +7372,7 @@ impl<R: Runtime> ForthVM<R> {
// FROT ( F: r1 r2 r3 -- r2 r3 r1 )
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_fsp();
let sp = host_fneed(ctx, 3)?;
let c: [u8; 8] = ctx.mem_read_slice(sp, 8).try_into().unwrap();
let b: [u8; 8] = ctx.mem_read_slice(sp + 8, 8).try_into().unwrap();
let a: [u8; 8] = ctx.mem_read_slice(sp + 16, 8).try_into().unwrap();
@@ -7311,14 +7439,9 @@ impl<R: Runtime> ForthVM<R> {
// If r3 < 0: true if |r1-r2| < |r3|*(|r1|+|r2|)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_fsp();
let r3_bytes: [u8; 8] = ctx.mem_read_slice(sp, 8).try_into().unwrap();
let r2_bytes: [u8; 8] = ctx.mem_read_slice(sp + 8, 8).try_into().unwrap();
let r1_bytes: [u8; 8] = ctx.mem_read_slice(sp + 16, 8).try_into().unwrap();
let r3 = f64::from_le_bytes(r3_bytes);
let r2 = f64::from_le_bytes(r2_bytes);
let r1 = f64::from_le_bytes(r1_bytes);
ctx.set_fsp(((sp + 24) as i32) as u32);
let r3 = host_fpop(ctx)?;
let r2 = host_fpop(ctx)?;
let r1 = host_fpop(ctx)?;
let result = if r3 > 0.0 {
(r1 - r2).abs() < r3
@@ -7366,7 +7489,7 @@ impl<R: Runtime> ForthVM<R> {
// FALIGNED ( addr -- f-addr ) align to float boundary (8 bytes)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let addr = ctx.mem_read_i32(sp as u32) as u32;
let aligned = (addr + 7) & !7;
ctx.mem_write_i32(sp as u32, aligned as i32);
@@ -7411,7 +7534,7 @@ impl<R: Runtime> ForthVM<R> {
// D>F ( d -- ) ( F: -- r ) convert double-cell integer to float
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
// Double-cell: hi on top, lo below
let hi_bytes: [u8; 4] = ctx.mem_read_slice(sp, 4).try_into().unwrap();
let lo_bytes: [u8; 4] = ctx.mem_read_slice(sp + 4, 4).try_into().unwrap();
@@ -7434,11 +7557,7 @@ impl<R: Runtime> ForthVM<R> {
// F>D ( -- d ) ( F: r -- ) convert float to double-cell integer
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Pop from float stack
let fsp_val = ctx.get_fsp();
let bytes: [u8; 8] = ctx.mem_read_slice(fsp_val, 8).try_into().unwrap();
let f = f64::from_le_bytes(bytes);
ctx.set_fsp(fsp_val + FLOAT_SIZE);
let f = host_fpop(ctx)?;
// Convert to i64
let d = f as i64;
let lo = d as i32;
@@ -7524,10 +7643,7 @@ impl<R: Runtime> ForthVM<R> {
let output = Arc::clone(&self.output);
let precision = Arc::clone(&self.float_precision);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_fsp();
let bytes: [u8; 8] = ctx.mem_read_slice(sp as u32, 8).try_into().unwrap();
let val = f64::from_le_bytes(bytes);
ctx.set_fsp(((sp + 8) as i32) as u32);
let val = host_fpop(ctx)?;
let prec = *precision.lock().unwrap();
let s = format!("{val:.prec$} ");
output.lock().unwrap().push_str(&s);
@@ -7541,10 +7657,7 @@ impl<R: Runtime> ForthVM<R> {
let output = Arc::clone(&self.output);
let precision = Arc::clone(&self.float_precision);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_fsp();
let bytes: [u8; 8] = ctx.mem_read_slice(sp as u32, 8).try_into().unwrap();
let val = f64::from_le_bytes(bytes);
ctx.set_fsp(((sp + 8) as i32) as u32);
let val = host_fpop(ctx)?;
let prec = *precision.lock().unwrap();
let s = format_engineering(val, prec);
output.lock().unwrap().push_str(&s);
@@ -7558,10 +7671,7 @@ impl<R: Runtime> ForthVM<R> {
let output = Arc::clone(&self.output);
let precision = Arc::clone(&self.float_precision);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_fsp();
let bytes: [u8; 8] = ctx.mem_read_slice(sp as u32, 8).try_into().unwrap();
let val = f64::from_le_bytes(bytes);
ctx.set_fsp(((sp + 8) as i32) as u32);
let val = host_fpop(ctx)?;
let prec = *precision.lock().unwrap();
let s = format!("{val:.prec$E} ");
output.lock().unwrap().push_str(&s);
@@ -7588,9 +7698,7 @@ impl<R: Runtime> ForthVM<R> {
{
let precision = Arc::clone(&self.float_precision);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let n = ctx.mem_read_i32(sp as u32) as usize;
ctx.set_dsp(((sp + CELL_SIZE) as i32) as u32);
let n = host_pop(ctx)? as usize;
*precision.lock().unwrap() = n;
Ok(())
});
@@ -7600,17 +7708,12 @@ impl<R: Runtime> ForthVM<R> {
// REPRESENT ( c-addr u -- n flag1 flag2 ) ( F: r -- )
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Read all values from memory first
let sp = ctx.get_dsp();
let fsp_val = ctx.get_fsp();
let sp = host_need(ctx, 2)?;
let u = ctx.mem_read_i32(sp) as usize;
let c_addr = ctx.mem_read_i32(sp + 4) as u32;
let f_bytes: [u8; 8] = ctx.mem_read_slice(fsp_val, 8).try_into().unwrap();
let val = f64::from_le_bytes(f_bytes);
// Update stack pointers: pop 2 data cells, pop 1 float
let val = host_fpop(ctx)?;
// Pop the 2 data cells
ctx.set_dsp(sp + 8);
ctx.set_fsp(fsp_val + FLOAT_SIZE);
let (digits, exp, is_negative, is_valid) = represent_float(val, u);
@@ -7638,7 +7741,7 @@ impl<R: Runtime> ForthVM<R> {
// >FLOAT ( c-addr u -- flag ) ( F: -- r | ) parse string as float
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 2)?;
let u = ctx.mem_read_i32(sp) as usize;
let c_addr = ctx.mem_read_i32(sp + 4) as u32;
let s_bytes = ctx.mem_read_slice(c_addr, u);
@@ -7679,14 +7782,9 @@ impl<R: Runtime> ForthVM<R> {
// SF! ( sf-addr -- ) ( F: r -- ) store as single-precision float (f32)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let fsp_val = ctx.get_fsp();
let addr = ctx.mem_read_i32(sp) as u32;
let f_bytes: [u8; 8] = ctx.mem_read_slice(fsp_val, 8).try_into().unwrap();
let val = f64::from_le_bytes(f_bytes);
let addr = host_pop(ctx)? as u32;
let val = host_fpop(ctx)?;
let f32_bytes = (val as f32).to_le_bytes();
ctx.set_dsp(sp + CELL_SIZE);
ctx.set_fsp(fsp_val + FLOAT_SIZE);
ctx.mem_write_slice(addr, &f32_bytes);
Ok(())
});
@@ -7696,12 +7794,10 @@ impl<R: Runtime> ForthVM<R> {
// SF@ ( sf-addr -- ) ( F: -- r ) fetch single-precision float (f32)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let addr = host_pop(ctx)? as u32;
let fsp_val = ctx.get_fsp();
let addr = ctx.mem_read_i32(sp) as u32;
let f32_bytes: [u8; 4] = ctx.mem_read_slice(addr, 4).try_into().unwrap();
let val = f32::from_le_bytes(f32_bytes) as f64;
ctx.set_dsp(sp + CELL_SIZE);
let new_fsp = fsp_val - FLOAT_SIZE;
ctx.set_fsp(new_fsp);
ctx.mem_write_slice(new_fsp, &val.to_le_bytes());
@@ -7713,12 +7809,8 @@ impl<R: Runtime> ForthVM<R> {
// DF! ( df-addr -- ) ( F: r -- ) same as F! (our floats are already f64)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let fsp_val = ctx.get_fsp();
let addr = ctx.mem_read_i32(sp) as u32;
let float_bytes: [u8; 8] = ctx.mem_read_slice(fsp_val, 8).try_into().unwrap();
ctx.set_dsp(sp + CELL_SIZE);
ctx.set_fsp(fsp_val + FLOAT_SIZE);
let addr = host_pop(ctx)? as u32;
let float_bytes = host_fpop(ctx)?.to_le_bytes();
ctx.mem_write_slice(addr, &float_bytes);
Ok(())
});
@@ -7728,12 +7820,10 @@ impl<R: Runtime> ForthVM<R> {
// DF@ ( df-addr -- ) ( F: -- r ) same as F@ (our floats are already f64)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let addr = host_pop(ctx)? as u32;
let fsp_val = ctx.get_fsp();
let addr = ctx.mem_read_i32(sp) as u32;
let float_bytes: [u8; 8] = ctx.mem_read_slice(addr, 8).try_into().unwrap();
let val = f64::from_le_bytes(float_bytes);
ctx.set_dsp(sp + CELL_SIZE);
let new_fsp = fsp_val - FLOAT_SIZE;
ctx.set_fsp(new_fsp);
ctx.mem_write_slice(new_fsp, &val.to_le_bytes());
@@ -7745,7 +7835,7 @@ impl<R: Runtime> ForthVM<R> {
// SFALIGNED, DFALIGNED (alignment words for single/double floats)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let addr = ctx.mem_read_i32(sp as u32) as u32;
let aligned = (addr + 3) & !3; // 4-byte alignment for single float
ctx.mem_write_i32(sp as u32, aligned as i32);
@@ -7757,7 +7847,7 @@ impl<R: Runtime> ForthVM<R> {
// DFALIGNED is the same as FALIGNED (8-byte alignment)
{
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_dsp();
let sp = host_need(ctx, 1)?;
let addr = ctx.mem_read_i32(sp as u32) as u32;
let aligned = (addr + 7) & !7;
ctx.mem_write_i32(sp as u32, aligned as i32);
@@ -9549,6 +9639,100 @@ mod tests {
assert!(!output.contains("__CTRL__"));
}
#[test]
fn test_rnd_seed_underflow_is_clean_error() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let err = vm.evaluate("RND-SEED").unwrap_err();
assert!(err.to_string().contains("underflow"), "{err}");
// The stack pointer must not have drifted above the base.
vm.evaluate("RANDOM .S").unwrap();
assert!(vm.take_output().starts_with("<1>"), "dsp drifted");
}
#[test]
fn test_host_words_underflow_cleanly() {
// Every argument-taking host word must fail cleanly on an empty
// stack and leave both stack pointers at their bases (host words
// are outside the compiled-code guards).
let words = [
"RND-SEED",
"WORD",
"SET-ORDER",
"SET-PRECISION",
"REPRESENT",
">FLOAT",
"SF!",
"SF@",
"DF!",
"DF@",
"D>F",
"F.",
"FE.",
"FS.",
"F~",
"ROLL",
">BODY",
"ENVIRONMENT?",
"M*",
"UM*",
"UM/MOD",
"COMPILE,",
"ACCEPT",
"ALLOCATE",
"FREE",
"RESIZE",
"N>R",
"UNESCAPE",
"REPLACES",
"SUBSTITUTE",
"M*/",
"SEARCH",
"FALIGNED",
"SFALIGNED",
"DFALIGNED",
"FROT",
"F>D",
"2R@",
// WORD and PARSE are intercepted by the outer interpreter in
// interpret mode; exercise their host variants compiled.
": T_ WORD ; T_",
": T_ PARSE ; T_",
#[cfg(feature = "crypto")]
"SHA256",
];
for w in words {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let r = vm.evaluate(w);
assert!(r.is_err(), "{w}: silent underflow accepted");
vm.evaluate("DEPTH FDEPTH + .").unwrap();
assert_eq!(vm.take_output(), "0 ", "{w}: stack pointer drifted");
}
}
// -- Search order is authoritative (matches gforth + SwiftForth) --
#[test]
fn test_search_order_hides_unlisted_wordlists() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(
"WORDLIST CONSTANT MY-WL MY-WL SET-CURRENT : SECRET 42 ; FORTH-WORDLIST SET-CURRENT",
)
.unwrap();
// MY-WL was never in the search order: SECRET must not resolve.
let err = vm.evaluate("SECRET").unwrap_err();
assert!(err.to_string().contains("unknown word"), "{err}");
// Push MY-WL onto the order: now it resolves.
vm.evaluate("GET-ORDER MY-WL SWAP 1+ SET-ORDER SECRET .")
.unwrap();
assert_eq!(vm.take_output(), "42 ");
// Back to the default order: hidden again.
vm.evaluate("-1 SET-ORDER").unwrap();
assert!(vm.evaluate("SECRET").is_err());
// FORTH words stay findable throughout.
vm.evaluate("1 2 + .").unwrap();
assert_eq!(vm.take_output(), "3 ");
}
// -- Error reporting (WS-008) --
#[test]
@@ -10732,6 +10916,111 @@ mod tests {
assert_eq!(eval_stack("1E 2.5E 1E F~"), vec![0]); // |1-2.5| = 1.5 >= 1
}
#[test]
fn punctuation_anywhere_converts_as_double() {
// The punctuation is a double-cell marker, not a fractional point:
// every form below carries the same digits, so the value is the same.
// eval_stack reports top-first, so a double reads as [hi, lo].
for token in ["1234.", "123.4", "12.34", "1.234", ".1234"] {
assert_eq!(eval_stack(token), vec![0, 1234], "token {token}");
}
// SwiftForth accepts comma, colon, slash, plus and dash too, which is
// what makes dates and times convert without a custom parser.
assert_eq!(eval_stack("1,234"), vec![0, 1234]);
assert_eq!(eval_stack("12:30:45"), vec![0, 123045]);
assert_eq!(eval_stack("2026-08-06"), vec![0, 20260806]);
assert_eq!(eval_stack("12/34"), vec![0, 1234]);
assert_eq!(eval_stack("1+234"), vec![0, 1234]);
}
#[test]
fn dpl_counts_digits_right_of_last_punctuation() {
for (token, dpl) in [("1234.", 0), ("123.4", 1), ("12.34", 2), (".1234", 4)] {
assert_eq!(eval_stack(&format!("{token} 2DROP DPL @")), vec![dpl]);
}
// Only the rightmost punctuation counts.
assert_eq!(eval_stack("12:30:45 2DROP DPL @"), vec![2]);
}
#[test]
fn dpl_stays_negative_for_unpunctuated_numbers() {
// A leading minus is a sign, not punctuation, so these stay single-cell.
for token in ["1234", "-1", "$FF"] {
let dpl = eval_stack(&format!("{token} DROP DPL @"))[0];
assert!(dpl < 0, "token {token} left DPL = {dpl}");
}
assert_eq!(eval_stack("-1"), vec![-1]);
// DPL counts up from its seed once per digit.
assert_eq!(eval_stack("1234 DROP DPL @"), vec![DPL_INIT + 4]);
assert_eq!(eval_stack("-1 DROP DPL @"), vec![DPL_INIT + 1]);
}
#[test]
fn leading_plus_is_punctuation_not_a_sign() {
// sf64 converts `+7` as the double 7 with DPL = 1: unlike `-`, a
// leading `+` does not bind to the number.
assert_eq!(eval_stack("+7"), vec![0, 7]);
assert_eq!(eval_stack("+7 2DROP DPL @"), vec![1]);
// Same after a base prefix.
assert_eq!(eval_stack("#+7"), vec![0, 7]);
assert_eq!(eval_stack("$+F"), vec![0, 15]);
}
#[test]
fn repeated_punctuation_only_counts_from_the_last_one() {
// sf64: `12..34` is 1234 with DPL 2, `1-2-3` is 123 with DPL 1.
assert_eq!(eval_stack("12..34"), vec![0, 1234]);
assert_eq!(eval_stack("12..34 2DROP DPL @"), vec![2]);
assert_eq!(eval_stack("1-2-3"), vec![0, 123]);
assert_eq!(eval_stack("1-2-3 2DROP DPL @"), vec![1]);
}
#[test]
fn nh_recovers_an_out_of_range_single_number() {
// 4000000000 overflows a signed cell, so the stack value is truncated.
assert_eq!(eval_stack("4000000000"), vec![-294967296]);
// NH carries the high cell, making the true value recoverable.
assert_eq!(eval_stack("4000000000 NH @"), vec![0, -294967296]);
assert_eq!(eval_output("4000000000 NH @ D."), "4000000000 ");
}
#[test]
fn float_literals_still_win_over_double_punctuation() {
// `1.5E0` has an embedded dot, but "15E0" is not a decimal number,
// so conversion falls through to the float parser.
assert_eq!(eval_output("1.5E0 F."), "1.500000 ");
assert_eq!(eval_output("-3.25E0 F."), "-3.250000 ");
assert_eq!(eval_output("1E-3 F."), "0.001000 ");
}
#[test]
fn double_punctuation_respects_base_prefixes() {
assert_eq!(eval_stack("$FF."), vec![0, 255]);
assert_eq!(eval_stack("$F.F"), vec![0, 255]);
assert_eq!(eval_stack("%1010."), vec![0, 10]);
assert_eq!(eval_stack("#12.34"), vec![0, 1234]);
assert_eq!(eval_stack("-$FF."), vec![-1, -255]);
}
#[test]
fn sign_after_a_base_prefix_is_a_sign_not_punctuation() {
// Forth 2012 spells signed based numbers with the sign after the
// prefix. The dash precedes the leftmost digit, so it must not
// trigger double-cell conversion.
assert_eq!(eval_stack("#-1289"), vec![-1289]);
assert_eq!(eval_stack("$-12eF"), vec![-4847]);
assert_eq!(eval_stack("%-10010110"), vec![-150]);
// The sign may also precede the prefix, and both spellings cancel.
assert_eq!(eval_stack("-$FF"), vec![-255]);
assert_eq!(eval_stack("-$-FF"), vec![255]);
}
#[test]
fn punctuated_numbers_compile_into_definitions() {
assert_eq!(eval_stack(": D1 12.34 ; D1"), vec![0, 1234]);
assert_eq!(eval_output(": STAMP 2026-08-06 D. ; STAMP"), "20260806 ");
}
#[test]
fn optimizer_doesnt_break_basic_arithmetic() {
assert_eq!(eval_stack("5 3 +"), vec![8]);
+10
View File
@@ -350,6 +350,16 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
"Convert digits, accumulating into ud.",
),
("BASE", "( -- addr )", "Variable holding the number base."),
(
"DPL",
"( -- addr )",
"Variable: digits right of the last punctuation; negative if none.",
),
(
"NH",
"( -- addr )",
"Variable: high cell dropped by the last single-cell conversion.",
),
("HEX", "( -- )", "Set BASE to sixteen."),
("DECIMAL", "( -- )", "Set BASE to ten."),
// -- Core: strings --
+15
View File
@@ -453,6 +453,21 @@ fn programs() -> Vec<Program> {
expected: "99 \n",
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 --
Program {
name: "s-quote-type",
+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
crate-type = ["cdylib", "rlib"]
[dependencies]
wafer-core = { path = "../core", version = "0.1.0", default-features = false, features = ["crypto"] }
wafer-core = { path = "../core", version = "0.2.3", default-features = false, features = ["crypto"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
send_wrapper = { workspace = true }
+2 -2
View File
@@ -18,11 +18,11 @@ confidence-threshold = 0.8
[bans]
multiple-versions = "deny"
wildcards = "deny"
# Transitive duplicates from wasmtime v31 -- will resolve when upgrading
# Transitive duplicates from wasmtime v47 dependencies
skip = [
"getrandom",
"syn",
"hashbrown",
"r-efi",
"thiserror",
"thiserror-impl",
"wasm-encoder",
-230
View File
@@ -1,230 +0,0 @@
# Plan: SEE / SEE-IR / HELP — Introspection Trio
Status: implemented 2026-08-05 (all phases; HELP covers every word in a fresh VM, enforced by test)
Scope: `SEE` (source-level decompile), `SEE-IR` (optimized-IR dump), `HELP` (per-word docs), shared lookup infrastructure.
Each phase is self-contained and executable in a fresh context. Execute in order; every phase leaves the tree green (`cargo test --workspace` passes).
---
## Phase 0 — Consolidated Findings (read this first, do not re-derive)
All references verified at commit `e31407a` (branch `usability`).
### The template to copy: WORDS
`WORDS` is a **host primitive whose body runs Rust-side via the `pending_define` mechanism**. This is the exact pattern for SEE/SEE-IR/HELP because it gives a real dictionary entry (→ findable by `'`, listed by `WORDS`, tab-completable in the CLI via `crates/cli/src/main.rs:452-455`, exposed to web palette via `crates/web/src/lib.rs:61`) while the implementation can still call `next_token()` and write `self.output`.
- Registration: `register_words()` at `crates/core/src/outer.rs:6301-6310` — host fn pushes code `40` into `pending_define`, called from `register_primitives()` at `outer.rs:2991` under the `// -- Programming-Tools word set --` header.
- Dispatch: `handle_pending_define()` arm at `outer.rs:5328`: `40 => self.do_words(),`.
- Body: `do_words()` at `outer.rs:6045-6074`. Note `outer.rs:6050-6052`: it reads an optional same-line argument with `self.next_token()` **gated on `self.state == 0`** — SEE must copy this gate.
- **Used `pending_define` codes: 112, 20, 21, 25, 33, 40.** Free: **41 (SEE), 42 (SEE-IR), 43 (HELP)**. Legend comment at `outer.rs:232-233` must be extended.
### Allowed APIs (verified signatures)
| API | Location | Notes |
|---|---|---|
| `Dictionary::find(&self, name: &str) -> Option<(u32, WordId, bool)>` | `dictionary.rs:182` | `(word_addr, WordId, is_immediate)`, case-insensitive |
| `Dictionary::word_name(word_addr)` / `code_field(word_addr)` / `read_link` / `latest()` | `dictionary.rs:375/392/287/282` | manual entry walk |
| `flags::IMMEDIATE = 0x80`, `HIDDEN = 0x40`, `INTERNAL = 0x20` | `dictionary.rs:16-27` | raw flags byte = `dict.memory()[(word_addr+4) as usize]` — no getter exists |
| `ir_bodies: HashMap<WordId, Vec<IrOp>>` | `outer.rs:256` | **post-optimization** IR; populated for colon words AND all defining-word products AND IR primitives (see kind table below) |
| `host_word_names: HashMap<WordId, String>` | `outer.rs:214` | only populated by `register_host_primitive` (`outer.rs:2702-2703`) |
| `does_definitions: HashMap<WordId, DoesDefinition>` | `outer.rs:223` | DOES>-words |
| `output: Arc<Mutex<String>>` | `outer.rs:208` | ALL text output goes here; `HostAccess` has **no** emit method (`runtime.rs:17-60`) |
| `next_token()` | `outer.rs:690-704` | whitespace-delimited, advances `input_pos` |
| `register_host_primitive(name, immediate, func) -> anyhow::Result<WordId>` | `outer.rs:2686-2691` | public |
| `IrOp` enum, `#[derive(Debug, Clone, PartialEq)]` | `ir.rs:9-218` | **no `Display` impl exists anywhere in core** — formatter is net-new |
| `eval_output(input) -> String` test helper | `outer.rs:7566` | fresh VM per call; multi-eval tests build VM inline like `outer.rs:9077-9080` |
### Word-kind classification (SEE must distinguish these)
| Kind | Detectable via | SEE output strategy |
|---|---|---|
| Colon word / `:NONAME` | in `ir_bodies`, has captured source (Phase 3) | source (Phase 3) or IR (Phase 2) |
| IR primitive (`DUP`…) | in `ir_bodies`, no source | IR body + "primitive" tag |
| Host primitive (`.S`, `WORDS`…) | in `host_word_names` | `<built-in host word>` stub |
| CONSTANT / VARIABLE / VALUE / CREATE / DEFER / SYNONYM / BUFFER: / 2\*/F\* | in `ir_bodies` with recognizable shape (e.g. CONSTANT = `[PushI32(v)]`, insert sites: `outer.rs:3194/3226/3263/3309/3351/3388/3440/6517/6547/6590/7344`) | synthesized definition, e.g. `42 CONSTANT ANSWER` (Phase 3) |
| DOES>-defined word | key in `does_definitions` | show CREATE part + DOES> body IR |
| Interpreter special token (`:`, `;`, `VARIABLE`, `'`, `CHAR`…) | hardcoded matches `outer.rs:755-820`, `927-992`; several have **no dictionary entry at all** | `<compiler word, handled by the outer interpreter>` stub |
### Hard constraints
1. **Feature-free.** `outer.rs`, `ir.rs`, `dictionary.rs` compile without the `native` feature (`lib.rs:17-42`); web consumes core with `default-features = false` (`crates/web/Cargo.toml:15`). No `#[cfg(feature = "native")]` in any SEE code. Unit tests live in the existing `#[cfg(all(test, feature = "native"))]` module (`outer.rs:7553`) — that is fine and matches practice.
2. **`ir_bodies` stores post-optimization IR** (`finish_colon_def`: optimize at `outer.rs:2297`, insert at `outer.rs:2298`; inlining threshold 8 at `optimizer.rs:56`). `: FOO SQ SQ ;` shows `SQ`'s body inlined. This is a *feature* for SEE-IR (shows what the optimizer did) and the *reason* SEE needs separate source capture (Phase 3).
3. **Multi-line definitions**: compile state persists across `evaluate()` calls (`outer.rs:512-514` resets only `input_buffer`/`input_pos`); the driver (CLI `main.rs:411-416`) feeds lines. Source capture must accumulate across calls. On error, `evaluate()` wipes compile state (`outer.rs:523-535`) — capture state must be wiped there too.
4. **Error house style** (`outer.rs:1294/3400/4057` precedents): `anyhow::bail!("SEE: unknown word: {name}")`, `anyhow::bail!("SEE: expected word name")`.
5. **Compliance suite gives SEE zero coverage**`toolstest.fth:38-39` explicitly excludes it. All coverage is hand-written unit tests. Adding SEE cannot break `compliance_tools`.
6. **MARKER correctness**: any new per-word map (source text, docs) must be snapshotted/restored in `MarkerState` (`outer.rs:166-176`, snapshot `outer.rs:3462-3480`, restore `outer.rs:3484-3506`), mirroring how `ir_bodies` is handled there.
### Anti-patterns (verified NOT to exist — do not invent)
- `HostAccess::emit(...)` / any output method on `HostAccess` — does not exist; capture `Arc::clone(&self.output)` instead.
- `Display for IrOp` — does not exist; write the formatter.
- A dictionary "entry struct" or kind tag — does not exist; classify via the VM-side maps above.
- `Dictionary::flags(addr)` getter — does not exist; read the raw byte.
- Refill-on-demand for a missing SEE argument — `REFILL`/`ACCEPT` are hardcoded to fail (`outer.rs:5824-5852`); `SEE` at end of line is an error, same as `'` (`outer.rs:4051-4053`).
---
## Phase 1 — IR pretty-printer (pure function, no VM changes)
**Goal:** a feature-free formatter turning `&[IrOp]` into readable, indented text. Foundation for SEE-IR and the SEE fallback path.
**What to implement:**
1. New module `crates/core/src/see.rs`, registered unconditionally in `lib.rs` next to `pub mod outer;` (`lib.rs:30`). Public API:
```rust
/// Format an IR body as indented, one-op-per-line text.
pub fn format_ir(ops: &[IrOp]) -> String
```
2. Exhaustive `match` over every `IrOp` variant (full list at `ir.rs:10-218`) — **no wildcard arm**, so adding a variant later forces a formatter update at compile time.
3. Simple ops print as their Forth-ish name plus payload: `PushI32(7)` → `push 7`, `Call(WordId(12))` → `call #12`, `TailCall` → `tail-call #12`. Resolve `#12` to a word name at a higher level (Phase 2) — `format_ir` itself stays name-agnostic, but takes an optional resolver to keep it pure:
```rust
pub fn format_ir_with(ops: &[IrOp], resolve: &dyn Fn(WordId) -> Option<String>) -> String
```
(`format_ir` delegates with a `|_| None` resolver.)
4. The six nested variants (`If`, `DoLoop`, `BeginUntil`, `BeginAgain`, `BeginWhileRepeat` at `ir.rs:78-99`, `BeginDoubleWhileRepeat` at `ir.rs:105-111`) print as Forth control words with 2-space indented bodies:
```
if
dup
mul
else
drop
then
```
5. Flat branch ops (`Block`/`BranchIfFalse`/`EndBlock`, `ir.rs:118-124`) print literally (`block L3` etc.) — they have no clean Forth surface syntax; do not attempt reconstruction.
**Verification checklist:**
- [ ] Unit tests in `see.rs` (plain `#[cfg(test)]`, NOT feature-gated — the module has no runtime dependency): nested `If` inside `DoLoop` indents correctly; every-variant smoke test via a `Vec` containing one of each simple op.
- [ ] `cargo check -p wafer-core --no-default-features` passes (proves feature-freedom).
- [ ] `cargo test --workspace` green; `cargo fmt --all` + `cargo clippy --workspace` clean.
**Anti-pattern guards:** no `impl Display for IrOp` (keep the formatter in `see.rs`, IrOp is data); no wildcard match arm; no `#[cfg(feature = "native")]`.
---## Phase 2 — SEE-IR word
**Goal:** `SEE-IR name` prints the stored post-optimization IR for any word — the optimizer-debugging view. Ship this before source-SEE: it is nearly free and immediately useful.
**What to implement:**
1. Copy the WORDS registration pattern verbatim (`outer.rs:6301-6310`): `register_see_ir()` pushes pending code **42**; register in `register_primitives()` next to `self.register_words()?` (`outer.rs:2991`). Extend the legend comment at `outer.rs:232-233`.
2. Dispatch arm in `handle_pending_define()` next to `outer.rs:5328`: `42 => self.do_see_ir(),`.
3. `do_see_ir()` (place near `do_words()`, `outer.rs:6045`):
- Parse name: `let Some(name) = self.next_token() else { bail!("SEE-IR: expected word name") }` — **no** interpret-mode gate here (unlike WORDS' optional filter, the argument is mandatory; compile-mode `SEE-IR` may simply also parse — matches `'`).
- Lookup: `self.dictionary.find(&name)` → else `bail!("SEE-IR: unknown word: {name}")`.
- Classify per the Phase 0 kind table, in this order: `ir_bodies` hit → header line + `see::format_ir_with(...)` with a resolver that maps `WordId` → name (build once from a dictionary walk: `latest()`/`read_link`/`word_name`/`code_field`, `dictionary.rs:282/287/375/392`); `host_word_names` hit → `SEE-IR: <name> is a built-in host word`; neither → `SEE-IR: <name> has no IR body`.
- Header line format: `\ <NAME> — <n> ops (optimized IR)`, plus ` immediate` when the find() flag is set, plus `does>` info when `does_definitions` has the id.
- Write everything into `self.output.lock().unwrap()`; end with `\n` (multi-line output convention from commit `2910884`).
4. Special-token names (`:`, `VARIABLE`, `'`, …): after dictionary miss, check a small const list of known interpreter tokens (source: match arms at `outer.rs:755-820`, `927-992`) and print `SEE-IR: <name> is handled directly by the outer interpreter` instead of erroring.
**Documentation references:** WORDS pattern `outer.rs:6301-6310`, `5328`, `6045-6074`; error style `outer.rs:4057`; output convention `outer.rs:6056-6073`.
**Verification checklist:**
- [ ] Tests (in `outer.rs` test module, `eval_output` style, cf. `outer.rs:9035-9041`):
- `: SQ DUP * ; SEE-IR SQ` output contains `dup` and `mul`;
- `: FOO SQ SQ ; SEE-IR FOO` shows the **inlined** body (contains two `mul`, no `call`) — locks in the "optimized view" semantics;
- `SEE-IR DUP` works (IR primitive); `SEE-IR WORDS` prints host-word stub; `SEE-IR NOSUCHWORD` errors with `SEE-IR: unknown word: NOSUCHWORD`; bare `SEE-IR` errors with `expected word name`;
- `SEE-IR :` prints the interpreter-token message.
- [ ] `IF`/`ELSE`/`THEN` and `DO LOOP` bodies render indented (one structured-word test).
- [ ] `cargo test --workspace` green; fmt + clippy clean; `cargo check -p wafer-core --no-default-features` passes.
**Anti-pattern guards:** do not print via a nonexistent `HostAccess` emit; do not gate name parsing on `state == 0` (mandatory arg, not optional filter); do not `THROW -13` (plain `bail!` matches TO/SYNONYM precedent).
---
## Phase 3 — Source capture + SEE
**Goal:** `SEE name` prints the original source text `: name … ;` for colon words, synthesized definitions for data words, graceful stubs otherwise. This is the user-facing SEE.
**What to implement:**
1. **Capture fields** on `ForthVM` (near `compiling_ir`, `outer.rs:204`):
```rust
compiling_source: String, // accumulated raw text of the definition in progress
source_capture_from: Option<usize>, // input_pos where capture started in the CURRENT buffer
word_sources: HashMap<WordId, String>,
```
2. **Capture protocol** (verbatim source, including comments and string literals — token-level reassembly would lose them):
- `start_colon_def()` (`outer.rs:2097`): set `source_capture_from` to the position where `:` began. `interpret_token()` receives the token already consumed, so record the position **before** dispatch: in the `evaluate()` loop (`outer.rs:518-521`), remember `pos_before = self.input_pos` minus token — simplest correct form: capture `token_start` inside `next_token()` (`outer.rs:690-704`) into a new field `last_token_start: usize` as it skips whitespace; `start_colon_def` then does `self.source_capture_from = Some(self.last_token_start)`.
- End of `evaluate()` (after the loop, `outer.rs:~536`): if still compiling and capture active, flush `input_buffer[from..]` + `'\n'` into `compiling_source`, reset `source_capture_from = Some(0)` so the next buffer continues capture from its start.
- `finish_colon_def()` (`outer.rs:2266`): flush `input_buffer[from..=pos of ';']`, store `word_sources.insert(word_id, normalized)`, clear capture state. Normalize only trailing whitespace; keep interior verbatim.
- Error path `outer.rs:523-535`: clear both capture fields alongside the existing compile-state wipe.
- `:NONAME` and quotations (`outer.rs:759-761, 773-778`): skip capture (no name to SEE) — guard on `compiling_name.is_some()`.
3. **MARKER integration**: add `word_sources` to `MarkerState` (`outer.rs:166-176`), snapshot (`outer.rs:3462-3480`) and restore (`outer.rs:3484-3506`) exactly as `ir_bodies` is handled there.
4. **SEE word**: pending code **41**, same registration/dispatch shape as Phase 2. `do_see()` resolution order:
1. `word_sources` hit → print stored source verbatim, append ` immediate` on its own line if flagged (cf. `set_immediate`, `dictionary.rs:404`).
2. Recognizable data-word IR shape (Phase 0 kind table) → synthesized one-liner. CONSTANT `[PushI32(v)]` → `<v> CONSTANT <NAME>`; VARIABLE → `VARIABLE <NAME> ( addr=<v> )`; VALUE `[PushI32(a), Fetch]` → `<cur> VALUE <NAME>` reading current value via `self.rt` memory read if cheap, else `VALUE <NAME>`; SYNONYM `[Call(id)]` → `SYNONYM <NAME> <OLD>`; DEFER → `DEFER <NAME>` plus current target name via `does`/pfa lookup when resolvable.
3. `ir_bodies` hit (primitive or pre-capture colon word) → `\ <NAME> is a primitive; IR:` + `format_ir_with` output (reuse Phase 1/2 machinery — SEE never dead-ends).
4. `host_word_names` hit → `<NAME> is a built-in host word`.
5. Interpreter-token list → `<NAME> is handled by the outer interpreter (compiler word)`.
6. Else → `bail!("SEE: unknown word: {name}")`.
5. **Boot words get sources for free**: `boot.fth` definitions flow through the same `evaluate()`/`finish_colon_def` path, so `SEE NIP` etc. shows real boot source. Verify, don't assume — one test below.
**Documentation references:** compile-state lifecycle `outer.rs:512-535`, `2097-2121`, `2266-2329`; multi-line REPL driver `main.rs:411-416`; MarkerState `outer.rs:166-176, 3462-3506`.
**Verification checklist:**
- [ ] `: SQ DUP * ; SEE SQ` prints `: SQ DUP * ;` (verbatim, one line).
- [ ] Multi-line: inline-VM test (pattern `outer.rs:9077-9080`): `evaluate(": TRI\")` then `evaluate(\" DUP DUP ;")`, then `SEE TRI` shows both lines.
- [ ] Comment survives: `: C ( n -- n ) 1+ ; SEE C` output contains `( n -- n )`.
- [ ] `42 CONSTANT A SEE A` → `42 CONSTANT A`; `VARIABLE V SEE V` → contains `VARIABLE V`.
- [ ] `SEE NIP` (boot word) prints a colon definition, not an IR dump.
- [ ] `SEE DUP` prints the primitive-IR fallback; `SEE WORDS` prints host stub; `SEE '` prints interpreter-token message; unknown word errors in house style.
- [ ] MARKER round-trip: define word, set marker, redefine, execute marker, `SEE` shows the original — plus existing marker tests still green.
- [ ] Immediate flag: `: I2 ; IMMEDIATE SEE I2` output contains `immediate`.
- [ ] Error path: force `unknown word` mid-definition, then define a fresh word — its captured source must not contain debris from the aborted definition.
- [ ] Full suite + fmt + clippy + `--no-default-features` check.
**Anti-pattern guards:** do not reconstruct source from tokens (loses comments/strings/spacing); do not capture into `word_sources` for `:NONAME`; do not forget the error-path wipe (`outer.rs:523-535`) — stale capture corrupts the next definition's source; `evaluate()` resets `input_pos` per call (`outer.rs:512-514`) so `source_capture_from` is per-buffer, never carried across calls uncleared.
---
## Phase 4 — HELP word + doc table
**Goal:** `HELP name` prints stack effect + one-line description; `HELP` alone prints usage. Shares lookup/classification with SEE.
**What to implement:**
1. New feature-free module `crates/core/src/wordhelp.rs`: a static table
```rust
/// (NAME, stack effect, one-line description)
pub const WORD_DOCS: &[(&str, &str, &str)] = &[
("DUP", "( x -- x x )", "Duplicate the top of the data stack."),
...
];
pub fn lookup(name: &str) -> Option<(&'static str, &'static str)> // case-insensitive
```
Seed from the Forth 2012 glossary (stack effects are standardized). Cover, in priority order: core + core-ext words WAFER implements, then tools/double/float sets. Incomplete coverage is acceptable and expected — `HELP` says `no help for <name> (word exists)` when the word is defined but undocumented, which doubles as the TODO list.
2. `HELP` word: pending code **43**, same registration/dispatch shape as Phase 2. Resolution: parse optional name (bare `HELP` → usage line `HELP <word> — also try: WORDS, SEE <word>, SEE-IR <word>`); table hit → print `NAME ( stack effect ) description`; miss but dictionary hit → `no help for <name>` + hint `try SEE <name>`; miss both → house-style unknown-word error.
3. Cross-wiring (the "as useful as possible" part):
- `SEE`/`SEE-IR` prepend the HELP line as a `\ ...` comment when the table has one.
- `HELP` appends ` immediate` / `built-in` / `defined in boot.fth or user code` classification reusing the Phase 2/3 classifier — factor that classifier into a shared `fn classify_word(&self, name) -> WordClass` when Phase 4 lands (do NOT pre-build it in Phase 2; extract once there are two users, per smallest-change rule).
4. User-defined words: optional docstring convention — if the captured source's first parenthesized comment looks like a stack effect (`( ... -- ... )`), `HELP` echoes it for user words. No new syntax, zero cost, rewards idiomatic Forth style.
**Verification checklist:**
- [ ] `HELP DUP` prints stack effect + description; `HELP dup` (lowercase) same.
- [ ] `HELP` alone prints usage; `HELP NOSUCH` errors house-style; `HELP MYWORD` for undocumented-but-defined word prints the `no help` + `SEE` hint.
- [ ] `: SQ ( n -- n^2 ) DUP * ; HELP SQ` echoes `( n -- n^2 )`.
- [ ] Table lint test: iterate `WORD_DOCS`, assert every documented name resolves in a booted VM's dictionary (catches typos/renames mechanically).
- [ ] Full suite + fmt + clippy + `--no-default-features`.
**Anti-pattern guards:** no doc strings threaded through `register_primitive` call sites (200+ call-site churn, bloats outer.rs — the side table is deliberate); no partial-coverage panic — missing docs degrade gracefully.
---
## Phase 5 — Final verification + docs
1. **Full gate:** `cargo fmt --all` && `cargo clippy --workspace` (zero warnings) && `cargo test --workspace` (expect baseline 431 unit + new SEE/SEE-IR/HELP tests, 1 benchmark, 11 compliance, 9 comparison — all green).
2. **Feature-freedom proof:** `cargo check -p wafer-core --no-default-features` and web build `cd crates/web && wasm-pack build --target web --dev --out-dir www/pkg`.
3. **Manual REPL pass** (CLI): `SEE SQ`, `SEE-IR FOO` with inlining, `HELP DUP`, multi-line definition then SEE, tab-complete `SE<tab>` — confirm multi-line output renders per commit `2910884` conventions (block output, ` ok` on own line).
4. **Web REPL smoke:** serve `crates/web/www`, run the same commands — output flows through `take_output()` (`web/src/lib.rs:38-43`), no web-side changes expected.
5. **Anti-pattern grep:** `grep -n "cfg(feature" crates/core/src/see.rs crates/core/src/wordhelp.rs` → empty; `grep -n "impl Display for IrOp" -r crates/core` → empty; `grep -rn "emit" crates/core/src/see.rs` → empty.
6. **Docs:** `docs/FORTH.md:95` already lists SEE under Programming-Tools — verify claim now true; add SEE/SEE-IR/HELP to README feature list if words are enumerated there; extend CLAUDE.md test-count line.
7. **Compliance untouched:** `cargo test -p wafer-core --test compliance` — must stay 11/11 (suite excludes SEE by design, `toolstest.fth:38-39`).
---
## Deliberate scope cuts (revisit later, not now)
- **`SEE-WASM`** (disassemble compiled module via `wasmprinter`): compiled bytes are likely dropped after instantiation; `codegen.rs` unexamined. Separate plan if wanted.
- **IR→Forth source reconstruction** for optimized bodies: lossy and misleading post-inlining; the source-capture path makes it unnecessary.
- **`LOCATE` / editor integration**: needs file/line provenance in the dictionary; out of scope.
- **Forth-side doc syntax (`:doc`)**: revisit after self-hosting work starts; the `( n -- n^2 )` echo in Phase 4 covers the 80% case with zero syntax.
+32 -10
View File
@@ -33,7 +33,10 @@ contexts:
- include: compare
- include: memory
- include: io
- include: pictured
- include: string_ops
- include: float
- include: tools
- include: dictionary
- include: exception
- include: parsing
@@ -95,27 +98,31 @@ contexts:
# Quotations (Core-Ext 6.2.0455): [: ... ;] compiles an anonymous word.
- match: '(?i)(?:^|(?<=\s))(\[:|;\]){{ident_break}}'
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:
1: keyword.other.defining.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
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}}'
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:
- 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
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
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
logic:
@@ -123,21 +130,36 @@ contexts:
scope: keyword.operator.logical.forth
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
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
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
# 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:
- 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
# 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:
- 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
@@ -147,7 +169,7 @@ contexts:
scope: keyword.control.exception.forth
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
literals:
@@ -185,5 +207,5 @@ contexts:
wafer_extras:
# WAFER-specific extensions beyond the Forth 2012 standard.
# 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