Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a js_class to implement the Class trait without boilerplate #3872

Merged
merged 7 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions core/interop/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -305,6 +325,74 @@ impl<'a, T: TryFromJs> TryFromJsArgument<'a> for JsThis<T> {
}
}

impl<T: TryFromJs> Deref for JsThis<T> {
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<T: NativeObject> {
inner: boa_engine::JsObject,
hansl marked this conversation as resolved.
Show resolved Hide resolved
_ty: PhantomData<T>,
}

impl<T: NativeObject> JsClass<T> {
/// 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) -> Ref<'_, T> {
self.inner.downcast_ref::<T>().unwrap()
}

/// 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) -> Option<RefMut<'_, ErasedObject, T>> {
self.inner.downcast_mut::<T>()
}
}

impl<'a, T: NativeObject + 'static> TryFromJsArgument<'a> for JsClass<T> {
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::<T>().is_some() {
return Ok((
JsClass {
inner: object.clone(),
_ty: PhantomData,
},
rest,
));
hansl marked this conversation as resolved.
Show resolved Hide resolved
}
}

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.
///
Expand Down Expand Up @@ -507,3 +595,69 @@ 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<Test>) -> 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<Self> {
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::<Test>().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()
);
}
195 changes: 195 additions & 0 deletions core/interop/src/macros.rs
Original file line number Diff line number Diff line change
@@ -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<Self>`.
/// 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<Animal>) -> 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::<Animal>().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)*)
};
}
Loading