Skip to main content

victauri_macros/
lib.rs

1//! Procedural macros for Victauri — currently provides the `#[inspectable]` attribute
2//! for auto-registering Tauri commands with the MCP introspection layer.
3
4use proc_macro::TokenStream;
5use quote::quote;
6use syn::{ItemFn, parse_macro_input};
7
8/// Marks a `#[tauri::command]` as inspectable by Victauri.
9///
10/// Generates a companion `<fn_name>__schema()` function that returns a
11/// `victauri_core::CommandInfo` with the command's name, description,
12/// argument types, return type, and NL-resolution metadata.
13/// Call the schema function at setup time to register the command in the
14/// Victauri `CommandRegistry`.
15///
16/// # Example
17///
18/// ```rust,ignore
19/// #[tauri::command]
20/// #[inspectable(description = "Save API key for a provider")]
21/// async fn save_api_key(provider: String, key: String) -> Result<(), String> {
22///     // ...
23/// }
24///
25/// // At setup:
26/// state.registry.register(save_api_key__schema());
27/// ```
28#[proc_macro_attribute]
29pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream {
30    let input = parse_macro_input!(item as ItemFn);
31    let attrs = match parse_attrs(attr) {
32        Ok(a) => a,
33        Err(e) => return e.to_compile_error().into(),
34    };
35
36    let fn_name = &input.sig.ident;
37    let fn_name_str = fn_name.to_string();
38    let schema_fn_name = syn::Ident::new(&format!("{fn_name_str}__schema"), fn_name.span());
39    let is_async = input.sig.asyncness.is_some();
40
41    let description = attrs
42        .description
43        .unwrap_or_else(|| fn_name_str.replace('_', " "));
44
45    let args_info = extract_args(&input.sig);
46    let arg_tokens: Vec<_> = args_info
47        .iter()
48        .map(|(name, type_str, required)| {
49            quote! {
50                victauri_core::registry::CommandArg {
51                    name: #name.to_string(),
52                    type_name: #type_str.to_string(),
53                    required: #required,
54                    schema: None,
55                }
56            }
57        })
58        .collect();
59
60    let return_type = extract_return_type(&input.sig);
61
62    let intent_token = if let Some(i) = &attrs.intent {
63        quote! { Some(#i.to_string()) }
64    } else {
65        quote! { None }
66    };
67
68    let category_token = if let Some(c) = &attrs.category {
69        quote! { Some(#c.to_string()) }
70    } else {
71        quote! { None }
72    };
73
74    let example_tokens: Vec<_> = attrs
75        .examples
76        .iter()
77        .map(|e| quote! { #e.to_string() })
78        .collect();
79
80    let expanded = quote! {
81        #input
82
83        #[allow(dead_code, non_snake_case)]
84        fn #schema_fn_name() -> victauri_core::registry::CommandInfo {
85            victauri_core::registry::CommandInfo {
86                name: #fn_name_str.to_string(),
87                plugin: None,
88                description: Some(#description.to_string()),
89                args: vec![#(#arg_tokens),*],
90                return_type: Some(#return_type.to_string()),
91                is_async: #is_async,
92                intent: #intent_token,
93                category: #category_token,
94                examples: vec![#(#example_tokens),*],
95            }
96        }
97
98        victauri_core::inventory::submit! {
99            victauri_core::registry::CommandInfoFactory(#schema_fn_name)
100        }
101    };
102
103    TokenStream::from(expanded)
104}
105
106struct InspectableAttrs {
107    description: Option<String>,
108    intent: Option<String>,
109    category: Option<String>,
110    examples: Vec<String>,
111}
112
113fn parse_attrs(attr: TokenStream) -> syn::Result<InspectableAttrs> {
114    let mut attrs = InspectableAttrs {
115        description: None,
116        intent: None,
117        category: None,
118        examples: Vec::new(),
119    };
120
121    let parser = syn::meta::parser(|meta| {
122        if meta.path.is_ident("description") {
123            attrs.description = Some(meta.value()?.parse::<syn::LitStr>()?.value());
124        } else if meta.path.is_ident("intent") {
125            attrs.intent = Some(meta.value()?.parse::<syn::LitStr>()?.value());
126        } else if meta.path.is_ident("category") {
127            attrs.category = Some(meta.value()?.parse::<syn::LitStr>()?.value());
128        } else if meta.path.is_ident("example") {
129            attrs
130                .examples
131                .push(meta.value()?.parse::<syn::LitStr>()?.value());
132        } else {
133            return Err(meta.error("unknown #[inspectable] attribute"));
134        }
135        Ok(())
136    });
137
138    syn::parse::Parser::parse(parser, attr)?;
139    Ok(attrs)
140}
141
142fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> {
143    sig.inputs
144        .iter()
145        .filter_map(|arg| {
146            if let syn::FnArg::Typed(pat_type) = arg {
147                let name = match &*pat_type.pat {
148                    syn::Pat::Ident(ident) => ident.ident.to_string(),
149                    _ => return None,
150                };
151
152                let ty = &*pat_type.ty;
153                let type_str = quote!(#ty).to_string();
154                if is_tauri_framework_type(&type_str) {
155                    return None;
156                }
157
158                let is_option = type_str.starts_with("Option")
159                    || type_str.starts_with("Option <")
160                    || type_str.contains(":: Option");
161                let type_name = type_str;
162
163                Some((name, type_name, !is_option))
164            } else {
165                None
166            }
167        })
168        .collect()
169}
170
171fn is_tauri_framework_type(type_str: &str) -> bool {
172    const FRAMEWORK_TYPES: &[&str] = &["AppHandle", "State", "Window", "Webview", "WebviewWindow"];
173    let last_segment = type_str
174        .rsplit("::")
175        .next()
176        .unwrap_or(type_str)
177        .split('<')
178        .next()
179        .unwrap_or(type_str)
180        .trim();
181    FRAMEWORK_TYPES.contains(&last_segment)
182}
183
184fn extract_return_type(sig: &syn::Signature) -> String {
185    match &sig.output {
186        syn::ReturnType::Default => "()".to_string(),
187        syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(),
188    }
189}