diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 2d467c3..21e0238 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -8,7 +8,7 @@ use crate::gc::{Gc, GcRef}; use crate::vm::*; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{SystemTime, UNIX_EPOCH}; /// Native callback type alias to satisfy clippy::type_complexity. @@ -253,6 +253,9 @@ pub fn init_builtins(vm: &mut Vm) { // Create and register JSON object (static methods only). init_json_object(vm); + // Create and register console object. + init_console_object(vm); + // Register global utility functions. init_global_functions(vm); @@ -1282,6 +1285,7 @@ fn array_to_string(_args: &[Value], ctx: &mut NativeContext) -> Result Result, func_ref: GcRef, args: &[Value], + console_output: &dyn ConsoleOutput, ) -> Result { match gc.get(func_ref) { Some(HeapObject::Function(fdata)) => match &fdata.kind { @@ -4608,6 +4618,7 @@ fn call_native_callback( let mut ctx = NativeContext { gc, this: Value::Undefined, + console_output, }; cb(args, &mut ctx) } @@ -4744,7 +4755,12 @@ fn set_proto_for_each(args: &[Value], ctx: &mut NativeContext) -> Result String { out } +// ── Console object ────────────────────────────────────────── + +fn init_console_object(vm: &mut Vm) { + let mut data = ObjectData::new(); + if let Some(proto) = vm.object_prototype { + data.prototype = Some(proto); + } + let console_ref = vm.gc.alloc(HeapObject::Object(data)); + + let methods: &[NativeMethod] = &[ + ("log", console_log), + ("info", console_log), + ("debug", console_log), + ("error", console_error), + ("warn", console_warn), + ]; + for &(name, callback) in methods { + let func = make_native(&mut vm.gc, name, callback); + set_builtin_prop(&mut vm.gc, console_ref, name, Value::Function(func)); + } + + vm.set_global("console", Value::Object(console_ref)); +} + +/// Format a JS value for console output with richer detail than `to_js_string`. +/// Arrays show their contents and objects show their properties. +fn console_format_value( + value: &Value, + gc: &Gc, + depth: usize, + seen: &mut HashSet, +) -> String { + const MAX_DEPTH: usize = 4; + match value { + Value::Undefined => "undefined".to_string(), + Value::Null => "null".to_string(), + Value::Boolean(b) => b.to_string(), + Value::Number(n) => js_number_to_string(*n), + Value::String(s) => s.clone(), + Value::Function(gc_ref) => gc + .get(*gc_ref) + .and_then(|obj| match obj { + HeapObject::Function(f) => Some(format!("[Function: {}]", f.name)), + _ => None, + }) + .unwrap_or_else(|| "[Function]".to_string()), + Value::Object(gc_ref) => { + if depth > MAX_DEPTH || seen.contains(gc_ref) { + return "[Object]".to_string(); + } + seen.insert(*gc_ref); + let result = match gc.get(*gc_ref) { + Some(HeapObject::Object(obj_data)) => { + // Check if it's an array (has a "length" property). + if obj_data.properties.contains_key("length") { + format_array(obj_data, gc, depth, seen) + } else { + format_object(obj_data, gc, depth, seen) + } + } + _ => "[Object]".to_string(), + }; + seen.remove(gc_ref); + result + } + } +} + +fn format_array( + data: &ObjectData, + gc: &Gc, + depth: usize, + seen: &mut HashSet, +) -> String { + let len = data + .properties + .get("length") + .map(|p| p.value.to_number() as usize) + .unwrap_or(0); + let mut parts = Vec::with_capacity(len); + for i in 0..len { + let val = data + .properties + .get(&i.to_string()) + .map(|p| &p.value) + .unwrap_or(&Value::Undefined); + parts.push(console_format_value(val, gc, depth + 1, seen)); + } + format!("[ {} ]", parts.join(", ")) +} + +fn format_object( + data: &ObjectData, + gc: &Gc, + depth: usize, + seen: &mut HashSet, +) -> String { + if data.properties.is_empty() { + return "{}".to_string(); + } + let mut parts = Vec::new(); + for (key, prop) in &data.properties { + let val_str = console_format_value(&prop.value, gc, depth + 1, seen); + parts.push(format!("{}: {}", key, val_str)); + } + parts.sort(); + format!("{{ {} }}", parts.join(", ")) +} + +/// Format all arguments for a console method, separated by spaces. +fn console_format_args(args: &[Value], gc: &Gc) -> String { + let mut seen = HashSet::new(); + args.iter() + .map(|v| console_format_value(v, gc, 0, &mut seen)) + .collect::>() + .join(" ") +} + +fn console_log(args: &[Value], ctx: &mut NativeContext) -> Result { + let msg = console_format_args(args, ctx.gc); + ctx.console_output.log(&msg); + Ok(Value::Undefined) +} + +fn console_error(args: &[Value], ctx: &mut NativeContext) -> Result { + let msg = console_format_args(args, ctx.gc); + ctx.console_output.error(&msg); + Ok(Value::Undefined) +} + +fn console_warn(args: &[Value], ctx: &mut NativeContext) -> Result { + let msg = console_format_args(args, ctx.gc); + ctx.console_output.warn(&msg); + Ok(Value::Undefined) +} + // ── Global utility functions ───────────────────────────────── fn init_global_functions(vm: &mut Vm) { diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index 9bef4fc..424d052 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -194,10 +194,35 @@ pub struct NativeFunc { pub callback: fn(&[Value], &mut NativeContext) -> Result, } +/// Trait for console output. Allows redirecting console output to a dev tools +/// panel or capturing it in tests. The default implementation writes to +/// stdout/stderr. +pub trait ConsoleOutput { + fn log(&self, message: &str); + fn error(&self, message: &str); + fn warn(&self, message: &str); +} + +/// Default console output that writes to stdout/stderr. +pub struct StdConsoleOutput; + +impl ConsoleOutput for StdConsoleOutput { + fn log(&self, message: &str) { + println!("{}", message); + } + fn error(&self, message: &str) { + eprintln!("{}", message); + } + fn warn(&self, message: &str) { + eprintln!("{}", message); + } +} + /// Context passed to native functions, providing GC access and `this` binding. pub struct NativeContext<'a> { pub gc: &'a mut Gc, pub this: Value, + pub console_output: &'a dyn ConsoleOutput, } // ── JS Value ────────────────────────────────────────────────── @@ -746,6 +771,8 @@ pub struct Vm { pub regexp_prototype: Option, /// Built-in Promise.prototype (for Promise objects). pub promise_prototype: Option, + /// Console output sink (configurable for dev tools or testing). + console_output: Box, } /// Maximum register file size. @@ -770,11 +797,17 @@ impl Vm { date_prototype: None, regexp_prototype: None, promise_prototype: None, + console_output: Box::new(StdConsoleOutput), }; crate::builtins::init_builtins(&mut vm); vm } + /// Replace the console output sink (e.g. for dev tools or testing). + pub fn set_console_output(&mut self, output: Box) { + self.console_output = output; + } + /// Set an instruction limit. The VM will return a RuntimeError after /// executing this many instructions. pub fn set_instruction_limit(&mut self, limit: u64) { @@ -831,6 +864,7 @@ impl Vm { let mut ctx = NativeContext { gc: &mut self.gc, this, + console_output: &*self.console_output, }; let result = (native.callback)(args, &mut ctx)?; @@ -2355,6 +2389,7 @@ impl Vm { let mut ctx = NativeContext { gc: &mut self.gc, this, + console_output: &*self.console_output, }; match callback(&args, &mut ctx) { Ok(val) => { @@ -7450,4 +7485,182 @@ mod tests { crate::parser::Parser::parse("async function f() { for await (let x of iter) { } }"); assert!(prog.is_ok()); } + + // ── Console API tests ───────────────────────────────────── + + use std::cell::RefCell; + use std::rc::Rc; + + /// A console output sink that captures messages for testing. + struct CapturedConsole { + log_messages: RefCell>, + error_messages: RefCell>, + warn_messages: RefCell>, + } + + impl CapturedConsole { + fn new() -> Self { + Self { + log_messages: RefCell::new(Vec::new()), + error_messages: RefCell::new(Vec::new()), + warn_messages: RefCell::new(Vec::new()), + } + } + } + + impl ConsoleOutput for CapturedConsole { + fn log(&self, message: &str) { + self.log_messages.borrow_mut().push(message.to_string()); + } + fn error(&self, message: &str) { + self.error_messages.borrow_mut().push(message.to_string()); + } + fn warn(&self, message: &str) { + self.warn_messages.borrow_mut().push(message.to_string()); + } + } + + /// Helper: compile and execute JS, capturing console output. + fn eval_with_console(source: &str) -> (Result, Rc) { + let console = Rc::new(CapturedConsole::new()); + let program = Parser::parse(source).expect("parse failed"); + let func = compiler::compile(&program).expect("compile failed"); + let mut vm = Vm::new(); + vm.set_console_output(Box::new(RcConsole(console.clone()))); + let result = vm.execute(&func); + (result, console) + } + + /// Wrapper to use Rc as Box. + struct RcConsole(Rc); + + impl ConsoleOutput for RcConsole { + fn log(&self, message: &str) { + self.0.log(message); + } + fn error(&self, message: &str) { + self.0.error(message); + } + fn warn(&self, message: &str) { + self.0.warn(message); + } + } + + #[test] + fn test_console_log_string() { + let (result, console) = eval_with_console("console.log('hello')"); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), Value::Undefined)); + let logs = console.log_messages.borrow(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0], "hello"); + } + + #[test] + fn test_console_log_multiple_args() { + let (_, console) = eval_with_console("console.log('a', 1, true)"); + let logs = console.log_messages.borrow(); + assert_eq!(logs[0], "a 1 true"); + } + + #[test] + fn test_console_error_to_error_channel() { + let (_, console) = eval_with_console("console.error('oops')"); + assert!(console.log_messages.borrow().is_empty()); + let errors = console.error_messages.borrow(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0], "oops"); + } + + #[test] + fn test_console_warn_to_warn_channel() { + let (_, console) = eval_with_console("console.warn('warning')"); + assert!(console.log_messages.borrow().is_empty()); + let warns = console.warn_messages.borrow(); + assert_eq!(warns.len(), 1); + assert_eq!(warns[0], "warning"); + } + + #[test] + fn test_console_info_aliases_log() { + let (_, console) = eval_with_console("console.info('info msg')"); + let logs = console.log_messages.borrow(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0], "info msg"); + } + + #[test] + fn test_console_debug_aliases_log() { + let (_, console) = eval_with_console("console.debug('debug msg')"); + let logs = console.log_messages.borrow(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0], "debug msg"); + } + + #[test] + fn test_console_log_returns_undefined() { + let result = eval("console.log('test')").unwrap(); + assert!(matches!(result, Value::Undefined)); + } + + #[test] + fn test_console_log_no_args() { + let (_, console) = eval_with_console("console.log()"); + let logs = console.log_messages.borrow(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0], ""); + } + + #[test] + fn test_console_log_primitives() { + let (_, console) = eval_with_console( + "console.log(undefined); console.log(null); console.log(42); console.log(true);", + ); + let logs = console.log_messages.borrow(); + assert_eq!(logs.len(), 4); + assert_eq!(logs[0], "undefined"); + assert_eq!(logs[1], "null"); + assert_eq!(logs[2], "42"); + assert_eq!(logs[3], "true"); + } + + #[test] + fn test_console_log_array() { + let (_, console) = eval_with_console("console.log([1, 2, 3])"); + let logs = console.log_messages.borrow(); + assert_eq!(logs[0], "[ 1, 2, 3 ]"); + } + + #[test] + fn test_console_log_object() { + let (_, console) = eval_with_console("console.log({x: 1})"); + let logs = console.log_messages.borrow(); + assert_eq!(logs[0], "{ x: 1 }"); + } + + #[test] + fn test_console_typeof() { + match eval("typeof console.log").unwrap() { + Value::String(s) => assert_eq!(s, "function"), + v => panic!("expected 'function', got {v:?}"), + } + } + + #[test] + fn test_console_is_object() { + match eval("typeof console").unwrap() { + Value::String(s) => assert_eq!(s, "object"), + v => panic!("expected 'object', got {v:?}"), + } + } + + #[test] + fn test_console_never_throws() { + let result = eval("console.log({x: 1}); console.log([1,2]); console.log(undefined); 'ok'"); + assert!(result.is_ok()); + match result.unwrap() { + Value::String(s) => assert_eq!(s, "ok"), + v => panic!("expected 'ok', got {v:?}"), + } + } }