Skip to main content

js_export_macro/
lib.rs

1//! Proc macro that generates dual `wasm_bindgen`/`napi` annotations from a single attribute.
2//!
3//! # Usage
4//!
5//! ```ignore
6//! #[js_export]                          // bare — applies to struct/enum/impl
7//! #[js_export(constructor)]             // forwarded to both wasm_bindgen & napi
8//! #[js_export(js_name = "foo")]         // forwarded to both
9//! #[js_export(getter, js_name = "x")]   // forwarded to both
10//! ```
11//!
12//! When a method signature contains [`JsU64`], the macro automatically splits it
13//! into browser (`u64`) and Node.js (`::napi::bindgen_prelude::BigInt`) variants.
14//! Both map to JavaScript `BigInt`, preserving full `u64` precision on both platforms.
15//!
16//! # Expansion example (JsU64 split)
17//!
18//! A method with `JsU64` in its signature:
19//!
20//! ```ignore
21//! #[js_export]
22//! impl Felt {
23//!     #[js_export(constructor)]
24//!     pub fn new(value: JsU64) -> Felt { ... }
25//! }
26//! ```
27//!
28//! expands to one impl block per platform, with `JsU64` replaced by the concrete type:
29//!
30//! ```ignore
31//! #[cfg(feature = "browser")]
32//! #[::wasm_bindgen::prelude::wasm_bindgen]
33//! impl Felt {
34//!     #[::wasm_bindgen::prelude::wasm_bindgen(constructor)]
35//!     pub fn new(value: u64) -> Felt { ... }
36//! }
37//!
38//! #[cfg(feature = "nodejs")]
39//! #[::napi_derive::napi]
40//! impl Felt {
41//!     #[::napi_derive::napi(constructor)]
42//!     pub fn new(value: ::napi::bindgen_prelude::BigInt) -> Felt { ... }
43//! }
44//! ```
45//!
46//! Methods without `JsU64` stay in a single impl block with `#[cfg_attr]` forwarding
47//! the platform-specific macro.
48
49use proc_macro::TokenStream;
50use proc_macro2::TokenStream as TokenStream2;
51use quote::quote;
52use syn::visit_mut::VisitMut;
53use syn::{ImplItem, ImplItemFn, Item, ItemEnum, ItemImpl, ItemStruct, parse_macro_input};
54
55// ================================================================================================
56// Entry point
57// ================================================================================================
58
59#[proc_macro_attribute]
60pub fn js_export(attr: TokenStream, item: TokenStream) -> TokenStream {
61    let attr: TokenStream2 = attr.into();
62    let item = parse_macro_input!(item as Item);
63
64    let output = match item {
65        Item::Struct(s) => handle_struct(&attr, s),
66        Item::Enum(e) => handle_enum(&attr, e),
67        Item::Impl(i) => handle_impl(&attr, i),
68        other => {
69            return syn::Error::new_spanned(other, "#[js_export] only supports struct, enum, impl")
70                .to_compile_error()
71                .into();
72        },
73    };
74
75    output.into()
76}
77
78// ================================================================================================
79// Struct / Enum handlers
80// ================================================================================================
81
82fn handle_struct(attr: &TokenStream2, item: ItemStruct) -> TokenStream2 {
83    let wasm = wasm_attr(attr);
84    let napi = napi_attr(attr);
85
86    // item already contains its own attributes (like #[derive(Clone)]),
87    // so we just prepend the platform attrs.
88    quote! {
89        #wasm
90        #napi
91        #item
92    }
93}
94
95fn handle_enum(attr: &TokenStream2, item: ItemEnum) -> TokenStream2 {
96    let wasm = wasm_attr(attr);
97    let napi = napi_attr(attr);
98
99    quote! {
100        #wasm
101        #napi
102        #item
103    }
104}
105
106// ================================================================================================
107// Impl block handler
108// ================================================================================================
109
110struct JsU64Method {
111    method: ImplItemFn,
112    export_args: TokenStream2,
113}
114
115fn handle_impl(outer_attr: &TokenStream2, mut item: ItemImpl) -> TokenStream2 {
116    let self_ty = &item.self_ty;
117    let generics = &item.generics;
118
119    // Partition methods into:
120    // - shared methods, which can stay in one cfg-gated impl block
121    // - JsU64 methods, which need platform-specific signatures generated later
122    let mut shared_methods: Vec<ImplItemFn> = Vec::new();
123    let mut jsu64_methods: Vec<JsU64Method> = Vec::new();
124    let mut other_items: Vec<ImplItem> = Vec::new(); // const, type, etc.
125
126    for member in item.items.drain(..) {
127        match member {
128            ImplItem::Fn(mut method) => {
129                // Strip #[js_export(...)] from the method before rebuilding the export attrs.
130                // Impl-level export attrs are added on the enclosing impl block, while
131                // method-level args (constructor, getter, js_name, etc.) are re-emitted on
132                // the generated method items below.
133                let method_attr = extract_js_export_attr(&mut method);
134                let method_attr_tokens = method_attr.unwrap_or_default();
135
136                if has_jsu64(&method) {
137                    // JsU64 maps to different JS-facing Rust types on each platform, so these
138                    // methods cannot stay in the shared impl block; `make_platform_method`
139                    // rebuilds them as browser/node-specific clones.
140                    jsu64_methods.push(JsU64Method { method, export_args: method_attr_tokens });
141                } else {
142                    // Annotate the method inline with dual cfg_attr.
143                    let wasm = wasm_attr(&method_attr_tokens);
144                    let napi = napi_attr(&method_attr_tokens);
145                    method.attrs.push(syn::parse_quote!(#wasm));
146                    method.attrs.push(syn::parse_quote!(#napi));
147                    shared_methods.push(method);
148                }
149            },
150            other => other_items.push(other),
151        }
152    }
153
154    let mut output = TokenStream2::new();
155
156    // --- Shared impl block (methods without JsU64) ---
157    if !shared_methods.is_empty() || !other_items.is_empty() {
158        let wasm_outer = wasm_attr(outer_attr);
159        let napi_outer = napi_attr(outer_attr);
160        output.extend(quote! {
161            #wasm_outer
162            #napi_outer
163            impl #generics #self_ty {
164                #(#other_items)*
165                #(#shared_methods)*
166            }
167        });
168    }
169
170    // --- Platform-specific impl blocks (methods with JsU64) ---
171    if !jsu64_methods.is_empty() {
172        let browser_methods: Vec<ImplItemFn> = jsu64_methods
173            .iter()
174            .map(|m| make_platform_method(&m.method, &m.export_args, Platform::Browser))
175            .collect();
176        let nodejs_methods: Vec<ImplItemFn> = jsu64_methods
177            .iter()
178            .map(|m| make_platform_method(&m.method, &m.export_args, Platform::Nodejs))
179            .collect();
180
181        output.extend(quote! {
182            #[cfg(feature = "browser")]
183            #[::wasm_bindgen::prelude::wasm_bindgen]
184            impl #generics #self_ty {
185                #(#browser_methods)*
186            }
187
188            #[cfg(feature = "nodejs")]
189            #[::napi_derive::napi]
190            impl #generics #self_ty {
191                #(#nodejs_methods)*
192            }
193        });
194    }
195
196    output
197}
198
199// ================================================================================================
200// Platform-specific method generation
201// ================================================================================================
202
203#[derive(Clone, Copy)]
204enum Platform {
205    Browser,
206    Nodejs,
207}
208
209fn make_platform_method(
210    method: &ImplItemFn,
211    args: &TokenStream2,
212    platform: Platform,
213) -> ImplItemFn {
214    // Clone once per platform so both generated variants start from the same source method.
215    let mut method = method.clone();
216
217    // Apply the original method-level export args to each platform-specific clone.
218    match platform {
219        Platform::Browser => {
220            if args.is_empty() {
221                method.attrs.push(syn::parse_quote!(#[::wasm_bindgen::prelude::wasm_bindgen]));
222            } else {
223                method
224                    .attrs
225                    .push(syn::parse_quote!(#[::wasm_bindgen::prelude::wasm_bindgen(#args)]));
226            }
227        },
228        Platform::Nodejs => {
229            if args.is_empty() {
230                method.attrs.push(syn::parse_quote!(#[::napi_derive::napi]));
231            } else {
232                method.attrs.push(syn::parse_quote!(#[::napi_derive::napi(#args)]));
233            }
234        },
235    }
236
237    // Replace JsU64 in signature with the platform-specific concrete type.
238    // Browser uses `u64` (maps to BigInt via wasm_bindgen). Node.js uses napi's
239    // `BigInt` struct because napi-rs does not implement `FromNapiValue` for `u64`
240    // Both resolve to a JS `BigInt` on the JS side.
241    let replacement: syn::Path = match platform {
242        Platform::Browser => syn::parse_quote!(u64),
243        Platform::Nodejs => syn::parse_quote!(::napi::bindgen_prelude::BigInt),
244    };
245    let mut replacer = JsU64Replacer { replacement };
246    replacer.visit_impl_item_fn_mut(&mut method);
247
248    method
249}
250
251// ================================================================================================
252// JsU64 detection
253// ================================================================================================
254
255fn has_jsu64(method: &ImplItemFn) -> bool {
256    let mut detector = JsU64Detector { found: false };
257    // Check params.
258    for arg in &method.sig.inputs {
259        if let syn::FnArg::Typed(pat_type) = arg {
260            let mut ty = (*pat_type.ty).clone();
261            detector.visit_type_mut(&mut ty);
262        }
263    }
264    // Check return type.
265    if let syn::ReturnType::Type(_, ty) = &method.sig.output {
266        let mut ty = (**ty).clone();
267        detector.visit_type_mut(&mut ty);
268    }
269    detector.found
270}
271
272struct JsU64Detector {
273    found: bool,
274}
275
276impl VisitMut for JsU64Detector {
277    fn visit_type_path_mut(&mut self, tp: &mut syn::TypePath) {
278        if tp.path.is_ident("JsU64") {
279            self.found = true;
280        }
281        syn::visit_mut::visit_type_path_mut(self, tp);
282    }
283}
284
285// ================================================================================================
286// JsU64 replacement (only in method signature, NOT in body)
287// ================================================================================================
288
289struct JsU64Replacer {
290    replacement: syn::Path,
291}
292
293impl VisitMut for JsU64Replacer {
294    fn visit_impl_item_fn_mut(&mut self, method: &mut ImplItemFn) {
295        // Only visit the signature, NOT the body.
296        for arg in &mut method.sig.inputs {
297            self.visit_fn_arg_mut(arg);
298        }
299        self.visit_return_type_mut(&mut method.sig.output);
300        // Deliberately skip: self.visit_block_mut(&mut method.block);
301    }
302
303    fn visit_type_path_mut(&mut self, tp: &mut syn::TypePath) {
304        if tp.path.is_ident("JsU64") {
305            tp.path = self.replacement.clone();
306        }
307        syn::visit_mut::visit_type_path_mut(self, tp);
308    }
309}
310
311// ================================================================================================
312// Attribute helpers
313// ================================================================================================
314
315/// Build `#[cfg_attr(feature = "browser", ::wasm_bindgen::prelude::wasm_bindgen(...))]`
316fn wasm_attr(args: &TokenStream2) -> TokenStream2 {
317    if args.is_empty() {
318        quote! { #[cfg_attr(feature = "browser", ::wasm_bindgen::prelude::wasm_bindgen)] }
319    } else {
320        quote! { #[cfg_attr(feature = "browser", ::wasm_bindgen::prelude::wasm_bindgen(#args))] }
321    }
322}
323
324/// Build `#[cfg_attr(feature = "nodejs", ::napi_derive::napi(...))]`
325fn napi_attr(args: &TokenStream2) -> TokenStream2 {
326    if args.is_empty() {
327        quote! { #[cfg_attr(feature = "nodejs", ::napi_derive::napi)] }
328    } else {
329        quote! { #[cfg_attr(feature = "nodejs", ::napi_derive::napi(#args))] }
330    }
331}
332
333/// Extract and remove a `#[js_export(...)]` attribute from a method, returning its args.
334fn extract_js_export_attr(method: &mut ImplItemFn) -> Option<TokenStream2> {
335    let mut extracted = None;
336    method.attrs.retain(|attr| {
337        if attr.path().is_ident("js_export") {
338            extracted = Some(match &attr.meta {
339                syn::Meta::Path(_) => TokenStream2::new(),
340                syn::Meta::List(list) => list.tokens.clone(),
341                syn::Meta::NameValue(_) => TokenStream2::new(),
342            });
343            false
344        } else {
345            true
346        }
347    });
348    extracted
349}