diff --git a/Cargo.toml b/Cargo.toml index 9e3aa52..36bdfc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ dioxus-notification = { path = "packages/notification", version = "0.1.0-alpha.1 dioxus-sync = { path = "packages/sync", version = "0.1.0-alpha.1" } dioxus-util = { path = "packages/util", version = "0.1.0-alpha.1" } dioxus-window = { path = "packages/window", version = "0.1.0-alpha.1" } +dioxus-call-js = { path = "packages/call-js", version = "0.1.0-alpha.1" } # Dioxus dioxus = "0.6.0" diff --git a/examples/call-js/Cargo.toml b/examples/call-js/Cargo.toml new file mode 100644 index 0000000..d10a36b --- /dev/null +++ b/examples/call-js/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "call-js-example" +version = "0.1.0" +edition = "2021" + +[dependencies] +dioxus = { workspace = true } +dioxus-call-js = { workspace = true } +serde_json = "1.0" diff --git a/examples/call-js/README.md b/examples/call-js/README.md new file mode 100644 index 0000000..39aa792 --- /dev/null +++ b/examples/call-js/README.md @@ -0,0 +1,7 @@ +# call-js + +Learn how to use `call_js!` macro from `dioxus-call-js`. + +Run: + +```dx serve``` \ No newline at end of file diff --git a/examples/call-js/assets/example.js b/examples/call-js/assets/example.js new file mode 100644 index 0000000..f16d102 --- /dev/null +++ b/examples/call-js/assets/example.js @@ -0,0 +1,5 @@ + + +export function greeting(from, to) { + return `Hello ${to} from ${from}`; +} \ No newline at end of file diff --git a/examples/call-js/src/main.rs b/examples/call-js/src/main.rs new file mode 100644 index 0000000..c995a18 --- /dev/null +++ b/examples/call-js/src/main.rs @@ -0,0 +1,35 @@ +use dioxus::prelude::*; +use dioxus_call_js::call_js; + +fn main() { + launch(App); +} + +#[component] +fn App() -> Element { + let future = use_resource(|| async move { + let from = "dave"; + let to = "john"; + let greeting = call_js!("assets/example.js", greeting(from, to)) + .await + .unwrap(); + let greeting: String = serde_json::from_value(greeting).unwrap(); + return greeting; + }); + + rsx!( + div { + h1 { "Dioxus `call_js!` macro example!" } + { + match &*future.read() { + Some(greeting) => rsx! { + p { "Greeting from JavaScript: {greeting}" } + }, + None => rsx! { + p { "Running js" } + }, + } + } + } + ) +} diff --git a/packages/call-js/Cargo.toml b/packages/call-js/Cargo.toml new file mode 100644 index 0000000..939388f --- /dev/null +++ b/packages/call-js/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dioxus-call-js" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full"] } +swc_ecma_parser = "17" +swc_ecma_ast = "13" +swc_ecma_visit = "13" +swc_common = "13" diff --git a/packages/call-js/README.md b/packages/call-js/README.md new file mode 100644 index 0000000..35a3a47 --- /dev/null +++ b/packages/call-js/README.md @@ -0,0 +1,47 @@ +# Dioxus call-js + +A macro to simplify calling javascript from rust + +## Usage +Add `dioxus-call-js` to your `Cargo.toml`: +```toml +[dependencies] +dioxus-call-js = "0.1" +``` + +Example: +```rust +use dioxus::prelude::*; +use dioxus_call_js::call_js; + +fn main() { + launch(App); +} + +#[component] +fn App() -> Element { + let future = use_resource(|| async move { + let from = "dave"; + let to = "john"; + let greeting = call_js!("assets/example.js", greeting(from, to)).await.unwrap(); + let greeting: String = serde_json::from_value(greeting).unwrap(); + return greeting; + }); + + rsx!( + div { + h1 { "Dioxus `call_js!` macro example!" } + { + match &*future.read() { + Some(greeting) => rsx! { + p { "Greeting from JavaScript: {greeting}" } + }, + None => rsx! { + p { "Running js" } + }, + } + } + } + ) +} +``` \ No newline at end of file diff --git a/packages/call-js/src/lib.rs b/packages/call-js/src/lib.rs new file mode 100644 index 0000000..a84c69c --- /dev/null +++ b/packages/call-js/src/lib.rs @@ -0,0 +1,302 @@ +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use std::sync::Arc; +use std::{fs, path::Path}; +use swc_common::SourceMap; +use swc_ecma_ast::{ + Decl, ExportDecl, ExportSpecifier, FnDecl, ModuleExportName, NamedExport, VarDeclarator, +}; +use swc_ecma_parser::EsSyntax; +use swc_ecma_parser::{Parser, StringInput, Syntax, lexer::Lexer}; +use swc_ecma_visit::{Visit, VisitWith}; +use syn::{ + Expr, ExprCall, LitStr, Result, Token, + parse::{Parse, ParseStream}, + parse_macro_input, +}; + +struct CallJsInput { + asset_path: LitStr, + function_call: ExprCall, +} + +impl Parse for CallJsInput { + fn parse(input: ParseStream) -> Result { + let asset_path: LitStr = input.parse()?; + input.parse::()?; + + let function_call: ExprCall = input.parse()?; + + Ok(CallJsInput { + asset_path, + function_call, + }) + } +} + +fn extract_function_name(call: &ExprCall) -> Result { + match &*call.func { + Expr::Path(path) => { + if let Some(ident) = path.path.get_ident() { + Ok(ident.to_string()) + } else { + Err(syn::Error::new_spanned( + &path.path, + "Function call must be a simple identifier", + )) + } + } + _ => Err(syn::Error::new_spanned( + &call.func, + "Function call must be a simple identifier", + )), + } +} + +#[derive(Debug)] +struct FunctionInfo { + name: String, + param_count: usize, + is_exported: bool, +} + +struct FunctionVisitor { + functions: Vec, +} + +impl FunctionVisitor { + fn new() -> Self { + Self { + functions: Vec::new(), + } + } +} + +impl Visit for FunctionVisitor { + /// Visit function declarations: function foo() {} + fn visit_fn_decl(&mut self, node: &FnDecl) { + self.functions.push(FunctionInfo { + name: node.ident.sym.to_string(), + param_count: node.function.params.len(), + is_exported: false, + }); + node.visit_children_with(self); + } + + /// Visit function expressions: const foo = function() {} + fn visit_var_declarator(&mut self, node: &VarDeclarator) { + if let swc_ecma_ast::Pat::Ident(ident) = &node.name { + if let Some(init) = &node.init { + match &**init { + swc_ecma_ast::Expr::Fn(fn_expr) => { + self.functions.push(FunctionInfo { + name: ident.id.sym.to_string(), + param_count: fn_expr.function.params.len(), + is_exported: false, + }); + } + swc_ecma_ast::Expr::Arrow(arrow_fn) => { + self.functions.push(FunctionInfo { + name: ident.id.sym.to_string(), + param_count: arrow_fn.params.len(), + is_exported: false, + }); + } + _ => {} + } + } + } + node.visit_children_with(self); + } + + /// Visit export declarations: export function foo() {} + fn visit_export_decl(&mut self, node: &ExportDecl) { + match &node.decl { + Decl::Fn(fn_decl) => { + self.functions.push(FunctionInfo { + name: fn_decl.ident.sym.to_string(), + param_count: fn_decl.function.params.len(), + is_exported: true, + }); + } + _ => {} + } + node.visit_children_with(self); + } + + /// Visit named exports: export { foo } + fn visit_named_export(&mut self, node: &NamedExport) { + for spec in &node.specifiers { + match spec { + ExportSpecifier::Named(named) => { + let name = match &named.orig { + ModuleExportName::Ident(ident) => ident.sym.to_string(), + ModuleExportName::Str(str_lit) => str_lit.value.to_string(), + }; + + if let Some(func) = self.functions.iter_mut().find(|f| f.name == name) { + func.is_exported = true; + } + } + _ => {} + } + } + node.visit_children_with(self); + } +} + +fn parse_js_file(file_path: &Path) -> Result> { + let js_content = fs::read_to_string(&file_path).map_err(|e| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "Could not read JavaScript file '{}': {}", + file_path.display(), + e + ), + ) + })?; + + let cm = Arc::new(SourceMap::default()); + let fm = cm.new_source_file( + swc_common::FileName::Custom(file_path.display().to_string()).into(), + js_content.clone(), + ); + + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + Default::default(), + StringInput::from(&*fm), + None, + ); + + let mut parser = Parser::new_from(lexer); + + let module = parser.parse_module().map_err(|e| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "Failed to parse JavaScript file '{}': {:?}", + file_path.display(), + e + ), + ) + })?; + + let mut visitor = FunctionVisitor::new(); + module.visit_with(&mut visitor); + + Ok(visitor.functions) +} + +fn validate_function_call( + functions: &[FunctionInfo], + function_name: &str, + arg_count: usize, +) -> Result<()> { + let function = functions + .iter() + .find(|f| f.name == function_name) + .ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("Function '{}' not found in JavaScript file", function_name), + ) + })?; + + if !function.is_exported { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "Function '{}' is not exported from the JavaScript module", + function_name + ), + )); + } + + if function.param_count != arg_count { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "Function '{}' expects {} arguments, but {} were provided", + function_name, function.param_count, arg_count + ), + )); + } + + Ok(()) +} + +#[proc_macro] +pub fn call_js(input: TokenStream) -> TokenStream { + // parse + let input = parse_macro_input!(input as CallJsInput); + + let asset_path = &input.asset_path; + let function_call = &input.function_call; + + let function_name = match extract_function_name(function_call) { + Ok(name) => name, + Err(e) => return TokenStream::from(e.to_compile_error()), + }; + + // validate js call + let arg_count = function_call.args.len(); + let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") { + Ok(dir) => dir, + Err(_) => { + return TokenStream::from( + syn::Error::new( + proc_macro2::Span::call_site(), + "CARGO_MANIFEST_DIR environment variable not found", + ) + .to_compile_error(), + ); + } + }; + + let js_file_path = std::path::Path::new(&manifest_dir).join(asset_path.value()); + let functions = match parse_js_file(&js_file_path) { + Ok(funcs) => funcs, + Err(e) => return TokenStream::from(e.to_compile_error()), + }; + if let Err(e) = validate_function_call(&functions, &function_name, arg_count) { + return TokenStream::from(e.to_compile_error()); + } + + // expand + let send_calls: Vec = function_call + .args + .iter() + .map(|arg| quote! { eval.send(#arg)?; }) + .collect(); + + let mut js_format = format!(r#"const {{{{ {function_name} }}}} = await import("{{}}");"#,); + for i in 0..arg_count { + js_format.push_str(&format!("\nlet arg{} = await dioxus.recv();", i)); + } + js_format.push_str(&format!("\nreturn {}(", function_name)); + for i in 0..arg_count { + if i > 0 { + js_format.push_str(", "); + } + js_format.push_str(&format!("arg{}", i)); + } + js_format.push_str(");"); + + let expanded = quote! { + async move { + const MODULE: Asset = asset!(#asset_path); + let js = format!(#js_format, MODULE); + let eval = document::eval(js.as_str()); + #(#send_calls)* + eval.await + } + }; + + TokenStream::from(expanded) +}