From abc6c58a4e61c36c4c813b9929341c141ccfd517 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 4 Jun 2024 12:31:17 -0700 Subject: [PATCH 1/7] Add a js_class to implement the Class trait without boilerplate This also adds a JsInstance that verifies that `this` is of the proper class, and an `Ignore` that ignore arguments. The syntax is a bit special because of limitations of macro_rules. For example, the way fields are defined. It was impossible to keep both assignments (e.g. `public field = 123;`) and dynamic fields. This reduces significantly the boilerplate for declaring classes. The example in class.rs is more than twice as long (if you remove comments) than the macro is. --- core/interop/src/lib.rs | 151 ++++++++++++++++++++++++++++ core/interop/src/macros.rs | 198 +++++++++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 core/interop/src/macros.rs diff --git a/core/interop/src/lib.rs b/core/interop/src/lib.rs index 54cfc27d547..42c3d560d0f 100644 --- a/core/interop/src/lib.rs +++ b/core/interop/src/lib.rs @@ -1,13 +1,19 @@ //! Interop utilities between Boa and its host. use boa_engine::module::SyntheticModuleInitializer; +use boa_engine::object::{ErasedObject, Ref, RefMut}; use boa_engine::value::TryFromJs; use boa_engine::{ Context, JsNativeError, JsResult, JsString, JsValue, Module, NativeFunction, NativeObject, }; +use std::marker::PhantomData; +use std::ops::Deref; + +pub use boa_engine; pub use boa_macros; pub mod loaders; +pub mod macros; /// Internal module only. pub(crate) mod private { @@ -144,6 +150,20 @@ impl<'a, T: TryFromJs> TryFromJsArgument<'a> for T { } } +/// An argument that would be ignored in a JS function. +#[derive(Debug, Clone, Copy)] +pub struct Ignore; + +impl<'a> TryFromJsArgument<'a> for Ignore { + fn try_from_js_argument( + _this: &'a JsValue, + rest: &'a [JsValue], + _: &mut Context, + ) -> JsResult<(Self, &'a [JsValue])> { + Ok((Ignore, &rest[1..])) + } +} + /// An argument that when used in a JS function will empty the list /// of JS arguments as `JsValue`s. This can be used for having the /// rest of the arguments in a function. It should be the last @@ -305,6 +325,72 @@ impl<'a, T: TryFromJs> TryFromJsArgument<'a> for JsThis { } } +impl Deref for JsThis { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Captures a class instance from the `this` value in a JS function. The class +/// will be a non-mutable reference of Rust type `T`, if it is an instance of `T`. +/// +/// To have more flexibility on the parsing of the `this` value, you can use the +/// [`JsThis`] capture instead. +#[derive(Debug, Clone)] +pub struct JsClass { + inner: boa_engine::JsObject, + _ty: PhantomData, +} + +impl JsClass { + /// Borrow a reference to the class instance of type `T`. + /// + /// # Panics + /// + /// Panics if the object is currently borrowed. + /// + /// This does not panic if the type is wrong, as the type is checked + /// during the construction of the `JsClass` instance. + pub fn borrow(&self) -> Ref<'_, T> { + self.inner.downcast_ref::().unwrap() + } + + /// Borrow a mutable reference to the class instance of type `T`. + /// + /// # Panics + /// + /// Panics if the object is currently mutably borrowed. + pub fn borrow_mut(&self) -> Option> { + self.inner.downcast_mut::() + } +} + +impl<'a, T: NativeObject + 'static> TryFromJsArgument<'a> for JsClass { + fn try_from_js_argument( + this: &'a JsValue, + rest: &'a [JsValue], + _context: &mut Context, + ) -> JsResult<(Self, &'a [JsValue])> { + if let Some(object) = this.as_object() { + if object.downcast_ref::().is_some() { + return Ok(( + JsClass { + inner: object.clone(), + _ty: Default::default(), + }, + rest, + )); + } + } + + Err(JsNativeError::typ() + .with_message("invalid this for class method") + .into()) + } +} + /// Captures a [`ContextData`] data from the [`Context`] as a JS function argument, /// based on its type. /// @@ -507,3 +593,68 @@ fn can_throw_exception() { Some(&JsString::from("from javascript").into()) ); } + +#[test] +fn class() { + use boa_engine::class::{Class, ClassBuilder}; + use boa_engine::{js_string, JsValue, Source}; + use boa_macros::{Finalize, JsData, Trace}; + use std::rc::Rc; + + #[derive(Debug, Trace, Finalize, JsData)] + struct Test { + value: i32, + } + + impl Test { + fn get_value(this: JsClass) -> i32 { + this.borrow().value + } + } + + impl Class for Test { + const NAME: &'static str = "Test"; + + fn init(class: &mut ClassBuilder<'_>) -> JsResult<()> { + let function = Self::get_value.into_js_function_copied(class.context()); + class.method(js_string!("getValue"), 0, function); + Ok(()) + } + + fn data_constructor( + _new_target: &JsValue, + _args: &[JsValue], + _context: &mut Context, + ) -> JsResult { + Ok(Self { value: 123 }) + } + } + + let loader = Rc::new(loaders::HashMapModuleLoader::new()); + let mut context = Context::builder() + .module_loader(loader.clone()) + .build() + .unwrap(); + + context.register_global_class::().unwrap(); + + let source = Source::from_bytes( + r" + let t = new Test(); + if (t.getValue() != 123) { + throw 'invalid value'; + } + ", + ); + let root_module = Module::parse(source, None, &mut context).unwrap(); + + let promise_result = root_module.load_link_evaluate(&mut context); + context.run_jobs(); + + // Checking if the final promise didn't return an error. + assert!( + promise_result.state().as_fulfilled().is_some(), + "module didn't execute successfully! Promise: {:?}", + promise_result.state() + ); +} diff --git a/core/interop/src/macros.rs b/core/interop/src/macros.rs new file mode 100644 index 00000000000..c0547e5d9e3 --- /dev/null +++ b/core/interop/src/macros.rs @@ -0,0 +1,198 @@ +//! Module declaring interop macro rules. + +/// Declare a JavaScript class, in a simpler way. +/// +/// This can make declaration of JavaScript classes easier by using an hybrid +/// declarative approach. The class itself follows a closer syntax to JavaScript +/// while the method arguments/results and bodies are written in Rust. +/// +/// This only declares the Boa interop parts of the class. The actual type must +/// be declared separately as a Rust type, along with necessary derives and +/// traits. +/// +/// Here's an example using the animal class declared in [`boa_engine::class`]: +/// # Example +/// ``` +/// # use boa_engine::{ +/// # NativeFunction, +/// # property::Attribute, +/// # class::{Class, ClassBuilder}, +/// # Context, JsResult, JsValue, JsString, +/// # JsArgs, Source, JsObject, js_str, js_string, +/// # JsNativeError, JsData, +/// # }; +/// # use boa_gc::{Finalize, Trace}; +/// use boa_interop::{js_class, Ignore, JsClass}; +/// +/// #[derive(Clone, Trace, Finalize, JsData)] +/// pub enum Animal { +/// Cat, +/// Dog, +/// Other, +/// } +/// +/// js_class! { +/// // Implement [`Class`] trait for the `Animal` enum. +/// class Animal { +/// // This sets a field on the JavaScript object. The arguments to +/// // `init` are the arguments passed to the constructor. This +/// // function MUST return the value to be set on the field. If this +/// // returns a `JsResult`, it will be unwrapped and error out during +/// // construction of the object. +/// public age(_name: Ignore, age: i32) -> i32 { +/// age +/// } +/// +/// // This is called when a new instance of the class is created in +/// // JavaScript, e.g. `new Animal("cat")`. +/// // This method is mandatory and MUST return `JsResult`. +/// constructor(name: String) { +/// match name.as_str() { +/// "cat" => Ok(Animal::Cat), +/// "dog" => Ok(Animal::Dog), +/// _ => Ok(Animal::Other), +/// } +/// } +/// +/// // Declare a function on the class itself. +/// // There is a current limitation using `self` in methods, so the +/// // instance must be accessed using an actual argument. +/// fn speak(this: JsClass) -> JsString { +/// match *this.borrow() { +/// Animal::Cat => js_string!("meow"), +/// Animal::Dog => js_string!("woof"), +/// Animal::Other => js_string!(r"¯\_(ツ)_/¯"), +/// } +/// } +/// } +/// } +/// +/// fn main() { +/// let mut context = Context::default(); +/// +/// context.register_global_class::().unwrap(); +/// +/// let result = context.eval(Source::from_bytes(r#" +/// let pet = new Animal("dog", 3); +/// +/// `My pet is ${pet.age} years old. Right, buddy? - ${pet.speak()}!` +/// "#)).expect("Could not evaluate script"); +/// +/// assert_eq!( +/// result.as_string().unwrap(), +/// &js_str!("My pet is 3 years old. Right, buddy? - woof!") +/// ); +/// } +/// ``` +#[macro_export] +macro_rules! js_class { + ( + class $class_name: ident $(as $class_js_name: literal)? { + $( + $(#[$field_attr: meta])* + public $field_name: ident + ( $( $field_arg: ident: $field_arg_type: ty ),* ) -> $field_ty: ty + $field_body: block + )* + + $(#[$constructor_attr: meta])* + constructor( $( $ctor_arg: ident: $ctor_arg_ty: ty ),* ) + $constructor_body: block + + $( + $(#[$method_attr: meta])* + fn $method_name: ident + ( $( $fn_arg: ident: $fn_arg_type: ty ),* ) + $(-> $result_type: ty)? + $method_body: block + )* + } + ) => { + impl $crate::boa_engine::class::Class for $class_name { + + const NAME: &'static str = $crate::__js_class_name!($class_name, $($class_js_name)?); + + const LENGTH: usize = $crate::__count!( $( $ctor_arg )* ); + + fn init(class: &mut ClassBuilder<'_>) -> $crate::boa_engine::JsResult<()> { + // Add all methods to the class. + $( + fn $method_name ( $($fn_arg: $fn_arg_type),* ) -> $( $result_type )? + $method_body + + let function = $crate::IntoJsFunctionCopied::into_js_function_copied( + $method_name, + class.context(), + ); + + class.method( + js_string!(stringify!($method_name)), + $crate::__count!($( $fn_arg )*), + function, + ); + )* + + Ok(()) + } + + fn data_constructor( + new_target: &$crate::boa_engine::JsValue, + args: &[$crate::boa_engine::JsValue], + context: &mut $crate::boa_engine::Context, + ) -> $crate::boa_engine::JsResult<$class_name> { + let rest = args; + $( + let ($ctor_arg, rest) : ($ctor_arg_ty, _) = $crate::TryFromJsArgument::try_from_js_argument(new_target, rest, context)?; + )* + + $constructor_body + } + + fn object_constructor( + instance: &$crate::boa_engine::JsObject, + args: &[$crate::boa_engine::JsValue], + context: &mut $crate::boa_engine::Context + ) -> $crate::boa_engine::JsResult<()> { + $( + fn $field_name ( $($field_arg: $field_arg_type),* ) -> $field_ty + $field_body + + let function = $crate::IntoJsFunctionCopied::into_js_function_copied( + $field_name, + context, + ); + + instance.set( + js_string!(stringify!($field_name)), + function.call(&JsValue::undefined(), args, context)?, + false, + context + ); + )* + + Ok(()) + } + + } + } +} + +/// Internal macro to get the JavaScript class name. +#[macro_export] +macro_rules! __js_class_name { + ($class_name: ident, $class_js_name: literal) => { + $class_js_name + }; + ($class_name: ident,) => { + stringify!($class_name) + }; +} + +/// Internal macro to get the JavaScript class length. +#[macro_export] +macro_rules! __count { + () => (0); + ($_: ident $($rest: ident)*) => { + 1 + $crate::__count!($($rest)*) + }; +} From 5eac70de1b300d6e2822ef7f6b4c7ac14fba35ae Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 4 Jun 2024 12:49:43 -0700 Subject: [PATCH 2/7] clippies --- core/interop/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/interop/src/lib.rs b/core/interop/src/lib.rs index 42c3d560d0f..e836b3bfc71 100644 --- a/core/interop/src/lib.rs +++ b/core/interop/src/lib.rs @@ -353,6 +353,7 @@ impl JsClass { /// /// This does not panic if the type is wrong, as the type is checked /// during the construction of the `JsClass` instance. + #[must_use] pub fn borrow(&self) -> Ref<'_, T> { self.inner.downcast_ref::().unwrap() } @@ -362,6 +363,7 @@ impl JsClass { /// # Panics /// /// Panics if the object is currently mutably borrowed. + #[must_use] pub fn borrow_mut(&self) -> Option> { self.inner.downcast_mut::() } @@ -378,7 +380,7 @@ impl<'a, T: NativeObject + 'static> TryFromJsArgument<'a> for JsClass { return Ok(( JsClass { inner: object.clone(), - _ty: Default::default(), + _ty: PhantomData, }, rest, )); @@ -607,6 +609,7 @@ fn class() { } impl Test { + #[allow(clippy::needless_pass_by_value)] fn get_value(this: JsClass) -> i32 { this.borrow().value } From 5f7def2bcf746dbeda86e3e2326ef966755bbb0c Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 4 Jun 2024 14:44:19 -0700 Subject: [PATCH 3/7] Fix usage of macros --- core/interop/src/macros.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/interop/src/macros.rs b/core/interop/src/macros.rs index c0547e5d9e3..93c134f24ab 100644 --- a/core/interop/src/macros.rs +++ b/core/interop/src/macros.rs @@ -126,7 +126,7 @@ macro_rules! js_class { ); class.method( - js_string!(stringify!($method_name)), + $crate::boa_engine::JsString::from(stringify!($method_name)), $crate::__count!($( $fn_arg )*), function, ); @@ -163,7 +163,7 @@ macro_rules! js_class { ); instance.set( - js_string!(stringify!($field_name)), + $crate::boa_engine::JsString::from(stringify!($field_name)), function.call(&JsValue::undefined(), args, context)?, false, context From 59a6400d042b8b042b02653f2e6ead52747474e3 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 4 Jun 2024 14:57:52 -0700 Subject: [PATCH 4/7] Fix some imports issues --- core/interop/src/macros.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/interop/src/macros.rs b/core/interop/src/macros.rs index 93c134f24ab..955b620bcc9 100644 --- a/core/interop/src/macros.rs +++ b/core/interop/src/macros.rs @@ -14,12 +14,9 @@ /// # Example /// ``` /// # use boa_engine::{ -/// # NativeFunction, -/// # property::Attribute, -/// # class::{Class, ClassBuilder}, /// # Context, JsResult, JsValue, JsString, -/// # JsArgs, Source, JsObject, js_str, js_string, -/// # JsNativeError, JsData, +/// # Source, js_str, js_string, +/// # JsData, /// # }; /// # use boa_gc::{Finalize, Trace}; /// use boa_interop::{js_class, Ignore, JsClass}; @@ -114,7 +111,7 @@ macro_rules! js_class { const LENGTH: usize = $crate::__count!( $( $ctor_arg )* ); - fn init(class: &mut ClassBuilder<'_>) -> $crate::boa_engine::JsResult<()> { + fn init(class: &mut $crate::boa_engine::class::ClassBuilder<'_>) -> $crate::boa_engine::JsResult<()> { // Add all methods to the class. $( fn $method_name ( $($fn_arg: $fn_arg_type),* ) -> $( $result_type )? From a7d3e2b29091412ed8a13dccd36bb40232ff1469 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 4 Jun 2024 15:03:19 -0700 Subject: [PATCH 5/7] Fix some imports issues, again --- core/interop/src/macros.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/core/interop/src/macros.rs b/core/interop/src/macros.rs index 955b620bcc9..029d5256b7d 100644 --- a/core/interop/src/macros.rs +++ b/core/interop/src/macros.rs @@ -13,11 +13,7 @@ /// Here's an example using the animal class declared in [`boa_engine::class`]: /// # Example /// ``` -/// # use boa_engine::{ -/// # Context, JsResult, JsValue, JsString, -/// # Source, js_str, js_string, -/// # JsData, -/// # }; +/// # use boa_engine::{JsString, JsData, js_string}; /// # use boa_gc::{Finalize, Trace}; /// use boa_interop::{js_class, Ignore, JsClass}; /// @@ -65,6 +61,8 @@ /// } /// /// fn main() { +///# use boa_engine::{Context, JsString, Source, js_str}; +/// /// let mut context = Context::default(); /// /// context.register_global_class::().unwrap(); @@ -161,7 +159,7 @@ macro_rules! js_class { instance.set( $crate::boa_engine::JsString::from(stringify!($field_name)), - function.call(&JsValue::undefined(), args, context)?, + function.call(&$crate::boa_engine::JsValue::undefined(), args, context)?, false, context ); From 6f5ae1a68e16176933692aa42c458510c35fc3a0 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 4 Jun 2024 17:01:29 -0700 Subject: [PATCH 6/7] Add a way to alias a function, e.g. change case --- core/interop/src/macros.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/interop/src/macros.rs b/core/interop/src/macros.rs index 029d5256b7d..ed5c280024d 100644 --- a/core/interop/src/macros.rs +++ b/core/interop/src/macros.rs @@ -96,7 +96,7 @@ macro_rules! js_class { $( $(#[$method_attr: meta])* - fn $method_name: ident + fn $method_name: ident $( as $method_js_name: literal )? ( $( $fn_arg: ident: $fn_arg_type: ty ),* ) $(-> $result_type: ty)? $method_body: block @@ -120,8 +120,10 @@ macro_rules! js_class { class.context(), ); + let function_name = $crate::__js_class_name!($method_name, $($method_js_name)?); + class.method( - $crate::boa_engine::JsString::from(stringify!($method_name)), + $crate::boa_engine::JsString::from(function_name), $crate::__count!($( $fn_arg )*), function, ); From 79f1586a78cf014f4edc8b955e2cb8b093705cbd Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 2 Jul 2024 12:00:02 -0700 Subject: [PATCH 7/7] Address comment and get rid of unwraps --- core/engine/src/object/mod.rs | 7 ++++++ core/interop/src/lib.rs | 40 +++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/core/engine/src/object/mod.rs b/core/engine/src/object/mod.rs index b6696e6e665..c7d4e13ceef 100644 --- a/core/engine/src/object/mod.rs +++ b/core/engine/src/object/mod.rs @@ -247,6 +247,13 @@ impl Object { &self.data } + /// Returns the data of the object. + #[inline] + #[must_use] + pub fn data_mut(&mut self) -> &mut T { + &mut self.data + } + /// Gets the prototype instance of this object. #[inline] #[must_use] diff --git a/core/interop/src/lib.rs b/core/interop/src/lib.rs index e836b3bfc71..693767f766f 100644 --- a/core/interop/src/lib.rs +++ b/core/interop/src/lib.rs @@ -1,15 +1,15 @@ //! Interop utilities between Boa and its host. use boa_engine::module::SyntheticModuleInitializer; -use boa_engine::object::{ErasedObject, Ref, RefMut}; +use boa_engine::object::Object; use boa_engine::value::TryFromJs; use boa_engine::{ Context, JsNativeError, JsResult, JsString, JsValue, Module, NativeFunction, NativeObject, }; -use std::marker::PhantomData; use std::ops::Deref; pub use boa_engine; +use boa_gc::{GcRef, GcRefMut}; pub use boa_macros; pub mod loaders; @@ -340,8 +340,7 @@ impl Deref for JsThis { /// [`JsThis`] capture instead. #[derive(Debug, Clone)] pub struct JsClass { - inner: boa_engine::JsObject, - _ty: PhantomData, + inner: boa_engine::JsObject, } impl JsClass { @@ -354,8 +353,8 @@ impl JsClass { /// This does not panic if the type is wrong, as the type is checked /// during the construction of the `JsClass` instance. #[must_use] - pub fn borrow(&self) -> Ref<'_, T> { - self.inner.downcast_ref::().unwrap() + pub fn borrow(&self) -> GcRef<'_, T> { + GcRef::map(self.inner.borrow(), |obj| obj.data()) } /// Borrow a mutable reference to the class instance of type `T`. @@ -364,8 +363,8 @@ impl JsClass { /// /// Panics if the object is currently mutably borrowed. #[must_use] - pub fn borrow_mut(&self) -> Option> { - self.inner.downcast_mut::() + pub fn borrow_mut(&self) -> GcRefMut<'_, Object, T> { + GcRefMut::map(self.inner.borrow_mut(), |obj| obj.data_mut()) } } @@ -376,14 +375,8 @@ impl<'a, T: NativeObject + 'static> TryFromJsArgument<'a> for JsClass { _context: &mut Context, ) -> JsResult<(Self, &'a [JsValue])> { if let Some(object) = this.as_object() { - if object.downcast_ref::().is_some() { - return Ok(( - JsClass { - inner: object.clone(), - _ty: PhantomData, - }, - rest, - )); + if let Ok(inner) = object.clone().downcast::() { + return Ok((JsClass { inner }, rest)); } } @@ -613,14 +606,21 @@ fn class() { fn get_value(this: JsClass) -> i32 { this.borrow().value } + + #[allow(clippy::needless_pass_by_value)] + fn set_value(this: JsClass, new_value: i32) { + (*this.borrow_mut()).value = new_value; + } } impl Class for Test { const NAME: &'static str = "Test"; fn init(class: &mut ClassBuilder<'_>) -> JsResult<()> { - let function = Self::get_value.into_js_function_copied(class.context()); - class.method(js_string!("getValue"), 0, function); + let get_value = Self::get_value.into_js_function_copied(class.context()); + class.method(js_string!("getValue"), 0, get_value); + let set_value = Self::set_value.into_js_function_copied(class.context()); + class.method(js_string!("setValue"), 1, set_value); Ok(()) } @@ -647,6 +647,10 @@ fn class() { if (t.getValue() != 123) { throw 'invalid value'; } + t.setValue(456); + if (t.getValue() != 456) { + throw 'invalid value 456'; + } ", ); let root_module = Module::parse(source, None, &mut context).unwrap();