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}