From 6c34907de91193e7d696d96abdf006cb52d2c69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Juli=C3=A1n=20Espina?= Date: Sun, 15 Oct 2023 02:16:30 +0000 Subject: [PATCH] Implement synthetic modules (#3294) * Implement synthetic modules * Add example * Fix example --- boa_engine/src/lib.rs | 3 +- boa_engine/src/module/loader.rs | 223 +++++++++++++++++ boa_engine/src/module/mod.rs | 306 +++++------------------ boa_engine/src/module/source.rs | 28 +-- boa_engine/src/module/synthetic.rs | 383 +++++++++++++++++++++++++++++ boa_examples/src/bin/modules.rs | 2 +- boa_examples/src/bin/synthetic.rs | 180 ++++++++++++++ 7 files changed, 864 insertions(+), 261 deletions(-) create mode 100644 boa_engine/src/module/loader.rs create mode 100644 boa_engine/src/module/synthetic.rs create mode 100644 boa_examples/src/bin/synthetic.rs diff --git a/boa_engine/src/lib.rs b/boa_engine/src/lib.rs index 3775454c8b9..a0e0b483837 100644 --- a/boa_engine/src/lib.rs +++ b/boa_engine/src/lib.rs @@ -199,8 +199,7 @@ pub trait JsArgs { /// `args.get(n).cloned().unwrap_or_default()` or /// `args.get(n).unwrap_or(&undefined)`. /// - /// This returns a reference for efficiency, in case you only need to call methods of `JsValue`, - /// so try to minimize calling `clone`. + /// This returns a reference for efficiency, in case you only need to call methods of `JsValue`. fn get_or_undefined(&self, index: usize) -> &JsValue; } diff --git a/boa_engine/src/module/loader.rs b/boa_engine/src/module/loader.rs new file mode 100644 index 00000000000..0fb735ad149 --- /dev/null +++ b/boa_engine/src/module/loader.rs @@ -0,0 +1,223 @@ +use std::path::{Path, PathBuf}; + +use rustc_hash::FxHashMap; + +use boa_gc::GcRefCell; +use boa_parser::Source; + +use crate::script::Script; +use crate::{ + js_string, object::JsObject, realm::Realm, vm::ActiveRunnable, Context, JsError, JsNativeError, + JsResult, JsString, +}; + +use super::Module; + +/// The referrer from which a load request of a module originates. +#[derive(Debug, Clone)] +pub enum Referrer { + /// A [**Source Text Module Record**](https://tc39.es/ecma262/#sec-source-text-module-records). + Module(Module), + /// A [**Realm**](https://tc39.es/ecma262/#sec-code-realms). + Realm(Realm), + /// A [**Script Record**](https://tc39.es/ecma262/#sec-script-records) + Script(Script), +} + +impl From for Referrer { + fn from(value: ActiveRunnable) -> Self { + match value { + ActiveRunnable::Script(script) => Self::Script(script), + ActiveRunnable::Module(module) => Self::Module(module), + } + } +} + +/// Module loading related host hooks. +/// +/// This trait allows to customize the behaviour of the engine on module load requests and +/// `import.meta` requests. +pub trait ModuleLoader { + /// Host hook [`HostLoadImportedModule ( referrer, specifier, hostDefined, payload )`][spec]. + /// + /// This hook allows to customize the module loading functionality of the engine. Technically, + /// this should call the [`FinishLoadingImportedModule`][finish] operation, but this simpler API just provides + /// a closure that replaces `FinishLoadingImportedModule`. + /// + /// # Requirements + /// + /// - The host environment must perform `FinishLoadingImportedModule(referrer, specifier, payload, result)`, + /// where result is either a normal completion containing the loaded Module Record or a throw + /// completion, either synchronously or asynchronously. This is equivalent to calling the `finish_load` + /// callback. + /// - If this operation is called multiple times with the same `(referrer, specifier)` pair and + /// it performs FinishLoadingImportedModule(referrer, specifier, payload, result) where result + /// is a normal completion, then it must perform + /// `FinishLoadingImportedModule(referrer, specifier, payload, result)` with the same result each + /// time. + /// - The operation must treat payload as an opaque value to be passed through to + /// `FinishLoadingImportedModule`. (can be ignored) + /// + /// [spec]: https://tc39.es/ecma262/#sec-HostLoadImportedModule + /// [finish]: https://tc39.es/ecma262/#sec-FinishLoadingImportedModule + #[allow(clippy::type_complexity)] + fn load_imported_module( + &self, + referrer: Referrer, + specifier: JsString, + finish_load: Box, &mut Context<'_>)>, + context: &mut Context<'_>, + ); + + /// Registers a new module into the module loader. + /// + /// This is a convenience method for module loaders caching already parsed modules, since it + /// allows registering a new module through the `&dyn ModuleLoader` provided by + /// [`Context::module_loader`]. + /// + /// Does nothing by default. + fn register_module(&self, _specifier: JsString, _module: Module) {} + + /// Gets the module associated with the provided specifier. + /// + /// This is a convenience method for module loaders caching already parsed modules, since it + /// allows getting a cached module through the `&dyn ModuleLoader` provided by + /// [`Context::module_loader`]. + /// + /// Returns `None` by default. + fn get_module(&self, _specifier: JsString) -> Option { + None + } + + /// Host hooks [`HostGetImportMetaProperties ( moduleRecord )`][meta] and + /// [`HostFinalizeImportMeta ( importMeta, moduleRecord )`][final]. + /// + /// This unifies both APIs into a single hook that can be overriden on both cases. + /// The most common usage is to add properties to `import_meta` and return, but this also + /// allows modifying the import meta object in more exotic ways before exposing it to ECMAScript + /// code. + /// + /// The default implementation of `HostGetImportMetaProperties` is to return a new empty List. + /// + /// [meta]: https://tc39.es/ecma262/#sec-hostgetimportmetaproperties + /// [final]: https://tc39.es/ecma262/#sec-hostfinalizeimportmeta + fn init_import_meta( + &self, + _import_meta: &JsObject, + _module: &Module, + _context: &mut Context<'_>, + ) { + } +} + +/// A module loader that throws when trying to load any modules. +/// +/// Useful to disable the module system on platforms that don't have a filesystem, for example. +#[derive(Debug, Clone, Copy)] +pub struct IdleModuleLoader; + +impl ModuleLoader for IdleModuleLoader { + fn load_imported_module( + &self, + _referrer: Referrer, + _specifier: JsString, + finish_load: Box, &mut Context<'_>)>, + context: &mut Context<'_>, + ) { + finish_load( + Err(JsNativeError::typ() + .with_message("module resolution is disabled for this context") + .into()), + context, + ); + } +} + +/// A simple module loader that loads modules relative to a root path. +/// +/// # Note +/// +/// This loader only works by using the type methods [`SimpleModuleLoader::insert`] and +/// [`SimpleModuleLoader::get`]. The utility methods on [`ModuleLoader`] don't work at the moment, +/// but we'll unify both APIs in the future. +#[derive(Debug)] +pub struct SimpleModuleLoader { + root: PathBuf, + module_map: GcRefCell>, +} + +impl SimpleModuleLoader { + /// Creates a new `SimpleModuleLoader` from a root module path. + pub fn new>(root: P) -> JsResult { + if cfg!(target_family = "wasm") { + return Err(JsNativeError::typ() + .with_message("cannot resolve a relative path in WASM targets") + .into()); + } + let root = root.as_ref(); + let absolute = root.canonicalize().map_err(|e| { + JsNativeError::typ() + .with_message(format!("could not set module root `{}`", root.display())) + .with_cause(JsError::from_opaque(js_string!(e.to_string()).into())) + })?; + Ok(Self { + root: absolute, + module_map: GcRefCell::default(), + }) + } + + /// Inserts a new module onto the module map. + #[inline] + pub fn insert(&self, path: PathBuf, module: Module) { + self.module_map.borrow_mut().insert(path, module); + } + + /// Gets a module from its original path. + #[inline] + pub fn get(&self, path: &Path) -> Option { + self.module_map.borrow().get(path).cloned() + } +} + +impl ModuleLoader for SimpleModuleLoader { + fn load_imported_module( + &self, + _referrer: Referrer, + specifier: JsString, + finish_load: Box, &mut Context<'_>)>, + context: &mut Context<'_>, + ) { + let result = (|| { + let path = specifier + .to_std_string() + .map_err(|err| JsNativeError::typ().with_message(err.to_string()))?; + let short_path = Path::new(&path); + let path = self.root.join(short_path); + let path = path.canonicalize().map_err(|err| { + JsNativeError::typ() + .with_message(format!( + "could not canonicalize path `{}`", + short_path.display() + )) + .with_cause(JsError::from_opaque(js_string!(err.to_string()).into())) + })?; + if let Some(module) = self.get(&path) { + return Ok(module); + } + let source = Source::from_filepath(&path).map_err(|err| { + JsNativeError::typ() + .with_message(format!("could not open file `{}`", short_path.display())) + .with_cause(JsError::from_opaque(js_string!(err.to_string()).into())) + })?; + let module = Module::parse(source, None, context).map_err(|err| { + JsNativeError::syntax() + .with_message(format!("could not parse module `{}`", short_path.display())) + .with_cause(err) + })?; + self.insert(path, module.clone()); + Ok(module) + })(); + + finish_load(result, context); + } +} diff --git a/boa_engine/src/module/mod.rs b/boa_engine/src/module/mod.rs index 868055cb5cb..a2941118e41 100644 --- a/boa_engine/src/module/mod.rs +++ b/boa_engine/src/module/mod.rs @@ -21,18 +21,21 @@ //! [spec]: https://tc39.es/ecma262/#sec-modules //! [module]: https://tc39.es/ecma262/#sec-abstract-module-records +mod loader; mod source; +mod synthetic; +pub use loader::*; use source::SourceTextModule; +pub use synthetic::{SyntheticModule, SyntheticModuleInitializer}; use std::cell::{Cell, RefCell}; use std::hash::Hash; use std::io::Read; -use std::path::{Path, PathBuf}; use std::rc::Rc; use std::{collections::HashSet, hash::BuildHasherDefault}; use indexmap::IndexMap; -use rustc_hash::{FxHashMap, FxHashSet, FxHasher}; +use rustc_hash::{FxHashSet, FxHasher}; use boa_ast::expression::Identifier; use boa_gc::{Finalize, Gc, GcRefCell, Trace}; @@ -40,233 +43,21 @@ use boa_interner::Sym; use boa_parser::{Parser, Source}; use boa_profiler::Profiler; -use crate::object::FunctionObjectBuilder; -use crate::script::Script; -use crate::vm::ActiveRunnable; use crate::{ builtins::promise::{PromiseCapability, PromiseState}, environments::DeclarativeEnvironment, - object::{JsObject, JsPromise, ObjectData}, + js_string, + object::{FunctionObjectBuilder, JsObject, JsPromise, ObjectData}, realm::Realm, - Context, JsError, JsResult, JsString, JsValue, + Context, HostDefined, JsError, JsResult, JsString, JsValue, NativeFunction, }; -use crate::{js_string, HostDefined, JsNativeError, NativeFunction}; - -/// The referrer from which a load request of a module originates. -#[derive(Debug, Clone)] -pub enum Referrer { - /// A [**Source Text Module Record**](https://tc39.es/ecma262/#sec-source-text-module-records). - Module(Module), - /// A [**Realm**](https://tc39.es/ecma262/#sec-code-realms). - Realm(Realm), - /// A [**Script Record**](https://tc39.es/ecma262/#sec-script-records) - Script(Script), -} - -impl From for Referrer { - fn from(value: ActiveRunnable) -> Self { - match value { - ActiveRunnable::Script(script) => Self::Script(script), - ActiveRunnable::Module(module) => Self::Module(module), - } - } -} - -/// Module loading related host hooks. -/// -/// This trait allows to customize the behaviour of the engine on module load requests and -/// `import.meta` requests. -pub trait ModuleLoader { - /// Host hook [`HostLoadImportedModule ( referrer, specifier, hostDefined, payload )`][spec]. - /// - /// This hook allows to customize the module loading functionality of the engine. Technically, - /// this should call the [`FinishLoadingImportedModule`][finish] operation, but this simpler API just provides - /// a closure that replaces `FinishLoadingImportedModule`. - /// - /// # Requirements - /// - /// - The host environment must perform `FinishLoadingImportedModule(referrer, specifier, payload, result)`, - /// where result is either a normal completion containing the loaded Module Record or a throw - /// completion, either synchronously or asynchronously. This is equivalent to calling the `finish_load` - /// callback. - /// - If this operation is called multiple times with the same `(referrer, specifier)` pair and - /// it performs FinishLoadingImportedModule(referrer, specifier, payload, result) where result - /// is a normal completion, then it must perform - /// `FinishLoadingImportedModule(referrer, specifier, payload, result)` with the same result each - /// time. - /// - The operation must treat payload as an opaque value to be passed through to - /// `FinishLoadingImportedModule`. (can be ignored) - /// - /// [spec]: https://tc39.es/ecma262/#sec-HostLoadImportedModule - /// [finish]: https://tc39.es/ecma262/#sec-FinishLoadingImportedModule - #[allow(clippy::type_complexity)] - fn load_imported_module( - &self, - referrer: Referrer, - specifier: JsString, - finish_load: Box, &mut Context<'_>)>, - context: &mut Context<'_>, - ); - - /// Registers a new module into the module loader. - /// - /// This is a convenience method for module loaders caching already parsed modules, since it - /// allows registering a new module through the `&dyn ModuleLoader` provided by - /// [`Context::module_loader`]. - /// - /// Does nothing by default. - fn register_module(&self, _specifier: JsString, _module: Module) {} - - /// Gets the module associated with the provided specifier. - /// - /// This is a convenience method for module loaders caching already parsed modules, since it - /// allows getting a cached module through the `&dyn ModuleLoader` provided by - /// [`Context::module_loader`]. - /// - /// Returns `None` by default. - fn get_module(&self, _specifier: JsString) -> Option { - None - } - - /// Host hooks [`HostGetImportMetaProperties ( moduleRecord )`][meta] and - /// [`HostFinalizeImportMeta ( importMeta, moduleRecord )`][final]. - /// - /// This unifies both APIs into a single hook that can be overriden on both cases. - /// The most common usage is to add properties to `import_meta` and return, but this also - /// allows modifying the import meta object in more exotic ways before exposing it to ECMAScript - /// code. - /// - /// The default implementation of `HostGetImportMetaProperties` is to return a new empty List. - /// - /// [meta]: https://tc39.es/ecma262/#sec-hostgetimportmetaproperties - /// [final]: https://tc39.es/ecma262/#sec-hostfinalizeimportmeta - fn init_import_meta( - &self, - _import_meta: &JsObject, - _module: &Module, - _context: &mut Context<'_>, - ) { - } -} - -/// A module loader that throws when trying to load any modules. -/// -/// Useful to disable the module system on platforms that don't have a filesystem, for example. -#[derive(Debug, Clone, Copy)] -pub struct IdleModuleLoader; - -impl ModuleLoader for IdleModuleLoader { - fn load_imported_module( - &self, - _referrer: Referrer, - _specifier: JsString, - finish_load: Box, &mut Context<'_>)>, - context: &mut Context<'_>, - ) { - finish_load( - Err(JsNativeError::typ() - .with_message("module resolution is disabled for this context") - .into()), - context, - ); - } -} - -/// A simple module loader that loads modules relative to a root path. -/// -/// # Note -/// -/// This loader only works by using the type methods [`SimpleModuleLoader::insert`] and -/// [`SimpleModuleLoader::get`]. The utility methods on [`ModuleLoader`] don't work at the moment, -/// but we'll unify both APIs in the future. -#[derive(Debug)] -pub struct SimpleModuleLoader { - root: PathBuf, - module_map: GcRefCell>, -} - -impl SimpleModuleLoader { - /// Creates a new `SimpleModuleLoader` from a root module path. - pub fn new>(root: P) -> JsResult { - if cfg!(target_family = "wasm") { - return Err(JsNativeError::typ() - .with_message("cannot resolve a relative path in WASM targets") - .into()); - } - let root = root.as_ref(); - let absolute = root.canonicalize().map_err(|e| { - JsNativeError::typ() - .with_message(format!("could not set module root `{}`", root.display())) - .with_cause(JsError::from_opaque(js_string!(e.to_string()).into())) - })?; - Ok(Self { - root: absolute, - module_map: GcRefCell::default(), - }) - } - - /// Inserts a new module onto the module map. - #[inline] - pub fn insert(&self, path: PathBuf, module: Module) { - self.module_map.borrow_mut().insert(path, module); - } - - /// Gets a module from its original path. - #[inline] - pub fn get(&self, path: &Path) -> Option { - self.module_map.borrow().get(path).cloned() - } -} - -impl ModuleLoader for SimpleModuleLoader { - fn load_imported_module( - &self, - _referrer: Referrer, - specifier: JsString, - finish_load: Box, &mut Context<'_>)>, - context: &mut Context<'_>, - ) { - let result = (|| { - let path = specifier - .to_std_string() - .map_err(|err| JsNativeError::typ().with_message(err.to_string()))?; - let short_path = Path::new(&path); - let path = self.root.join(short_path); - let path = path.canonicalize().map_err(|err| { - JsNativeError::typ() - .with_message(format!( - "could not canonicalize path `{}`", - short_path.display() - )) - .with_cause(JsError::from_opaque(js_string!(err.to_string()).into())) - })?; - if let Some(module) = self.get(&path) { - return Ok(module); - } - let source = Source::from_filepath(&path).map_err(|err| { - JsNativeError::typ() - .with_message(format!("could not open file `{}`", short_path.display())) - .with_cause(JsError::from_opaque(js_string!(err.to_string()).into())) - })?; - let module = Module::parse(source, None, context).map_err(|err| { - JsNativeError::syntax() - .with_message(format!("could not parse module `{}`", short_path.display())) - .with_cause(err) - })?; - self.insert(path, module.clone()); - Ok(module) - })(); - - finish_load(result, context); - } -} /// ECMAScript's [**Abstract module record**][spec]. /// /// [spec]: https://tc39.es/ecma262/#sec-abstract-module-records #[derive(Clone, Trace, Finalize)] pub struct Module { - inner: Gc, + inner: Gc, } impl std::fmt::Debug for Module { @@ -281,7 +72,7 @@ impl std::fmt::Debug for Module { } #[derive(Trace, Finalize)] -struct Inner { +struct ModuleRepr { realm: Realm, environment: GcRefCell>>, namespace: GcRefCell>, @@ -295,8 +86,7 @@ pub(crate) enum ModuleKind { /// A [**Source Text Module Record**](https://tc39.es/ecma262/#sec-source-text-module-records) SourceText(SourceTextModule), /// A [**Synthetic Module Record**](https://tc39.es/proposal-json-modules/#sec-synthetic-module-records) - #[allow(unused)] - Synthetic, + Synthetic(SyntheticModule), } /// Return value of the [`Module::resolve_export`] operation. @@ -352,7 +142,6 @@ impl Module { /// Parses the provided `src` as an ECMAScript module, returning an error if parsing fails. /// /// [spec]: https://tc39.es/ecma262/#sec-parsemodule - #[inline] pub fn parse( src: Source<'_, R>, realm: Option, @@ -363,21 +152,52 @@ impl Module { parser.set_identifier(context.next_parser_identifier()); let module = parser.parse_module(context.interner_mut())?; - let src = SourceTextModule::new(module); + let inner = Gc::new_cyclic(|weak| { + let src = SourceTextModule::new(module, weak.clone()); - let module = Self { - inner: Gc::new(Inner { + ModuleRepr { realm: realm.unwrap_or_else(|| context.realm().clone()), environment: GcRefCell::default(), namespace: GcRefCell::default(), - kind: ModuleKind::SourceText(src.clone()), + kind: ModuleKind::SourceText(src), host_defined: HostDefined::default(), - }), - }; + } + }); - src.set_parent(module.clone()); + Ok(Self { inner }) + } - Ok(module) + /// Abstract operation [`CreateSyntheticModule ( exportNames, evaluationSteps, realm )`][spec]. + /// + /// Creates a new Synthetic Module from its list of exported names, its evaluation steps and + /// optionally a root realm. + /// + /// [spec]: https://tc39.es/proposal-json-modules/#sec-createsyntheticmodule + #[inline] + pub fn synthetic( + export_names: &[JsString], + evaluation_steps: SyntheticModuleInitializer, + realm: Option, + context: &mut Context<'_>, + ) -> Self { + let names: FxHashSet = export_names + .iter() + .map(|string| context.interner_mut().get_or_intern(&**string)) + .collect(); + let realm = realm.unwrap_or_else(|| context.realm().clone()); + let inner = Gc::new_cyclic(|weak| { + let synth = SyntheticModule::new(names, evaluation_steps, weak.clone()); + + ModuleRepr { + realm, + environment: GcRefCell::default(), + namespace: GcRefCell::default(), + kind: ModuleKind::Synthetic(synth), + host_defined: HostDefined::default(), + } + }); + + Self { inner } } /// Gets the realm of this `Module`. @@ -420,7 +240,7 @@ impl Module { // Concrete method [`LoadRequestedModules ( [ hostDefined ] )`][spec]. // // [spec]: https://tc39.es/ecma262/#sec-LoadRequestedModules - // TODO: 1. If hostDefined is not present, let hostDefined be empty. + // 1. If hostDefined is not present, let hostDefined be empty. // 2. Let pc be ! NewPromiseCapability(%Promise%). let pc = PromiseCapability::new( @@ -450,7 +270,7 @@ impl Module { JsPromise::from_object(pc.promise().clone()) .expect("promise created from the %Promise% intrinsic is always native") } - ModuleKind::Synthetic => todo!("synthetic.load()"), + ModuleKind::Synthetic(_) => SyntheticModule::load(context), } } @@ -505,7 +325,7 @@ impl Module { fn get_exported_names(&self, export_star_set: &mut Vec) -> FxHashSet { match self.kind() { ModuleKind::SourceText(src) => src.get_exported_names(export_star_set), - ModuleKind::Synthetic => todo!("synthetic.get_exported_names()"), + ModuleKind::Synthetic(synth) => synth.get_exported_names(), } } @@ -528,7 +348,7 @@ impl Module { ) -> Result { match self.kind() { ModuleKind::SourceText(src) => src.resolve_export(export_name, resolve_set), - ModuleKind::Synthetic => todo!("synthetic.resolve_export()"), + ModuleKind::Synthetic(synth) => synth.resolve_export(export_name), } } @@ -547,7 +367,10 @@ impl Module { pub fn link(&self, context: &mut Context<'_>) -> JsResult<()> { match self.kind() { ModuleKind::SourceText(src) => src.link(context), - ModuleKind::Synthetic => todo!("synthetic.link()"), + ModuleKind::Synthetic(synth) => { + synth.link(context); + Ok(()) + } } } @@ -562,11 +385,10 @@ impl Module { ) -> JsResult { match self.kind() { ModuleKind::SourceText(src) => src.inner_link(stack, index, context), - #[allow(unreachable_code)] // If module is not a Cyclic Module Record, then - ModuleKind::Synthetic => { + ModuleKind::Synthetic(synth) => { // a. Perform ? module.Link(). - todo!("synthetic.link()"); + synth.link(context); // b. Return index. Ok(index) } @@ -585,12 +407,11 @@ impl Module { /// This must only be called if the [`Module::link`] method finished successfully. /// /// [spec]: https://tc39.es/ecma262/#table-abstract-methods-of-module-records - #[allow(clippy::missing_panics_doc)] #[inline] pub fn evaluate(&self, context: &mut Context<'_>) -> JsPromise { match self.kind() { ModuleKind::SourceText(src) => src.evaluate(context), - ModuleKind::Synthetic => todo!("synthetic.evaluate()"), + ModuleKind::Synthetic(synth) => synth.evaluate(context), } } @@ -606,10 +427,9 @@ impl Module { match self.kind() { ModuleKind::SourceText(src) => src.inner_evaluate(stack, index, None, context), // 1. If module is not a Cyclic Module Record, then - #[allow(unused, clippy::diverging_sub_expression)] - ModuleKind::Synthetic => { + ModuleKind::Synthetic(synth) => { // a. Let promise be ! module.Evaluate(). - let promise: JsPromise = todo!("module.Evaluate()"); + let promise: JsPromise = synth.evaluate(context); let state = promise.state()?; match state { PromiseState::Pending => { diff --git a/boa_engine/src/module/source.rs b/boa_engine/src/module/source.rs index 3c0714d5682..75bb6322dd4 100644 --- a/boa_engine/src/module/source.rs +++ b/boa_engine/src/module/source.rs @@ -15,7 +15,7 @@ use boa_ast::{ ContainsSymbol, LexicallyScopedDeclaration, }, }; -use boa_gc::{custom_trace, empty_trace, Finalize, Gc, GcRefCell, Trace}; +use boa_gc::{custom_trace, empty_trace, Finalize, Gc, GcRefCell, Trace, WeakGc}; use boa_interner::Sym; use indexmap::IndexSet; use rustc_hash::{FxHashMap, FxHashSet, FxHasher}; @@ -35,7 +35,8 @@ use crate::{ }; use super::{ - BindingName, GraphLoadingState, Module, Referrer, ResolveExportError, ResolvedBinding, + BindingName, GraphLoadingState, Module, ModuleRepr, Referrer, ResolveExportError, + ResolvedBinding, }; /// Information for the [**Depth-first search**] algorithm used in the @@ -270,7 +271,7 @@ impl std::fmt::Debug for SourceTextModule { #[derive(Trace, Finalize)] struct Inner { - parent: GcRefCell>, + parent: WeakGc, status: GcRefCell, loaded_modules: GcRefCell>, async_parent_modules: GcRefCell>, @@ -291,18 +292,15 @@ struct ModuleCode { } impl SourceTextModule { - /// Sets the parent module of this source module. - pub(super) fn set_parent(&self, parent: Module) { - *self.inner.parent.borrow_mut() = Some(parent); - } - /// Gets the parent module of this source module. fn parent(&self) -> Module { - self.inner - .parent - .borrow() - .clone() - .expect("parent module must be initialized") + Module { + inner: self + .inner + .parent + .upgrade() + .expect("parent module must be live"), + } } /// Creates a new `SourceTextModule` from a parsed `ModuleSource`. @@ -310,7 +308,7 @@ impl SourceTextModule { /// Contains part of the abstract operation [`ParseModule`][parse]. /// /// [parse]: https://tc39.es/ecma262/#sec-parsemodule - pub(super) fn new(code: boa_ast::Module) -> Self { + pub(super) fn new(code: boa_ast::Module, parent: WeakGc) -> Self { // 3. Let requestedModules be the ModuleRequests of body. let requested_modules = code.items().requests(); // 4. Let importEntries be ImportEntries of body. @@ -391,7 +389,7 @@ impl SourceTextModule { // Most of this can be ignored, since `Status` takes care of the remaining state. Self { inner: Gc::new(Inner { - parent: GcRefCell::default(), + parent, status: GcRefCell::default(), loaded_modules: GcRefCell::default(), async_parent_modules: GcRefCell::default(), diff --git a/boa_engine/src/module/synthetic.rs b/boa_engine/src/module/synthetic.rs new file mode 100644 index 00000000000..80716e7c5d9 --- /dev/null +++ b/boa_engine/src/module/synthetic.rs @@ -0,0 +1,383 @@ +use std::rc::Rc; + +use boa_ast::expression::Identifier; +use boa_gc::{Finalize, Gc, GcRefCell, Trace, WeakGc}; +use boa_interner::Sym; +use rustc_hash::FxHashSet; + +use crate::{ + builtins::promise::ResolvingFunctions, + bytecompiler::ByteCompiler, + environments::{CompileTimeEnvironment, EnvironmentStack}, + object::JsPromise, + vm::{ActiveRunnable, CallFrame, CodeBlock}, + Context, JsNativeError, JsResult, JsString, JsValue, Module, +}; + +use super::{BindingName, ModuleRepr, ResolveExportError, ResolvedBinding}; + +trait TraceableCallback: Trace { + fn call(&self, module: &SyntheticModule, context: &mut Context<'_>) -> JsResult<()>; +} + +#[derive(Trace, Finalize)] +struct Callback +where + F: Fn(&SyntheticModule, &T, &mut Context<'_>) -> JsResult<()>, + T: Trace, +{ + // SAFETY: `SyntheticModuleInitializer`'s safe API ensures only `Copy` closures are stored; its unsafe API, + // on the other hand, explains the invariants to hold in order for this to be safe, shifting + // the responsibility to the caller. + #[unsafe_ignore_trace] + f: F, + captures: T, +} + +impl TraceableCallback for Callback +where + F: Fn(&SyntheticModule, &T, &mut Context<'_>) -> JsResult<()>, + T: Trace, +{ + fn call(&self, module: &SyntheticModule, context: &mut Context<'_>) -> JsResult<()> { + (self.f)(module, &self.captures, context) + } +} + +/// The initializing steps of a [`SyntheticModule`]. +/// +/// # Caveats +/// +/// By limitations of the Rust language, the garbage collector currently cannot inspect closures +/// in order to trace their captured variables. This means that only [`Copy`] closures are 100% safe +/// to use. All other closures can also be stored in a `NativeFunction`, albeit by using an `unsafe` +/// API, but note that passing closures implicitly capturing traceable types could cause +/// **Undefined Behaviour**. +#[derive(Clone, Trace, Finalize)] +pub struct SyntheticModuleInitializer { + inner: Gc, +} + +impl std::fmt::Debug for SyntheticModuleInitializer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ModuleInitializer").finish_non_exhaustive() + } +} + +impl SyntheticModuleInitializer { + /// Creates a `SyntheticModuleInitializer` from a [`Copy`] closure. + pub fn from_copy_closure(closure: F) -> Self + where + F: Fn(&SyntheticModule, &mut Context<'_>) -> JsResult<()> + Copy + 'static, + { + // SAFETY: The `Copy` bound ensures there are no traceable types inside the closure. + unsafe { Self::from_closure(closure) } + } + + /// Creates a `SyntheticModuleInitializer` from a [`Copy`] closure and a list of traceable captures. + pub fn from_copy_closure_with_captures(closure: F, captures: T) -> Self + where + F: Fn(&SyntheticModule, &T, &mut Context<'_>) -> JsResult<()> + Copy + 'static, + T: Trace + 'static, + { + // SAFETY: The `Copy` bound ensures there are no traceable types inside the closure. + unsafe { Self::from_closure_with_captures(closure, captures) } + } + + /// Creates a new `SyntheticModuleInitializer` from a closure. + /// + /// # Safety + /// + /// Passing a closure that contains a captured variable that needs to be traced by the garbage + /// collector could cause an use after free, memory corruption or other kinds of **Undefined + /// Behaviour**. See for a technical explanation + /// on why that is the case. + pub unsafe fn from_closure(closure: F) -> Self + where + F: Fn(&SyntheticModule, &mut Context<'_>) -> JsResult<()> + 'static, + { + // SAFETY: The caller must ensure the invariants of the closure hold. + unsafe { + Self::from_closure_with_captures(move |module, _, context| closure(module, context), ()) + } + } + + /// Create a new `SyntheticModuleInitializer` from a closure and a list of traceable captures. + /// + /// # Safety + /// + /// Passing a closure that contains a captured variable that needs to be traced by the garbage + /// collector could cause an use after free, memory corruption or other kinds of **Undefined + /// Behaviour**. See for a technical explanation + /// on why that is the case. + pub unsafe fn from_closure_with_captures(closure: F, captures: T) -> Self + where + F: Fn(&SyntheticModule, &T, &mut Context<'_>) -> JsResult<()> + 'static, + T: Trace + 'static, + { + // Hopefully, this unsafe operation will be replaced by the `CoerceUnsized` API in the + // future: https://github.com/rust-lang/rust/issues/18598 + let ptr = Gc::into_raw(Gc::new(Callback { + f: closure, + captures, + })); + + // SAFETY: The pointer returned by `into_raw` is only used to coerce to a trait object, + // meaning this is safe. + unsafe { + Self { + inner: Gc::from_raw(ptr), + } + } + } + + /// Calls this `SyntheticModuleInitializer`, forwarding the arguments to the corresponding function. + #[inline] + pub fn call(&self, module: &SyntheticModule, context: &mut Context<'_>) -> JsResult<()> { + self.inner.call(module, context) + } +} + +/// ECMAScript's [**Synthetic Module Records**][spec]. +/// +/// [spec]: https://tc39.es/proposal-json-modules/#sec-synthetic-module-records +#[derive(Clone, Trace, Finalize)] +pub struct SyntheticModule { + inner: Gc, +} + +impl std::fmt::Debug for SyntheticModule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SyntheticModule") + .field("export_names", &self.inner.export_names) + .field("eval_steps", &self.inner.eval_steps) + .finish_non_exhaustive() + } +} + +#[derive(Trace, Finalize)] +struct Inner { + parent: WeakGc, + #[unsafe_ignore_trace] + export_names: FxHashSet, + eval_context: GcRefCell)>>, + eval_steps: SyntheticModuleInitializer, +} + +impl SyntheticModule { + /// Gets the parent module of this source module. + fn parent(&self) -> Module { + Module { + inner: self + .inner + .parent + .upgrade() + .expect("parent module must be live"), + } + } + + /// Creates a new synthetic module. + pub(super) fn new( + names: FxHashSet, + eval_steps: SyntheticModuleInitializer, + parent: WeakGc, + ) -> Self { + Self { + inner: Gc::new(Inner { + parent, + export_names: names, + eval_steps, + eval_context: GcRefCell::default(), + }), + } + } + + /// Concrete method [`LoadRequestedModules ( )`][spec]. + /// + /// [spec]: https://tc39.es/proposal-json-modules/#sec-smr-LoadRequestedModules + pub(super) fn load(context: &mut Context<'_>) -> JsPromise { + // 1. Return ! PromiseResolve(%Promise%, undefined). + JsPromise::resolve(JsValue::undefined(), context) + .expect("creating a promise from the %Promise% constructor must not fail") + } + + /// Concrete method [`GetExportedNames ( [ exportStarSet ] )`][spec]. + /// + /// [spec]: https://tc39.es/proposal-json-modules/#sec-smr-getexportednames + pub(super) fn get_exported_names(&self) -> FxHashSet { + // 1. Return module.[[ExportNames]]. + self.inner.export_names.clone() + } + + /// Concrete method [`ResolveExport ( exportName )`][spec] + /// + /// [spec]: https://tc39.es/proposal-json-modules/#sec-smr-resolveexport + #[allow(clippy::mutable_key_type)] + pub(super) fn resolve_export( + &self, + export_name: Sym, + ) -> Result { + if self.inner.export_names.contains(&export_name) { + // 2. Return ResolvedBinding Record { [[Module]]: module, [[BindingName]]: exportName }. + Ok(ResolvedBinding { + module: self.parent(), + binding_name: BindingName::Name(Identifier::new(export_name)), + }) + } else { + // 1. If module.[[ExportNames]] does not contain exportName, return null. + Err(ResolveExportError::NotFound) + } + } + + /// Concrete method [`Link ( )`][spec]. + /// + /// [spec]: https://tc39.es/ecma262/#sec-moduledeclarationlinking + pub(super) fn link(&self, context: &mut Context<'_>) { + let parent = self.parent(); + // 1. Let realm be module.[[Realm]]. + // 2. Let env be NewModuleEnvironment(realm.[[GlobalEnv]]). + // 3. Set module.[[Environment]] to env. + let global_env = parent.realm().environment().clone(); + let global_compile_env = global_env.compile_env(); + let module_compile_env = Rc::new(CompileTimeEnvironment::new(global_compile_env, true)); + + // TODO: A bit of a hack to be able to pass the currently active runnable without an + // available codeblock to execute. + let compiler = ByteCompiler::new( + Sym::MAIN, + true, + false, + module_compile_env.clone(), + module_compile_env.clone(), + context, + ); + + // 4. For each String exportName in module.[[ExportNames]], do + let exports = self + .inner + .export_names + .iter() + .map(|name| { + let ident = Identifier::new(*name); + // a. Perform ! env.CreateMutableBinding(exportName, false). + module_compile_env.create_mutable_binding(ident, false) + }) + .collect::>(); + + let cb = Gc::new(compiler.finish()); + + let mut envs = EnvironmentStack::new(global_env); + envs.push_module(module_compile_env); + + for locator in exports { + // b. Perform ! env.InitializeBinding(exportName, undefined). + envs.put_lexical_value( + locator.environment_index(), + locator.binding_index(), + JsValue::undefined(), + ); + } + + *parent.inner.environment.borrow_mut() = envs.current().as_declarative().cloned(); + + *self.inner.eval_context.borrow_mut() = Some((envs, cb)); + + // 5. Return unused. + } + + /// Concrete method [`Evaluate ( )`][spec]. + /// + /// [spec]: https://tc39.es/proposal-json-modules/#sec-smr-Evaluate + pub(super) fn evaluate(&self, context: &mut Context<'_>) -> JsPromise { + // 1. Let moduleContext be a new ECMAScript code execution context. + + let parent = self.parent(); + let mut realm = parent.realm().clone(); + let (mut environments, codeblock) = self + .inner + .eval_context + .borrow() + .clone() + .expect("should have been initialized on `link`"); + + let env_fp = environments.len() as u32; + let callframe = CallFrame::new( + codeblock, + // 4. Set the ScriptOrModule of moduleContext to module. + Some(ActiveRunnable::Module(parent)), + // 2. Set the Function of moduleContext to null. + None, + ) + .with_env_fp(env_fp); + + // 5. Set the VariableEnvironment of moduleContext to module.[[Environment]]. + // 6. Set the LexicalEnvironment of moduleContext to module.[[Environment]]. + std::mem::swap(&mut context.vm.environments, &mut environments); + // 3. Set the Realm of moduleContext to module.[[Realm]]. + context.swap_realm(&mut realm); + // 7. Suspend the currently running execution context. + // 8. Push moduleContext on to the execution context stack; moduleContext is now the running execution context. + context.vm.push_frame(callframe); + + // 9. Let steps be module.[[EvaluationSteps]]. + // 10. Let result be Completion(steps(module)). + let result = self.inner.eval_steps.call(self, context); + + // 11. Suspend moduleContext and remove it from the execution context stack. + // 12. Resume the context that is now on the top of the execution context stack as the running execution context. + std::mem::swap(&mut context.vm.environments, &mut environments); + context.swap_realm(&mut realm); + context.vm.pop_frame(); + + // 13. Let pc be ! NewPromiseCapability(%Promise%). + let (promise, ResolvingFunctions { resolve, reject }) = JsPromise::new_pending(context); + + match result { + // 15. Perform ! pc.[[Resolve]](result). + Ok(()) => resolve.call(&JsValue::undefined(), &[], context), + // 14. IfAbruptRejectPromise(result, pc). + Err(err) => reject.call(&JsValue::undefined(), &[err.to_opaque(context)], context), + } + .expect("default resolving functions cannot throw"); + + // 16. Return pc.[[Promise]]. + promise + } + + /// Abstract operation [`SetSyntheticModuleExport ( module, exportName, exportValue )`][spec]. + /// + /// Sets or changes the exported value for `exportName` in the synthetic module. + /// + /// # Note + /// + /// The default export corresponds to the name `"default"`, but note that it needs to + /// be passed to the list of exported names in [`Module::synthetic`] beforehand. + /// + /// [spec]: https://tc39.es/proposal-json-modules/#sec-createsyntheticmodule + pub fn set_export( + &self, + export_name: &JsString, + export_value: JsValue, + context: &mut Context<'_>, + ) -> JsResult<()> { + let identifier = context.interner_mut().get_or_intern(&**export_name); + let identifier = Identifier::new(identifier); + + let environment = self + .parent() + .environment() + .expect("this must be initialized before evaluating"); + let locator = environment + .compile_env() + .get_binding(identifier) + .ok_or_else(|| { + JsNativeError::reference().with_message(format!( + "cannot set name `{}` which was not included in the list of exports", + export_name.to_std_string_escaped() + )) + })?; + environment.set(locator.binding_index(), export_value); + + Ok(()) + } +} diff --git a/boa_examples/src/bin/modules.rs b/boa_examples/src/bin/modules.rs index 67a91940683..e6c22600caf 100644 --- a/boa_examples/src/bin/modules.rs +++ b/boa_examples/src/bin/modules.rs @@ -33,7 +33,7 @@ fn main() -> Result<(), Box> { // Can also pass a `Some(realm)` if you need to execute the module in another realm. let module = Module::parse(source, None, context)?; - // Don't forget to insert the parsed module into the loader itself! Since the root module + // Don't forget to insert the parsed module into the loader itself, since the root module // is not automatically inserted by the `ModuleLoader::load_imported_module` impl. // // Simulate as if the "fake" module is located in the modules root, just to ensure that diff --git a/boa_examples/src/bin/synthetic.rs b/boa_examples/src/bin/synthetic.rs new file mode 100644 index 00000000000..54564cf340a --- /dev/null +++ b/boa_examples/src/bin/synthetic.rs @@ -0,0 +1,180 @@ +// This example implements a synthetic Rust module that is exposed to JS code. +// This mirrors the `modules.rs` example but uses synthetic modules instead. + +use std::path::PathBuf; +use std::{error::Error, path::Path}; + +use boa_engine::builtins::promise::PromiseState; +use boa_engine::module::{ModuleLoader, SimpleModuleLoader, SyntheticModuleInitializer}; +use boa_engine::object::FunctionObjectBuilder; +use boa_engine::{ + js_string, Context, JsArgs, JsError, JsNativeError, JsValue, Module, NativeFunction, Source, +}; + +fn main() -> Result<(), Box> { + // A simple module that we want to compile from Rust code. + const MODULE_SRC: &str = r#" + import { pyth } from "./trig.mjs"; + import * as ops from "./operations.mjs"; + + export let result = pyth(3, 4); + export function mix(a, b) { + return ops.sum(ops.mult(a, ops.sub(b, a)), 10); + } + "#; + + // This can be overriden with any custom implementation of `ModuleLoader`. + let loader = &SimpleModuleLoader::new("./scripts/modules")?; + let dyn_loader: &dyn ModuleLoader = loader; + + // Just need to cast to a `ModuleLoader` before passing it to the builder. + let context = &mut Context::builder().module_loader(dyn_loader).build()?; + + // Now, create the synthetic module and insert it into the loader. + let operations = create_operations_module(context); + loader.insert( + PathBuf::from("./scripts/modules") + .canonicalize()? + .join("operations.mjs"), + operations, + ); + + let source = Source::from_reader(MODULE_SRC.as_bytes(), Some(Path::new("./main.mjs"))); + + // Can also pass a `Some(realm)` if you need to execute the module in another realm. + let module = Module::parse(source, None, context)?; + + // Don't forget to insert the parsed module into the loader itself, since the root module + // is not automatically inserted by the `ModuleLoader::load_imported_module` impl. + // + // Simulate as if the "fake" module is located in the modules root, just to ensure that + // the loader won't double load in case someone tries to import "./main.mjs". + loader.insert( + Path::new("./scripts/modules") + .canonicalize()? + .join("main.mjs"), + module.clone(), + ); + + // This uses the utility function to load, link and evaluate a module without having to deal + // with callbacks. For an example demonstrating the whole lifecycle of a module, see + // `modules.rs` + let promise_result = module.load_link_evaluate(context)?; + + // Very important to push forward the job queue after queueing promises. + context.run_jobs(); + + // Checking if the final promise didn't return an error. + match promise_result.state()? { + PromiseState::Pending => return Err("module didn't execute!".into()), + PromiseState::Fulfilled(v) => { + assert_eq!(v, JsValue::undefined()) + } + PromiseState::Rejected(err) => { + return Err(JsError::from_opaque(err).try_native(context)?.into()) + } + } + + // We can access the full namespace of the module with all its exports. + let namespace = module.namespace(context); + let result = namespace.get(js_string!("result"), context)?; + + println!("result = {}", result.display()); + + assert_eq!( + namespace.get(js_string!("result"), context)?, + JsValue::from(5) + ); + + let mix = namespace + .get(js_string!("mix"), context)? + .as_callable() + .cloned() + .ok_or_else(|| JsNativeError::typ().with_message("mix export wasn't a function!"))?; + let result = mix.call(&JsValue::undefined(), &[5.into(), 10.into()], context)?; + + println!("mix(5, 10) = {}", result.display()); + + assert_eq!(result, 35.into()); + + Ok(()) +} + +// Creates the synthetic equivalent to the `./modules/operations.mjs` file. +fn create_operations_module(context: &mut Context<'_>) -> Module { + // We first create the function objects that will be exported by the module. More + // on that below. + let sum = FunctionObjectBuilder::new( + context.realm(), + NativeFunction::from_fn_ptr(|_, args, ctx| { + args.get_or_undefined(0).add(args.get_or_undefined(1), ctx) + }), + ) + .length(2) + .name(js_string!("sum")) + .build(); + let sub = FunctionObjectBuilder::new( + context.realm(), + NativeFunction::from_fn_ptr(|_, args, ctx| { + args.get_or_undefined(0).sub(args.get_or_undefined(1), ctx) + }), + ) + .length(2) + .name(js_string!("sub")) + .build(); + let mult = FunctionObjectBuilder::new( + context.realm(), + NativeFunction::from_fn_ptr(|_, args, ctx| { + args.get_or_undefined(0).mul(args.get_or_undefined(1), ctx) + }), + ) + .length(2) + .name(js_string!("mult")) + .build(); + let div = FunctionObjectBuilder::new( + context.realm(), + NativeFunction::from_fn_ptr(|_, args, ctx| { + args.get_or_undefined(0).div(args.get_or_undefined(1), ctx) + }), + ) + .length(2) + .name(js_string!("div")) + .build(); + let sqrt = FunctionObjectBuilder::new( + context.realm(), + NativeFunction::from_fn_ptr(|_, args, ctx| { + let a = args.get_or_undefined(0).to_number(ctx)?; + Ok(JsValue::from(a.sqrt())) + }), + ) + .length(1) + .name(js_string!("sqrt")) + .build(); + + Module::synthetic( + // Make sure to list all exports beforehand. + &[ + js_string!("sum"), + js_string!("sub"), + js_string!("mult"), + js_string!("div"), + js_string!("sqrt"), + ], + // The initializer is evaluated every time a module imports this synthetic module, + // so we avoid creating duplicate objects by capturing and cloning them instead. + SyntheticModuleInitializer::from_copy_closure_with_captures( + |module, fns, context| { + println!("Running initializer!"); + module.set_export(&js_string!("sum"), fns.0.clone().into(), context)?; + module.set_export(&js_string!("sub"), fns.1.clone().into(), context)?; + module.set_export(&js_string!("mult"), fns.2.clone().into(), context)?; + module.set_export(&js_string!("div"), fns.3.clone().into(), context)?; + module.set_export(&js_string!("sqrt"), fns.4.clone().into(), context)?; + Ok(()) + }, + (sum, sub, mult, div, sqrt), + ), + None, + context, + ) +}