Skip to main content

wasvy_macros/
lib.rs

1//! Procedural macros for Wasvy component authoring and bindings.
2
3use proc_macro::TokenStream;
4use proc_macro_crate::{FoundCrate, crate_name};
5use quote::{format_ident, quote};
6use std::path::{Path, PathBuf};
7use syn::{Attribute, DeriveInput, Ident, ImplItem, Item, ItemImpl, ItemStruct, Type, TypePath};
8use wit_parser::{FunctionKind, Resolve, TypeDefKind, WorldItem};
9
10/// Attribute used to skip exporting a method in a `#[wasvy::methods]` impl.
11#[proc_macro_attribute]
12pub fn skip(_attr: TokenStream, item: TokenStream) -> TokenStream {
13    item
14}
15
16/// Derive macro to mark a type as a Wasvy-exported component.
17///
18/// This is required even for components without methods so they can be exported
19/// to mods and appear in generated WIT.
20#[proc_macro_derive(WasvyComponent)]
21pub fn derive_wasvy_component(input: TokenStream) -> TokenStream {
22    let input = syn::parse_macro_input!(input as DeriveInput);
23    let wasvy_path = wasvy_path();
24    let ident = &input.ident;
25    let register_ident = format_ident!("__wasvy_register_component_{}", ident);
26
27    let expanded = quote! {
28        impl #wasvy_path::authoring::WasvyComponent for #ident {}
29
30        #[allow(non_snake_case)]
31        fn #register_ident(app: &mut #wasvy_path::authoring::App) {
32            <#ident as #wasvy_path::authoring::WasvyComponent>::register(app);
33        }
34
35        #wasvy_path::__wasvy_submit_component_registration!(
36            #wasvy_path::authoring::WasvyComponentRegistration { register: #register_ident }
37        );
38    };
39
40    expanded.into()
41}
42
43/// Generate host-side bindings for the WIT components interface.
44///
45/// This expands to `wasmtime::component::bindgen!`, implements host traits
46/// for `WasmHost`, and exposes an `add_components_to_linker` helper.
47///
48/// # Example
49/// ```ignore
50/// wasvy::auto_host_components! {
51///     path = "wit",
52///     world = "host",
53///     module = components_bindings,
54/// }
55/// ```
56#[proc_macro]
57pub fn auto_host_components(input: TokenStream) -> TokenStream {
58    let args = syn::parse_macro_input!(input as AutoHostArgs);
59    match expand_auto_host_components(args) {
60        Ok(tokens) => tokens.into(),
61        Err(err) => err.to_compile_error().into(),
62    }
63}
64
65/// Generate `type_path` helpers for guest bindings.
66///
67/// This reads `wasvy:type-path=` doc tags from resources and adds
68/// `TYPE_PATH`, `type_path()`, and `type_path_str()` helpers.
69///
70/// # Example
71/// ```ignore
72/// wasvy::guest_type_paths! {
73///     path = "wit",
74///     package = "game:components",
75///     interface = "components",
76///     module = crate::bindings,
77/// }
78/// ```
79#[proc_macro]
80pub fn guest_type_paths(input: TokenStream) -> TokenStream {
81    let args = syn::parse_macro_input!(input as GuestTypePathsArgs);
82    match expand_guest_type_paths(args) {
83        Ok(tokens) => tokens.into(),
84        Err(err) => err.to_compile_error().into(),
85    }
86}
87
88/// Wrapper around `wit_bindgen::generate!` that also adds type-path helpers.
89///
90/// This is intended for mods so they only need to call this macro.
91///
92/// # Example
93/// ```ignore
94/// wasvy::guest_bindings!({
95///     path: "wit",
96///     world: "guest",
97/// });
98/// ```
99#[proc_macro]
100pub fn guest_bindings(input: TokenStream) -> TokenStream {
101    let input_tokens = proc_macro2::TokenStream::from(input.clone());
102    let args = syn::parse_macro_input!(input as GuestBindingsArgs);
103    match expand_guest_bindings(args, input_tokens) {
104        Ok(tokens) => tokens.into(),
105        Err(err) => err.to_compile_error().into(),
106    }
107}
108
109/// Include all Rust modules under a path that contain Wasvy macros.
110///
111/// This is primarily used in `build.rs` to ensure `inventory` sees all
112/// components/methods when generating WIT.
113///
114/// # Example
115/// ```ignore
116/// fn main() {
117///     wasvy::include_wasvy_components!("src");
118/// }
119/// ```
120#[proc_macro]
121pub fn include_wasvy_components(input: TokenStream) -> TokenStream {
122    let args = syn::parse_macro_input!(input as IncludeComponentsArgs);
123    match expand_include_components(args) {
124        Ok(tokens) => tokens.into(),
125        Err(err) => err.to_compile_error().into(),
126    }
127}
128
129/// Mark a type as a Wasvy component and register it for WIT generation.
130///
131/// This implements `WasvyComponent` and submits metadata to `inventory`.
132///
133/// # Example
134/// ```ignore
135/// #[wasvy::component]
136/// #[derive(Reflect)]
137/// pub struct Health {
138///     pub current: f32,
139///     pub max: f32,
140/// }
141/// ```
142#[deprecated(note = "Use #[derive(WasvyComponent)] instead")]
143#[proc_macro_attribute]
144pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream {
145    let input = syn::parse_macro_input!(item as Item);
146    let wasvy_path = wasvy_path();
147
148    let expanded = match input {
149        Item::Struct(item) => expand_component_struct(item, &wasvy_path),
150        Item::Enum(item) => {
151            let ident = &item.ident;
152            let register_ident = format_ident!("__wasvy_register_component_{}", ident);
153            quote! {
154                #item
155
156                impl #wasvy_path::authoring::WasvyComponent for #ident {}
157
158                #[allow(non_snake_case)]
159                fn #register_ident(app: &mut #wasvy_path::authoring::App) {
160                    <#ident as #wasvy_path::authoring::WasvyComponent>::register(app);
161                }
162
163                #wasvy_path::__wasvy_submit_component_registration!(
164                    #wasvy_path::authoring::WasvyComponentRegistration { register: #register_ident }
165                );
166            }
167        }
168        other => {
169            return syn::Error::new_spanned(
170                other,
171                "#[wasvy::component] can only be applied to structs or enums",
172            )
173            .to_compile_error()
174            .into();
175        }
176    };
177
178    expanded.into()
179}
180
181/// Export methods from an `impl` block for Wasvy.
182///
183/// All `&self` / `&mut self` methods are registered for dynamic invoke, and
184/// argument names are captured for WIT generation. Use `#[wasvy::skip]` to
185/// exclude a method from export.
186///
187/// # Example
188/// ```ignore
189/// #[wasvy::methods]
190/// impl Health {
191///     pub fn heal(&mut self, amount: f32) {
192///         self.current = (self.current + amount).min(self.max);
193///     }
194/// }
195/// ```
196#[proc_macro_attribute]
197pub fn methods(_attr: TokenStream, item: TokenStream) -> TokenStream {
198    let input = syn::parse_macro_input!(item as ItemImpl);
199    let wasvy_path = wasvy_path();
200
201    let Some(self_ty) = extract_type_path(&input.self_ty) else {
202        return syn::Error::new_spanned(
203            input.self_ty,
204            "#[wasvy::methods] requires a concrete type (no generics)",
205        )
206        .to_compile_error()
207        .into();
208    };
209
210    let type_ident = self_ty
211        .path
212        .segments
213        .last()
214        .map(|seg| seg.ident.clone())
215        .unwrap();
216    let mut method_idents = Vec::new();
217    let mut items = Vec::new();
218    let mut metadata_submits = Vec::new();
219    let mut errors: Option<syn::Error> = None;
220
221    for item in input.items.into_iter() {
222        match item {
223            ImplItem::Fn(mut func) => {
224                let skip = has_wasvy_skip_attr(&func.attrs);
225                func.attrs.retain(|attr| !is_wasvy_skip_attr(attr));
226
227                if skip {
228                    items.push(ImplItem::Fn(func));
229                    continue;
230                }
231
232                match func.sig.receiver() {
233                    Some(receiver) => {
234                        if receiver.reference.is_none() {
235                            let err = syn::Error::new_spanned(
236                                &func.sig,
237                                "#[wasvy::methods] only supports &self or &mut self receivers; add #[wasvy::skip] or move this method to another impl",
238                            );
239                            if let Some(errors) = errors.as_mut() {
240                                errors.combine(err);
241                            } else {
242                                errors = Some(err);
243                            }
244                        }
245                    }
246                    None => {
247                        let err = syn::Error::new_spanned(
248                            &func.sig,
249                            "#[wasvy::methods] requires a self receiver; add #[wasvy::skip] or move this method to another impl",
250                        );
251                        if let Some(errors) = errors.as_mut() {
252                            errors.combine(err);
253                        } else {
254                            errors = Some(err);
255                        }
256                    }
257                }
258
259                if !func.sig.generics.params.is_empty() {
260                    let err = syn::Error::new_spanned(
261                        &func.sig.generics,
262                        "#[wasvy::methods] does not support generic methods; add #[wasvy::skip] or move this method to another impl",
263                    );
264                    if let Some(errors) = errors.as_mut() {
265                        errors.combine(err);
266                    } else {
267                        errors = Some(err);
268                    }
269                }
270
271                let method_ident = func.sig.ident.clone();
272                let type_path_expr = quote!(concat!(module_path!(), "::", stringify!(#type_ident)));
273                let method_lit =
274                    syn::LitStr::new(&method_ident.to_string(), proc_macro2::Span::call_site());
275                let mut arg_names = Vec::new();
276                for (idx, arg) in func.sig.inputs.iter().skip(1).enumerate() {
277                    if let syn::FnArg::Typed(pat) = arg {
278                        match pat.pat.as_ref() {
279                            syn::Pat::Ident(ident) => {
280                                arg_names.push(ident.ident.to_string());
281                            }
282                            _ => {
283                                let err = syn::Error::new_spanned(
284                                    pat.pat.as_ref(),
285                                    format!(
286                                        "#[wasvy::methods] only supports identifier arguments; add #[wasvy::skip] or rename parameter {} to an identifier",
287                                        idx
288                                    ),
289                                );
290                                if let Some(errors) = errors.as_mut() {
291                                    errors.combine(err);
292                                } else {
293                                    errors = Some(err);
294                                }
295                            }
296                        }
297                    }
298                }
299                let arg_name_lits: Vec<syn::LitStr> = arg_names
300                    .iter()
301                    .map(|name| syn::LitStr::new(name, proc_macro2::Span::call_site()))
302                    .collect();
303                let metadata_ident = format_ident!("__wasvy_args_{}_{}", type_ident, method_ident);
304
305                metadata_submits.push(quote! {
306                    #[allow(non_upper_case_globals)]
307                    const #metadata_ident: &[&str] = &[#(#arg_name_lits),*];
308                    #wasvy_path::__wasvy_submit_method_metadata!(
309                        #wasvy_path::authoring::WasvyMethodMetadata {
310                            type_path: #type_path_expr,
311                            method: #method_lit,
312                            arg_names: #metadata_ident,
313                        }
314                    );
315                });
316
317                method_idents.push(method_ident);
318                items.push(ImplItem::Fn(func));
319            }
320            other => items.push(other),
321        }
322    }
323
324    let impl_block = ItemImpl { items, ..input };
325
326    let register_ident = format_ident!("__wasvy_register_methods_{}", type_ident);
327
328    let expanded = quote! {
329        #impl_block
330
331        impl #wasvy_path::authoring::WasvyMethods for #type_ident {
332            fn register_methods(app: &mut #wasvy_path::authoring::App) {
333                #(
334                    app.register_function(#type_ident::#method_idents);
335                )*
336            }
337        }
338
339        #[allow(non_snake_case)]
340        fn #register_ident(app: &mut #wasvy_path::authoring::App) {
341            <#type_ident as #wasvy_path::authoring::WasvyMethods>::register_methods(app);
342        }
343
344        #(#metadata_submits)*
345
346        #wasvy_path::__wasvy_submit_methods_registration!(
347            #wasvy_path::authoring::WasvyMethodsRegistration { register: #register_ident }
348        );
349    };
350
351    if let Some(err) = errors {
352        err.to_compile_error().into()
353    } else {
354        expanded.into()
355    }
356}
357
358fn expand_component_struct(
359    item: ItemStruct,
360    wasvy_path: &proc_macro2::TokenStream,
361) -> proc_macro2::TokenStream {
362    let ident = &item.ident;
363    let register_ident = format_ident!("__wasvy_register_component_{}", ident);
364    quote! {
365        #item
366
367        impl #wasvy_path::authoring::WasvyComponent for #ident {}
368
369        #[allow(non_snake_case)]
370        fn #register_ident(app: &mut #wasvy_path::authoring::App) {
371            <#ident as #wasvy_path::authoring::WasvyComponent>::register(app);
372        }
373
374        #wasvy_path::__wasvy_submit_component_registration!(
375            #wasvy_path::authoring::WasvyComponentRegistration { register: #register_ident }
376        );
377    }
378}
379
380fn has_wasvy_skip_attr(attrs: &[Attribute]) -> bool {
381    attrs.iter().any(is_wasvy_skip_attr)
382}
383
384fn is_wasvy_skip_attr(attr: &Attribute) -> bool {
385    attr.path()
386        .segments
387        .last()
388        .is_some_and(|seg| seg.ident == "skip")
389}
390
391fn extract_type_path(ty: &Type) -> Option<&TypePath> {
392    match ty {
393        Type::Path(path) if path.qself.is_none() => Some(path),
394        _ => None,
395    }
396}
397
398fn wasvy_path() -> proc_macro2::TokenStream {
399    let name = match crate_name("wasvy") {
400        Ok(FoundCrate::Name(name)) => name,
401        Ok(FoundCrate::Itself) | Err(_) => "wasvy".to_string(),
402    };
403    let ident = Ident::new(&name, proc_macro2::Span::call_site());
404    quote!(::#ident)
405}
406
407struct AutoHostArgs {
408    path: syn::LitStr,
409    world: syn::LitStr,
410    module: Ident,
411}
412
413struct GuestTypePathsArgs {
414    path: syn::LitStr,
415    package: syn::LitStr,
416    interface: syn::LitStr,
417    module: syn::Path,
418}
419
420struct GuestBindingsArgs {
421    paths: Vec<syn::LitStr>,
422}
423
424struct IncludeComponentsArgs {
425    path: syn::LitStr,
426}
427
428impl syn::parse::Parse for AutoHostArgs {
429    fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
430        let mut path = None;
431        let mut world = None;
432        let mut module = None;
433
434        while !input.is_empty() {
435            let key: Ident = input.parse()?;
436            let _: syn::Token![=] = input.parse()?;
437            match key.to_string().as_str() {
438                "path" => path = Some(input.parse()?),
439                "world" => world = Some(input.parse()?),
440                "module" => module = Some(input.parse()?),
441                other => {
442                    return Err(syn::Error::new(
443                        key.span(),
444                        format!("unknown key `{other}`"),
445                    ));
446                }
447            }
448
449            if input.peek(syn::Token![,]) {
450                let _: syn::Token![,] = input.parse()?;
451            }
452        }
453
454        Ok(Self {
455            path: path.ok_or_else(|| input.error("missing `path`"))?,
456            world: world.ok_or_else(|| input.error("missing `world`"))?,
457            module: module.unwrap_or_else(|| {
458                Ident::new("components_bindings", proc_macro2::Span::call_site())
459            }),
460        })
461    }
462}
463
464impl syn::parse::Parse for GuestTypePathsArgs {
465    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
466        let mut path = None;
467        let mut package = None;
468        let mut interface = None;
469        let mut module = None;
470
471        while !input.is_empty() {
472            let key: Ident = input.parse()?;
473            let _: syn::Token![=] = input.parse()?;
474            match key.to_string().as_str() {
475                "path" => path = Some(input.parse()?),
476                "package" => package = Some(input.parse()?),
477                "interface" => interface = Some(input.parse()?),
478                "module" => module = Some(input.parse()?),
479                _ => return Err(input.error("unsupported key")),
480            }
481
482            if input.peek(syn::Token![,]) {
483                let _: syn::Token![,] = input.parse()?;
484            }
485        }
486
487        Ok(Self {
488            path: path.ok_or_else(|| input.error("missing `path`"))?,
489            package: package.ok_or_else(|| input.error("missing `package`"))?,
490            interface: interface.ok_or_else(|| input.error("missing `interface`"))?,
491            module: module.ok_or_else(|| input.error("missing `module`"))?,
492        })
493    }
494}
495
496impl syn::parse::Parse for GuestBindingsArgs {
497    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
498        let tokens: proc_macro2::TokenStream = input.parse()?;
499        let paths = extract_paths_from_stream(tokens)?;
500        Ok(Self { paths })
501    }
502}
503
504impl syn::parse::Parse for IncludeComponentsArgs {
505    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
506        let lit: syn::LitStr = input.parse()?;
507        Ok(Self { path: lit })
508    }
509}
510
511fn expand_auto_host_components(args: AutoHostArgs) -> syn::Result<proc_macro2::TokenStream> {
512    let wasvy_path = wasvy_path();
513    let path_value = resolve_wit_path_with_fallbacks(&args.path);
514    let world_value = args.world.value();
515
516    let mut resolve = Resolve::default();
517    let (pkg_id, _sources) = resolve
518        .push_path(&path_value)
519        .map_err(|err| syn::Error::new(args.path.span(), err.to_string()))?;
520    let world_id = resolve
521        .select_world(&[pkg_id], Some(&world_value))
522        .map_err(|err| syn::Error::new(args.world.span(), err.to_string()))?;
523    let world = &resolve.worlds[world_id];
524
525    let interface_id = world
526        .imports
527        .iter()
528        .find_map(|(name, item)| match (name, item) {
529            (wit_parser::WorldKey::Name(name), WorldItem::Interface { id, .. })
530                if name == "components" =>
531            {
532                Some(*id)
533            }
534            (wit_parser::WorldKey::Interface(id), WorldItem::Interface { .. }) => {
535                let iface = &resolve.interfaces[*id];
536                if iface.name.as_deref() == Some("components") {
537                    Some(*id)
538                } else {
539                    None
540                }
541            }
542            _ => None,
543        })
544        .ok_or_else(|| {
545            syn::Error::new(args.world.span(), "missing `components` interface import")
546        })?;
547    let interface = &resolve.interfaces[interface_id];
548    let package_id = interface
549        .package
550        .ok_or_else(|| syn::Error::new(args.world.span(), "interface has no package"))?;
551    let package = &resolve.packages[package_id];
552
553    let pkg_namespace = rust_ident(&package.name.namespace.to_string());
554    let pkg_name = rust_ident(&package.name.name.to_string());
555    let interface_name = rust_ident(interface.name.as_deref().unwrap_or("components"));
556
557    let module_ident = args.module;
558
559    let mut with_entries = Vec::new();
560    for (name, type_id) in interface.types.iter() {
561        let type_def = &resolve.types[*type_id];
562        if !matches!(type_def.kind, TypeDefKind::Resource) {
563            continue;
564        }
565        let path = format!(
566            "{}:{}/{}.{}",
567            package.name.namespace,
568            package.name.name,
569            interface.name.as_deref().unwrap_or("components"),
570            name
571        );
572        let lit = syn::LitStr::new(&path, proc_macro2::Span::call_site());
573        with_entries.push(quote!(#lit: #wasvy_path::host::WasmComponent));
574    }
575
576    let wasvy_component =
577        syn::LitStr::new("wasvy:ecs/app.component", proc_macro2::Span::call_site());
578    with_entries.push(quote!(#wasvy_component: #wasvy_path::host::WasmComponent));
579
580    let mut impls = Vec::new();
581
582    for (name, type_id) in interface.types.iter() {
583        let type_def = &resolve.types[*type_id];
584        if !matches!(type_def.kind, TypeDefKind::Resource) {
585            continue;
586        }
587        let trait_ident = format_ident!("Host{}", upper_camel(name));
588
589        let mut methods = Vec::new();
590        for function in interface.functions.values() {
591            match function.kind {
592                FunctionKind::Constructor(id) if id == *type_id => {
593                    let params = render_params(&resolve, &function.params, &wasvy_path, true);
594                    let ret_tokens =
595                        quote!(::wasmtime::component::Resource<#wasvy_path::host::WasmComponent>);
596                    let body = quote!(component);
597                    methods.push(quote! {
598                        fn new(&mut self, #params) -> #ret_tokens {
599                            #body
600                        }
601                    });
602                }
603                FunctionKind::Method(id) if id == *type_id => {
604                    let method_name = method_name(&function.name);
605                    let method_ident = rust_ident(&method_name);
606                    let params = render_params(&resolve, &function.params, &wasvy_path, false);
607                    let ret = render_return(&resolve, function.result.as_ref(), &wasvy_path);
608                    let invoke = render_invoke_body(
609                        &method_name,
610                        &function.params,
611                        function.result.as_ref(),
612                        &wasvy_path,
613                    );
614                    methods.push(quote! {
615                        fn #method_ident(&mut self, #params) #ret {
616                            #invoke
617                        }
618                    });
619                }
620                _ => {}
621            }
622        }
623
624        methods.push(quote! {
625            fn drop(&mut self, component: ::wasmtime::component::Resource<#wasvy_path::host::WasmComponent>) -> Result<(), ::wasmtime::Error> {
626                let _ = component;
627                Ok(())
628            }
629        });
630
631        let trait_path =
632            quote!(#module_ident::#pkg_namespace::#pkg_name::#interface_name::#trait_ident);
633        impls.push(quote! {
634            impl #trait_path for #wasvy_path::host::WasmHost {
635                #(#methods)*
636            }
637        });
638    }
639
640    let trait_host_path = quote!(#module_ident::#pkg_namespace::#pkg_name::#interface_name::Host);
641    let add_to_linker_path =
642        quote!(#module_ident::#pkg_namespace::#pkg_name::#interface_name::add_to_linker);
643
644    let expanded = quote! {
645        mod #module_ident {
646            ::wasmtime::component::bindgen!({
647                path: #path_value,
648                world: #world_value,
649                with: { #(#with_entries),* },
650            });
651        }
652
653        pub fn add_components_to_linker(linker: &mut #wasvy_path::engine::Linker) {
654            type Data = ::wasmtime::component::HasSelf<#wasvy_path::host::WasmHost>;
655            #add_to_linker_path::<_, Data>(linker, |state| state)
656                .expect("implement components interface");
657        }
658
659        impl #trait_host_path for #wasvy_path::host::WasmHost {}
660
661        #(#impls)*
662    };
663
664    Ok(expanded)
665}
666
667fn expand_guest_type_paths(args: GuestTypePathsArgs) -> syn::Result<proc_macro2::TokenStream> {
668    let path_value = resolve_wit_path(&args.path);
669    let package_value = args.package.value();
670    let interface_value = args.interface.value();
671    let module = args.module;
672
673    let (namespace, name) = package_value
674        .split_once(':')
675        .ok_or_else(|| syn::Error::new(args.package.span(), "package must be `namespace:name`"))?;
676
677    let mut resolve = Resolve::default();
678    resolve
679        .push_path(&path_value)
680        .map_err(|err| syn::Error::new(args.path.span(), err.to_string()))?;
681
682    let package_id = resolve
683        .packages
684        .iter()
685        .find_map(|(id, pkg)| {
686            if pkg.name.namespace == namespace && pkg.name.name == name {
687                Some(id)
688            } else {
689                None
690            }
691        })
692        .ok_or_else(|| syn::Error::new(args.package.span(), "package not found"))?;
693
694    let interface_id = resolve
695        .interfaces
696        .iter()
697        .find_map(|(id, iface)| {
698            if iface.name.as_deref() == Some(&interface_value) && iface.package == Some(package_id)
699            {
700                Some(id)
701            } else {
702                None
703            }
704        })
705        .ok_or_else(|| syn::Error::new(args.interface.span(), "interface not found"))?;
706
707    let interface = &resolve.interfaces[interface_id];
708    let mut impls = Vec::new();
709
710    for (name, type_id) in interface.types.iter() {
711        let type_def = &resolve.types[*type_id];
712        if !matches!(type_def.kind, TypeDefKind::Resource) {
713            continue;
714        }
715        let type_path = extract_wit_type_path(&type_def.docs).ok_or_else(|| {
716            syn::Error::new(
717                args.interface.span(),
718                format!("resource `{name}` missing wasvy:type-path doc"),
719            )
720        })?;
721
722        let type_ident = format_ident!("{}", upper_camel(name));
723        let type_path_lit = syn::LitStr::new(&type_path, proc_macro2::Span::call_site());
724        impls.push(quote! {
725            impl #module::#type_ident {
726                pub const TYPE_PATH: &'static str = #type_path_lit;
727
728                pub fn type_path() -> String {
729                    Self::TYPE_PATH.to_string()
730                }
731
732                pub fn type_path_str() -> &'static str {
733                    Self::TYPE_PATH
734                }
735            }
736        });
737    }
738
739    Ok(quote! {
740        #(#impls)*
741    })
742}
743
744fn expand_guest_bindings(
745    args: GuestBindingsArgs,
746    input_tokens: proc_macro2::TokenStream,
747) -> syn::Result<proc_macro2::TokenStream> {
748    let mut resolve = Resolve::default();
749    for path in &args.paths {
750        let resolved = resolve_wit_path(path);
751        resolve
752            .push_path(&resolved)
753            .map_err(|err| syn::Error::new(path.span(), err.to_string()))?;
754    }
755
756    let mut impls = Vec::new();
757
758    for (_id, interface) in resolve.interfaces.iter() {
759        let Some(package_id) = interface.package else {
760            continue;
761        };
762        let package = &resolve.packages[package_id];
763        let namespace = rust_ident(&package.name.namespace);
764        let name = rust_ident(&package.name.name);
765        let interface_name = rust_ident(interface.name.as_deref().unwrap_or("components"));
766        let module = quote!(self::#namespace::#name::#interface_name);
767
768        for (resource_name, type_id) in interface.types.iter() {
769            let type_def = &resolve.types[*type_id];
770            if !matches!(type_def.kind, TypeDefKind::Resource) {
771                continue;
772            }
773            let Some(type_path) = extract_wit_type_path(&type_def.docs) else {
774                continue;
775            };
776            let type_ident = format_ident!("{}", upper_camel(resource_name));
777            let type_path_lit = syn::LitStr::new(&type_path, proc_macro2::Span::call_site());
778            impls.push(quote! {
779                impl #module::#type_ident {
780                    pub const TYPE_PATH: &'static str = #type_path_lit;
781
782                    pub fn type_path() -> String {
783                        Self::TYPE_PATH.to_string()
784                    }
785
786                    pub fn type_path_str() -> &'static str {
787                        Self::TYPE_PATH
788                    }
789                }
790            });
791        }
792    }
793
794    Ok(quote! {
795        wit_bindgen::generate!(#input_tokens);
796        #(#impls)*
797    })
798}
799
800fn expand_include_components(args: IncludeComponentsArgs) -> syn::Result<proc_macro2::TokenStream> {
801    let base = resolve_wit_path(&args.path);
802    let base = PathBuf::from(base);
803    let mut files = Vec::new();
804    collect_rs_files(&base, &mut files)
805        .map_err(|err| syn::Error::new(args.path.span(), err.to_string()))?;
806
807    let mut root = ModuleNode::default();
808    for path in files.iter() {
809        let Ok(contents) = std::fs::read_to_string(path) else {
810            continue;
811        };
812        if !contains_wasvy_attr(&contents) {
813            continue;
814        }
815        let segments =
816            module_segments(&base, path).map_err(|err| syn::Error::new(args.path.span(), err))?;
817        root.insert(&segments, path.clone());
818    }
819
820    let rendered = render_modules(&root);
821    Ok(rendered)
822}
823
824fn render_params(
825    resolve: &Resolve,
826    params: &[wit_parser::Param],
827    wasvy_path: &proc_macro2::TokenStream,
828    is_constructor: bool,
829) -> proc_macro2::TokenStream {
830    let mut out = Vec::new();
831    if !is_constructor {
832        out.push(
833            quote!(component: ::wasmtime::component::Resource<#wasvy_path::host::WasmComponent>),
834        );
835    }
836    for param in params.iter().filter(|param| param.name != "self") {
837        let ident = rust_ident(&param.name);
838        let ty_tokens = ty_to_tokens(resolve, &param.ty, wasvy_path);
839        out.push(quote!(#ident: #ty_tokens));
840    }
841    quote!(#(#out),*)
842}
843
844fn render_return(
845    resolve: &Resolve,
846    result: Option<&wit_parser::Type>,
847    wasvy_path: &proc_macro2::TokenStream,
848) -> proc_macro2::TokenStream {
849    match result {
850        None => quote!(),
851        Some(ty) => {
852            let tokens = ty_to_tokens(resolve, ty, wasvy_path);
853            quote!(-> #tokens)
854        }
855    }
856}
857
858fn render_invoke_body(
859    method: &str,
860    params: &[wit_parser::Param],
861    result: Option<&wit_parser::Type>,
862    wasvy_path: &proc_macro2::TokenStream,
863) -> proc_macro2::TokenStream {
864    let arg_idents: Vec<Ident> = params
865        .iter()
866        .filter(|param| param.name != "self")
867        .map(|param| rust_ident(&param.name))
868        .collect();
869    let args_expr = if arg_idents.is_empty() {
870        quote!(())
871    } else {
872        quote!((#(#arg_idents),*,))
873    };
874    let method_lit = syn::LitStr::new(method, proc_macro2::Span::call_site());
875    match result {
876        None => quote! {
877            #[allow(unused_imports)]
878            use #wasvy_path::serialize::*;
879            // Note: when implementing a custom codec, a wasvy_encode method is expected to be in scope
880            let params = wasvy_encode(&#args_expr).expect("serialize params");
881            let _ = #wasvy_path::host::invoke_component_method(self, component, #method_lit, &params)
882                .expect("invoke method");
883        },
884        Some(_) => quote! {
885            #[allow(unused_imports)]
886            use #wasvy_path::serialize::*;
887            // Note: when implementing a custom codec, a wasvy_encode and wasvy_decode method is expected to be in scope
888            let params = wasvy_encode(&#args_expr).expect("serialize params");
889            let output = #wasvy_path::host::invoke_component_method(self, component, #method_lit, &params)
890                .expect("invoke method");
891            wasvy_decode(&output).expect("deserialize")
892        },
893    }
894}
895
896fn ty_to_tokens(
897    resolve: &Resolve,
898    ty: &wit_parser::Type,
899    wasvy_path: &proc_macro2::TokenStream,
900) -> proc_macro2::TokenStream {
901    match ty {
902        wit_parser::Type::Bool => quote!(bool),
903        wit_parser::Type::U8 => quote!(u8),
904        wit_parser::Type::U16 => quote!(u16),
905        wit_parser::Type::U32 => quote!(u32),
906        wit_parser::Type::U64 => quote!(u64),
907        wit_parser::Type::S8 => quote!(i8),
908        wit_parser::Type::S16 => quote!(i16),
909        wit_parser::Type::S32 => quote!(i32),
910        wit_parser::Type::S64 => quote!(i64),
911        wit_parser::Type::F32 => quote!(f32),
912        wit_parser::Type::F64 => quote!(f64),
913        wit_parser::Type::Char => quote!(char),
914        wit_parser::Type::String => quote!(String),
915        wit_parser::Type::Id(id) => match &resolve.types[*id].kind {
916            TypeDefKind::Resource => {
917                quote!(::wasmtime::component::Resource<#wasvy_path::host::WasmComponent>)
918            }
919            TypeDefKind::Handle(handle) => match handle {
920                wit_parser::Handle::Borrow(_) | wit_parser::Handle::Own(_) => {
921                    quote!(::wasmtime::component::Resource<#wasvy_path::host::WasmComponent>)
922                }
923            },
924            TypeDefKind::Option(inner) => {
925                let inner = ty_to_tokens(resolve, inner, wasvy_path);
926                quote!(Option<#inner>)
927            }
928            TypeDefKind::List(inner) => {
929                let inner = ty_to_tokens(resolve, inner, wasvy_path);
930                quote!(Vec<#inner>)
931            }
932            _ => quote!(String),
933        },
934        wit_parser::Type::ErrorContext => quote!(String),
935    }
936}
937
938fn rust_ident(name: &str) -> Ident {
939    let mut cleaned = String::new();
940    for (i, ch) in name.chars().enumerate() {
941        let c = if ch == '-' { '_' } else { ch };
942        if i == 0 && c.is_ascii_digit() {
943            cleaned.push('_');
944        }
945        cleaned.push(c);
946    }
947    Ident::new(&cleaned, proc_macro2::Span::call_site())
948}
949
950fn method_name(name: &str) -> String {
951    let name = if let Some(pos) = name.rfind('.') {
952        &name[pos + 1..]
953    } else if let Some(pos) = name.rfind(']') {
954        &name[pos + 1..]
955    } else {
956        name
957    };
958    name.to_string()
959}
960
961fn upper_camel(name: &str) -> String {
962    let mut out = String::new();
963    let mut capitalize = true;
964    for ch in name.chars() {
965        if ch == '-' || ch == '_' {
966            capitalize = true;
967            continue;
968        }
969        if capitalize {
970            out.push(ch.to_ascii_uppercase());
971            capitalize = false;
972        } else {
973            out.push(ch);
974        }
975    }
976    if out.is_empty() {
977        "Component".to_string()
978    } else {
979        out
980    }
981}
982
983fn extract_wit_type_path(docs: &wit_parser::Docs) -> Option<String> {
984    let contents = docs.contents.as_deref()?;
985    for line in contents.lines() {
986        let line = line.trim();
987        if let Some(value) = line.strip_prefix("wasvy:type-path=") {
988            let value = value.trim();
989            if !value.is_empty() {
990                return Some(value.to_string());
991            }
992        }
993    }
994    None
995}
996
997fn extract_paths_from_stream(stream: proc_macro2::TokenStream) -> syn::Result<Vec<syn::LitStr>> {
998    let mut paths = Vec::new();
999    collect_paths(stream, &mut paths)?;
1000    if paths.is_empty() {
1001        return Err(syn::Error::new(
1002            proc_macro2::Span::call_site(),
1003            "missing `path`",
1004        ));
1005    }
1006    Ok(paths)
1007}
1008
1009fn collect_paths(
1010    stream: proc_macro2::TokenStream,
1011    paths: &mut Vec<syn::LitStr>,
1012) -> syn::Result<()> {
1013    let mut iter = stream.into_iter().peekable();
1014    while let Some(tt) = iter.next() {
1015        match tt {
1016            proc_macro2::TokenTree::Group(group) => {
1017                collect_paths(group.stream(), paths)?;
1018            }
1019            proc_macro2::TokenTree::Ident(ident) if ident == "path" => {
1020                // Skip any punctuation until ':'
1021                while let Some(proc_macro2::TokenTree::Punct(p)) = iter.peek() {
1022                    if p.as_char() == ':' {
1023                        iter.next();
1024                        break;
1025                    }
1026                    iter.next();
1027                }
1028
1029                let Some(next) = iter.next() else {
1030                    continue;
1031                };
1032                match next {
1033                    proc_macro2::TokenTree::Literal(lit) => {
1034                        if let Some(value) = lit_to_litstr(&lit) {
1035                            paths.push(value);
1036                        } else {
1037                            return Err(syn::Error::new(
1038                                lit.span(),
1039                                "path must be a string literal or array of string literals",
1040                            ));
1041                        }
1042                    }
1043                    proc_macro2::TokenTree::Group(group)
1044                        if group.delimiter() == proc_macro2::Delimiter::Bracket =>
1045                    {
1046                        for elem in group.stream() {
1047                            if let proc_macro2::TokenTree::Literal(lit) = elem {
1048                                if let Some(value) = lit_to_litstr(&lit) {
1049                                    paths.push(value);
1050                                } else {
1051                                    return Err(syn::Error::new(
1052                                        lit.span(),
1053                                        "path array entries must be string literals",
1054                                    ));
1055                                }
1056                            }
1057                        }
1058                    }
1059                    other => {
1060                        return Err(syn::Error::new(
1061                            other.span(),
1062                            "path must be a string literal or array of string literals",
1063                        ));
1064                    }
1065                }
1066            }
1067            _ => {}
1068        }
1069    }
1070    Ok(())
1071}
1072
1073fn lit_to_litstr(lit: &proc_macro2::Literal) -> Option<syn::LitStr> {
1074    syn::parse_str::<syn::LitStr>(&lit.to_string()).ok()
1075}
1076
1077fn collect_rs_files(dir: &PathBuf, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
1078    if !dir.exists() {
1079        return Ok(());
1080    }
1081    for entry in std::fs::read_dir(dir)? {
1082        let entry = entry?;
1083        let path = entry.path();
1084        if path.file_name().and_then(|s| s.to_str()) == Some("target") {
1085            continue;
1086        }
1087        if path.is_dir() {
1088            collect_rs_files(&path, out)?;
1089        } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
1090            out.push(path);
1091        }
1092    }
1093    Ok(())
1094}
1095
1096fn contains_wasvy_attr(contents: &str) -> bool {
1097    contents.contains("wasvy::component")
1098        || contents.contains("wasvy::methods")
1099        || contents.contains("WasvyComponent")
1100}
1101
1102#[derive(Default)]
1103struct ModuleNode {
1104    file: Option<PathBuf>,
1105    children: std::collections::BTreeMap<String, ModuleNode>,
1106}
1107
1108impl ModuleNode {
1109    fn insert(&mut self, segments: &[String], file: PathBuf) {
1110        if segments.is_empty() {
1111            self.file = Some(file);
1112            return;
1113        }
1114        let head = segments[0].clone();
1115        let tail = &segments[1..];
1116        let child = self.children.entry(head).or_default();
1117        child.insert(tail, file);
1118    }
1119}
1120
1121fn render_modules(node: &ModuleNode) -> proc_macro2::TokenStream {
1122    render_module_node(None, node)
1123}
1124
1125fn render_module_node(name: Option<&str>, node: &ModuleNode) -> proc_macro2::TokenStream {
1126    let mut items = Vec::new();
1127    if let Some(file) = &node.file {
1128        let lit = syn::LitStr::new(&file.to_string_lossy(), proc_macro2::Span::call_site());
1129        items.push(quote! {
1130            include!(#lit);
1131        });
1132    } else {
1133        for (child_name, child) in node.children.iter() {
1134            items.push(render_module_node(Some(child_name), child));
1135        }
1136    }
1137
1138    if let Some(name) = name {
1139        let ident = rust_ident(name);
1140        quote! {
1141            mod #ident {
1142                #(#items)*
1143            }
1144        }
1145    } else {
1146        quote! {
1147            #(#items)*
1148        }
1149    }
1150}
1151
1152fn module_segments(base: &Path, file: &Path) -> Result<Vec<String>, String> {
1153    let rel = file
1154        .strip_prefix(base)
1155        .map_err(|_| "file is not under base path".to_string())?;
1156    let file_name = rel
1157        .file_name()
1158        .and_then(|s| s.to_str())
1159        .ok_or_else(|| format!("invalid utf-8 path: {}", file.to_string_lossy()))?;
1160
1161    let mut segments: Vec<String> = rel
1162        .parent()
1163        .map(|p| {
1164            p.components()
1165                .filter_map(|c| c.as_os_str().to_str().map(sanitize_ident))
1166                .collect::<Vec<_>>()
1167        })
1168        .unwrap_or_default();
1169
1170    if file_name == "lib.rs" || file_name == "main.rs" {
1171        segments.clear();
1172        return Ok(segments);
1173    }
1174
1175    if file_name != "mod.rs" {
1176        let stem_path = PathBuf::from(file_name);
1177        let stem = stem_path
1178            .file_stem()
1179            .and_then(|s| s.to_str())
1180            .ok_or_else(|| "invalid file stem".to_string())?;
1181        segments.push(sanitize_ident(stem));
1182    }
1183
1184    Ok(segments)
1185}
1186
1187fn sanitize_ident(raw: &str) -> String {
1188    let mut cleaned = String::new();
1189    for (i, ch) in raw.chars().enumerate() {
1190        let c = if ch == '-' { '_' } else { ch };
1191        if i == 0 && c.is_ascii_digit() {
1192            cleaned.push('_');
1193        }
1194        cleaned.push(c);
1195    }
1196    if cleaned.is_empty() {
1197        "_".to_string()
1198    } else {
1199        cleaned
1200    }
1201}
1202
1203fn resolve_wit_path(path: &syn::LitStr) -> String {
1204    let path_value = path.value();
1205    let resolved_path = PathBuf::from(&path_value);
1206    let resolved_path = if resolved_path.is_absolute() {
1207        resolved_path
1208    } else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
1209        PathBuf::from(manifest_dir).join(resolved_path)
1210    } else {
1211        resolved_path
1212    };
1213    if resolved_path.exists() {
1214        return resolved_path.to_string_lossy().to_string();
1215    }
1216
1217    if let Some(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from) {
1218        let candidates = [
1219            manifest_dir.join("target/wasvy/components.wit"),
1220            manifest_dir.join("../target/wasvy/components.wit"),
1221            manifest_dir.join("../../target/wasvy/components.wit"),
1222        ];
1223        for candidate in candidates {
1224            if candidate.exists() {
1225                return candidate.to_string_lossy().to_string();
1226            }
1227        }
1228    }
1229
1230    resolved_path.to_string_lossy().to_string()
1231}
1232
1233fn resolve_wit_path_with_fallbacks(path: &syn::LitStr) -> String {
1234    let resolved_path = PathBuf::from(resolve_wit_path(path));
1235    if resolved_path.exists() {
1236        return resolved_path.to_string_lossy().to_string();
1237    }
1238
1239    if let Some(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from) {
1240        let candidates = [
1241            manifest_dir.join("target/wasvy/components.wit"),
1242            manifest_dir.join("../target/wasvy/components.wit"),
1243            manifest_dir.join("../../target/wasvy/components.wit"),
1244        ];
1245        for candidate in candidates {
1246            if candidate.exists() {
1247                return candidate.to_string_lossy().to_string();
1248            }
1249        }
1250    }
1251
1252    resolved_path.to_string_lossy().to_string()
1253}