From f584066a0aa2d740418927bbae5238901c2e58e3 Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk <201152+ok2@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:57:12 +0200 Subject: [PATCH] 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 = ") 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. --- crates/cli/src/main.rs | 206 ++++++++++++++------ crates/core/boot.fth | 3 + crates/core/src/dictionary.rs | 17 +- crates/core/src/outer.rs | 347 +++++++++++++++++++++++++++++++--- crates/web/src/lib.rs | 12 +- crates/web/www/app.js | 65 ++++--- 6 files changed, 539 insertions(+), 111 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index ba17205..4b5bd2a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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, +} + +impl rustyline::completion::Completer for WaferHelper { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + 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 = 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 { + 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) -> 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 = + 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| { + 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(()) +} diff --git a/crates/core/boot.fth b/crates/core/boot.fth index 83162ea..cc9d8e0 100644 --- a/crates/core/boot.fth +++ b/crates/core/boot.fth @@ -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 ; diff --git a/crates/core/src/dictionary.rs b/crates/core/src/dictionary.rs index eeaaf50..bc079de 100644 --- a/crates/core/src/dictionary.rs +++ b/crates/core/src/dictionary.rs @@ -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 { + /// With `include_internal` false, words flagged INTERNAL are skipped. + pub fn visible_words(&self, include_internal: bool) -> Vec { 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]) diff --git a/crates/core/src/outer.rs b/crates/core/src/outer.rs index b9cdb5d..45327d8 100644 --- a/crates/core/src/outer.rs +++ b/crates/core/src/outer.rs @@ -227,6 +227,12 @@ pub struct ForthVM { pending_does_patch: Arc>>, // Exception word set: throw code shared between CATCH and THROW host functions throw_code: Arc>>, + // 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>>, + abort_message: Arc>>, + // Set by BYE: the embedding REPL/driver should exit + bye: Arc, // Shared dictionary lookup: maps uppercase name -> (WordId, is_immediate) word_lookup: Arc>>, // 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::().to_ascii_uppercase() +} + +/// Pop the data-stack top from host-function context. +fn host_pop(ctx: &mut dyn HostAccess) -> anyhow::Result { + 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 ForthVM { 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 ForthVM { 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 ForthVM { 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 ForthVM { &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 { + 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 { self.dictionary @@ -836,11 +942,20 @@ impl ForthVM { "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 = 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 ForthVM { // Handle ABORT" in compile mode if token_upper == "ABORT\"" { if let Some(s) = self.parse_until('"') { - // Compile: IF 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 _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 ForthVM { 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 ForthVM { 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 ForthVM { 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 = 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 ForthVM { /// 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 ForthVM { Ok(()) } - /// WORDS ( -- ) Print all visible dictionary words. + /// WORDS ( -- ) list visible words; `WORDS ` 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::::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::::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::::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 // =================================================================== diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 226919f..7b1ff0e 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -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 { + self.vm.word_names() } /// Reset the VM to initial state. diff --git a/crates/web/www/app.js b/crates/web/www/app.js index 9d6a4ec..55bdfab 100644 --- a/crates/web/www/app.js +++ b/crates/web/www/app.js @@ -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 */ } }