dioxus_use_js_macro/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use proc_macro::TokenStream;
4use proc_macro2::TokenStream as TokenStream2;
5use quote::{format_ident, quote};
6use std::{fs, path::Path};
7use swc_common::Spanned;
8use swc_common::comments::{CommentKind, Comments};
9use swc_common::{SourceMap, Span, comments::SingleThreadedComments};
10use swc_ecma_ast::{
11    Decl, ExportDecl, ExportSpecifier, FnDecl, ModuleExportName, NamedExport, Param, Pat,
12    VarDeclarator,
13};
14use swc_ecma_parser::EsSyntax;
15use swc_ecma_parser::{Parser, StringInput, Syntax, lexer::Lexer};
16use swc_ecma_visit::{Visit, VisitWith};
17use syn::{
18    Ident, LitStr, Result, Token,
19    parse::{Parse, ParseStream},
20    parse_macro_input,
21};
22
23#[derive(Debug, Clone)]
24enum ImportSpec {
25    /// *
26    All,
27    /// {greeting, other_func}
28    Named(Vec<Ident>),
29    /// greeting
30    Single(Ident),
31}
32
33struct UseJsInput {
34    asset_path: LitStr,
35    import_spec: ImportSpec,
36}
37
38impl Parse for UseJsInput {
39    fn parse(input: ParseStream) -> Result<Self> {
40        let asset_path: LitStr = input.parse()?;
41        input.parse::<Token![::]>()?;
42
43        let import_spec = if input.peek(Token![*]) {
44            input.parse::<Token![*]>()?;
45            ImportSpec::All
46        } else if input.peek(syn::token::Brace) {
47            let content;
48            syn::braced!(content in input);
49            let mut functions = Vec::new();
50
51            loop {
52                let ident: Ident = content.parse()?;
53                functions.push(ident);
54
55                if content.peek(Token![,]) {
56                    content.parse::<Token![,]>()?;
57                    if content.is_empty() {
58                        break;
59                    }
60                } else {
61                    break;
62                }
63            }
64
65            ImportSpec::Named(functions)
66        } else {
67            let ident: Ident = input.parse()?;
68            ImportSpec::Single(ident)
69        };
70
71        Ok(UseJsInput {
72            asset_path,
73            import_spec,
74        })
75    }
76}
77
78#[derive(Debug, Clone)]
79struct FunctionInfo {
80    name: String,
81    /// If specified in the use declaration
82    name_ident: Option<Ident>,
83    params: Vec<String>,
84    is_exported: bool,
85    /// The stripped lines
86    doc_comment: Vec<String>,
87}
88
89struct FunctionVisitor {
90    functions: Vec<FunctionInfo>,
91    comments: SingleThreadedComments,
92}
93
94impl FunctionVisitor {
95    fn new(comments: SingleThreadedComments) -> Self {
96        Self {
97            functions: Vec::new(),
98            comments,
99        }
100    }
101
102    fn extract_doc_comment(&self, span: Span) -> Vec<String> {
103        // Get leading comments for the span
104        let leading_comment = self.comments.get_leading(span.lo());
105
106        if let Some(comments) = leading_comment {
107            let mut doc_lines = Vec::new();
108
109            for comment in comments.iter() {
110                let comment_text = &comment.text;
111                match comment.kind {
112                    // Handle `///`. `//` is already stripped
113                    CommentKind::Line => {
114                        if let Some(content) = comment_text.strip_prefix("/") {
115                            let cleaned = content.trim_start();
116                            doc_lines.push(cleaned.to_string());
117                        }
118                    }
119                    // Handle `/*` `*/`. `/*` `*/` is already stripped
120                    CommentKind::Block => {
121                        for line in comment_text.lines() {
122                            if let Some(cleaned) = line.trim_start().strip_prefix("*") {
123                                doc_lines.push(cleaned.to_string());
124                            }
125                        }
126                    }
127                };
128            }
129
130            doc_lines
131        } else {
132            Vec::new()
133        }
134    }
135}
136
137fn function_params_to_names(params: &[Param]) -> Vec<String> {
138    params
139        .iter()
140        .enumerate()
141        .map(|(i, param)| {
142            if let Some(ident) = param.pat.as_ident() {
143                ident.id.sym.to_string()
144            } else {
145                format!("arg{}", i)
146            }
147        })
148        .collect()
149}
150
151fn function_pat_to_names(pats: &[Pat]) -> Vec<String> {
152    pats.iter()
153        .enumerate()
154        .map(|(i, pat)| {
155            if let Some(ident) = pat.as_ident() {
156                ident.id.sym.to_string()
157            } else {
158                format!("arg{}", i)
159            }
160        })
161        .collect()
162}
163
164impl Visit for FunctionVisitor {
165    /// Visit function declarations: function foo() {}
166    fn visit_fn_decl(&mut self, node: &FnDecl) {
167        let doc_comment = self.extract_doc_comment(node.span());
168
169        self.functions.push(FunctionInfo {
170            name: node.ident.sym.to_string(),
171            name_ident: None,
172            params: function_params_to_names(&node.function.params),
173            is_exported: false,
174            doc_comment,
175        });
176        node.visit_children_with(self);
177    }
178
179    /// Visit function expressions: const foo = function() {}
180    fn visit_var_declarator(&mut self, node: &VarDeclarator) {
181        if let swc_ecma_ast::Pat::Ident(ident) = &node.name {
182            if let Some(init) = &node.init {
183                let doc_comment = self.extract_doc_comment(node.span());
184
185                match &**init {
186                    swc_ecma_ast::Expr::Fn(fn_expr) => {
187                        self.functions.push(FunctionInfo {
188                            name: ident.id.sym.to_string(),
189                            name_ident: None,
190                            params: function_params_to_names(&fn_expr.function.params),
191                            is_exported: false,
192                            doc_comment,
193                        });
194                    }
195                    swc_ecma_ast::Expr::Arrow(arrow_fn) => {
196                        self.functions.push(FunctionInfo {
197                            name: ident.id.sym.to_string(),
198                            name_ident: None,
199                            params: function_pat_to_names(&arrow_fn.params),
200                            is_exported: false,
201                            doc_comment,
202                        });
203                    }
204                    _ => {}
205                }
206            }
207        }
208        node.visit_children_with(self);
209    }
210
211    /// Visit export declarations: export function foo() {}
212    fn visit_export_decl(&mut self, node: &ExportDecl) {
213        if let Decl::Fn(fn_decl) = &node.decl {
214            let doc_comment = self.extract_doc_comment(node.span());
215
216            self.functions.push(FunctionInfo {
217                name: fn_decl.ident.sym.to_string(),
218                name_ident: None,
219                params: function_params_to_names(&fn_decl.function.params),
220                is_exported: true,
221                doc_comment,
222            });
223        }
224        node.visit_children_with(self);
225    }
226
227    /// Visit named exports: export { foo }
228    fn visit_named_export(&mut self, node: &NamedExport) {
229        for spec in &node.specifiers {
230            if let ExportSpecifier::Named(named) = spec {
231                let name = match &named.orig {
232                    ModuleExportName::Ident(ident) => ident.sym.to_string(),
233                    ModuleExportName::Str(str_lit) => str_lit.value.to_string(),
234                };
235
236                if let Some(func) = self.functions.iter_mut().find(|f| f.name == name) {
237                    func.is_exported = true;
238                }
239            }
240        }
241        node.visit_children_with(self);
242    }
243}
244
245fn parse_js_file(file_path: &Path) -> Result<Vec<FunctionInfo>> {
246    let js_content = fs::read_to_string(file_path).map_err(|e| {
247        syn::Error::new(
248            proc_macro2::Span::call_site(),
249            format!(
250                "Could not read JavaScript file '{}': {}",
251                file_path.display(),
252                e
253            ),
254        )
255    })?;
256
257    let cm = SourceMap::default();
258    let fm = cm.new_source_file(
259        swc_common::FileName::Custom(file_path.display().to_string()).into(),
260        js_content.clone(),
261    );
262    let comments = SingleThreadedComments::default();
263    let lexer = Lexer::new(
264        Syntax::Es(EsSyntax::default()),
265        Default::default(),
266        StringInput::from(&*fm),
267        Some(&comments),
268    );
269
270    let mut parser = Parser::new_from(lexer);
271
272    let module = parser.parse_module().map_err(|e| {
273        syn::Error::new(
274            proc_macro2::Span::call_site(),
275            format!(
276                "Failed to parse JavaScript file '{}': {:?}",
277                file_path.display(),
278                e
279            ),
280        )
281    })?;
282
283    let mut visitor = FunctionVisitor::new(comments);
284    module.visit_with(&mut visitor);
285
286    // Functions are added twice for some reason
287    visitor
288        .functions
289        .dedup_by(|e1, e2| e1.name.as_str() == e2.name.as_str());
290    Ok(visitor.functions)
291}
292
293fn remove_valid_function_info(
294    name: &str,
295    functions: &mut Vec<FunctionInfo>,
296) -> Result<FunctionInfo> {
297    let function_info = if let Some(pos) = functions.iter().position(|f| f.name == name) {
298        functions.remove(pos)
299    } else {
300        return Err(syn::Error::new(
301            proc_macro2::Span::call_site(),
302            format!("Function '{}' not found in JavaScript file", name),
303        ));
304    };
305    if !function_info.is_exported {
306        return Err(syn::Error::new(
307            proc_macro2::Span::call_site(),
308            format!("Function '{}' not exported in JavaScript file", name),
309        ));
310    }
311    Ok(function_info)
312}
313
314fn get_functions_to_generate(
315    mut functions: Vec<FunctionInfo>,
316    import_spec: ImportSpec,
317) -> Result<Vec<FunctionInfo>> {
318    match import_spec {
319        ImportSpec::All => Ok(functions),
320        ImportSpec::Single(name) => {
321            let mut func = remove_valid_function_info(name.to_string().as_str(), &mut functions)?;
322            func.name_ident.replace(name);
323            Ok(vec![func])
324        }
325        ImportSpec::Named(names) => {
326            let mut result = Vec::new();
327            for name in names {
328                let mut func =
329                    remove_valid_function_info(name.to_string().as_str(), &mut functions)?;
330                func.name_ident.replace(name);
331                result.push(func);
332            }
333            Ok(result)
334        }
335    }
336}
337
338fn generate_function_wrapper(func: &FunctionInfo, asset_path: &LitStr) -> TokenStream2 {
339    let send_calls: Vec<TokenStream2> = func
340        .params
341        .iter()
342        .map(|param| {
343            let param = format_ident!("{}", param);
344            quote! {
345                eval.send(#param)?;
346            }
347        })
348        .collect();
349
350    let js_func_name = &func.name;
351    let mut js_format = format!(r#"const {{{{ {js_func_name} }}}} = await import("{{}}");"#);
352    for param in func.params.iter() {
353        js_format.push_str(&format!("\nlet {} = await dioxus.recv();", param));
354    }
355    js_format.push_str(&format!("\nreturn {}(", js_func_name));
356    for (i, param) in func.params.iter().enumerate() {
357        if i > 0 {
358            js_format.push_str(", ");
359        }
360        js_format.push_str(param.as_str());
361    }
362    js_format.push_str(");");
363
364    let param_types: Vec<_> = func
365        .params
366        .iter()
367        .map(|param| {
368            let param = format_ident!("{}", param);
369            quote! { #param: impl serde::Serialize }
370        })
371        .collect();
372
373    // Generate documentation comment if available - preserve original JSDoc format
374    let doc_comment = if func.doc_comment.is_empty() {
375        quote! {}
376    } else {
377        let doc_lines: Vec<_> = func
378            .doc_comment
379            .iter()
380            .map(|line| quote! { #[doc = #line] })
381            .collect();
382        quote! { #(#doc_lines)* }
383    };
384
385    let func_name = func
386        .name_ident
387        .clone()
388        // Can not exist if `::*`
389        .unwrap_or_else(|| Ident::new(func.name.as_str(), proc_macro2::Span::call_site()));
390    quote! {
391        #doc_comment
392        #[allow(non_snake_case)]
393        pub async fn #func_name(#(#param_types),*) -> Result<serde_json::Value, document::EvalError> {
394            const MODULE: Asset = asset!(#asset_path);
395            let js = format!(#js_format, MODULE);
396            let eval = document::eval(js.as_str());
397            #(#send_calls)*
398            eval.await
399        }
400    }
401}
402
403/// A macro to create rust binding to javascript functions.
404///```rust,no_run
405/// use dioxus::prelude::*;
406/// use dioxus_use_js::use_js;
407///
408/// // Generate the greeting function at compile time
409/// use_js!("example/assets/example.js"::greeting);
410///
411///  // Or generate multiple functions:
412///  // use_js!("example/assets/example.js"::{greeting, add});
413///
414///  // Or generate all exported functions:
415///  // use_js!("example/assets/example.js"::*);
416///
417/// fn main() {
418///     launch(App);
419/// }
420///
421/// #[component]
422/// fn App() -> Element {
423///     let future = use_resource(|| async move {
424///         let from = "dave";
425///         let to = "john";
426///
427///         // Now we can call the generated function directly!
428///         let greeting_result = greeting(from, to)
429///             .await
430///             .map_err(Box::<dyn std::error::Error>::from)?;
431///         let greeting: String =
432///             serde_json::from_value(greeting_result).map_err(Box::<dyn std::error::Error>::from)?;
433///         Ok::<String, Box<dyn std::error::Error>>(greeting)
434///     });
435///
436///     rsx!(
437///         div {
438///             h1 { "Dioxus `use_js!` macro example!" }
439///             {
440///                 match &*future.read() {
441///                     Some(Ok(greeting)) => rsx! {
442///                         p { "Greeting from JavaScript: {greeting}" }
443///                     },
444///                     Some(Err(e)) => rsx! {
445///                         p { "Error: {e}" }
446///                     },
447///                     None => rsx! {
448///                         p { "Running js..." }
449///                     },
450///                 }
451///             }
452///         }
453///     )
454/// }
455/// ```
456#[proc_macro]
457pub fn use_js(input: TokenStream) -> TokenStream {
458    let input = parse_macro_input!(input as UseJsInput);
459
460    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
461        Ok(dir) => dir,
462        Err(_) => {
463            return TokenStream::from(
464                syn::Error::new(
465                    proc_macro2::Span::call_site(),
466                    "CARGO_MANIFEST_DIR environment variable not found",
467                )
468                .to_compile_error(),
469            );
470        }
471    };
472
473    let asset_path = &input.asset_path;
474    let js_file_path = std::path::Path::new(&manifest_dir).join(asset_path.value());
475
476    let all_functions = match parse_js_file(&js_file_path) {
477        Ok(funcs) => funcs,
478        Err(e) => return TokenStream::from(e.to_compile_error()),
479    };
480
481    let import_spec = input.import_spec;
482    let functions_to_generate = match get_functions_to_generate(all_functions, import_spec) {
483        Ok(funcs) => funcs,
484        Err(e) => return TokenStream::from(e.to_compile_error()),
485    };
486
487    let function_wrappers: Vec<TokenStream2> = functions_to_generate
488        .iter()
489        .map(|func| generate_function_wrapper(func, asset_path))
490        .collect();
491
492    let expanded = quote! {
493        #(#function_wrappers)*
494    };
495
496    TokenStream::from(expanded)
497}