Skip to main content

wasm_js_bridge_macros/
lib.rs

1//! Proc macros for wasm-js-bridge code generation.
2
3use proc_macro::TokenStream;
4use proc_macro2::TokenStream as TokenStream2;
5use quote::{format_ident, quote};
6use syn::{
7    parse_macro_input, FnArg, GenericArgument, ItemFn, LitStr, Pat, PathArguments, ReturnType,
8    Token, Type,
9};
10
11// ---------------------------------------------------------------------------
12// Type classification helpers
13// ---------------------------------------------------------------------------
14
15fn is_str_ref(ty: &Type) -> bool {
16    matches!(ty, Type::Reference(r) if matches!(r.elem.as_ref(), Type::Path(p) if p.path.is_ident("str")))
17}
18
19fn is_string(ty: &Type) -> bool {
20    matches!(ty, Type::Path(p) if p.path.is_ident("String"))
21}
22
23fn is_bool(ty: &Type) -> bool {
24    matches!(ty, Type::Path(p) if p.path.is_ident("bool"))
25}
26
27fn is_numeric(ty: &Type) -> bool {
28    if let Type::Path(p) = ty {
29        if let Some(ident) = p.path.get_ident() {
30            return matches!(
31                ident.to_string().as_str(),
32                "u8" | "u16"
33                    | "u32"
34                    | "u64"
35                    | "i8"
36                    | "i16"
37                    | "i32"
38                    | "i64"
39                    | "f32"
40                    | "f64"
41                    | "usize"
42                    | "isize"
43            );
44        }
45    }
46    false
47}
48
49fn unwrap_single_generic<'a>(ty: &'a Type, name: &str) -> Option<&'a Type> {
50    if let Type::Path(p) = ty {
51        let last = p.path.segments.last()?;
52        if last.ident != name {
53            return None;
54        }
55        if let PathArguments::AngleBracketed(args) = &last.arguments {
56            if let Some(GenericArgument::Type(inner)) = args.args.first() {
57                return Some(inner);
58            }
59        }
60    }
61    None
62}
63
64fn unwrap_option(ty: &Type) -> Option<&Type> {
65    unwrap_single_generic(ty, "Option")
66}
67
68fn unwrap_vec(ty: &Type) -> Option<&Type> {
69    unwrap_single_generic(ty, "Vec")
70}
71
72fn unwrap_result(ty: &Type) -> Option<(&Type, &Type)> {
73    if let Type::Path(p) = ty {
74        let last = p.path.segments.last()?;
75        if last.ident != "Result" {
76            return None;
77        }
78        if let PathArguments::AngleBracketed(args) = &last.arguments {
79            let mut iter = args.args.iter();
80            if let (Some(GenericArgument::Type(ok)), Some(GenericArgument::Type(err))) =
81                (iter.next(), iter.next())
82            {
83                return Some((ok, err));
84            }
85        }
86    }
87    None
88}
89
90fn contains_nested_reference(ty: &Type) -> bool {
91    match ty {
92        Type::Reference(_) => true,
93        Type::Array(a) => contains_nested_reference(&a.elem),
94        Type::Group(g) => contains_nested_reference(&g.elem),
95        Type::Paren(p) => contains_nested_reference(&p.elem),
96        Type::Slice(s) => contains_nested_reference(&s.elem),
97        Type::Tuple(t) => t.elems.iter().any(contains_nested_reference),
98        Type::Path(p) => p.path.segments.iter().any(|seg| {
99            if let PathArguments::AngleBracketed(args) = &seg.arguments {
100                args.args.iter().any(|arg| match arg {
101                    GenericArgument::Type(inner) => contains_nested_reference(inner),
102                    _ => false,
103                })
104            } else {
105                false
106            }
107        }),
108        _ => false,
109    }
110}
111
112/// Strip any `r#` raw-identifier prefix from a param ident before emitting as JS.
113fn js_param_name(ident: &syn::Ident) -> String {
114    let s = ident.to_string();
115    s.strip_prefix("r#").unwrap_or(&s).to_string()
116}
117
118// ---------------------------------------------------------------------------
119// WASM adapter parameter generation
120// ---------------------------------------------------------------------------
121
122/// Returns (wasm_param_decl, deserialization_stmt).
123/// `wasm_param_decl` is used in the adapter fn signature.
124/// `deserialization_stmt` rebinds the param to the Rust type (empty if no conversion needed).
125fn wasm_param(name: &syn::Ident, ty: &Type) -> (TokenStream2, TokenStream2) {
126    if is_str_ref(ty) {
127        // &str -> pass directly (wasm-bindgen native support)
128        (quote!(#name: &str), quote!())
129    } else if let Type::Reference(r) = ty {
130        if r.mutability.is_some() {
131            return (
132                syn::Error::new_spanned(
133                    ty,
134                    "#[wasm_export] does not support &mut T params; take T by value",
135                )
136                .to_compile_error(),
137                quote!(),
138            );
139        }
140        if matches!(r.elem.as_ref(), Type::Slice(_)) {
141            return (
142                syn::Error::new_spanned(
143                    ty,
144                    "#[wasm_export] does not support &[T] params; use Vec<T>",
145                )
146                .to_compile_error(),
147                quote!(),
148            );
149        }
150        // &T (non-str, non-mut, non-slice) -> receive JsValue, deserialize as T, rebind as &T
151        let inner = &*r.elem;
152        let owned = format_ident!("{name}_owned_");
153        (
154            quote!(#name: ::wasm_bindgen::JsValue),
155            quote!(
156                let #owned: #inner = ::serde_wasm_bindgen::from_value(#name)
157                    .map_err(|e| ::wasm_bindgen::JsError::new(&e.to_string()))?;
158                let #name = &#owned;
159            ),
160        )
161    } else if is_string(ty) {
162        (quote!(#name: String), quote!())
163    } else if is_bool(ty) {
164        (quote!(#name: bool), quote!())
165    } else if is_numeric(ty) {
166        (quote!(#name: #ty), quote!())
167    } else if contains_nested_reference(ty) {
168        (
169            syn::Error::new_spanned(
170                ty,
171                "#[wasm_export] does not support borrowed references inside generic/container types (e.g. Option<&T>, Vec<&T>); use owned data",
172            )
173            .to_compile_error(),
174            quote!(),
175        )
176    } else {
177        (
178            quote!(#name: ::wasm_bindgen::JsValue),
179            quote!(
180                let #name: #ty = ::serde_wasm_bindgen::from_value(#name)
181                    .map_err(|e| ::wasm_bindgen::JsError::new(&e.to_string()))?;
182            ),
183        )
184    }
185}
186
187fn wasm_return_body(ret_ty: &Type, call: TokenStream2) -> TokenStream2 {
188    if let Some((_, err_ty)) = unwrap_result(ret_ty) {
189        let err_conv = if is_string(err_ty) {
190            quote!(|e| ::wasm_bindgen::JsError::new(&e))
191        } else {
192            quote!(|e| ::wasm_bindgen::JsError::new(&e.to_string()))
193        };
194        quote!(
195            let __result = #call.map_err(#err_conv)?;
196            let __serializer = ::serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
197            ::serde::Serialize::serialize(&__result, &__serializer)
198                .map_err(|e| ::wasm_bindgen::JsError::new(&e.to_string()))
199        )
200    } else {
201        quote!(
202            let __result = #call;
203            let __serializer = ::serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
204            ::serde::Serialize::serialize(&__result, &__serializer)
205                .map_err(|e| ::wasm_bindgen::JsError::new(&e.to_string()))
206        )
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Type string code generation (for WasmFn descriptor helper fns)
212// ---------------------------------------------------------------------------
213
214#[derive(Clone, Copy)]
215enum Dialect {
216    Ts,
217    Flow,
218}
219
220/// Generate code that, when executed, produces a type string for the given Rust type.
221///
222/// References are transparent at the JS boundary. Vec<T> maps to the dialect's readonly
223/// array form. Option<T> maps to nullable. Result<T, _> maps to T (errors surface as
224/// thrown exceptions). All other types delegate to the dialect's trait (`TS` or `Flow`).
225fn type_expr(ty: &Type, dialect: Dialect) -> TokenStream2 {
226    if is_str_ref(ty) || is_string(ty) {
227        quote!("string".to_string())
228    } else if let Type::Reference(r) = ty {
229        type_expr(&r.elem, dialect)
230    } else if is_bool(ty) {
231        quote!("boolean".to_string())
232    } else if let Some(inner) = unwrap_vec(ty) {
233        let inner_expr = type_expr(inner, dialect);
234        match dialect {
235            Dialect::Ts => quote!(format!("ReadonlyArray<{}>", #inner_expr)),
236            Dialect::Flow => quote!(format!("$ReadOnlyArray<{}>", #inner_expr)),
237        }
238    } else if let Some(inner) = unwrap_option(ty) {
239        let inner_expr = type_expr(inner, dialect);
240        match dialect {
241            Dialect::Ts => quote!(format!("{} | null", #inner_expr)),
242            Dialect::Flow => quote!(format!("?{}", #inner_expr)),
243        }
244    } else if let Some((ok_ty, _)) = unwrap_result(ty) {
245        type_expr(ok_ty, dialect)
246    } else {
247        match dialect {
248            Dialect::Ts => quote!(<#ty as ::ts_rs::TS>::name(&cfg)),
249            Dialect::Flow => quote!(<#ty as ::flowjs_rs::Flow>::name(&cfg)),
250        }
251    }
252}
253
254/// Strip a leading reference to get the underlying type for optionality detection.
255fn deref_ty(ty: &Type) -> &Type {
256    if let Type::Reference(r) = ty {
257        &r.elem
258    } else {
259        ty
260    }
261}
262
263fn params_body(params: &[(syn::Ident, Type)], dialect: Dialect) -> TokenStream2 {
264    if params.is_empty() {
265        return quote!(String::new());
266    }
267    let parts: Vec<TokenStream2> = params
268        .iter()
269        .enumerate()
270        .map(|(i, (name, ty))| {
271            let name_str = js_param_name(name);
272            if let Some(inner) = unwrap_option(deref_ty(ty)) {
273                let inner_expr = type_expr(inner, dialect);
274                match dialect {
275                    Dialect::Ts => {
276                        // Use ?: only when all remaining params are also Option<T>
277                        let all_remaining_optional = params[i..]
278                            .iter()
279                            .all(|(_, t)| unwrap_option(deref_ty(t)).is_some());
280                        if all_remaining_optional {
281                            quote!(format!("{}?: {} | null", #name_str, #inner_expr))
282                        } else {
283                            quote!(format!("{}: {} | null | undefined", #name_str, #inner_expr))
284                        }
285                    }
286                    Dialect::Flow => quote!(format!("{}: ?{}", #name_str, #inner_expr)),
287                }
288            } else {
289                let expr = type_expr(ty, dialect);
290                quote!(format!("{}: {}", #name_str, #expr))
291            }
292        })
293        .collect();
294    quote!([#(#parts),*].join(", "))
295}
296
297// ---------------------------------------------------------------------------
298// Helpers
299// ---------------------------------------------------------------------------
300
301fn snake_to_camel(s: &str) -> String {
302    let mut result = String::new();
303    let mut capitalize_next = false;
304    for c in s.chars() {
305        if c == '_' {
306            capitalize_next = true;
307        } else if capitalize_next {
308            result.push(c.to_ascii_uppercase());
309            capitalize_next = false;
310        } else {
311            result.push(c);
312        }
313    }
314    result
315}
316
317fn snake_to_screaming(s: &str) -> String {
318    s.to_uppercase()
319}
320
321// ---------------------------------------------------------------------------
322// #[wasm_export] -- reads Rust function signature, no string annotations
323// ---------------------------------------------------------------------------
324
325/// Mark a pure Rust function as a wasm-js-bridge WASM export.
326///
327/// Emits three things:
328/// 1. The original function, unchanged -- works for any Rust consumer.
329/// 2. A `#[wasm_bindgen]` adapter under `#[cfg(feature = "wasm")]` that
330///    deserializes complex params via `serde_wasm_bindgen` and serializes output.
331/// 3. A `WasmFn` const descriptor + helper fns under
332///    `#[cfg(all(feature = "codegen", any(feature = "ts", feature = "flow")))]`
333///    for npm package codegen (used by `bundle!`).
334///
335/// # Example
336///
337/// ```ignore
338/// #[wasm_export]
339/// pub fn parse_selector(selector: &str) -> Result<SelectorAst, MyError> { ... }
340/// ```
341#[proc_macro_attribute]
342pub fn wasm_export(attr: TokenStream, item: TokenStream) -> TokenStream {
343    if !attr.is_empty() {
344        return syn::Error::new(
345            proc_macro2::Span::call_site(),
346            "#[wasm_export] takes no arguments -- it reads the Rust function signature directly",
347        )
348        .to_compile_error()
349        .into();
350    }
351
352    let func = parse_macro_input!(item as ItemFn);
353
354    if func.sig.asyncness.is_some() {
355        return syn::Error::new_spanned(
356            &func.sig,
357            "#[wasm_export] does not support async functions",
358        )
359        .to_compile_error()
360        .into();
361    }
362    if !func.sig.generics.params.is_empty() {
363        return syn::Error::new_spanned(
364            &func.sig.generics,
365            "#[wasm_export] does not support generic functions",
366        )
367        .to_compile_error()
368        .into();
369    }
370
371    let fn_name = &func.sig.ident;
372    let fn_name_str = fn_name.to_string();
373    let fn_name_bare = fn_name_str.strip_prefix("r#").unwrap_or(&fn_name_str);
374    let js_name = snake_to_camel(fn_name_bare);
375    let wasm_fn_name = format_ident!("__wasm_{}", fn_name);
376    let const_name = format_ident!(
377        "_WASM_JS_BRIDGE_{}",
378        snake_to_screaming(&fn_name.to_string())
379    );
380    let ts_params_fn = format_ident!("__wjb_ts_params_{}", fn_name);
381    let ts_ret_fn = format_ident!("__wjb_ts_ret_{}", fn_name);
382    let flow_params_fn = format_ident!("__wjb_flow_params_{}", fn_name);
383    let flow_ret_fn = format_ident!("__wjb_flow_ret_{}", fn_name);
384
385    // Collect typed parameters -- error on self receivers or destructuring patterns
386    let mut typed_params: Vec<(syn::Ident, Type)> = Vec::new();
387    for arg in &func.sig.inputs {
388        match arg {
389            FnArg::Receiver(r) => {
390                return syn::Error::new_spanned(
391                    r,
392                    "#[wasm_export] does not support `self` receivers",
393                )
394                .to_compile_error()
395                .into();
396            }
397            FnArg::Typed(pt) => match pt.pat.as_ref() {
398                Pat::Ident(pi) => typed_params.push((pi.ident.clone(), *pt.ty.clone())),
399                _ => {
400                    return syn::Error::new_spanned(
401                        &pt.pat,
402                        "#[wasm_export] requires simple identifier patterns; destructuring and `_` are not supported",
403                    )
404                    .to_compile_error()
405                    .into();
406                }
407            },
408        }
409    }
410
411    // Return type
412    let ret_ty: Type = match &func.sig.output {
413        ReturnType::Default => syn::parse_quote!(()),
414        ReturnType::Type(_, ty) => *ty.clone(),
415    };
416
417    // WASM adapter params
418    let (wasm_param_decls, wasm_deser_stmts): (Vec<_>, Vec<_>) = typed_params
419        .iter()
420        .map(|(name, ty)| wasm_param(name, ty))
421        .unzip();
422
423    // Build call expression
424    let param_idents: Vec<&syn::Ident> = typed_params.iter().map(|(n, _)| n).collect();
425    let call_expr = quote!(#fn_name(#(#param_idents),*));
426
427    // WASM adapter return body
428    let wasm_body = wasm_return_body(&ret_ty, call_expr);
429
430    // TS/Flow type expressions
431    let ts_params_expr = params_body(&typed_params, Dialect::Ts);
432    let ts_ret_expr = type_expr(&ret_ty, Dialect::Ts);
433    let flow_params_expr = params_body(&typed_params, Dialect::Flow);
434    let flow_ret_expr = type_expr(&ret_ty, Dialect::Flow);
435
436    // Split attrs: non-doc attrs (e.g. #[cfg(...)], #[allow(...)]) are propagated
437    // to the WASM adapter and WasmFn descriptor so conditional compilation is
438    // preserved. Doc attrs stay only on the original fn (already included via
439    // `all_attrs`).
440    // Propagate cfg/allow/deny/warn but not doc or fn-only attrs like must_use
441    // (which is invalid on consts and helper fns).
442    let non_doc_attrs: Vec<_> = func
443        .attrs
444        .iter()
445        .filter(|a| {
446            let path = a.path();
447            !path.is_ident("doc") && !path.is_ident("must_use")
448        })
449        .collect();
450    let all_attrs = &func.attrs;
451
452    let vis = &func.vis;
453    let sig = &func.sig;
454    let block = &func.block;
455
456    // The WasmFn descriptor is gated on codegen + at least one declaration target.
457    // Each helper fn has a real implementation for its feature and a fallback
458    // implementation otherwise, so descriptor emission works with ts-only or flow-only.
459    let descriptor_cfg = quote!(all(
460        feature = "codegen",
461        any(feature = "ts", feature = "flow")
462    ));
463
464    quote! {
465        // 1. Original function -- unchanged, no WASM overhead for Rust consumers
466        #(#all_attrs)*
467        #vis #sig #block
468
469        // 2. WASM adapter -- only compiled when wasm feature is enabled.
470        //    Non-doc attrs (e.g. #[cfg(...)]) are propagated so the adapter
471        //    inherits the same conditional compilation as the original fn.
472        #(#non_doc_attrs)*
473        #[cfg(feature = "wasm")]
474        #[::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name)]
475        pub fn #wasm_fn_name(
476            #(#wasm_param_decls),*
477        ) -> ::std::result::Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsError> {
478            #(#wasm_deser_stmts)*
479            #wasm_body
480        }
481
482        // 3. WasmFn descriptor -- compiled when codegen+ts+flow features are enabled.
483        //    Non-doc attrs are propagated so cfg-gated fns don't appear in wrong builds.
484        #(#non_doc_attrs)*
485        #[cfg(all(feature = "codegen", feature = "ts"))]
486        fn #ts_params_fn() -> String {
487            let cfg: ::ts_rs::Config = ::std::default::Default::default();
488            #ts_params_expr
489        }
490        #(#non_doc_attrs)*
491        #[cfg(all(feature = "codegen", not(feature = "ts")))]
492        fn #ts_params_fn() -> String {
493            "any".to_string()
494        }
495        #(#non_doc_attrs)*
496        #[cfg(all(feature = "codegen", feature = "ts"))]
497        fn #ts_ret_fn() -> String {
498            let cfg: ::ts_rs::Config = ::std::default::Default::default();
499            #ts_ret_expr
500        }
501        #(#non_doc_attrs)*
502        #[cfg(all(feature = "codegen", not(feature = "ts")))]
503        fn #ts_ret_fn() -> String {
504            "any".to_string()
505        }
506        #(#non_doc_attrs)*
507        #[cfg(all(feature = "codegen", feature = "flow"))]
508        fn #flow_params_fn() -> String {
509            let cfg: ::flowjs_rs::Config = ::std::default::Default::default();
510            #flow_params_expr
511        }
512        #(#non_doc_attrs)*
513        #[cfg(all(feature = "codegen", not(feature = "flow")))]
514        fn #flow_params_fn() -> String {
515            "any".to_string()
516        }
517        #(#non_doc_attrs)*
518        #[cfg(all(feature = "codegen", feature = "flow"))]
519        fn #flow_ret_fn() -> String {
520            let cfg: ::flowjs_rs::Config = ::std::default::Default::default();
521            #flow_ret_expr
522        }
523        #(#non_doc_attrs)*
524        #[cfg(all(feature = "codegen", not(feature = "flow")))]
525        fn #flow_ret_fn() -> String {
526            "any".to_string()
527        }
528        #(#non_doc_attrs)*
529        #[cfg(#descriptor_cfg)]
530        #[doc(hidden)]
531        #[allow(dead_code)]
532        #vis const #const_name: ::wasm_js_bridge::WasmFn = ::wasm_js_bridge::WasmFn {
533            name: #js_name,
534            file: file!(),
535            ts_params: #ts_params_fn,
536            ts_ret: #ts_ret_fn,
537            flow_params: #flow_params_fn,
538            flow_ret: #flow_ret_fn,
539        };
540    }
541    .into()
542}
543
544// ---------------------------------------------------------------------------
545// bundle! -- replaces hand-written ts_codegen test modules
546// ---------------------------------------------------------------------------
547
548struct BundleArgs {
549    types: Vec<syn::Type>,
550    fns: Vec<syn::Ident>,
551    aliases: Vec<(String, String)>,
552    opaque: Vec<(String, Option<String>)>,
553}
554
555impl syn::parse::Parse for BundleArgs {
556    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
557        let mut types = Vec::new();
558        let mut fns = Vec::new();
559        let mut aliases = Vec::new();
560        let mut opaque = Vec::new();
561
562        while !input.is_empty() {
563            let key: syn::Ident = input.parse()?;
564            input.parse::<Token![=]>()?;
565
566            match key.to_string().as_str() {
567                "types" => {
568                    let content;
569                    syn::bracketed!(content in input);
570                    while !content.is_empty() {
571                        types.push(content.parse::<syn::Type>()?);
572                        if !content.is_empty() {
573                            content.parse::<Token![,]>()?;
574                        }
575                    }
576                }
577                "fns" => {
578                    let content;
579                    syn::bracketed!(content in input);
580                    while !content.is_empty() {
581                        fns.push(content.parse::<syn::Ident>()?);
582                        if !content.is_empty() {
583                            content.parse::<Token![,]>()?;
584                        }
585                    }
586                }
587                "aliases" => {
588                    let content;
589                    syn::bracketed!(content in input);
590                    while !content.is_empty() {
591                        let tuple;
592                        syn::parenthesized!(tuple in content);
593                        let name: LitStr = tuple.parse()?;
594                        tuple.parse::<Token![,]>()?;
595                        let target: LitStr = tuple.parse()?;
596                        aliases.push((name.value(), target.value()));
597                        if !content.is_empty() {
598                            content.parse::<Token![,]>()?;
599                        }
600                    }
601                }
602                "opaque" => {
603                    let content;
604                    syn::bracketed!(content in input);
605                    while !content.is_empty() {
606                        let tuple;
607                        syn::parenthesized!(tuple in content);
608                        let name: LitStr = tuple.parse()?;
609                        tuple.parse::<Token![,]>()?;
610                        // Parse `None` or `Some("bound")`
611                        let ident: syn::Ident = tuple.parse()?;
612                        let bound = if ident == "None" {
613                            None
614                        } else if ident == "Some" {
615                            let inner;
616                            syn::parenthesized!(inner in tuple);
617                            let lit: LitStr = inner.parse()?;
618                            Some(lit.value())
619                        } else {
620                            return Err(syn::Error::new(
621                                ident.span(),
622                                "expected `None` or `Some(\"bound\")`",
623                            ));
624                        };
625                        opaque.push((name.value(), bound));
626                        if !content.is_empty() {
627                            content.parse::<Token![,]>()?;
628                        }
629                    }
630                }
631                _ => {
632                    return Err(syn::Error::new(key.span(), format!("unknown key: {key}")));
633                }
634            }
635
636            if !input.is_empty() {
637                input.parse::<Token![,]>()?;
638            }
639        }
640
641        Ok(BundleArgs {
642            types,
643            fns,
644            aliases,
645            opaque,
646        })
647    }
648}
649
650/// Generate `#[test] fn generate_npm_files()` that writes `.d.ts` and/or
651/// `.js.flow` output files depending on enabled features.
652///
653/// Groups functions by source file stem and writes one output file set per stem.
654/// `"src/lib.rs"` -> `"lib"`, `"src/foo_bar.rs"` -> `"fooBar"`, `"src/wasm.rs"` -> `"wasm"`.
655///
656/// # Example
657///
658/// ```ignore
659/// wasm_js_bridge::bundle! {
660///     types  = [PredicateOp, PredicateValue, Predicate, Token],
661///     fns    = [parse_predicate, parse_predicate_list, eval_predicate, tokenize],
662///     aliases = [],
663///     opaque  = [],
664/// }
665/// ```
666/// Inject WASM peer imports for npm-packaged dependencies.
667///
668/// When `WJB_PEER_SHIM` is set (by `wasm-js-bridge build-workspace`), reads
669/// the shim file and emits its contents — a `#[wasm_bindgen(module = "...")]
670/// extern "C" { ... }` block that imports each peer's exported functions.
671///
672/// When `WJB_PEER_SHIM` is not set (direct `cargo build`, tests, native builds),
673/// expands to nothing. Call once near the top of the crate root.
674///
675/// ```rust,ignore
676/// // In lib.rs or wasm.rs:
677/// wasm_js_bridge::wasm_peers!();
678/// ```
679#[proc_macro]
680pub fn wasm_peers(_input: TokenStream) -> TokenStream {
681    let shim_path = match std::env::var("WJB_PEER_SHIM") {
682        Ok(p) => p,
683        Err(_) => return TokenStream::new(),
684    };
685
686    let content = match std::fs::read_to_string(&shim_path) {
687        Ok(s) => s,
688        Err(e) => {
689            return syn::Error::new(
690                proc_macro2::Span::call_site(),
691                format!("Failed to read WJB_PEER_SHIM {shim_path}: {e}"),
692            )
693            .to_compile_error()
694            .into()
695        }
696    };
697
698    match content.parse::<TokenStream2>() {
699        Ok(ts) => ts.into(),
700        Err(e) => syn::Error::new(
701            proc_macro2::Span::call_site(),
702            format!("Invalid peer shim: {e}"),
703        )
704        .to_compile_error()
705        .into(),
706    }
707}
708
709#[proc_macro]
710pub fn bundle(input: TokenStream) -> TokenStream {
711    let args = parse_macro_input!(input as BundleArgs);
712
713    // Unique module name per invocation to avoid collision if bundle! is called
714    // multiple times in the same crate. Uses a hash of the span's source location.
715    let mod_name = {
716        let span = proc_macro2::Span::call_site();
717        let src = format!("{span:?}");
718        let hash: u64 = src
719            .bytes()
720            .fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
721        format_ident!("__wjb_bundle_{:016x}", hash)
722    };
723
724    // types -> TS and Flow decl calls
725    let types = &args.types;
726
727    // fns -> _WASM_JS_BRIDGE_{UPPER} const names
728    let export_consts: Vec<syn::Ident> = args
729        .fns
730        .iter()
731        .map(|f| format_ident!("_WASM_JS_BRIDGE_{}", snake_to_screaming(&f.to_string())))
732        .collect();
733
734    // aliases
735    let alias_items: Vec<TokenStream2> = args
736        .aliases
737        .iter()
738        .map(|(name, target)| quote!(::wasm_js_bridge::TypeAlias { name: #name, target: #target }))
739        .collect();
740
741    // opaque types
742    let opaque_items: Vec<TokenStream2> = args
743        .opaque
744        .iter()
745        .map(|(name, bound)| match bound {
746            Some(b) => quote!(::wasm_js_bridge::OpaqueType { name: #name, bound: Some(#b) }),
747            None => quote!(::wasm_js_bridge::OpaqueType { name: #name, bound: None }),
748        })
749        .collect();
750
751    quote! {
752        #[cfg(all(test, feature = "codegen", any(feature = "ts", feature = "flow")))]
753        #[allow(non_snake_case)]
754        mod #mod_name {
755            use super::*;
756
757            #[test]
758            fn generate_npm_files() {
759                #[cfg(feature = "ts")]
760                use ::ts_rs::TS as _;
761                #[cfg(feature = "flow")]
762                use ::flowjs_rs::Flow as _;
763
764                #[cfg(feature = "ts")]
765                let ts_decls: ::std::vec::Vec<::std::string::String> = {
766                    let ts_cfg: ::ts_rs::Config = ::std::default::Default::default();
767                    ::std::vec![
768                        #(<#types as ::ts_rs::TS>::decl(&ts_cfg)),*
769                    ]
770                };
771
772                #[cfg(feature = "flow")]
773                let flow_decls: ::std::vec::Vec<::std::string::String> = {
774                    let flow_cfg: ::flowjs_rs::Config = ::std::default::Default::default();
775                    ::std::vec![
776                        #(<#types as ::flowjs_rs::Flow>::decl(&flow_cfg)),*
777                    ]
778                };
779
780                let aliases: &[::wasm_js_bridge::TypeAlias] = &[#(#alias_items),*];
781                let opaque: &[::wasm_js_bridge::OpaqueType] = &[#(#opaque_items),*];
782
783                let all_fns: &[::wasm_js_bridge::WasmFn] = &[
784                    #(#export_consts),*
785                ];
786
787                // Output directory: WJB_OUT_DIR env var if set, otherwise CARGO_MANIFEST_DIR.
788                let dir = match ::std::env::var("WJB_OUT_DIR") {
789                    Ok(d) if !d.is_empty() => ::std::path::PathBuf::from(d),
790                    _ => ::std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
791                };
792                ::std::fs::create_dir_all(&dir).expect("Failed to create output directory");
793
794                // Group functions by source file stem. Each stem produces its own output
795                // file set. Every stem receives all type declarations so files are self-contained.
796                let mut by_stem: ::std::collections::BTreeMap<
797                    ::std::string::String,
798                    ::std::vec::Vec<::wasm_js_bridge::WasmFn>,
799                > = ::std::default::Default::default();
800
801                // Stem collision guard: two different source file paths must not produce
802                // the same camelCase stem, or output files would silently overwrite each other.
803                let mut stem_to_file: ::std::collections::BTreeMap<
804                    ::std::string::String,
805                    &'static str,
806                > = ::std::default::Default::default();
807                for f in all_fns {
808                    let s = ::wasm_js_bridge::file_to_stem(f.file);
809                    if let Some(existing_file) = stem_to_file.get(&s) {
810                        if *existing_file != f.file {
811                            panic!(
812                                "wasm-js-bridge bundle!: stem collision — \"{}\" and \"{}\" \
813                                 both produce stem \"{}\". Rename one of the source files.",
814                                existing_file, f.file, s
815                            );
816                        }
817                    } else {
818                        stem_to_file.insert(s.clone(), f.file);
819                    }
820                    by_stem.entry(s).or_default().push(*f);
821                }
822
823                // Fallback: if no fns provided, derive stem from this file
824                if by_stem.is_empty() {
825                    let stem = ::wasm_js_bridge::file_to_stem(file!());
826                    by_stem.insert(stem, ::std::vec::Vec::new());
827                }
828
829                for (stem, fns) in &by_stem {
830                    #[cfg(feature = "ts")]
831                    {
832                        let dts = ::wasm_js_bridge::generate_index_dts(&ts_decls, aliases, &[], fns);
833                        ::std::fs::write(dir.join(format!("{stem}.d.ts")), dts)
834                            .expect("Failed to write .d.ts");
835                    }
836                    #[cfg(feature = "flow")]
837                    {
838                        let flow = ::wasm_js_bridge::generate_index_flow(&flow_decls, aliases, &[], fns, opaque);
839                        ::std::fs::write(dir.join(format!("{stem}.js.flow")), flow)
840                            .expect("Failed to write .js.flow");
841                    }
842                }
843            }
844        }
845    }
846    .into()
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn snake_to_camel_basic() {
855        // Arrange and Act and Assert
856        assert_eq!(snake_to_camel("parse_selector"), "parseSelector", "basic");
857        assert_eq!(snake_to_camel("select"), "select", "no underscore");
858        assert_eq!(
859            snake_to_camel("diff_annotations"),
860            "diffAnnotations",
861            "two words"
862        );
863        assert_eq!(
864            snake_to_camel("extract_aql_symbols"),
865            "extractAqlSymbols",
866            "three words"
867        );
868    }
869
870    #[test]
871    fn snake_to_screaming_basic() {
872        // Arrange and Act and Assert
873        assert_eq!(
874            snake_to_screaming("parse_selector"),
875            "PARSE_SELECTOR",
876            "underscore preserved"
877        );
878        assert_eq!(snake_to_screaming("select"), "SELECT", "single word");
879    }
880
881    #[test]
882    fn detects_nested_reference_types() {
883        // Arrange
884        let ty_option_ref: Type = syn::parse_quote!(Option<&str>);
885        let ty_vec_ref: Type = syn::parse_quote!(Vec<&MyType>);
886        let ty_owned: Type = syn::parse_quote!(Option<String>);
887
888        // Act and Assert
889        assert!(
890            contains_nested_reference(&ty_option_ref),
891            "Option<&T> should be rejected"
892        );
893        assert!(
894            contains_nested_reference(&ty_vec_ref),
895            "Vec<&T> should be rejected"
896        );
897        assert!(
898            !contains_nested_reference(&ty_owned),
899            "owned generic types should be allowed"
900        );
901    }
902}