Skip to main content

victauri_macros/
lib.rs

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