From 58d0fe64d290d4152865f7c669970ac3a9fc1f6e Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Mon, 8 Jul 2024 14:58:01 -0700 Subject: [PATCH] Add a js_class to implement the Class trait without boilerplate (#3872) * 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. * clippies * Fix usage of macros * Fix some imports issues * Fix some imports issues, again * Add a way to alias a function, e.g. change case * Address comment and get rid of unwraps --- core/engine/src/object/mod.rs | 7 ++ core/interop/src/lib.rs | 158 +++++++++++++++++++++++++++ core/interop/src/macros.rs | 195 ++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 core/interop/src/macros.rs diff --git a/core/engine/src/object/mod.rs b/core/engine/src/object/mod.rs index b6696e6e66..c7d4e13cee 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 a15ab16c1b..154e7d4a1c 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::Object; use boa_engine::value::TryFromJs; use boa_engine::{ Context, JsNativeError, JsResult, JsString, JsValue, Module, NativeFunction, NativeObject, }; +use std::ops::Deref; + +pub use boa_engine; +use boa_gc::{GcRef, GcRefMut}; pub use boa_macros; pub mod loaders; +pub mod macros; /// Internal module only. pub(crate) mod private { @@ -154,6 +160,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 @@ -327,6 +347,67 @@ 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, +} + +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. + #[must_use] + 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`. + /// + /// # Panics + /// + /// Panics if the object is currently mutably borrowed. + #[must_use] + pub fn borrow_mut(&self) -> GcRefMut<'_, Object, T> { + GcRefMut::map(self.inner.borrow_mut(), |obj| obj.data_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 let Ok(inner) = object.clone().downcast::() { + return Ok((JsClass { inner }, 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. /// @@ -531,3 +612,80 @@ 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 { + #[allow(clippy::needless_pass_by_value)] + 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 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(()) + } + + 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'; + } + t.setValue(456); + if (t.getValue() != 456) { + throw 'invalid value 456'; + } + ", + ); + 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 0000000000..ed5c280024 --- /dev/null +++ b/core/interop/src/macros.rs @@ -0,0 +1,195 @@ +//! 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::{JsString, JsData, js_string}; +/// # 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() { +///# use boa_engine::{Context, JsString, Source, js_str}; +/// +/// 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 $( as $method_js_name: literal )? + ( $( $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 $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 )? + $method_body + + let function = $crate::IntoJsFunctionCopied::into_js_function_copied( + $method_name, + class.context(), + ); + + let function_name = $crate::__js_class_name!($method_name, $($method_js_name)?); + + class.method( + $crate::boa_engine::JsString::from(function_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( + $crate::boa_engine::JsString::from(stringify!($field_name)), + function.call(&$crate::boa_engine::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)*) + }; +}