Skip to main content

bitwarden_ffi_macro/
wasm_export.rs

1use proc_macro2::TokenStream;
2use quote::quote;
3use syn::{Attribute, ImplItem, ItemFn, ItemImpl, Signature, Visibility, parse2, spanned::Spanned};
4
5use crate::attrs;
6
7/// Applies `#[wasm_bindgen]` to an impl block or a free function, forwarding this macro's
8/// arguments to it, and rewrites the methods marked `#[wasm_only]`.
9pub(crate) fn wasm_export(attr: TokenStream, item: TokenStream) -> TokenStream {
10    match parse2::<ItemImpl>(item.clone()) {
11        Ok(impl_block) => wasm_export_impl(attr, impl_block),
12        // An impl block is by far the common case, so its parse error is the one worth reporting
13        // when the item is neither.
14        Err(impl_err) => match parse2::<ItemFn>(item) {
15            Ok(function) => wasm_export_fn(attr, function),
16            Err(_) => impl_err.to_compile_error(),
17        },
18    }
19}
20
21/// `#[wasm_export]` on an impl block: the block takes the attribute, and each method keeps the
22/// `#[wasm_bindgen(..)]` it declares for itself.
23fn wasm_export_impl(attr: TokenStream, mut impl_block: ItemImpl) -> TokenStream {
24    let forwarded = match attrs::parse_args(attr) {
25        Ok(args) => args,
26        Err(err) => return err.to_compile_error(),
27    };
28
29    if let Err(err) = check_exportable(&impl_block.generics, &impl_block.attrs, "impl blocks") {
30        return err.to_compile_error();
31    }
32
33    for item in &mut impl_block.items {
34        let ImplItem::Fn(method) = item else { continue };
35
36        // Applied before the visibility check so the marker is never silently dropped: on a
37        // private method it still renames and deprecates.
38        let renamed = match apply_wasm_only(&mut method.attrs, &mut method.sig) {
39            Ok(renamed) => renamed,
40            Err(err) => return err.to_compile_error(),
41        };
42
43        // wasm_bindgen exports every `pub` method in the block and ignores the rest, so a private
44        // generic helper is allowed to stay.
45        if !matches!(method.vis, Visibility::Public(_)) {
46            continue;
47        }
48
49        if !method.sig.generics.params.is_empty() {
50            return syn::Error::new(
51                method.sig.generics.span(),
52                "#[wasm_export] does not support generic methods; wasm_bindgen cannot export them",
53            )
54            .to_compile_error();
55        }
56
57        if let Some(name) = renamed {
58            // A constructor is named by wasm_bindgen itself, which rejects a `js_name` on one.
59            let named = method
60                .attrs
61                .iter()
62                .filter_map(attrs::wasm_bindgen_args)
63                .flatten()
64                .any(|meta| meta.path().is_ident("js_name") || meta.path().is_ident("constructor"));
65            if !named {
66                method.attrs.push(syn::parse_quote!(
67                    #[cfg_attr(feature = "wasm", wasm_bindgen(js_name = #name))]
68                ));
69            }
70        }
71    }
72
73    let bindgen = attrs::wasm_bindgen_attr(&forwarded);
74    quote! {
75        #bindgen
76        #impl_block
77    }
78}
79
80/// `#[wasm_export]` on a free function, which carries a single `#[wasm_bindgen(..)]`.
81fn wasm_export_fn(attr: TokenStream, mut function: ItemFn) -> TokenStream {
82    let mut forwarded = match attrs::parse_args(attr) {
83        Ok(args) => args,
84        Err(err) => return err.to_compile_error(),
85    };
86
87    if let Err(err) = check_exportable(&function.sig.generics, &function.attrs, "functions") {
88        return err.to_compile_error();
89    }
90
91    let renamed = match apply_wasm_only(&mut function.attrs, &mut function.sig) {
92        Ok(renamed) => renamed,
93        Err(err) => return err.to_compile_error(),
94    };
95
96    // There is no second attribute to put the JS name on, so it joins this macro's arguments.
97    if let Some(name) = renamed {
98        let named = forwarded.iter().any(|meta| meta.path().is_ident("js_name"));
99        if !named {
100            forwarded.push(syn::parse_quote!(js_name = #name));
101        }
102    }
103
104    let bindgen = attrs::wasm_bindgen_attr(&forwarded);
105    quote! {
106        #bindgen
107        #function
108    }
109}
110
111/// Rejects a generic item, which wasm_bindgen cannot export, and a leftover `#[wasm_bindgen]`.
112///
113/// `kind` names the item in the error, plural, as in "generic impl blocks".
114fn check_exportable(
115    generics: &syn::Generics,
116    item_attrs: &[Attribute],
117    kind: &str,
118) -> syn::Result<()> {
119    if !generics.params.is_empty() {
120        return Err(syn::Error::new(
121            generics.span(),
122            format!(
123                "#[wasm_export] does not support generic {kind}; wasm_bindgen cannot export them"
124            ),
125        ));
126    }
127
128    if let Some(attr) = attrs::find_wasm_bindgen(item_attrs) {
129        return Err(syn::Error::new_spanned(
130            attr,
131            "#[wasm_export] replaces #[wasm_bindgen]; pass its arguments to #[wasm_export] \
132             instead, as in #[wasm_export(js_class = Foo)]",
133        ));
134    }
135
136    Ok(())
137}
138
139/// Applies `#[wasm_only]`, if present, returning the name the item had.
140///
141/// The marker means JavaScript is the only intended caller. Rename the function so Rust callers do
142/// not reach for it, hide it from documentation, and deprecate it so any that do get a warning and
143/// see it struck through in autocomplete. The JS name is unaffected, and the caller declares it.
144fn apply_wasm_only(attrs: &mut Vec<Attribute>, sig: &mut Signature) -> syn::Result<Option<String>> {
145    let idx = attrs.iter().position(|a| a.path().is_ident("wasm_only"));
146    let Some(idx) = idx else { return Ok(None) };
147    let note = wasm_only_note(&attrs.remove(idx))?;
148
149    let original = sig.ident.to_string();
150    attrs.push(syn::parse_quote!(#[doc(hidden)]));
151    attrs.push(syn::parse_quote!(#[deprecated(note = #note)]));
152    attrs.push(syn::parse_quote!(#[allow(deprecated)]));
153    sig.ident = syn::Ident::new(&format!("__wasm_only_{original}"), sig.ident.span());
154
155    Ok(Some(original))
156}
157
158/// Reads the deprecation note from `#[wasm_only]` / `#[wasm_only(note = "...")]`.
159fn wasm_only_note(attr: &Attribute) -> syn::Result<String> {
160    const DEFAULT: &str = "This is a WASM-only binding. Calling it from Rust is not allowed.";
161    if attr.meta.require_path_only().is_ok() {
162        return Ok(DEFAULT.to_owned());
163    }
164
165    let mut note = None;
166    attr.parse_nested_meta(|meta| {
167        if meta.path.is_ident("note") {
168            note = Some(meta.value()?.parse::<syn::LitStr>()?.value());
169            Ok(())
170        } else {
171            Err(meta.error("unknown attribute, expected `note`"))
172        }
173    })?;
174    Ok(note.unwrap_or_else(|| DEFAULT.to_owned()))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    /// Expansion with the whitespace `TokenStream::to_string` inserts between tokens removed, so
182    /// assertions can be written the way the code is.
183    fn expand(attr: TokenStream, item: TokenStream) -> String {
184        wasm_export(attr, item).to_string().replace(' ', "")
185    }
186
187    #[test]
188    fn applies_wasm_bindgen_to_the_block_under_the_wasm_feature() {
189        let out = expand(
190            TokenStream::new(),
191            quote! {
192                impl Canvas {
193                    pub fn translate(&self, point: Point) -> Point { point }
194                }
195            },
196        );
197
198        assert!(!out.contains("compile_error!"), "{out}");
199        assert!(
200            out.contains("#[cfg_attr(feature=\"wasm\",::bitwarden_ffi::_macro::wasm_bindgen)]"),
201            "{out}"
202        );
203        // The method is left exactly as written.
204        assert!(
205            out.contains("pubfntranslate(&self,point:Point)->Point{point}"),
206            "{out}"
207        );
208    }
209
210    #[test]
211    fn forwards_its_arguments_to_wasm_bindgen() {
212        let out = expand(
213            quote!(js_class = IpcClient),
214            quote! {
215                impl JsIpcClient {
216                    pub fn is_running(&self) -> bool { true }
217                }
218            },
219        );
220
221        assert!(
222            out.contains("::bitwarden_ffi::_macro::wasm_bindgen(js_class=IpcClient)"),
223            "{out}"
224        );
225    }
226
227    #[test]
228    fn renames_a_wasm_only_method_and_keeps_its_js_name() {
229        let out = expand(
230            TokenStream::new(),
231            quote! {
232                impl JsIpcClient {
233                    #[wasm_only]
234                    pub fn is_running(&self) -> bool { true }
235                }
236            },
237        );
238
239        assert!(out.contains("fn__wasm_only_is_running"), "{out}");
240        assert!(
241            out.contains("#[cfg_attr(feature=\"wasm\",wasm_bindgen(js_name=\"is_running\"))]"),
242            "{out}"
243        );
244        assert!(out.contains("#[doc(hidden)]"), "{out}");
245        assert!(
246            out.contains(
247                "#[deprecated(note=\"ThisisaWASM-onlybinding.CallingitfromRustisnotallowed.\")]"
248            ),
249            "{out}"
250        );
251        assert!(out.contains("#[allow(deprecated)]"), "{out}");
252    }
253
254    #[test]
255    fn carries_a_custom_note() {
256        let out = expand(
257            TokenStream::new(),
258            quote! {
259                impl JsIpcClient {
260                    #[wasm_only(note = "Use `IpcClient::start`.")]
261                    pub fn start(&self) {}
262                }
263            },
264        );
265
266        assert!(
267            out.contains("#[deprecated(note=\"Use`IpcClient::start`.\")]"),
268            "{out}"
269        );
270    }
271
272    #[test]
273    fn keeps_a_js_name_the_method_already_declares() {
274        let out = expand(
275            TokenStream::new(),
276            quote! {
277                impl JsIpcClient {
278                    #[wasm_only]
279                    #[wasm_bindgen(js_name = isRunning)]
280                    pub fn is_running(&self) -> bool { true }
281                }
282            },
283        );
284
285        assert!(out.contains("#[wasm_bindgen(js_name=isRunning)]"), "{out}");
286        assert!(!out.contains("js_name=\"is_running\""), "{out}");
287    }
288
289    #[test]
290    fn keeps_a_js_name_the_method_declares_under_a_cfg_attr() {
291        let out = expand(
292            TokenStream::new(),
293            quote! {
294                impl PolicyClient {
295                    #[wasm_only]
296                    #[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "get_enforced"))]
297                    pub fn get_enforced(&self) {}
298                }
299            },
300        );
301
302        assert_eq!(out.matches("js_name=").count(), 1, "{out}");
303        assert!(out.contains("js_name=\"get_enforced\""), "{out}");
304    }
305
306    #[test]
307    fn leaves_a_wasm_only_constructor_for_wasm_bindgen_to_name() {
308        let out = expand(
309            TokenStream::new(),
310            quote! {
311                impl JsIpcClient {
312                    #[wasm_only]
313                    #[wasm_bindgen(constructor)]
314                    pub fn new() -> JsIpcClient { todo!() }
315                }
316            },
317        );
318
319        assert!(!out.contains("js_name"), "{out}");
320        assert!(out.contains("fn__wasm_only_new"), "{out}");
321    }
322
323    #[test]
324    fn renames_a_private_wasm_only_method_without_naming_it_for_javascript() {
325        let out = expand(
326            TokenStream::new(),
327            quote! {
328                impl JsIpcClient {
329                    #[wasm_only]
330                    fn internal(&self) {}
331                }
332            },
333        );
334
335        assert!(out.contains("fn__wasm_only_internal"), "{out}");
336        assert!(!out.contains("js_name"), "{out}");
337    }
338
339    #[test]
340    fn leaves_a_private_generic_method_alone() {
341        // wasm_bindgen only exports `pub` methods, so a private helper may stay generic.
342        let out = expand(
343            TokenStream::new(),
344            quote! {
345                impl Canvas {
346                    fn draw<T: Shape>(&self, shape: T) {}
347                }
348            },
349        );
350
351        assert!(!out.contains("compile_error!"), "{out}");
352    }
353
354    #[test]
355    fn rejects_a_generic_impl_block() {
356        let out = expand(
357            TokenStream::new(),
358            quote! {
359                impl<T> Canvas<T> {
360                    pub fn translate(&self, point: Point) -> Point { point }
361                }
362            },
363        );
364
365        assert!(out.contains("compile_error!"), "{out}");
366        assert!(out.contains("doesnotsupportgenericimplblocks"), "{out}");
367    }
368
369    #[test]
370    fn rejects_a_generic_method() {
371        let out = expand(
372            TokenStream::new(),
373            quote! {
374                impl Canvas {
375                    pub fn draw<T: Shape>(&self, shape: T) {}
376                }
377            },
378        );
379
380        assert!(out.contains("compile_error!"), "{out}");
381        assert!(out.contains("doesnotsupportgenericmethods"), "{out}");
382    }
383
384    #[test]
385    fn rejects_a_block_that_still_carries_wasm_bindgen() {
386        // The macro stands in for the attribute rather than decorating it, so leaving one behind
387        // would export every method twice.
388        let out = expand(
389            TokenStream::new(),
390            quote! {
391                #[wasm_bindgen(js_class = IpcClient)]
392                impl JsIpcClient {
393                    pub fn is_running(&self) -> bool { true }
394                }
395            },
396        );
397
398        assert!(out.contains("compile_error!"), "{out}");
399        assert!(out.contains("replaces#[wasm_bindgen]"), "{out}");
400    }
401
402    #[test]
403    fn exports_a_free_function() {
404        let out = expand(
405            quote!(js_name = doThing),
406            quote! {
407                pub fn do_thing(point: Point) -> Point { point }
408            },
409        );
410
411        assert!(!out.contains("compile_error!"), "{out}");
412        assert!(
413            out.contains("::bitwarden_ffi::_macro::wasm_bindgen(js_name=doThing)"),
414            "{out}"
415        );
416        assert!(out.contains("pubfndo_thing"), "{out}");
417    }
418
419    #[test]
420    fn names_a_wasm_only_free_function_in_its_own_arguments() {
421        let out = expand(
422            TokenStream::new(),
423            quote! {
424                #[wasm_only]
425                pub fn do_thing() {}
426            },
427        );
428
429        assert!(
430            out.contains("::bitwarden_ffi::_macro::wasm_bindgen(js_name=\"do_thing\")"),
431            "{out}"
432        );
433        assert!(out.contains("pubfn__wasm_only_do_thing"), "{out}");
434    }
435
436    #[test]
437    fn rejects_a_generic_free_function() {
438        let out = expand(
439            TokenStream::new(),
440            quote! {
441                pub fn do_thing<T>(value: T) {}
442            },
443        );
444
445        assert!(out.contains("compile_error!"), "{out}");
446        assert!(out.contains("doesnotsupportgenericfunctions"), "{out}");
447    }
448
449    #[test]
450    fn rejects_an_item_that_is_neither() {
451        let out = expand(TokenStream::new(), quote! { pub struct Canvas; });
452
453        assert!(out.contains("compile_error!"), "{out}");
454    }
455}