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:
+153
-53
@@ -285,6 +285,9 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
|
|||||||
if !output.is_empty() {
|
if !output.is_empty() {
|
||||||
print!("{output}");
|
print!("{output}");
|
||||||
}
|
}
|
||||||
|
if vm.bye_requested() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error: {e}");
|
eprintln!("Error: {e}");
|
||||||
@@ -292,59 +295,7 @@ fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Interactive REPL
|
run_repl(&mut vm)?;
|
||||||
println!(
|
|
||||||
"WAFER v{} - WebAssembly Forth Engine in Rust",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
);
|
|
||||||
println!("Type BYE to exit.");
|
|
||||||
|
|
||||||
let mut rl = rustyline::DefaultEditor::new()?;
|
|
||||||
loop {
|
|
||||||
let prompt = if vm.is_compiling() { " ] " } else { "> " };
|
|
||||||
match rl.readline(prompt) {
|
|
||||||
Ok(line) => {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if trimmed.eq_ignore_ascii_case("BYE") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let _ = rl.add_history_entry(&line);
|
|
||||||
match vm.evaluate(&line) {
|
|
||||||
Ok(()) => {
|
|
||||||
let output = vm.take_output();
|
|
||||||
// PAGE (form feed) clears the terminal
|
|
||||||
if output.contains('\x0C') {
|
|
||||||
print!("\x1b[2J\x1b[H");
|
|
||||||
}
|
|
||||||
let output = output.replace('\x0C', "");
|
|
||||||
if !vm.is_compiling() {
|
|
||||||
// Move cursor back up to end of input line so
|
|
||||||
// output appears inline, like traditional Forth:
|
|
||||||
// > 2 2 + . 4 ok
|
|
||||||
let col = prompt.len() + line.len() + 1;
|
|
||||||
print!("\x1b[A\x1b[{col}G {output} ok");
|
|
||||||
println!();
|
|
||||||
} else if !output.is_empty() {
|
|
||||||
print!("{output}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(
|
|
||||||
rustyline::error::ReadlineError::Interrupted
|
|
||||||
| rustyline::error::ReadlineError::Eof,
|
|
||||||
) => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Readline error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,3 +308,152 @@ fn stdin_is_tty() -> bool {
|
|||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
std::io::stdin().is_terminal()
|
std::io::stdin().is_terminal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Completes the token under the cursor against the live dictionary.
|
||||||
|
struct WaferHelper {
|
||||||
|
words: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustyline::completion::Completer for WaferHelper {
|
||||||
|
type Candidate = String;
|
||||||
|
|
||||||
|
fn complete(
|
||||||
|
&self,
|
||||||
|
line: &str,
|
||||||
|
pos: usize,
|
||||||
|
_ctx: &rustyline::Context<'_>,
|
||||||
|
) -> rustyline::Result<(usize, Vec<String>)> {
|
||||||
|
let start = line[..pos]
|
||||||
|
.rfind(|c: char| c.is_whitespace())
|
||||||
|
.map_or(0, |i| i + 1);
|
||||||
|
let prefix = line[start..pos].to_ascii_uppercase();
|
||||||
|
let mut matches: Vec<String> = self
|
||||||
|
.words
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.to_ascii_uppercase().starts_with(&prefix))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
matches.sort();
|
||||||
|
matches.dedup();
|
||||||
|
Ok((start, matches))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustyline::hint::Hinter for WaferHelper {
|
||||||
|
type Hint = String;
|
||||||
|
}
|
||||||
|
impl rustyline::highlight::Highlighter for WaferHelper {}
|
||||||
|
impl rustyline::validate::Validator for WaferHelper {}
|
||||||
|
impl rustyline::Helper for WaferHelper {}
|
||||||
|
|
||||||
|
/// History file: `$WAFER_HISTORY`, else `$XDG_STATE_HOME/wafer/history`,
|
||||||
|
/// else `~/.local/state/wafer/history`.
|
||||||
|
fn history_path() -> Option<std::path::PathBuf> {
|
||||||
|
if let Some(p) = std::env::var_os("WAFER_HISTORY") {
|
||||||
|
return Some(p.into());
|
||||||
|
}
|
||||||
|
let base = std::env::var_os("XDG_STATE_HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/state"))
|
||||||
|
})?;
|
||||||
|
Some(base.join("wafer/history"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive REPL: line editing, persistent history with prefix search
|
||||||
|
/// on Up/Down, and Tab completion over the live dictionary.
|
||||||
|
fn run_repl(vm: &mut ForthVM<NativeRuntime>) -> anyhow::Result<()> {
|
||||||
|
use rustyline::{Cmd, Editor, EventHandler, KeyCode, KeyEvent, Modifiers};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"WAFER v{} - WebAssembly Forth Engine in Rust",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
|
println!("Type BYE to exit.");
|
||||||
|
|
||||||
|
let config = rustyline::Config::builder()
|
||||||
|
.completion_type(rustyline::CompletionType::List)
|
||||||
|
.history_ignore_dups(true)?
|
||||||
|
.build();
|
||||||
|
let mut rl: Editor<WaferHelper, rustyline::history::DefaultHistory> =
|
||||||
|
Editor::with_config(config)?;
|
||||||
|
rl.set_helper(Some(WaferHelper {
|
||||||
|
words: vm.word_names(),
|
||||||
|
}));
|
||||||
|
// Up/Down recall only entries starting with the typed prefix
|
||||||
|
rl.bind_sequence(
|
||||||
|
KeyEvent(KeyCode::Up, Modifiers::NONE),
|
||||||
|
EventHandler::Simple(Cmd::HistorySearchBackward),
|
||||||
|
);
|
||||||
|
rl.bind_sequence(
|
||||||
|
KeyEvent(KeyCode::Down, Modifiers::NONE),
|
||||||
|
EventHandler::Simple(Cmd::HistorySearchForward),
|
||||||
|
);
|
||||||
|
|
||||||
|
let history = history_path();
|
||||||
|
if let Some(path) = &history {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
let _ = rl.load_history(path);
|
||||||
|
}
|
||||||
|
let save_history = |rl: &mut Editor<WaferHelper, rustyline::history::DefaultHistory>| {
|
||||||
|
if let Some(path) = &history {
|
||||||
|
let _ = rl.save_history(path);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let prompt = if vm.is_compiling() { " ] " } else { "> " };
|
||||||
|
match rl.readline(prompt) {
|
||||||
|
Ok(line) => {
|
||||||
|
let _ = rl.add_history_entry(&line);
|
||||||
|
match vm.evaluate(&line) {
|
||||||
|
Ok(()) => {
|
||||||
|
let output = vm.take_output();
|
||||||
|
if vm.bye_requested() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// PAGE (form feed) clears the terminal
|
||||||
|
if output.contains('\x0C') {
|
||||||
|
print!("\x1b[2J\x1b[H");
|
||||||
|
}
|
||||||
|
let output = output.replace('\x0C', "");
|
||||||
|
if !vm.is_compiling() {
|
||||||
|
// 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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,6 +243,9 @@
|
|||||||
\ U. ( u -- ) print unsigned number and space
|
\ U. ( u -- ) print unsigned number and space
|
||||||
: U. 0 <# #S #> TYPE SPACE ;
|
: U. 0 <# #S #> TYPE SPACE ;
|
||||||
|
|
||||||
|
\ ? ( a-addr -- ) fetch and print
|
||||||
|
: ? @ . ;
|
||||||
|
|
||||||
\ .R ( n width -- ) print right-justified signed number
|
\ .R ( n width -- ) print right-justified signed number
|
||||||
: .R >R DUP ABS 0 <# #S ROT SIGN #> R> OVER - SPACES TYPE ;
|
: .R >R DUP ABS 0 <# #S ROT SIGN #> R> OVER - SPACES TYPE ;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub mod flags {
|
|||||||
pub const IMMEDIATE: u8 = 0x80;
|
pub const IMMEDIATE: u8 = 0x80;
|
||||||
/// Word is hidden (being compiled, not yet findable).
|
/// Word is hidden (being compiled, not yet findable).
|
||||||
pub const HIDDEN: u8 = 0x40;
|
pub const HIDDEN: u8 = 0x40;
|
||||||
|
/// Word is an implementation detail: findable, but skipped by WORDS.
|
||||||
|
pub const INTERNAL: u8 = 0x20;
|
||||||
/// Mask for the name length (lower 5 bits).
|
/// Mask for the name length (lower 5 bits).
|
||||||
pub const LENGTH_MASK: u8 = 0x1F;
|
pub const LENGTH_MASK: u8 = 0x1F;
|
||||||
/// Maximum word name length.
|
/// Maximum word name length.
|
||||||
@@ -95,11 +97,17 @@ impl Dictionary {
|
|||||||
// Write link field (points to previous LATEST)
|
// Write link field (points to previous LATEST)
|
||||||
self.write_u32_unchecked(entry_start, self.latest);
|
self.write_u32_unchecked(entry_start, self.latest);
|
||||||
|
|
||||||
// Write flags byte: HIDDEN | length, optionally IMMEDIATE
|
// Write flags byte: HIDDEN | length, optionally IMMEDIATE.
|
||||||
|
// Underscore-prefixed names are implementation details by repo
|
||||||
|
// convention (see tools/editor-support): flag them INTERNAL so
|
||||||
|
// WORDS and completion skip them while FIND still works.
|
||||||
let mut flag_byte = flags::HIDDEN | (name_len as u8 & flags::LENGTH_MASK);
|
let mut flag_byte = flags::HIDDEN | (name_len as u8 & flags::LENGTH_MASK);
|
||||||
if immediate {
|
if immediate {
|
||||||
flag_byte |= flags::IMMEDIATE;
|
flag_byte |= flags::IMMEDIATE;
|
||||||
}
|
}
|
||||||
|
if name_bytes.first() == Some(&b'_') {
|
||||||
|
flag_byte |= flags::INTERNAL;
|
||||||
|
}
|
||||||
self.memory[(entry_start + 4) as usize] = flag_byte;
|
self.memory[(entry_start + 4) as usize] = flag_byte;
|
||||||
|
|
||||||
// Write name bytes
|
// Write name bytes
|
||||||
@@ -410,12 +418,15 @@ impl Dictionary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return names of all visible (non-hidden) words, newest first.
|
/// Return names of all visible (non-hidden) words, newest first.
|
||||||
pub fn visible_words(&self) -> Vec<String> {
|
/// With `include_internal` false, words flagged INTERNAL are skipped.
|
||||||
|
pub fn visible_words(&self, include_internal: bool) -> Vec<String> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
let mut addr = self.latest;
|
let mut addr = self.latest;
|
||||||
while addr != 0 {
|
while addr != 0 {
|
||||||
let flags_byte = self.memory[(addr + 4) as usize];
|
let flags_byte = self.memory[(addr + 4) as usize];
|
||||||
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_len = (flags_byte & flags::LENGTH_MASK) as usize;
|
||||||
let name_start = (addr + 5) as usize;
|
let name_start = (addr + 5) as usize;
|
||||||
let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len])
|
let name = String::from_utf8_lossy(&self.memory[name_start..name_start + name_len])
|
||||||
|
|||||||
+319
-28
@@ -227,6 +227,12 @@ pub struct ForthVM<R: Runtime> {
|
|||||||
pending_does_patch: Arc<Mutex<Option<u32>>>,
|
pending_does_patch: Arc<Mutex<Option<u32>>>,
|
||||||
// Exception word set: throw code shared between CATCH and THROW host functions
|
// Exception word set: throw code shared between CATCH and THROW host functions
|
||||||
throw_code: Arc<Mutex<Option<i32>>>,
|
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)
|
// Shared dictionary lookup: maps uppercase name -> (WordId, is_immediate)
|
||||||
word_lookup: Arc<Mutex<HashMap<String, (u32, bool)>>>,
|
word_lookup: Arc<Mutex<HashMap<String, (u32, bool)>>>,
|
||||||
// Set of word_ids that are 2VALUEs (need 2-cell TO semantics)
|
// Set of word_ids that are 2VALUEs (need 2-cell TO semantics)
|
||||||
@@ -308,6 +314,68 @@ pub enum LocalKind {
|
|||||||
Float,
|
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
|
/// 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
|
/// 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
|
/// 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_actions: Arc::new(Mutex::new(Vec::new())),
|
||||||
pending_does_patch: Arc::new(Mutex::new(None)),
|
pending_does_patch: Arc::new(Mutex::new(None)),
|
||||||
throw_code: 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())),
|
word_lookup: Arc::new(Mutex::new(HashMap::new())),
|
||||||
two_value_words: std::collections::HashSet::new(),
|
two_value_words: std::collections::HashSet::new(),
|
||||||
fvalue_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.compiling_local_kinds.clear();
|
||||||
self.local_batch_base = None;
|
self.local_batch_base = None;
|
||||||
self.compile_frames.clear();
|
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
|
// 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).
|
// (i.e., the WASM value differs from what sync_input_to_wasm wrote).
|
||||||
// This distinguishes Forth's `>IN !` from Rust-side parse_until changes.
|
// This distinguishes Forth's `>IN !` from Rust-side parse_until changes.
|
||||||
@@ -467,6 +541,32 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
self.state != 0
|
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.
|
/// Get and clear the output buffer.
|
||||||
pub fn take_output(&mut self) -> String {
|
pub fn take_output(&mut self) -> String {
|
||||||
let mut out = self.output.lock().unwrap();
|
let mut out = self.output.lock().unwrap();
|
||||||
@@ -542,6 +642,12 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
&self.host_word_names
|
&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.
|
/// Resolve a word name to its `WordId`. Returns `None` if not found.
|
||||||
pub fn resolve_word(&self, name: &str) -> Option<WordId> {
|
pub fn resolve_word(&self, name: &str) -> Option<WordId> {
|
||||||
self.dictionary
|
self.dictionary
|
||||||
@@ -836,11 +942,20 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
"CONSOLIDATE" => return self.consolidate(),
|
"CONSOLIDATE" => return self.consolidate(),
|
||||||
"SYNONYM" => return self.define_synonym(),
|
"SYNONYM" => return self.define_synonym(),
|
||||||
"ORDER" => {
|
"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 so = self.search_order.lock().unwrap();
|
||||||
|
let names: Vec<String> = so.iter().map(|&w| wid_name(w)).collect();
|
||||||
let output = format!(
|
let output = format!(
|
||||||
"Search order: {:?} Compilation: {}\n",
|
"Search order: {} Compilation: {}\n",
|
||||||
*so,
|
names.join(" "),
|
||||||
self.dictionary.current_wid()
|
wid_name(self.dictionary.current_wid())
|
||||||
);
|
);
|
||||||
self.output.lock().unwrap().push_str(&output);
|
self.output.lock().unwrap().push_str(&output);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -991,22 +1106,18 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
// Handle ABORT" in compile mode
|
// Handle ABORT" in compile mode
|
||||||
if token_upper == "ABORT\"" {
|
if token_upper == "ABORT\"" {
|
||||||
if let Some(s) = self.parse_until('"') {
|
if let Some(s) = self.parse_until('"') {
|
||||||
// Compile: IF <push-addr> <push-len> TYPE ABORT THEN
|
// Record the text and compile: IF <idx> _ABORT_Q_ THEN.
|
||||||
// The flag is already on stack; compile the check
|
// _ABORT_Q_ stashes the text as the pending abort payload
|
||||||
self.refresh_user_here();
|
// and throws -2; the text is shown only if nothing CATCHes.
|
||||||
let addr = self.user_here;
|
let idx = {
|
||||||
let bytes = s.as_bytes();
|
let mut msgs = self.abort_messages.lock().unwrap();
|
||||||
let len = bytes.len() as u32;
|
msgs.push(s);
|
||||||
self.rt.mem_write_slice(addr as u32, bytes);
|
(msgs.len() - 1) as i32
|
||||||
self.user_here += len;
|
};
|
||||||
self.sync_here_cell();
|
let abort_q = self.dictionary.find("_ABORT_Q_").map(|(_, id, _)| id);
|
||||||
|
let mut then_body = vec![IrOp::PushI32(idx)];
|
||||||
// ABORT" throws -2 without displaying the message.
|
if let Some(id) = abort_q {
|
||||||
// The message (addr, len) is saved but not typed here.
|
then_body.push(IrOp::Call(id));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
self.push_ir(IrOp::If {
|
self.push_ir(IrOp::If {
|
||||||
then_body,
|
then_body,
|
||||||
@@ -2700,6 +2811,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
self.register_environment_q()?;
|
self.register_environment_q()?;
|
||||||
// SOURCE: defined in boot.fth
|
// SOURCE: defined in boot.fth
|
||||||
self.register_abort()?;
|
self.register_abort()?;
|
||||||
|
self.register_tools()?;
|
||||||
|
|
||||||
// . (dot): defined in boot.fth
|
// . (dot): defined in boot.fth
|
||||||
self.register_dot_s()?;
|
self.register_dot_s()?;
|
||||||
@@ -2886,12 +2998,14 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
let depth = (DATA_STACK_TOP - sp) / CELL_SIZE;
|
||||||
|
let base = ctx.mem_read_i32(SYSVAR_BASE_VAR) as u32;
|
||||||
out.push_str(&format!("<{depth}> "));
|
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;
|
let mut addr = DATA_STACK_TOP - CELL_SIZE;
|
||||||
while addr >= sp {
|
while addr >= sp {
|
||||||
let v = ctx.mem_read_i32(addr as u32);
|
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 {
|
if addr < CELL_SIZE {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2904,6 +3018,63 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
Ok(())
|
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`)
|
// 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.
|
/// ABORT -- clear stacks and throw error.
|
||||||
fn register_abort(&mut self) -> anyhow::Result<()> {
|
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| {
|
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_dsp((DATA_STACK_TOP as i32) as u32);
|
||||||
ctx.set_rsp((RETURN_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)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5784,14 +5976,35 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
Ok(())
|
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) {
|
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();
|
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_str(name);
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
|
col += name.len() + 1;
|
||||||
|
shown += 1;
|
||||||
}
|
}
|
||||||
|
out.push_str(&format!("\n{shown} words\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register Search-Order word set words.
|
/// Register Search-Order word set words.
|
||||||
@@ -8753,6 +8966,84 @@ mod tests {
|
|||||||
assert!(output.contains("MYTEST"));
|
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
|
// Double DOES>: Forth 2012 WEIRD: W1 test
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ use send_wrapper::SendWrapper;
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use wafer_core::config::WaferConfig;
|
use wafer_core::config::WaferConfig;
|
||||||
use wafer_core::memory::{CELL_SIZE, PAD_BASE, PAD_SIZE};
|
use wafer_core::memory::{CELL_SIZE, PAD_BASE, PAD_SIZE, SYSVAR_BASE_VAR};
|
||||||
use wafer_core::outer::ForthVM;
|
use wafer_core::outer::ForthVM;
|
||||||
|
use wafer_core::runtime::Runtime;
|
||||||
use wafer_core::runtime::{HostAccess, HostFn};
|
use wafer_core::runtime::{HostAccess, HostFn};
|
||||||
|
|
||||||
use crate::runtime_web::WebRuntime;
|
use crate::runtime_web::WebRuntime;
|
||||||
@@ -53,9 +54,12 @@ impl WaferRepl {
|
|||||||
|
|
||||||
/// Get the current number base (10 = decimal, 16 = hex).
|
/// Get the current number base (10 = decimal, 16 = hex).
|
||||||
pub fn base(&mut self) -> u32 {
|
pub fn base(&mut self) -> u32 {
|
||||||
// BASE is stored at SYSVAR_BASE_VAR in WASM memory
|
self.vm.runtime_mut().mem_read_i32(SYSVAR_BASE_VAR) as u32
|
||||||
self.vm.take_output(); // no-op side effect; just return base
|
}
|
||||||
10 // TODO: read from memory once we have a getter
|
|
||||||
|
/// Names of all user-facing words (visible, non-internal), newest first.
|
||||||
|
pub fn words(&self) -> Vec<String> {
|
||||||
|
self.vm.word_names()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the VM to initial state.
|
/// Reset the VM to initial state.
|
||||||
|
|||||||
+42
-23
@@ -1,8 +1,11 @@
|
|||||||
import init, { WaferRepl } from './pkg/wafer_web.js';
|
import init, { WaferRepl } from './pkg/wafer_web.js';
|
||||||
|
|
||||||
let repl = null;
|
let repl = null;
|
||||||
const history = [];
|
const HISTORY_KEY = 'wafer-history';
|
||||||
let historyIdx = -1;
|
const HISTORY_MAX = 200;
|
||||||
|
const history = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]');
|
||||||
|
let historyIdx = history.length;
|
||||||
|
let builtinWords = null;
|
||||||
|
|
||||||
const WORD_CATEGORIES = {
|
const WORD_CATEGORIES = {
|
||||||
'Stack': 'DUP DROP SWAP OVER ROT NIP TUCK 2DUP 2DROP 2SWAP 2OVER PICK ROLL DEPTH .S'.split(' '),
|
'Stack': 'DUP DROP SWAP OVER ROT NIP TUCK 2DUP 2DROP 2SWAP 2OVER PICK ROLL DEPTH .S'.split(' '),
|
||||||
@@ -39,10 +42,12 @@ function updateStack() {
|
|||||||
if (!repl) return;
|
if (!repl) return;
|
||||||
try {
|
try {
|
||||||
const stack = repl.data_stack();
|
const stack = repl.data_stack();
|
||||||
|
const base = repl.base();
|
||||||
|
const suffix = base !== 10 ? ` [base ${base}]` : '';
|
||||||
if (stack.length === 0) {
|
if (stack.length === 0) {
|
||||||
stackBar.textContent = 'Stack: (empty)';
|
stackBar.textContent = `Stack: (empty)${suffix}`;
|
||||||
} else {
|
} else {
|
||||||
stackBar.textContent = `Stack <${stack.length}> ${stack.join(' ')}`;
|
stackBar.textContent = `Stack <${stack.length}> ${stack.join(' ')}${suffix}`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
stackBar.textContent = 'Stack: (error)';
|
stackBar.textContent = 'Stack: (error)';
|
||||||
@@ -50,19 +55,25 @@ function updateStack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateUserWords() {
|
function updateUserWords() {
|
||||||
const cat = document.getElementById('cat-user');
|
const list = document.getElementById('user-word-list');
|
||||||
if (!cat) return;
|
if (!list || !repl || !builtinWords) return;
|
||||||
// We'll track user words by checking what the REPL evaluates
|
list.innerHTML = '';
|
||||||
// For now, just show the category
|
for (const w of repl.words()) {
|
||||||
|
if (!builtinWords.has(w)) list.appendChild(wordChip(w));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluate(line) {
|
function evaluate(line, record = true) {
|
||||||
if (!repl) return;
|
if (!repl) return;
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// Add to history
|
// Add to history (user-typed lines only; skip consecutive duplicates)
|
||||||
history.push(trimmed);
|
if (record && history[history.length - 1] !== trimmed) {
|
||||||
|
history.push(trimmed);
|
||||||
|
if (history.length > HISTORY_MAX) history.splice(0, history.length - HISTORY_MAX);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||||
|
}
|
||||||
historyIdx = history.length;
|
historyIdx = history.length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +94,7 @@ function evaluate(line) {
|
|||||||
|
|
||||||
updatePrompt();
|
updatePrompt();
|
||||||
updateStack();
|
updateStack();
|
||||||
|
updateUserWords();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input handling
|
// Input handling
|
||||||
@@ -116,6 +128,18 @@ document.getElementById('btn-toggle-words').addEventListener('click', () => {
|
|||||||
document.getElementById('word-panel').classList.toggle('collapsed');
|
document.getElementById('word-panel').classList.toggle('collapsed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function wordChip(w) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'word-chip';
|
||||||
|
chip.textContent = w;
|
||||||
|
chip.title = w;
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
input.value += (input.value.length > 0 ? ' ' : '') + w;
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWordPanel() {
|
function buildWordPanel() {
|
||||||
const container = document.getElementById('word-categories');
|
const container = document.getElementById('word-categories');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -129,15 +153,7 @@ function buildWordPanel() {
|
|||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'word-list';
|
list.className = 'word-list';
|
||||||
for (const w of words) {
|
for (const w of words) {
|
||||||
const chip = document.createElement('span');
|
list.appendChild(wordChip(w));
|
||||||
chip.className = 'word-chip';
|
|
||||||
chip.textContent = w;
|
|
||||||
chip.title = w;
|
|
||||||
chip.addEventListener('click', () => {
|
|
||||||
input.value += (input.value.length > 0 ? ' ' : '') + w;
|
|
||||||
input.focus();
|
|
||||||
});
|
|
||||||
list.appendChild(chip);
|
|
||||||
}
|
}
|
||||||
cat.appendChild(list);
|
cat.appendChild(list);
|
||||||
container.appendChild(cat);
|
container.appendChild(cat);
|
||||||
@@ -179,7 +195,7 @@ document.getElementById('btn-run-init').addEventListener('click', () => {
|
|||||||
if (code.trim()) {
|
if (code.trim()) {
|
||||||
// Run each line separately
|
// Run each line separately
|
||||||
for (const line of code.split('\n')) {
|
for (const line of code.split('\n')) {
|
||||||
if (line.trim()) evaluate(line);
|
if (line.trim()) evaluate(line, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localStorage.setItem('wafer-init-code', code);
|
localStorage.setItem('wafer-init-code', code);
|
||||||
@@ -214,6 +230,7 @@ document.getElementById('btn-reset').addEventListener('click', () => {
|
|||||||
appendLine('WAFER reset.', 'line-ok');
|
appendLine('WAFER reset.', 'line-ok');
|
||||||
updatePrompt();
|
updatePrompt();
|
||||||
updateStack();
|
updateStack();
|
||||||
|
updateUserWords();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appendLine(`Reset error: ${e.message}`, 'line-error');
|
appendLine(`Reset error: ${e.message}`, 'line-error');
|
||||||
}
|
}
|
||||||
@@ -225,6 +242,8 @@ async function boot() {
|
|||||||
try {
|
try {
|
||||||
await init();
|
await init();
|
||||||
repl = new WaferRepl();
|
repl = new WaferRepl();
|
||||||
|
// Everything defined at boot is "builtin"; later definitions are user words
|
||||||
|
builtinWords = new Set(repl.words());
|
||||||
output.innerHTML = '';
|
output.innerHTML = '';
|
||||||
appendLine('WAFER — WebAssembly Forth Engine in Rust', 'line-output');
|
appendLine('WAFER — WebAssembly Forth Engine in Rust', 'line-output');
|
||||||
appendLine(`Type Forth at the > prompt. Press ? for help.`, 'line-output');
|
appendLine(`Type Forth at the > prompt. Press ? for help.`, 'line-output');
|
||||||
@@ -241,7 +260,7 @@ async function boot() {
|
|||||||
const initCode = document.getElementById('init-code').value;
|
const initCode = document.getElementById('init-code').value;
|
||||||
if (initCode.trim()) {
|
if (initCode.trim()) {
|
||||||
for (const line of initCode.split('\n')) {
|
for (const line of initCode.split('\n')) {
|
||||||
if (line.trim()) evaluate(line);
|
if (line.trim()) evaluate(line, false);
|
||||||
}
|
}
|
||||||
localStorage.setItem('wafer-init-code', initCode);
|
localStorage.setItem('wafer-init-code', initCode);
|
||||||
}
|
}
|
||||||
@@ -252,7 +271,7 @@ async function boot() {
|
|||||||
const code = atob(location.hash.slice(1));
|
const code = atob(location.hash.slice(1));
|
||||||
document.getElementById('init-code').value = code;
|
document.getElementById('init-code').value = code;
|
||||||
for (const line of code.split('\n')) {
|
for (const line of code.split('\n')) {
|
||||||
if (line.trim()) evaluate(line);
|
if (line.trim()) evaluate(line, false);
|
||||||
}
|
}
|
||||||
} catch { /* ignore bad hash */ }
|
} catch { /* ignore bad hash */ }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user