diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000000..331e4f2920d --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,5 @@ +[advisories] +ignore = [ + "RUSTSEC-2024-0436", # paste is unmaintained + "RUSTSEC-2024-0384", # instant is unmaintained +] diff --git a/Cargo.lock b/Cargo.lock index e714078ea8b..9d3193b6050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,7 @@ dependencies = [ "boa_engine", "boa_gc", "bytemuck", + "comfy-table", "either", "futures", "futures-lite", diff --git a/core/engine/Cargo.toml b/core/engine/Cargo.toml index 480a7a7adeb..461eb4bf149 100644 --- a/core/engine/Cargo.toml +++ b/core/engine/Cargo.toml @@ -77,13 +77,13 @@ temporal = ["dep:icu_calendar", "dep:temporal_rs", "dep:timezone_provider", "tim system-time-zone = ["dep:iana-time-zone"] # Enable experimental features, like Stage 3 proposals. -experimental = [] +experimental = ["boa_string/experimental"] # Enable binding to JS APIs for system related utilities. js = ["dep:web-time", "dep:getrandom", "getrandom/wasm_js", "time/wasm-bindgen"] # Enable support for Float16 typed arrays -float16 = ["dep:float16"] +float16 = ["dep:float16", "boa_string/float16"] # Enable support for `Math.sumPrecise` xsum = ["dep:xsum"] diff --git a/core/engine/src/builtins/mod.rs b/core/engine/src/builtins/mod.rs index 470e6ea399b..8229a32c614 100644 --- a/core/engine/src/builtins/mod.rs +++ b/core/engine/src/builtins/mod.rs @@ -28,6 +28,8 @@ pub mod proxy; pub mod reflect; pub mod regexp; pub mod set; +#[cfg(feature = "experimental")] +pub mod shadow_realm; pub mod string; pub mod symbol; pub mod typed_array; @@ -85,6 +87,9 @@ pub(crate) use self::{ }, }; +#[cfg(feature = "experimental")] +pub(crate) use self::shadow_realm::ShadowRealm; + use crate::{ Context, JsResult, JsString, JsValue, builtins::{ @@ -272,8 +277,10 @@ impl Realm { Number::init(self); Eval::init(self); Set::init(self); - SetIterator::init(self); + #[cfg(feature = "experimental")] + ShadowRealm::init(self); String::init(self); + SetIterator::init(self); StringIterator::init(self); RegExp::init(self); RegExpStringIterator::init(self); @@ -408,6 +415,8 @@ pub(crate) fn set_default_global_bindings(context: &mut Context) -> JsResult<()> global_binding::(context)?; global_binding::(context)?; global_binding::(context)?; + #[cfg(feature = "experimental")] + global_binding::(context)?; global_binding::(context)?; global_binding::(context)?; global_binding::(context)?; diff --git a/core/engine/src/builtins/shadow_realm/mod.rs b/core/engine/src/builtins/shadow_realm/mod.rs new file mode 100644 index 00000000000..512202441af --- /dev/null +++ b/core/engine/src/builtins/shadow_realm/mod.rs @@ -0,0 +1,150 @@ +#![cfg(feature = "experimental")] +//! Boa's implementation of ECMAScript's global `ShadowRealm` object. +//! +//! The `ShadowRealm` object is a distinct global environment that can execute +//! JavaScript code in a new, isolated realm. +//! +//! More information: +//! - [ECMAScript reference][spec] +//! +//! [spec]: https://tc39.es/proposal-shadowrealm/ + +#[cfg(test)] +mod tests; + +use crate::{ + builtins::{BuiltInBuilder, BuiltInConstructor, BuiltInObject, IntrinsicObject}, + context::intrinsics::{Intrinsics, StandardConstructor, StandardConstructors}, + error::JsNativeError, + js_string, + object::{JsData, JsObject, internal_methods::get_prototype_from_constructor}, + realm::Realm, + string::StaticJsStrings, + Context, JsArgs, JsResult, JsString, JsValue, +}; +use boa_gc::{Finalize, Trace}; + +/// The `ShadowRealm` built-in object. +#[derive(Debug, Trace, Finalize)] +pub struct ShadowRealm { + inner: Realm, +} + +impl JsData for ShadowRealm {} + +impl IntrinsicObject for ShadowRealm { + fn init(realm: &Realm) { + BuiltInBuilder::from_standard_constructor::(realm) + .method(Self::evaluate, js_string!("evaluate"), 1) + .method(Self::import_value, js_string!("importValue"), 2) + .build(); + } + + fn get(intrinsics: &Intrinsics) -> JsObject { + Self::STANDARD_CONSTRUCTOR(intrinsics.constructors()).constructor() + } +} + +impl BuiltInObject for ShadowRealm { + const NAME: JsString = StaticJsStrings::SHADOW_REALM; +} + +impl BuiltInConstructor for ShadowRealm { + const CONSTRUCTOR_ARGUMENTS: usize = 0; + const PROTOTYPE_STORAGE_SLOTS: usize = 2; + const CONSTRUCTOR_STORAGE_SLOTS: usize = 0; + + const STANDARD_CONSTRUCTOR: fn(&StandardConstructors) -> &StandardConstructor = + StandardConstructors::shadow_realm; + + fn constructor( + new_target: &JsValue, + _args: &[JsValue], + context: &mut Context, + ) -> JsResult { + // 1. If NewTarget is undefined, throw a TypeError exception. + if new_target.is_undefined() { + return Err(JsNativeError::typ() + .with_message("ShadowRealm constructor: NewTarget is undefined") + .into()); + } + + // 2. Let realmRec be ? CreateRealm(). + let realm = context.create_realm()?; + + // 3. Let shadowRealm be ? OrdinaryCreateFromConstructor(newTarget, "%ShadowRealm.prototype%", « [[ShadowRealm]] »). + // 4. Set shadowRealm.[[ShadowRealm]] to realmRec. + let prototype = get_prototype_from_constructor(new_target, StandardConstructors::shadow_realm, context)?; + let shadow_realm = JsObject::from_proto_and_data(prototype, ShadowRealm { inner: realm }); + + // 5. Return shadowRealm. + Ok(shadow_realm.into()) + } +} + +impl ShadowRealm { + /// `ShadowRealm.prototype.evaluate ( sourceText )` + pub(crate) fn evaluate(this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + // 1. Let shadowRealm be the this value. + // 2. Perform ? ValidateShadowRealm(shadowRealm). + let shadow_realm_obj = this + .as_object() + .ok_or_else(|| { + JsNativeError::typ() + .with_message("ShadowRealm.prototype.evaluate: this is not a ShadowRealm object") + })?; + + let shadow_realm = shadow_realm_obj.downcast_ref::().ok_or_else(|| { + JsNativeError::typ() + .with_message("ShadowRealm.prototype.evaluate: this is not a ShadowRealm object") + })?; + + // 3. If Type(sourceText) is not String, throw a TypeError exception. + let source_text = args.get_or_undefined(0); + if !source_text.is_string() { + return Err(JsNativeError::typ() + .with_message("ShadowRealm.prototype.evaluate: sourceText is not a string") + .into()); + } + + // 4. Let realmRec be shadowRealm.[[ShadowRealm]]. + let realm = shadow_realm.inner.clone(); + + // 5. Return ? PerformShadowRealmEval(sourceText, realmRec). + + // Switch realm + let old_realm = context.enter_realm(realm); + + // Perform eval (indirect) + let result = + crate::builtins::eval::Eval::perform_eval(source_text, false, None, false, context); + + // Restore realm + context.enter_realm(old_realm); + + let result = result?; + + // 6. Return ? GetWrappedValue(realm, result). + // TODO: Implement GetWrappedValue (Callable Masking) + // For now, just return the result if it's not a function. + if result.is_callable() { + return Err(JsNativeError::typ() + .with_message("ShadowRealm: Callable masking (function wrapping) not yet implemented") + .into()); + } + + Ok(result) + } + + /// `ShadowRealm.prototype.importValue ( specifier, name )` + pub(crate) fn import_value( + _this: &JsValue, + _args: &[JsValue], + _context: &mut Context, + ) -> JsResult { + // TODO: Implementation of importValue + Err(JsNativeError::typ() + .with_message("ShadowRealm.prototype.importValue: not yet implemented") + .into()) + } +} diff --git a/core/engine/src/builtins/shadow_realm/tests.rs b/core/engine/src/builtins/shadow_realm/tests.rs new file mode 100644 index 00000000000..31fe1390a3f --- /dev/null +++ b/core/engine/src/builtins/shadow_realm/tests.rs @@ -0,0 +1,22 @@ +#![cfg(feature = "experimental")] +use crate::{run_test_actions, TestAction}; + +#[test] +fn constructor() { + run_test_actions([ + TestAction::assert("new ShadowRealm() instanceof ShadowRealm"), + TestAction::assert("typeof ShadowRealm.prototype.evaluate === 'function'"), + TestAction::assert("typeof ShadowRealm.prototype.importValue === 'function'"), + ]); +} + +#[test] +fn evaluate_isolation() { + run_test_actions([ + TestAction::run("const realm = new ShadowRealm();"), + TestAction::run("realm.evaluate('globalThis.x = 42;');"), + TestAction::assert("globalThis.x === undefined"), + TestAction::assert("realm.evaluate('globalThis.x') === 42"), + TestAction::assert("realm.evaluate('globalThis.x = 100;'); realm.evaluate('globalThis.x') === 100"), + ]); +} diff --git a/core/engine/src/context/intrinsics.rs b/core/engine/src/context/intrinsics.rs index b4dcc162e9d..9519c7ccb8b 100644 --- a/core/engine/src/context/intrinsics.rs +++ b/core/engine/src/context/intrinsics.rs @@ -148,6 +148,8 @@ pub struct StandardConstructors { aggregate_error: StandardConstructor, map: StandardConstructor, set: StandardConstructor, + #[cfg(feature = "experimental")] + shadow_realm: StandardConstructor, typed_array: StandardConstructor, typed_int8_array: StandardConstructor, typed_uint8_array: StandardConstructor, @@ -243,6 +245,8 @@ impl Default for StandardConstructors { aggregate_error: StandardConstructor::default(), map: StandardConstructor::default(), set: StandardConstructor::default(), + #[cfg(feature = "experimental")] + shadow_realm: StandardConstructor::default(), typed_array: StandardConstructor::default(), typed_int8_array: StandardConstructor::default(), typed_uint8_array: StandardConstructor::default(), @@ -591,6 +595,19 @@ impl StandardConstructors { &self.set } + #[cfg(feature = "experimental")] + /// Returns the `ShadowRealm` constructor. + /// + /// More information: + /// - [ECMAScript reference][spec] + /// + /// [spec]: https://tc39.es/proposal-shadowrealm/#sec-shadowrealm-constructor + #[inline] + #[must_use] + pub const fn shadow_realm(&self) -> &StandardConstructor { + &self.shadow_realm + } + /// Returns the `TypedArray` constructor. /// /// More information: diff --git a/core/engine/src/module/loader/mod.rs b/core/engine/src/module/loader/mod.rs index c38f7b708d7..36082c8b684 100644 --- a/core/engine/src/module/loader/mod.rs +++ b/core/engine/src/module/loader/mod.rs @@ -1,3 +1,4 @@ +use cow_utils::CowUtils; use std::any::Any; use std::cell::RefCell; use std::path::{Component, Path, PathBuf}; @@ -60,11 +61,10 @@ pub fn resolve_module_specifier( let specifier = specifier.to_std_string_escaped(); - // On Windows, also replace `/` with `\`. JavaScript imports use `/` as path separator. #[cfg(target_family = "windows")] - let specifier = specifier.replace('/', "\\"); + let specifier = specifier.cow_replace('/', "\\"); - let short_path = Path::new(&specifier); + let short_path = Path::new(&*specifier); // In ECMAScript, a path is considered relative if it starts with // `./` or `../`. In Rust it's any path that start with `/`. @@ -79,7 +79,7 @@ pub fn resolve_module_specifier( )); } } else { - base_path.join(&specifier) + base_path.join(&*specifier) }; if long_path.is_relative() && base.is_some() { diff --git a/core/runtime/Cargo.toml b/core/runtime/Cargo.toml index dd75e661be8..c5ce6f8fbd2 100644 --- a/core/runtime/Cargo.toml +++ b/core/runtime/Cargo.toml @@ -21,6 +21,7 @@ futures-lite.workspace = true http = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } rustc-hash = { workspace = true, features = ["std"] } +comfy-table.workspace = true serde_json = { workspace = true, optional = true } url = { workspace = true, optional = true } diff --git a/core/runtime/src/console/mod.rs b/core/runtime/src/console/mod.rs index 2942be12380..cebbf2ce1d3 100644 --- a/core/runtime/src/console/mod.rs +++ b/core/runtime/src/console/mod.rs @@ -15,15 +15,19 @@ pub(crate) mod tests; use boa_engine::JsVariant; +use boa_engine::builtins::object::OrdinaryObject as BuiltinObject; use boa_engine::property::Attribute; use boa_engine::{ Context, JsArgs, JsData, JsError, JsResult, JsString, JsSymbol, js_str, js_string, native_function::NativeFunction, object::{JsObject, ObjectInitializer}, - value::{JsValue, Numeric}, + value::{JsValue, Numeric, TryFromJs}, }; use boa_gc::{Finalize, Trace}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; + +type TableData = (Vec>, Vec); +use comfy_table::{Cell, Table}; use std::{ cell::RefCell, collections::hash_map::Entry, fmt::Write as _, io::Write, rc::Rc, time::SystemTime, @@ -83,6 +87,14 @@ pub trait Logger: Trace { /// # Errors /// Returning an error will throw an exception in JavaScript. fn error(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()>; + + /// Log a table (`console.table`). By default, passes the message to `log`. + /// + /// # Errors + /// Returning an error will throw an exception in JavaScript. + fn table(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()> { + self.log(msg, state, context) + } } /// The default implementation for logging from the console. @@ -434,10 +446,15 @@ impl Console { 0, ) .function( - console_method(Self::dir, state, logger.clone()), + console_method(Self::dir, state.clone(), logger.clone()), js_string!("dirxml"), 0, ) + .function( + console_method(Self::table, state.clone(), logger), + js_string!("table"), + 0, + ) .build() } @@ -633,6 +650,150 @@ impl Console { Ok(JsValue::undefined()) } + /// `console.table(tabularData, properties)` + /// + /// Prints a table with the data from the first argument. + /// + /// More information: + /// - [MDN documentation][mdn] + /// - [WHATWG `console` specification][spec] + /// + /// [spec]: https://console.spec.whatwg.org/#table + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/table_static + fn table( + _: &JsValue, + args: &[JsValue], + console: &Self, + logger: &impl Logger, + context: &mut Context, + ) -> JsResult { + let tabular_data = args.get_or_undefined(0); + + let Some(obj) = tabular_data.as_object() else { + return Self::log(&JsValue::undefined(), args, console, logger, context); + }; + + let (rows, mut col_names) = Self::extract_rows(&obj, context)?; + + if rows.is_empty() { + return Self::log(&JsValue::undefined(), args, console, logger, context); + } + + if let Some(props) = args.get(1) { + col_names = Self::filter_columns(col_names, props, context)?; + } + + let mut table = Table::new(); + table.load_preset(comfy_table::presets::UTF8_FULL); + table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic); + table.set_header(&col_names); + + for row in rows { + let mut cells = Vec::new(); + for name in &col_names { + cells.push(Cell::new(row.get(name).cloned().unwrap_or_default())); + } + table.add_row(cells); + } + + logger.table(table.to_string(), &console.state, context)?; + + Ok(JsValue::undefined()) + } + + /// Extracts rows and initial column names from tabular data. + fn extract_rows(obj: &JsObject, context: &mut Context) -> JsResult { + let tabular_keys = Self::get_object_keys(obj, context)?; + let mut col_names = vec!["(index)".to_string()]; + let mut seen_cols = FxHashSet::default(); + seen_cols.insert("(index)".to_string()); + + let mut rows = Vec::new(); + let len = tabular_keys + .get(js_string!("length"), context)? + .to_length(context)?; + for i in 0..len { + let index_key = tabular_keys.get(i, context)?; + let index_str = index_key.to_string(context)?.to_std_string_escaped(); + let mut row_data = FxHashMap::default(); + row_data.insert("(index)".to_string(), index_str); + + let val = obj.get(index_key.to_property_key(context)?, context)?; + Self::extract_row_data(&val, &mut row_data, &mut col_names, &mut seen_cols, context)?; + rows.push(row_data); + } + + Ok((rows, col_names)) + } + + /// Gets keys of an object as a `JsObject` (array). + fn get_object_keys(obj: &JsObject, context: &mut Context) -> JsResult { + let tabular_data = JsValue::from(obj.clone()); + let tabular_keys_val = BuiltinObject::keys( + &JsValue::undefined(), + std::slice::from_ref(&tabular_data), + context, + )?; + tabular_keys_val.as_object().ok_or_else(|| { + JsError::from_opaque(js_string!("Object.keys did not return an object").into()) + }) + } + + /// Extracts data for a single row from a value. + fn extract_row_data( + val: &JsValue, + row_data: &mut FxHashMap, + col_names: &mut Vec, + seen_cols: &mut FxHashSet, + context: &mut Context, + ) -> JsResult<()> { + if let Some(val_obj) = val.as_object() { + let inner_keys_obj = Self::get_object_keys(&val_obj, context)?; + let inner_len = inner_keys_obj + .get(js_string!("length"), context)? + .to_length(context)?; + + for j in 0..inner_len { + let ik_val = inner_keys_obj.get(j, context)?; + let ik_str = ik_val.to_string(context)?.to_std_string_escaped(); + if seen_cols.insert(ik_str.clone()) { + col_names.push(ik_str.clone()); + } + let cell_val = val_obj.get(ik_val.to_property_key(context)?, context)?; + row_data.insert(ik_str, cell_val.display().to_string()); + } + } else { + let v_key = "Value".to_string(); + if seen_cols.insert(v_key.clone()) { + col_names.push(v_key.clone()); + } + row_data.insert(v_key, val.display().to_string()); + } + Ok(()) + } + + /// Filters column names based on the optional properties argument. + fn filter_columns( + col_names: Vec, + properties: &JsValue, + context: &mut Context, + ) -> JsResult> { + if properties.is_null_or_undefined() { + return Ok(col_names); + } + + if let Ok(iterator) = Vec::::try_from_js(properties, context) { + let mut filtered_cols = vec!["(index)".to_string()]; + for prop in iterator { + let prop_str = prop.to_string(context)?; + filtered_cols.push(prop_str.to_std_string_escaped()); + } + return Ok(filtered_cols); + } + + Ok(col_names) + } + /// `console.count(label)` /// /// Prints number of times the function was called with that particular label. diff --git a/core/runtime/src/console/tests.rs b/core/runtime/src/console/tests.rs index 66c72147ede..5f612b8f016 100644 --- a/core/runtime/src/console/tests.rs +++ b/core/runtime/src/console/tests.rs @@ -375,3 +375,33 @@ fn trace_with_stack_trace() { "# } ); } + +#[test] +fn console_table() { + let mut context = Context::default(); + let logger = RecordingLogger::default(); + Console::register_with_logger(logger.clone(), &mut context).unwrap(); + + run_test_actions_with( + [ + TestAction::run(TEST_HARNESS), + TestAction::run(indoc! {r#" + console.table([{a: 1, b: 2}, {a: 3, b: 4}]); + console.table([{a: 1, b: 2}, {a: 3, b: 4}], ["a"]); + "#}), + ], + &mut context, + ); + + let logs = logger.log.borrow().clone(); + + // Check that data is present. Border styling varies by platform/preset. + assert!(logs.contains("(index)")); + assert!(logs.contains("a")); + assert!(logs.contains("b")); + assert!(logs.contains("0")); + assert!(logs.contains("1")); + assert!(logs.contains("2")); + assert!(logs.contains("3")); + assert!(logs.contains("4")); +} diff --git a/core/string/Cargo.toml b/core/string/Cargo.toml index fca96f4bcd5..c713bbfaf50 100644 --- a/core/string/Cargo.toml +++ b/core/string/Cargo.toml @@ -11,6 +11,10 @@ license.workspace = true repository.workspace = true rust-version.workspace = true +[features] +experimental = [] +float16 = [] + [dependencies] itoa.workspace = true rustc-hash = { workspace = true, features = ["std"] } diff --git a/core/string/src/common.rs b/core/string/src/common.rs index 3a4c0d3ff1f..540a0ddab58 100644 --- a/core/string/src/common.rs +++ b/core/string/src/common.rs @@ -9,6 +9,7 @@ use std::sync::LazyLock; macro_rules! well_known_statics { ( $( $(#[$attr:meta])* ($name:ident, $string:literal) ),+$(,)? ) => { $( + $(#[$attr])* paste!{ #[doc = "Gets the static `JsString` for `\"" $string "\"`."] pub const $name: JsString = const { @@ -164,6 +165,8 @@ impl StaticJsStrings { (REFLECT, "Reflect"), (REG_EXP, "RegExp"), (SET, "Set"), + #[cfg(feature = "experimental")] + (SHADOW_REALM, "ShadowRealm"), (STRING, "String"), (SYMBOL, "Symbol"), (TYPED_ARRAY, "TypedArray"), @@ -307,6 +310,8 @@ const RAW_STATICS: &[StaticString] = &[ StaticString::new(JsStr::latin1("Reflect".as_bytes())), StaticString::new(JsStr::latin1("RegExp".as_bytes())), StaticString::new(JsStr::latin1("Set".as_bytes())), + #[cfg(feature = "experimental")] + StaticString::new(JsStr::latin1("ShadowRealm".as_bytes())), StaticString::new(JsStr::latin1("String".as_bytes())), StaticString::new(JsStr::latin1("Symbol".as_bytes())), StaticString::new(JsStr::latin1("TypedArray".as_bytes())),