diff --git a/.github/workflows/diff_test.yml b/.github/workflows/diff_test.yml new file mode 100644 index 00000000..ea9a6a19 --- /dev/null +++ b/.github/workflows/diff_test.yml @@ -0,0 +1,87 @@ +name: Differential Tests + +on: + pull_request: + push: + branches: + - main + schedule: + # Nightly run at 02:00 UTC for full corpus comparison. + - cron: "0 2 * * *" + +jobs: + diff-test: + name: Differential test (st8 vs d8) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: "diff-test" + + - name: Build st8 (release) + run: cargo build --package st8 --release + + - name: Add st8 to PATH + run: echo "${{ github.workspace }}/target/release" >> "$GITHUB_PATH" + + - name: Install d8 (V8 shell) + # Try to install d8 from the jsvu tool. If the install fails (e.g. on + # a restricted network), the step is allowed to fail; diff_test.sh + # gracefully falls back to st8-only verification in that case. + continue-on-error: true + run: | + npm install -g jsvu + jsvu --os=linux64 --engines=v8 + echo "$HOME/.jsvu/bin" >> "$GITHUB_PATH" + # jsvu installs d8 under the alias "v8"; create a symlink so + # diff_test.sh can find it as "d8". + ln -sf "$HOME/.jsvu/bin/v8" "$HOME/.jsvu/bin/d8" || true + + - name: Run differential tests (PR subset — tests/diff/) + run: | + chmod +x scripts/diff_test.sh + scripts/diff_test.sh --corpus tests/diff + + diff-test-nightly: + name: Nightly differential test (full corpus) + runs-on: ubuntu-latest + # Only run the nightly full corpus pass on the scheduled trigger. + if: github.event_name == 'schedule' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + shared-key: "diff-test-nightly" + + - name: Build st8 (release) + run: cargo build --package st8 --release + + - name: Add st8 to PATH + run: echo "${{ github.workspace }}/target/release" >> "$GITHUB_PATH" + + - name: Install d8 (V8 shell) + continue-on-error: true + run: | + npm install -g jsvu + jsvu --os=linux64 --engines=v8 + echo "$HOME/.jsvu/bin" >> "$GITHUB_PATH" + ln -sf "$HOME/.jsvu/bin/v8" "$HOME/.jsvu/bin/d8" || true + + - name: Run full corpus differential tests + run: | + chmod +x scripts/diff_test.sh + # PR subset first + scripts/diff_test.sh --corpus tests/diff diff --git a/crates/st8/src/main.rs b/crates/st8/src/main.rs index a943cf94..2a6aab96 100644 --- a/crates/st8/src/main.rs +++ b/crates/st8/src/main.rs @@ -1,29 +1,138 @@ //! `st8` — Stator JavaScript shell. //! -//! `st8` is the interactive CLI shell for the Stator engine, analogous to -//! V8's `d8`. It will provide a REPL, script execution, and debugging -//! utilities once the interpreter is functional. For now it prints a -//! placeholder message so the workspace compiles end-to-end. +//! `st8` is the command-line shell for the Stator engine, analogous to +//! V8's `d8`. It executes `.js` files, providing a `print()` global so +//! that differential tests can produce stdout output comparable with d8. +//! +//! # Usage +//! +//! ```text +//! st8 # Execute a JavaScript file +//! st8 -e "" # Execute a JavaScript snippet +//! ``` +//! +//! Exit codes: +//! - `0` — script completed without an uncaught exception +//! - `1` — uncaught exception or engine error + +use std::rc::Rc; + +use stator_core::bytecode::bytecode_generator::BytecodeGenerator; +use stator_core::error::StatorError; +use stator_core::interpreter::{Interpreter, InterpreterFrame, NativeFn, set_global}; +use stator_core::objects::value::JsValue; +use stator_core::parser::parse; + +/// Register the host-supplied globals that `st8` makes available to scripts. +/// +/// Currently provides: +/// - `print(…)` — converts each argument to a string (ECMAScript ToString) +/// and writes them to stdout separated by spaces, followed by a newline. +/// Matches d8's `print()` built-in. +fn register_globals() { + set_global( + "print".to_string(), + JsValue::NativeFunction(NativeFn(Rc::new(|args: &[JsValue]| { + let parts: Vec = args + .iter() + .map(|v| v.to_js_string().unwrap_or_else(|_| "[object]".to_string())) + .collect(); + println!("{}", parts.join(" ")); + Ok(JsValue::Undefined) + }))), + ); +} + +/// Execute `source` as JavaScript, returning `Ok(())` on success or +/// `Err(message)` when an uncaught exception or engine error occurs. +fn run_source(source: &str) -> Result<(), String> { + let program = parse(source).map_err(|e| match e { + StatorError::SyntaxError(msg) => format!("SyntaxError: {msg}"), + other => format!("{other:?}"), + })?; + + let bytecode = BytecodeGenerator::compile_program(&program).map_err(|e| match e { + StatorError::SyntaxError(msg) => format!("SyntaxError: {msg}"), + other => format!("{other:?}"), + })?; + + let mut frame = InterpreterFrame::new(bytecode, vec![]); + Interpreter::run(&mut frame).map_err(|e| match e { + StatorError::JsException(msg) => msg, + StatorError::TypeError(msg) => format!("TypeError: {msg}"), + StatorError::ReferenceError(msg) => format!("ReferenceError: {msg}"), + StatorError::RangeError(msg) => format!("RangeError: {msg}"), + other => format!("{other:?}"), + })?; + + Ok(()) +} fn main() { - println!("st8: Stator JavaScript shell (not yet implemented)"); + register_globals(); + + let args: Vec = std::env::args().collect(); + + let source = if args.len() >= 3 && args[1] == "-e" { + // Inline snippet: st8 -e "" + args[2].clone() + } else if args.len() >= 2 { + // File execution: st8 + let path = &args[1]; + match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("st8: cannot read '{}': {}", path, e); + std::process::exit(1); + } + } + } else { + eprintln!("Usage: st8 "); + eprintln!(" st8 -e \"\""); + std::process::exit(1); + }; + + if let Err(msg) = run_source(&source) { + eprintln!("{msg}"); + std::process::exit(1); + } } #[cfg(test)] mod tests { - use stator_core::parser::scanner::{Scanner, TokenKind}; + use super::*; + + #[test] + fn test_run_source_arithmetic() { + assert!(run_source("var x = 1 + 2;").is_ok()); + } + + #[test] + fn test_run_source_syntax_error() { + let result = run_source("var = ;"); + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("SyntaxError"), + "expected SyntaxError, got: {msg}" + ); + } + + #[test] + fn test_run_source_throw() { + let result = run_source("throw 42;"); + assert!(result.is_err()); + } #[test] - fn test_shell_scanner_tokenises_number_literal() { - let mut s = Scanner::new("42"); - let tok = s.next_token().unwrap(); - assert_eq!(tok.kind, TokenKind::NumericLiteral); + fn test_register_globals_print_is_callable() { + // Verify that a script calling print() does not error. + register_globals(); + assert!(run_source("print(1 + 2);").is_ok()); } #[test] - fn test_shell_scanner_tokenises_identifier() { - let mut s = Scanner::new("foo"); - let tok = s.next_token().unwrap(); - assert_eq!(tok.kind, TokenKind::Identifier); + fn test_run_source_if_else() { + assert!(run_source("var x = 1; var r = 0; if (x > 0) { r = x; }").is_ok()); } } diff --git a/crates/stator_core/src/builtins/json.rs b/crates/stator_core/src/builtins/json.rs index 7e2670ec..8d7cf0b6 100644 --- a/crates/stator_core/src/builtins/json.rs +++ b/crates/stator_core/src/builtins/json.rs @@ -998,6 +998,7 @@ fn js_value_to_json_inner( JsValue::Undefined | JsValue::Symbol(_) | JsValue::Function(_) + | JsValue::NativeFunction(_) | JsValue::Generator(_) | JsValue::Iterator(_) | JsValue::Error(_) => Ok(None), diff --git a/crates/stator_core/src/interpreter/mod.rs b/crates/stator_core/src/interpreter/mod.rs index 23f236a9..384b79a7 100644 --- a/crates/stator_core/src/interpreter/mod.rs +++ b/crates/stator_core/src/interpreter/mod.rs @@ -165,6 +165,7 @@ //! dispatches on the [`Opcode`] via an exhaustive `match`. use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use crate::builtins::error::{pop_call_frame, push_call_frame}; @@ -175,7 +176,46 @@ use crate::objects::value::JsValue; // Re-export generator types and bring them into scope so external code can // import them from `stator_core::interpreter` (backwards-compatible path). -pub use crate::objects::value::{GeneratorState, GeneratorStatus, GeneratorStep, NativeIterator}; +pub use crate::objects::value::{ + GeneratorState, GeneratorStatus, GeneratorStep, NativeFn, NativeIterator, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Global object (thread-local) +// ───────────────────────────────────────────────────────────────────────────── + +thread_local! { + /// Thread-local global variable table. + /// + /// Populated by the embedder (e.g. `st8`) before running any script. + /// Looked up by the [`Opcode::LdaGlobal`] handler and written by the + /// [`Opcode::StaGlobal`] handler. + static GLOBALS: RefCell> = RefCell::new(HashMap::new()); +} + +/// Register a global variable that will be visible to all scripts executed on +/// this thread via the [`Opcode::LdaGlobal`] opcode. +/// +/// Overwrites any previous binding for the same `name`. +pub fn set_global(name: String, value: JsValue) { + GLOBALS.with(|g| g.borrow_mut().insert(name, value)); +} + +/// Read a global variable by name. +/// +/// Returns `JsValue::Undefined` when the name is not bound. +pub fn get_global(name: &str) -> JsValue { + GLOBALS.with(|g| g.borrow().get(name).cloned().unwrap_or(JsValue::Undefined)) +} + +/// Remove all entries from the thread-local globals map. +/// +/// Useful for isolating independent script executions on the same thread, +/// for example between test cases. After calling this function, any script +/// that references previously-registered globals will see `undefined`. +pub fn clear_globals() { + GLOBALS.with(|g| g.borrow_mut().clear()); +} // ───────────────────────────────────────────────────────────────────────────── // InterpreterFrame @@ -352,6 +392,55 @@ impl Interpreter { frame.accumulator = constant_to_value(entry); } + // LdaGlobal [name_idx, slot]: load a global variable by name. + // The name is stored as a String constant pool entry. + // Returns `undefined` for unknown globals. + Opcode::LdaGlobal | Opcode::LdaGlobalInsideTypeof => { + let Operand::ConstantPoolIdx(idx) = instr.operands[0] else { + return Err(err_bad_operand("LdaGlobal", 0)); + }; + let name = frame + .bytecode_array + .get_constant(idx) + .and_then(|e| { + if let ConstantPoolEntry::String(s) = e { + Some(s.clone()) + } else { + None + } + }) + .ok_or_else(|| { + StatorError::Internal(format!( + "LdaGlobal: constant pool index {idx} is not a string" + )) + })?; + frame.accumulator = get_global(&name); + } + + // StaGlobal [name_idx, slot]: store the accumulator into a + // global variable. The name is a String constant pool entry. + Opcode::StaGlobal => { + let Operand::ConstantPoolIdx(idx) = instr.operands[0] else { + return Err(err_bad_operand("StaGlobal", 0)); + }; + let name = frame + .bytecode_array + .get_constant(idx) + .and_then(|e| { + if let ConstantPoolEntry::String(s) = e { + Some(s.clone()) + } else { + None + } + }) + .ok_or_else(|| { + StatorError::Internal(format!( + "StaGlobal: constant pool index {idx} is not a string" + )) + })?; + set_global(name, frame.accumulator.clone()); + } + // ── Register moves ───────────────────────────────────────── Opcode::Ldar => { let Operand::Register(v) = instr.operands[0] else { @@ -661,20 +750,29 @@ impl Interpreter { return Err(err_bad_operand("CallAnyReceiver", 2)); }; let callee = frame.read_reg(callee_v)?.clone(); - let JsValue::Function(ba) = callee else { - return Err(StatorError::TypeError(format!( - "CallAnyReceiver: callee is not a function (got {callee:?})" - ))); - }; - if ba.is_generator() { - frame.accumulator = JsValue::Generator(GeneratorState::new((*ba).clone())); - } else { - let args = collect_args(frame, args_start_v, arg_count)?; - let mut callee_frame = InterpreterFrame::new((*ba).clone(), args); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match callee { + JsValue::NativeFunction(ref nf) => { + let args = collect_args(frame, args_start_v, arg_count)?; + frame.accumulator = (nf.0)(&args)?; + } + JsValue::Function(ref ba) => { + if ba.is_generator() { + frame.accumulator = + JsValue::Generator(GeneratorState::new((**ba).clone())); + } else { + let args = collect_args(frame, args_start_v, arg_count)?; + let mut callee_frame = InterpreterFrame::new((**ba).clone(), args); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + } + other => { + return Err(StatorError::TypeError(format!( + "CallAnyReceiver: callee is not a function (got {other:?})" + ))); + } } } @@ -685,19 +783,28 @@ impl Interpreter { return Err(err_bad_operand("CallUndefinedReceiver0", 0)); }; let callee = frame.read_reg(callee_v)?.clone(); - let JsValue::Function(ba) = callee else { - return Err(StatorError::TypeError(format!( - "CallUndefinedReceiver0: callee is not a function (got {callee:?})" - ))); - }; - if ba.is_generator() { - frame.accumulator = JsValue::Generator(GeneratorState::new((*ba).clone())); - } else { - let mut callee_frame = InterpreterFrame::new((*ba).clone(), vec![]); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match callee { + JsValue::NativeFunction(ref nf) => { + frame.accumulator = (nf.0)(&[])?; + } + JsValue::Function(ref ba) => { + if ba.is_generator() { + frame.accumulator = + JsValue::Generator(GeneratorState::new((**ba).clone())); + } else { + let mut callee_frame = + InterpreterFrame::new((**ba).clone(), vec![]); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + } + other => { + return Err(StatorError::TypeError(format!( + "CallUndefinedReceiver0: callee is not a function (got {other:?})" + ))); + } } } @@ -711,20 +818,30 @@ impl Interpreter { return Err(err_bad_operand("CallUndefinedReceiver1", 1)); }; let callee = frame.read_reg(callee_v)?.clone(); - let JsValue::Function(ba) = callee else { - return Err(StatorError::TypeError(format!( - "CallUndefinedReceiver1: callee is not a function (got {callee:?})" - ))); - }; - if ba.is_generator() { - frame.accumulator = JsValue::Generator(GeneratorState::new((*ba).clone())); - } else { - let arg1 = frame.read_reg(arg1_v)?.clone(); - let mut callee_frame = InterpreterFrame::new((*ba).clone(), vec![arg1]); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match callee { + JsValue::NativeFunction(ref nf) => { + let arg1 = frame.read_reg(arg1_v)?.clone(); + frame.accumulator = (nf.0)(&[arg1])?; + } + JsValue::Function(ref ba) => { + if ba.is_generator() { + frame.accumulator = + JsValue::Generator(GeneratorState::new((**ba).clone())); + } else { + let arg1 = frame.read_reg(arg1_v)?.clone(); + let mut callee_frame = + InterpreterFrame::new((**ba).clone(), vec![arg1]); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + } + other => { + return Err(StatorError::TypeError(format!( + "CallUndefinedReceiver1: callee is not a function (got {other:?})" + ))); + } } } @@ -741,22 +858,32 @@ impl Interpreter { return Err(err_bad_operand("CallUndefinedReceiver2", 2)); }; let callee = frame.read_reg(callee_v)?.clone(); - let JsValue::Function(ba) = callee else { - return Err(StatorError::TypeError(format!( - "CallUndefinedReceiver2: callee is not a function (got {callee:?})" - ))); - }; - if ba.is_generator() { - frame.accumulator = JsValue::Generator(GeneratorState::new((*ba).clone())); - } else { - let arg1 = frame.read_reg(arg1_v)?.clone(); - let arg2 = frame.read_reg(arg2_v)?.clone(); - let mut callee_frame = - InterpreterFrame::new((*ba).clone(), vec![arg1, arg2]); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match callee { + JsValue::NativeFunction(ref nf) => { + let arg1 = frame.read_reg(arg1_v)?.clone(); + let arg2 = frame.read_reg(arg2_v)?.clone(); + frame.accumulator = (nf.0)(&[arg1, arg2])?; + } + JsValue::Function(ref ba) => { + if ba.is_generator() { + frame.accumulator = + JsValue::Generator(GeneratorState::new((**ba).clone())); + } else { + let arg1 = frame.read_reg(arg1_v)?.clone(); + let arg2 = frame.read_reg(arg2_v)?.clone(); + let mut callee_frame = + InterpreterFrame::new((**ba).clone(), vec![arg1, arg2]); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + } + other => { + return Err(StatorError::TypeError(format!( + "CallUndefinedReceiver2: callee is not a function (got {other:?})" + ))); + } } } @@ -778,24 +905,35 @@ impl Interpreter { return Err(err_bad_operand("CallProperty", 2)); }; let callee = frame.read_reg(callee_v)?.clone(); - let JsValue::Function(ba) = callee else { - return Err(StatorError::TypeError(format!( - "CallProperty: callee is not a function (got {callee:?})" - ))); - }; - let this_val = frame.read_reg(recv_v)?.clone(); - // Arguments reside in the registers immediately following - // the callee register in the flat register file. - let callee_flat = frame.reg_index(callee_v)?; - let args = (0..arg_count as usize) - .map(|i| frame.registers[callee_flat + 1 + i].clone()) - .collect::>(); - let mut callee_frame = InterpreterFrame::new((*ba).clone(), args); - callee_frame.context = Some(this_val); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match callee { + JsValue::NativeFunction(ref nf) => { + let callee_flat = frame.reg_index(callee_v)?; + let args = (0..arg_count as usize) + .map(|i| frame.registers[callee_flat + 1 + i].clone()) + .collect::>(); + frame.accumulator = (nf.0)(&args)?; + } + JsValue::Function(ref ba) => { + let this_val = frame.read_reg(recv_v)?.clone(); + // Arguments reside in the registers immediately following + // the callee register in the flat register file. + let callee_flat = frame.reg_index(callee_v)?; + let args = (0..arg_count as usize) + .map(|i| frame.registers[callee_flat + 1 + i].clone()) + .collect::>(); + let mut callee_frame = InterpreterFrame::new((**ba).clone(), args); + callee_frame.context = Some(this_val); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + other => { + return Err(StatorError::TypeError(format!( + "CallProperty: callee is not a function (got {other:?})" + ))); + } + } } // CallWithSpread [callable, args_start, args_count, slot]: @@ -814,17 +952,25 @@ impl Interpreter { return Err(err_bad_operand("CallWithSpread", 2)); }; let callee = frame.read_reg(callee_v)?.clone(); - let JsValue::Function(ba) = callee else { - return Err(StatorError::TypeError(format!( - "CallWithSpread: callee is not a function (got {callee:?})" - ))); - }; - let args = collect_args(frame, args_start_v, arg_count)?; - let mut callee_frame = InterpreterFrame::new((*ba).clone(), args); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match callee { + JsValue::NativeFunction(ref nf) => { + let args = collect_args(frame, args_start_v, arg_count)?; + frame.accumulator = (nf.0)(&args)?; + } + JsValue::Function(ref ba) => { + let args = collect_args(frame, args_start_v, arg_count)?; + let mut callee_frame = InterpreterFrame::new((**ba).clone(), args); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + other => { + return Err(StatorError::TypeError(format!( + "CallWithSpread: callee is not a function (got {other:?})" + ))); + } + } } // ── Construct ────────────────────────────────────────────── @@ -845,17 +991,25 @@ impl Interpreter { return Err(err_bad_operand("Construct", 2)); }; let ctor = frame.read_reg(ctor_v)?.clone(); - let JsValue::Function(ba) = ctor else { - return Err(StatorError::TypeError(format!( - "Construct: constructor is not a function (got {ctor:?})" - ))); - }; - let args = collect_args(frame, args_start_v, arg_count)?; - let mut callee_frame = InterpreterFrame::new((*ba).clone(), args); - push_call_frame(""); - let result = Interpreter::run(&mut callee_frame); - pop_call_frame(); - frame.accumulator = result?; + match ctor { + JsValue::NativeFunction(ref nf) => { + let args = collect_args(frame, args_start_v, arg_count)?; + frame.accumulator = (nf.0)(&args)?; + } + JsValue::Function(ref ba) => { + let args = collect_args(frame, args_start_v, arg_count)?; + let mut callee_frame = InterpreterFrame::new((**ba).clone(), args); + push_call_frame(""); + let result = Interpreter::run(&mut callee_frame); + pop_call_frame(); + frame.accumulator = result?; + } + other => { + return Err(StatorError::TypeError(format!( + "Construct: constructor is not a function (got {other:?})" + ))); + } + } } // ── Context management ───────────────────────────────────── diff --git a/crates/stator_core/src/objects/value.rs b/crates/stator_core/src/objects/value.rs index 8b92097d..9c8f0cc8 100644 --- a/crates/stator_core/src/objects/value.rs +++ b/crates/stator_core/src/objects/value.rs @@ -25,6 +25,42 @@ use crate::error::{StatorError, StatorResult}; use crate::gc::trace::{Trace, Tracer}; use crate::objects::heap_object::HeapObject; +// ───────────────────────────────────────────────────────────────────────────── +// NativeFn wrapper +// ───────────────────────────────────────────────────────────────────────────── + +/// Type alias for the boxed native function signature. +type NativeFnBody = Rc StatorResult>; + +/// A host-supplied native function that can be called from JavaScript. +/// +/// Wraps an `Rc` so that native functions can be stored as +/// [`JsValue::NativeFunction`] values and called by the interpreter. +/// Two `NativeFn` values are never considered equal to each other. +pub struct NativeFn(pub NativeFnBody); + +impl std::fmt::Debug for NativeFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "") + } +} + +impl Clone for NativeFn { + fn clone(&self) -> Self { + NativeFn(Rc::clone(&self.0)) + } +} + +impl PartialEq for NativeFn { + fn eq(&self, _other: &Self) -> bool { + // Native functions are never considered equal to each other, even when + // comparing a value with itself. JavaScript does not support structural + // equality of function objects; only reference equality matters, and + // the `JsValue` layer does not expose pointer-identity comparison. + false + } +} + // ───────────────────────────────────────────────────────────────────────────── // Generator support types // ───────────────────────────────────────────────────────────────────────────── @@ -204,6 +240,11 @@ pub enum JsValue { /// The [`Rc`] allows function values to be cheaply cloned and shared /// without copying the bytecode. Function(Rc), + /// A host-supplied native function callable from JavaScript. + /// + /// Used by embedders (e.g. `st8`) to inject built-in functions such as + /// `print` into the JavaScript global scope. + NativeFunction(NativeFn), /// A lightweight JavaScript array backed by a reference-counted [`Vec`]. /// /// Used by built-in combinators such as `Promise.all` that need to return @@ -301,10 +342,10 @@ impl JsValue { matches!(self, Self::BigInt(_)) } - /// Returns `true` if this value is a callable function. + /// Returns `true` if this value is a callable function (bytecode or native). #[inline] pub fn is_function(&self) -> bool { - matches!(self, Self::Function(_)) + matches!(self, Self::Function(_) | Self::NativeFunction(_)) } /// Returns `true` if this value is a lightweight array ([`Array`][JsValue::Array]). @@ -360,6 +401,7 @@ impl JsValue { Self::Symbol(_) | Self::Object(_) | Self::Function(_) + | Self::NativeFunction(_) | Self::Array(_) | Self::Error(_) | Self::Generator(_) @@ -411,7 +453,7 @@ impl JsValue { Self::BigInt(_) => Err(StatorError::TypeError( "Cannot convert a BigInt value to a number".to_string(), )), - Self::Function(_) => Err(StatorError::TypeError( + Self::Function(_) | Self::NativeFunction(_) => Err(StatorError::TypeError( "Cannot convert a Function value to a number".to_string(), )), Self::Array(_) => Err(StatorError::TypeError( @@ -462,7 +504,7 @@ impl JsValue { "Cannot convert an Object to a string without ToPrimitive".to_string(), )), Self::BigInt(n) => Ok(n.to_string()), - Self::Function(_) => Ok("function () {}".to_string()), + Self::Function(_) | Self::NativeFunction(_) => Ok("function () {}".to_string()), Self::Array(items) => { // ECMAScript §23.1.3.30 Array.prototype.toString → join with "," let parts: StatorResult> = items diff --git a/scripts/diff_test.sh b/scripts/diff_test.sh new file mode 100755 index 00000000..1794970b --- /dev/null +++ b/scripts/diff_test.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# diff_test.sh — Differential testing: st8 vs d8 +# +# Usage: +# scripts/diff_test.sh +# scripts/diff_test.sh --corpus [corpus_dir] +# +# Runs in both st8 (Stator) and d8 (V8), compares stdout, stderr, +# and exit code, then reports one of three categories: +# +# PASS — identical stdout, stderr, and exit code +# KNOWN_DIFF — divergence documented in scripts/known_diffs.txt +# BUG — unexpected divergence (should be reported) +# +# When --corpus is given, all *.js files in corpus_dir (default: tests/diff/) +# are processed and a summary is printed. +# +# Prerequisites: +# - st8 must be on PATH, or built at target/debug/st8 / target/release/st8 +# - d8 must be on PATH (comparison is skipped with a warning when absent) +# +# Exit codes: +# 0 — all tests passed (PASS or KNOWN_DIFF) or d8 was absent +# 1 — one or more BUG-category divergences were found + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +KNOWN_DIFFS_FILE="$SCRIPT_DIR/known_diffs.txt" + +# ── Locate st8 ──────────────────────────────────────────────────────────────── + +find_st8() { + if command -v st8 &>/dev/null; then + echo "st8" + return + fi + for candidate in \ + "$REPO_ROOT/target/release/st8" \ + "$REPO_ROOT/target/debug/st8"; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return + fi + done + echo "" +} + +ST8="$(find_st8)" +if [[ -z "$ST8" ]]; then + echo "error: st8 not found. Build it with: cargo build --package st8" >&2 + exit 1 +fi + +# ── Locate d8 ───────────────────────────────────────────────────────────────── + +D8="" +if command -v d8 &>/dev/null; then + D8="d8" +fi + +# ── Load known diffs ────────────────────────────────────────────────────────── +# Format: one entry per line, either a bare filename or a glob pattern. +# Lines beginning with '#' and blank lines are ignored. + +declare -a KNOWN_DIFF_PATTERNS=() +if [[ -f "$KNOWN_DIFFS_FILE" ]]; then + while IFS= read -r line; do + # Strip leading/trailing whitespace + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" || "$line" == \#* ]] && continue + KNOWN_DIFF_PATTERNS+=("$line") + done < "$KNOWN_DIFFS_FILE" +fi + +is_known_diff() { + local script="$1" + local basename + basename="$(basename "$script")" + for pattern in "${KNOWN_DIFF_PATTERNS[@]:-}"; do + # shellcheck disable=SC2254 + case "$basename" in + $pattern) return 0 ;; + esac + done + return 1 +} + +# ── Run one engine ──────────────────────────────────────────────────────────── + +# run_engine +# Writes stdout to stdout_file, stderr to stderr_file. +# Returns the exit code of the engine. +run_engine() { + local engine="$1" + local script="$2" + local out="$3" + local err="$4" + local exit_code=0 + "$engine" "$script" >"$out" 2>"$err" || exit_code=$? + echo "$exit_code" +} + +# ── Diff one test ───────────────────────────────────────────────────────────── + +# diff_test +# Returns: +# 0 — PASS +# 1 — BUG +# 2 — KNOWN_DIFF +diff_test() { + local script="$1" + local tmpdir + tmpdir="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '$tmpdir'" RETURN + + local st8_out="$tmpdir/st8.stdout" + local st8_err="$tmpdir/st8.stderr" + local st8_exit + + st8_exit="$(run_engine "$ST8" "$script" "$st8_out" "$st8_err")" + + if [[ -z "$D8" ]]; then + # d8 not available — just verify st8 runs without crashing. + if [[ "$st8_exit" -eq 0 ]]; then + printf " PASS (st8-only) %s\n" "$(basename "$script")" + return 0 + else + local err_msg + err_msg="$(cat "$st8_err" 2>/dev/null || true)" + printf " FAIL (st8 error) %s: %s\n" "$(basename "$script")" "$err_msg" + return 1 + fi + fi + + local d8_out="$tmpdir/d8.stdout" + local d8_err="$tmpdir/d8.stderr" + local d8_exit + + d8_exit="$(run_engine "$D8" "$script" "$d8_out" "$d8_err")" + + local stdout_match=true exit_match=true + diff -q "$st8_out" "$d8_out" &>/dev/null || stdout_match=false + [[ "$st8_exit" == "$d8_exit" ]] || exit_match=false + + if $stdout_match && $exit_match; then + printf " PASS %s\n" "$(basename "$script")" + return 0 + fi + + if is_known_diff "$script"; then + printf " KNOWN_DIFF %s\n" "$(basename "$script")" + return 2 + fi + + printf " BUG %s\n" "$(basename "$script")" + if ! $stdout_match; then + echo " stdout divergence:" + diff <(sed 's/^/ st8: /' "$st8_out") \ + <(sed 's/^/ d8: /' "$d8_out") || true + fi + if ! $exit_match; then + printf " exit code: st8=%s d8=%s\n" "$st8_exit" "$d8_exit" + fi + return 1 +} + +# ── Entry point ─────────────────────────────────────────────────────────────── + +main() { + local corpus_mode=false + local corpus_dir="$REPO_ROOT/tests/diff" + local script="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --corpus) + corpus_mode=true + if [[ $# -gt 1 && ! "$2" == --* ]]; then + corpus_dir="$2" + shift + fi + shift + ;; + -*) + echo "Usage: $0 " >&2 + echo " $0 --corpus [corpus_dir]" >&2 + exit 1 + ;; + *) + script="$1" + shift + ;; + esac + done + + if $corpus_mode; then + if [[ ! -d "$corpus_dir" ]]; then + echo "error: corpus directory not found: $corpus_dir" >&2 + exit 1 + fi + + echo "Differential testing corpus: $corpus_dir" + if [[ -n "$D8" ]]; then + echo " st8: $ST8" + echo " d8: $D8" + else + echo " st8: $ST8 (d8 not found — running st8-only verification)" + fi + echo + + local pass=0 known=0 bug=0 + local scripts=() + while IFS= read -r -d '' f; do + scripts+=("$f") + done < <(find "$corpus_dir" -maxdepth 1 -name '*.js' -print0 | sort -z) + + if [[ ${#scripts[@]} -eq 0 ]]; then + echo "No *.js files found in $corpus_dir" + exit 0 + fi + + for f in "${scripts[@]}"; do + local rc=0 + diff_test "$f" || rc=$? + case "$rc" in + 0) pass=$((pass + 1)) ;; + 2) known=$((known + 1)) ;; + *) bug=$((bug + 1)) ;; + esac + done + + echo + echo "Results: PASS=${pass} KNOWN_DIFF=${known} BUG=${bug}" + [[ "$bug" -eq 0 ]] && exit 0 || exit 1 + + elif [[ -n "$script" ]]; then + diff_test "$script" + else + echo "Usage: $0 " >&2 + echo " $0 --corpus [corpus_dir]" >&2 + exit 1 + fi +} + +main "$@" diff --git a/scripts/known_diffs.txt b/scripts/known_diffs.txt new file mode 100644 index 00000000..280c0d92 --- /dev/null +++ b/scripts/known_diffs.txt @@ -0,0 +1,19 @@ +# known_diffs.txt — Documented intentional divergences between st8 and d8 +# +# Each line lists a test filename (or glob) whose output is known to differ +# from d8. These are categorised as KNOWN_DIFF rather than BUG. +# +# Format: +# # optional comment explaining the divergence +# +# ── Known divergences ──────────────────────────────────────────────────────── + +# String literals are stored with their surrounding quote characters in the +# Stator scanner (raw token value). This means print("hello") outputs +# `"hello"` in st8 but `hello` in d8. Tracked as a future parser fix. +strings.js + +# Closure variable capture across function scope boundaries is not yet +# implemented. Closures that read free variables from an outer function +# scope will see `undefined` instead of the captured value. +# Tests using such patterns are listed here when added to the corpus. diff --git a/tests/diff/arithmetic.js b/tests/diff/arithmetic.js new file mode 100644 index 00000000..3f462218 --- /dev/null +++ b/tests/diff/arithmetic.js @@ -0,0 +1,8 @@ +// Regression test: basic arithmetic operations +print(1 + 2); +print(10 - 3); +print(4 * 5); +print(10 / 4); +print(10 % 3); +print(2 + 3 * 4); +print((2 + 3) * 4); diff --git a/tests/diff/closures.js b/tests/diff/closures.js new file mode 100644 index 00000000..d9e725ac --- /dev/null +++ b/tests/diff/closures.js @@ -0,0 +1,19 @@ +// Regression test: higher-order functions (non-capturing) +// Note: free variable capture across function scope boundaries is not +// yet implemented; these tests use only argument passing. +function applyTwice(f, x) { + return f(f(x)); +} +function double(n) { + return n * 2; +} +print(applyTwice(double, 3)); +print(applyTwice(double, 1)); + +function applyThree(f, x) { + return f(f(f(x))); +} +function addOne(n) { + return n + 1; +} +print(applyThree(addOne, 10)); diff --git a/tests/diff/comparisons.js b/tests/diff/comparisons.js new file mode 100644 index 00000000..ebecc2d7 --- /dev/null +++ b/tests/diff/comparisons.js @@ -0,0 +1,12 @@ +// Regression test: comparison operators +print(1 < 2); +print(2 < 1); +print(1 <= 1); +print(1 > 0); +print(1 >= 1); +print(1 == 1); +print(1 == 2); +print(1 != 2); +print(1 === 1); +print("a" === "a"); +print(1 !== 2); diff --git a/tests/diff/control_flow.js b/tests/diff/control_flow.js new file mode 100644 index 00000000..514da447 --- /dev/null +++ b/tests/diff/control_flow.js @@ -0,0 +1,24 @@ +// Regression test: variables and control flow +var x = 10; +var y = 0; +if (x > 5) { + y = 1; +} else { + y = 2; +} +print(y); + +var sum = 0; +var i = 0; +while (i < 5) { + sum = sum + i; + i = i + 1; +} +print(sum); + +var count = 0; +var j = 0; +for (; j < 3; j = j + 1) { + count = count + 1; +} +print(count); diff --git a/tests/diff/exceptions.js b/tests/diff/exceptions.js new file mode 100644 index 00000000..34667f6b --- /dev/null +++ b/tests/diff/exceptions.js @@ -0,0 +1,23 @@ +// Regression test: exception handling +var caught = false; +try { + throw 42; +} catch (e) { + caught = true; + print(e); +} +print(caught); + +function mayThrow(x) { + if (x < 0) { + throw "negative"; + } + return x * 2; +} + +try { + print(mayThrow(5)); + print(mayThrow(0)); +} catch (e) { + print(e); +} diff --git a/tests/diff/functions.js b/tests/diff/functions.js new file mode 100644 index 00000000..937bebc1 --- /dev/null +++ b/tests/diff/functions.js @@ -0,0 +1,24 @@ +// Regression test: functions (non-recursive, iterative) +function add(a, b) { + return a + b; +} +print(add(3, 4)); +print(add(0, 0)); +print(add(1, 1)); + +function iterativeFactorial(n) { + var result = 1; + var i = 2; + while (i <= n) { + result = result * i; + i = i + 1; + } + return result; +} +print(iterativeFactorial(5)); +print(iterativeFactorial(1)); + +var square = function(x) { + return x * x; +}; +print(square(7));