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.
This commit is contained in:
Oleksandr Kozachuk
2026-08-05 14:57:12 +02:00
parent 4980648982
commit f584066a0a
6 changed files with 539 additions and 111 deletions
+153 -53
View File
@@ -285,6 +285,9 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
if !output.is_empty() {
print!("{output}");
}
if vm.bye_requested() {
break;
}
}
Err(e) => {
eprintln!("Error: {e}");
@@ -292,59 +295,7 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
}
}
} else {
// Interactive REPL
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;
}
}
}
run_repl(&mut vm)?;
}
}
}
@@ -357,3 +308,152 @@ fn stdin_is_tty() -> bool {
use std::io::IsTerminal;
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() {
// 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(())
}
+3
View File
@@ -243,6 +243,9 @@
\ U. ( u -- ) print unsigned number and space
: U. 0 <# #S #> TYPE SPACE ;
\ ? ( a-addr -- ) fetch and print
: ? @ . ;
\ .R ( n width -- ) print right-justified signed number
: .R >R DUP ABS 0 <# #S ROT SIGN #> R> OVER - SPACES TYPE ;
+14 -3
View File
@@ -18,6 +18,8 @@ pub mod flags {
pub const IMMEDIATE: u8 = 0x80;
/// Word is hidden (being compiled, not yet findable).
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).
pub const LENGTH_MASK: u8 = 0x1F;
/// Maximum word name length.
@@ -95,11 +97,17 @@ impl Dictionary {
// Write link field (points to previous 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);
if immediate {
flag_byte |= flags::IMMEDIATE;
}
if name_bytes.first() == Some(&b'_') {
flag_byte |= flags::INTERNAL;
}
self.memory[(entry_start + 4) as usize] = flag_byte;
// Write name bytes
@@ -410,12 +418,15 @@ impl Dictionary {
}
/// 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.
pub fn visible_words(&self, include_internal: bool) -> Vec<String> {
let mut names = Vec::new();
let mut addr = self.latest;
while addr != 0 {
let flags_byte = self.memory[(addr + 4) as usize];
if flags_byte & flags::HIDDEN == 0 {
let skip = flags_byte & flags::HIDDEN != 0
|| (!include_internal && flags_byte & flags::INTERNAL != 0);
if !skip {
let name_len = (flags_byte & flags::LENGTH_MASK) as usize;
let name_start = (addr + 5) as usize;
let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len])
+319 -28
View File
@@ -227,6 +227,12 @@ pub struct ForthVM<R: Runtime> {
pending_does_patch: Arc<Mutex<Option<u32>>>,
// Exception word set: throw code shared between CATCH and THROW host functions
throw_code: Arc<Mutex<Option<i32>>>,
// ABORT" texts by compiled index, plus the not-yet-reported payload of
// the most recent ABORT" (printed only if the -2 throw goes uncaught)
abort_messages: Arc<Mutex<Vec<String>>>,
abort_message: Arc<Mutex<Option<String>>>,
// Set by BYE: the embedding REPL/driver should exit
bye: Arc<std::sync::atomic::AtomicBool>,
// Shared dictionary lookup: maps uppercase name -> (WordId, is_immediate)
word_lookup: Arc<Mutex<HashMap<String, (u32, bool)>>>,
// Set of word_ids that are 2VALUEs (need 2-cell TO semantics)
@@ -308,6 +314,68 @@ pub enum LocalKind {
Float,
}
/// Standard message for a Forth 2012 THROW code (subset wafer can raise).
fn throw_message(code: i32) -> Option<&'static str> {
Some(match code {
-1 => "ABORT",
-2 => "ABORT\"",
-3 => "Stack overflow",
-4 => "Stack underflow",
-5 => "Return stack overflow",
-6 => "Return stack underflow",
-8 => "Dictionary overflow",
-9 => "Invalid memory address",
-10 => "Division by zero",
-11 => "Result out of range",
-13 => "Undefined word",
-14 => "Interpreting a compile-only word",
-16 => "Attempt to use zero-length string as a name",
-18 => "Parsed string overflow",
-22 => "Control structure mismatch",
-24 => "Invalid numeric argument",
-28 => "User interrupt",
-31 => "Word not defined by CREATE",
-42 => "Floating-point divide by zero",
-43 => "Floating-point result out of range",
-45 => "Floating-point stack underflow",
-56 => "QUIT",
_ => return None,
})
}
/// Format a cell as Forth `.` would: signed, in the given base (2..=36).
fn fmt_in_base(v: i32, base: u32) -> String {
let base = if (2..=36).contains(&base) { base } else { 10 };
if base == 10 {
return v.to_string();
}
let neg = v < 0;
let mut m = (v as i64).unsigned_abs();
let mut digits = Vec::new();
loop {
digits.push(char::from_digit((m % u64::from(base)) as u32, base).unwrap());
m /= u64::from(base);
if m == 0 {
break;
}
}
if neg {
digits.push('-');
}
digits.iter().rev().collect::<String>().to_ascii_uppercase()
}
/// Pop the data-stack top from host-function context.
fn host_pop(ctx: &mut dyn HostAccess) -> anyhow::Result<i32> {
let sp = ctx.get_dsp();
if sp >= DATA_STACK_TOP {
anyhow::bail!("Stack underflow");
}
let v = ctx.mem_read_i32(sp);
ctx.set_dsp(sp + CELL_SIZE);
Ok(v)
}
/// 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
@@ -372,6 +440,9 @@ impl<R: Runtime> ForthVM<R> {
pending_actions: Arc::new(Mutex::new(Vec::new())),
pending_does_patch: Arc::new(Mutex::new(None)),
throw_code: Arc::new(Mutex::new(None)),
abort_messages: Arc::new(Mutex::new(Vec::new())),
abort_message: Arc::new(Mutex::new(None)),
bye: Arc::new(std::sync::atomic::AtomicBool::new(false)),
word_lookup: Arc::new(Mutex::new(HashMap::new())),
two_value_words: std::collections::HashSet::new(),
fvalue_words: std::collections::HashSet::new(),
@@ -443,9 +514,12 @@ impl<R: Runtime> ForthVM<R> {
self.compiling_local_kinds.clear();
self.local_batch_base = None;
self.compile_frames.clear();
return Err(e);
return Err(self.describe_uncaught(e));
}
}
if self.bye.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
// Read >IN back from WASM memory. Only apply if Forth code changed it
// (i.e., the WASM value differs from what sync_input_to_wasm wrote).
// This distinguishes Forth's `>IN !` from Rust-side parse_until changes.
@@ -467,6 +541,32 @@ impl<R: Runtime> ForthVM<R> {
self.state != 0
}
/// True once BYE has executed; the embedding REPL/driver should exit.
pub fn bye_requested(&self) -> bool {
self.bye.load(std::sync::atomic::Ordering::Relaxed)
}
/// Turn an internal error into the message shown to the user.
///
/// An uncaught THROW leaves its code in `throw_code` (the "forth-throw"
/// sentinel error itself may get wrapped in a wasmtime trap when the
/// throw crosses compiled code, so the code — not the message — is the
/// reliable signal). Map the code to its standard message, or to the
/// recorded text for `ABORT"`. Always drains the payload so a stale
/// code can't leak into a later, unrelated error.
fn describe_uncaught(&mut self, e: anyhow::Error) -> anyhow::Error {
let code = self.throw_code.lock().unwrap().take();
let text = self.abort_message.lock().unwrap().take();
match (code, text) {
(Some(-2), Some(t)) => anyhow::anyhow!("{t}"),
(Some(code), _) => match throw_message(code) {
Some(m) => anyhow::anyhow!("{m} (throw {code})"),
None => anyhow::anyhow!("Catch = {code}"),
},
(None, _) => e,
}
}
/// Get and clear the output buffer.
pub fn take_output(&mut self) -> String {
let mut out = self.output.lock().unwrap();
@@ -542,6 +642,12 @@ impl<R: Runtime> ForthVM<R> {
&self.host_word_names
}
/// Names of all user-facing words (visible, non-internal), newest
/// first — the completion/browsing view of the dictionary.
pub fn word_names(&self) -> Vec<String> {
self.dictionary.visible_words(false)
}
/// Resolve a word name to its `WordId`. Returns `None` if not found.
pub fn resolve_word(&self, name: &str) -> Option<WordId> {
self.dictionary
@@ -836,11 +942,20 @@ impl<R: Runtime> ForthVM<R> {
"CONSOLIDATE" => return self.consolidate(),
"SYNONYM" => return self.define_synonym(),
"ORDER" => {
// wid 1 is FORTH-WORDLIST; other wids are anonymous.
let wid_name = |wid: u32| {
if wid == 1 {
"FORTH".to_string()
} else {
format!("wid#{wid}")
}
};
let so = self.search_order.lock().unwrap();
let names: Vec<String> = so.iter().map(|&w| wid_name(w)).collect();
let output = format!(
"Search order: {:?} Compilation: {}\n",
*so,
self.dictionary.current_wid()
"Search order: {} Compilation: {}\n",
names.join(" "),
wid_name(self.dictionary.current_wid())
);
self.output.lock().unwrap().push_str(&output);
return Ok(());
@@ -991,22 +1106,18 @@ impl<R: Runtime> ForthVM<R> {
// Handle ABORT" in compile mode
if token_upper == "ABORT\"" {
if let Some(s) = self.parse_until('"') {
// Compile: IF <push-addr> <push-len> TYPE ABORT THEN
// The flag is already on stack; compile the check
self.refresh_user_here();
let addr = self.user_here;
let bytes = s.as_bytes();
let len = bytes.len() as u32;
self.rt.mem_write_slice(addr as u32, bytes);
self.user_here += len;
self.sync_here_cell();
// ABORT" throws -2 without displaying the message.
// The message (addr, len) is saved but not typed here.
let throw_call = self.dictionary.find("THROW").map(|(_, id, _)| id);
let mut then_body = vec![IrOp::PushI32(-2)];
if let Some(throw_id) = throw_call {
then_body.push(IrOp::Call(throw_id));
// Record the text and compile: IF <idx> _ABORT_Q_ THEN.
// _ABORT_Q_ stashes the text as the pending abort payload
// and throws -2; the text is shown only if nothing CATCHes.
let idx = {
let mut msgs = self.abort_messages.lock().unwrap();
msgs.push(s);
(msgs.len() - 1) as i32
};
let abort_q = self.dictionary.find("_ABORT_Q_").map(|(_, id, _)| id);
let mut then_body = vec![IrOp::PushI32(idx)];
if let Some(id) = abort_q {
then_body.push(IrOp::Call(id));
}
self.push_ir(IrOp::If {
then_body,
@@ -2700,6 +2811,7 @@ impl<R: Runtime> ForthVM<R> {
self.register_environment_q()?;
// SOURCE: defined in boot.fth
self.register_abort()?;
self.register_tools()?;
// . (dot): defined in boot.fth
self.register_dot_s()?;
@@ -2886,12 +2998,14 @@ impl<R: Runtime> ForthVM<R> {
return Ok(());
}
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
let base = ctx.mem_read_i32(SYSVAR_BASE_VAR) as u32;
out.push_str(&format!("<{depth}> "));
// Print from bottom to top
// Print from bottom to top, in the current BASE
let mut addr = DATA_STACK_TOP - CELL_SIZE;
while addr >= sp {
let v = ctx.mem_read_i32(addr as u32);
out.push_str(&format!("{v} "));
out.push_str(&fmt_in_base(v, base));
out.push(' ');
if addr < CELL_SIZE {
break;
}
@@ -2904,6 +3018,63 @@ impl<R: Runtime> ForthVM<R> {
Ok(())
}
/// Register interactive inspection tools: DUMP and F.S.
fn register_tools(&mut self) -> anyhow::Result<()> {
// DUMP ( addr u -- ) hex+ASCII dump, 16 bytes per line.
let output = Arc::clone(&self.output);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
const MAX: i32 = 4096;
let len = host_pop(ctx)?;
let addr = host_pop(ctx)? as u32;
let end = ctx.mem_len() as u32;
let clipped = (len.clamp(0, MAX) as u32).min(end.saturating_sub(addr.min(end)));
let bytes = ctx.mem_read_slice(addr, clipped as usize);
let mut out = output.lock().unwrap();
for (i, row) in bytes.chunks(16).enumerate() {
let hex: Vec<String> = row.iter().map(|b| format!("{b:02X}")).collect();
let ascii: String = row
.iter()
.map(|&b| {
if (0x20..0x7F).contains(&b) {
b as char
} else {
'.'
}
})
.collect();
let row_addr = addr + (i as u32) * 16;
out.push_str(&format!(
"{row_addr:08X}: {:<47} |{ascii}|\n",
hex.join(" ")
));
}
if len as u32 > clipped {
out.push_str("... (truncated)\n");
}
Ok(())
});
self.register_host_primitive("DUMP", false, func)?;
// F.S ( -- ) print the float stack without consuming.
let output = Arc::clone(&self.output);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let sp = ctx.get_fsp();
let depth = (FLOAT_STACK_TOP.saturating_sub(sp)) / FLOAT_SIZE;
let mut out = output.lock().unwrap();
out.push_str(&format!("F:<{depth}> "));
for i in (0..depth).rev() {
let bytes: [u8; 8] = ctx
.mem_read_slice(sp + i * FLOAT_SIZE, 8)
.try_into()
.unwrap();
out.push_str(&format!("{} ", f64::from_le_bytes(bytes)));
}
Ok(())
});
self.register_host_primitive("F.S", false, func)?;
Ok(())
}
// -----------------------------------------------------------------------
// Crypto: SHA1 / SHA256 / SHA512 (and any algos in `crypto::ALGOS`)
// -----------------------------------------------------------------------
@@ -3888,14 +4059,35 @@ impl<R: Runtime> ForthVM<R> {
/// ABORT -- clear stacks and throw error.
fn register_abort(&mut self) -> anyhow::Result<()> {
let throw_code = Arc::clone(&self.throw_code);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
// Reset stack pointers
// Reset stack pointers and throw -1 (ABORT)
ctx.set_dsp((DATA_STACK_TOP as i32) as u32);
ctx.set_rsp((RETURN_STACK_TOP as i32) as u32);
Err(anyhow::anyhow!("ABORT"))
*throw_code.lock().unwrap() = Some(-1);
Err(anyhow::anyhow!("forth-throw"))
});
self.register_host_primitive("ABORT", false, func)?;
// _ABORT_Q_ ( idx -- ) runtime of ABORT": record text, throw -2.
let msgs = Arc::clone(&self.abort_messages);
let pending = Arc::clone(&self.abort_message);
let throw_code = Arc::clone(&self.throw_code);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let idx = host_pop(ctx)? as usize;
*pending.lock().unwrap() = msgs.lock().unwrap().get(idx).cloned();
*throw_code.lock().unwrap() = Some(-2);
Err(anyhow::anyhow!("forth-throw"))
});
self.register_host_primitive("_ABORT_Q_", false, func)?;
// BYE ( -- ) request REPL/driver exit.
let bye = Arc::clone(&self.bye);
let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| {
bye.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
});
self.register_host_primitive("BYE", false, func)?;
Ok(())
}
@@ -5784,14 +5976,35 @@ impl<R: Runtime> ForthVM<R> {
Ok(())
}
/// WORDS ( -- ) Print all visible dictionary words.
/// WORDS ( -- ) list visible words; `WORDS <substring>` filters them
/// (case-insensitive). Internal (underscore-prefixed) words are
/// skipped. Output wraps at 78 columns and ends with a count.
fn do_words(&mut self) {
let names = self.dictionary.visible_words();
// In interpret mode an optional token on the same line is a filter.
let filter = if self.state == 0 {
self.next_token().map(|t| t.to_ascii_uppercase())
} else {
None
};
let names = self.dictionary.visible_words(false);
let mut out = self.output.lock().unwrap();
for name in &names {
let mut shown = 0usize;
let mut col = 0usize;
for name in names.iter().filter(|n| {
filter
.as_ref()
.is_none_or(|f| n.to_ascii_uppercase().contains(f))
}) {
if col + name.len() + 1 > 78 && col > 0 {
out.push('\n');
col = 0;
}
out.push_str(name);
out.push(' ');
col += name.len() + 1;
shown += 1;
}
out.push_str(&format!("\n{shown} words\n"));
}
/// Register Search-Order word set words.
@@ -8753,6 +8966,84 @@ mod tests {
assert!(output.contains("MYTEST"));
}
#[test]
fn test_words_filter_and_count() {
let output = eval_output("WORDS FDEPTH");
assert!(output.contains("FDEPTH"));
assert!(!output.contains("SWAP"));
assert!(output.contains(" words\n"));
}
#[test]
fn test_words_hides_internal() {
let output = eval_output("WORDS");
assert!(!output.contains("_ABORT_Q_"));
assert!(!output.contains("__CTRL__"));
}
#[test]
fn test_dot_s_honors_base() {
assert_eq!(eval_output("HEX FF .S"), "<1> FF ");
assert_eq!(eval_output("HEX -A .S"), "<1> -A ");
assert_eq!(eval_output("5 2 BASE ! .S"), "<1> 101 ");
}
#[test]
fn test_question_fetches_and_prints() {
assert_eq!(eval_output("VARIABLE QV 17 QV ! QV ?"), "17 ");
}
#[test]
fn test_dump_smoke() {
let output = eval_output("VARIABLE DV 65 DV C! DV 4 DUMP");
assert!(output.contains("41"));
assert!(output.contains("|A"));
}
#[test]
fn test_f_dot_s() {
let output = eval_output("1.5E0 2.5E0 F.S");
assert!(output.starts_with("F:<2> 1.5 2.5"));
}
#[test]
fn test_bye_sets_flag_and_stops_line() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate("1 BYE 2").unwrap();
assert!(vm.bye_requested());
// BYE stops the rest of the line: only the 1 was pushed
assert_eq!(vm.data_stack(), vec![1]);
}
#[test]
fn test_uncaught_throw_has_message() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let e = vm.evaluate("-4 THROW").unwrap_err();
assert_eq!(e.to_string(), "Stack underflow (throw -4)");
let e = vm.evaluate("-77 THROW").unwrap_err();
assert_eq!(e.to_string(), "Catch = -77");
}
#[test]
fn test_uncaught_abort_quote_shows_text() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let e = vm
.evaluate(": TST -1 ABORT\" all is lost\" ; TST")
.unwrap_err();
assert_eq!(e.to_string(), "all is lost");
// Caught -2 must NOT print the text, and the payload must not leak
vm.evaluate(": TC ['] TST CATCH ; TC").unwrap();
assert_eq!(vm.take_output(), "");
assert_eq!(vm.data_stack(), vec![-2]);
}
#[test]
fn test_order_names_forth() {
let output = eval_output("ORDER");
assert!(output.contains("FORTH"));
assert!(!output.contains('['));
}
// ===================================================================
// Double DOES>: Forth 2012 WEIRD: W1 test
// ===================================================================
+8 -4
View File
@@ -6,8 +6,9 @@ use send_wrapper::SendWrapper;
use wasm_bindgen::prelude::*;
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::runtime::Runtime;
use wafer_core::runtime::{HostAccess, HostFn};
use crate::runtime_web::WebRuntime;
@@ -53,9 +54,12 @@ impl WaferRepl {
/// Get the current number base (10 = decimal, 16 = hex).
pub fn base(&mut self) -> u32 {
// BASE is stored at SYSVAR_BASE_VAR in WASM memory
self.vm.take_output(); // no-op side effect; just return base
10 // TODO: read from memory once we have a getter
self.vm.runtime_mut().mem_read_i32(SYSVAR_BASE_VAR) as u32
}
/// 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.
+42 -23
View File
@@ -1,8 +1,11 @@
import init, { WaferRepl } from './pkg/wafer_web.js';
let repl = null;
const history = [];
let historyIdx = -1;
const HISTORY_KEY = 'wafer-history';
const HISTORY_MAX = 200;
const history = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
let historyIdx = history.length;
let builtinWords = null;
const WORD_CATEGORIES = {
'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;
try {
const stack = repl.data_stack();
const base = repl.base();
const suffix = base !== 10 ? ` [base ${base}]` : '';
if (stack.length === 0) {
stackBar.textContent = 'Stack: (empty)';
stackBar.textContent = `Stack: (empty)${suffix}`;
} else {
stackBar.textContent = `Stack <${stack.length}> ${stack.join(' ')}`;
stackBar.textContent = `Stack <${stack.length}> ${stack.join(' ')}${suffix}`;
}
} catch {
stackBar.textContent = 'Stack: (error)';
@@ -50,19 +55,25 @@ function updateStack() {
}
function updateUserWords() {
const cat = document.getElementById('cat-user');
if (!cat) return;
// We'll track user words by checking what the REPL evaluates
// For now, just show the category
const list = document.getElementById('user-word-list');
if (!list || !repl || !builtinWords) return;
list.innerHTML = '';
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;
const trimmed = line.trim();
if (!trimmed) return;
// Add to history
history.push(trimmed);
// Add to history (user-typed lines only; skip consecutive duplicates)
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;
try {
@@ -83,6 +94,7 @@ function evaluate(line) {
updatePrompt();
updateStack();
updateUserWords();
}
// Input handling
@@ -116,6 +128,18 @@ document.getElementById('btn-toggle-words').addEventListener('click', () => {
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() {
const container = document.getElementById('word-categories');
container.innerHTML = '';
@@ -129,15 +153,7 @@ function buildWordPanel() {
const list = document.createElement('div');
list.className = 'word-list';
for (const w of words) {
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();
});
list.appendChild(chip);
list.appendChild(wordChip(w));
}
cat.appendChild(list);
container.appendChild(cat);
@@ -179,7 +195,7 @@ document.getElementById('btn-run-init').addEventListener('click', () => {
if (code.trim()) {
// Run each line separately
for (const line of code.split('\n')) {
if (line.trim()) evaluate(line);
if (line.trim()) evaluate(line, false);
}
}
localStorage.setItem('wafer-init-code', code);
@@ -214,6 +230,7 @@ document.getElementById('btn-reset').addEventListener('click', () => {
appendLine('WAFER reset.', 'line-ok');
updatePrompt();
updateStack();
updateUserWords();
} catch (e) {
appendLine(`Reset error: ${e.message}`, 'line-error');
}
@@ -225,6 +242,8 @@ async function boot() {
try {
await init();
repl = new WaferRepl();
// Everything defined at boot is "builtin"; later definitions are user words
builtinWords = new Set(repl.words());
output.innerHTML = '';
appendLine('WAFER — WebAssembly Forth Engine in Rust', '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;
if (initCode.trim()) {
for (const line of initCode.split('\n')) {
if (line.trim()) evaluate(line);
if (line.trim()) evaluate(line, false);
}
localStorage.setItem('wafer-init-code', initCode);
}
@@ -252,7 +271,7 @@ async function boot() {
const code = atob(location.hash.slice(1));
document.getElementById('init-code').value = code;
for (const line of code.split('\n')) {
if (line.trim()) evaluate(line);
if (line.trim()) evaluate(line, false);
}
} catch { /* ignore bad hash */ }
}