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 heck::{ToLowerCamelCase, ToSnakeCase};
6use proc_macro::TokenStream;
7use quote::quote;
8use syn::ext::IdentExt;
9use syn::{ItemFn, parse_macro_input};
10
11/// Marks a `#[tauri::command]` as inspectable by Victauri.
12///
13/// Generates a companion `<fn_name>__schema()` function that returns a
14/// `victauri_core::CommandInfo` with the command's name, description,
15/// argument types, return type, and NL-resolution metadata.
16/// Call the schema function at setup time to register the command in the
17/// Victauri `CommandRegistry`.
18///
19/// # Dependency requirement
20///
21/// The generated code names `victauri_core::...` by absolute path, so the crate
22/// using this attribute must list `victauri-core` in its own `[dependencies]`
23/// (this holds whether the macro is imported from `victauri_macros` or via the
24/// `victauri_plugin::inspectable` re-export). Without it, compilation fails with
25/// `E0433: failed to resolve: use of undeclared crate victauri_core`.
26/// `victauri init` adds the dependency automatically.
27///
28/// # Argument keys
29///
30/// Tauri renames command arguments to camelCase by default: a `size_kb` argument is
31/// invoked as `invoke("cmd", { sizeKb })`. Each registered argument records that IPC
32/// key (`CommandArg::key`), so an agent reading the registry passes the key the
33/// command actually accepts. `#[tauri::command]` consumes its own attribute before
34/// this macro runs, so when the command uses `rename_all = "snake_case"` or
35/// `rename = "..."`, repeat it here — `#[inspectable(rename_all = "snake_case")]`,
36/// `#[inspectable(rename = "...")]`. (Written *above* `#[tauri::command(...)]`,
37/// `#[inspectable]` reads those options itself.)
38///
39/// # Example
40///
41/// ```rust,ignore
42/// #[tauri::command]
43/// #[inspectable(description = "Save API key for a provider")]
44/// async fn save_api_key(provider: String, key: String) -> Result<(), String> {
45///     // ...
46/// }
47///
48/// // At setup:
49/// state.registry.register(save_api_key__schema());
50/// ```
51#[proc_macro_attribute]
52pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream {
53    let input = parse_macro_input!(item as ItemFn);
54    let attrs = match parse_attrs(attr) {
55        Ok(a) => a,
56        Err(e) => return e.to_compile_error().into(),
57    };
58    let command_options = tauri_command_options(&input.attrs);
59
60    let fn_name = &input.sig.ident;
61    let schema_fn_name = syn::Ident::new(&format!("{fn_name}__schema"), fn_name.span());
62    // The name the frontend invokes: Tauri's `rename`, else the bare identifier
63    // (`r#type` is invoked as `type`).
64    let command_name = attrs
65        .rename
66        .clone()
67        .or(command_options.rename)
68        .unwrap_or_else(|| fn_name.unraw().to_string());
69    let is_async = input.sig.asyncness.is_some();
70
71    let description = attrs
72        .description
73        .unwrap_or_else(|| command_name.replace('_', " "));
74
75    let snake_case = attrs
76        .snake_case
77        .or(command_options.snake_case)
78        .unwrap_or(false);
79    let args_info = extract_args(&input.sig);
80    let arg_tokens: Vec<_> = args_info
81        .iter()
82        .map(|(name, type_str, required)| {
83            let key = invoke_key(name, snake_case);
84            quote! {
85                victauri_core::registry::CommandArg::new(#name, #type_str, #required)
86                    .with_key(#key)
87            }
88        })
89        .collect();
90
91    let return_type = extract_return_type(&input.sig);
92
93    let intent_token = attrs.intent.as_ref().map(|i| quote! { .with_intent(#i) });
94
95    let category_token = attrs
96        .category
97        .as_ref()
98        .map(|c| quote! { .with_category(#c) });
99
100    let example_tokens: Vec<_> = attrs
101        .examples
102        .iter()
103        .map(|e| quote! { #e.to_string() })
104        .collect();
105
106    let expanded = quote! {
107        #input
108
109        #[allow(dead_code, non_snake_case)]
110        fn #schema_fn_name() -> victauri_core::registry::CommandInfo {
111            // Built via constructors, not a struct literal: `CommandInfo` and
112            // `CommandArg` are `#[non_exhaustive]`, and this code expands in the
113            // user's crate.
114            victauri_core::registry::CommandInfo::new(#command_name)
115                .with_description(#description)
116                .with_args(vec![#(#arg_tokens),*])
117                .with_return_type(#return_type)
118                .with_async(#is_async)
119                #intent_token
120                #category_token
121                .with_examples(vec![#(#example_tokens),*])
122        }
123
124        victauri_core::inventory::submit! {
125            victauri_core::registry::CommandInfoFactory(#schema_fn_name)
126        }
127    };
128
129    TokenStream::from(expanded)
130}
131
132struct InspectableAttrs {
133    description: Option<String>,
134    intent: Option<String>,
135    category: Option<String>,
136    examples: Vec<String>,
137    /// `rename = "..."`: the name the command is invoked by.
138    rename: Option<String>,
139    /// `rename_all = "snake_case" | "camelCase"`: the argument key case.
140    snake_case: Option<bool>,
141}
142
143/// The options of a `#[tauri::command(...)]` still present on the function, which
144/// happens only when `#[inspectable]` is written above it.
145#[derive(Default)]
146struct CommandOptions {
147    rename: Option<String>,
148    snake_case: Option<bool>,
149}
150
151fn parse_rename_all(lit: &syn::LitStr) -> syn::Result<bool> {
152    match lit.value().as_str() {
153        "snake_case" => Ok(true),
154        "camelCase" => Ok(false),
155        _ => Err(syn::Error::new(
156            lit.span(),
157            "expected \"camelCase\" or \"snake_case\"",
158        )),
159    }
160}
161
162fn tauri_command_options(fn_attrs: &[syn::Attribute]) -> CommandOptions {
163    let mut out = CommandOptions::default();
164    for attr in fn_attrs {
165        let path = attr.path();
166        let is_command = path.segments.last().is_some_and(|s| s.ident == "command")
167            && (path.segments.len() == 1 || path.segments[0].ident == "tauri");
168        if !is_command || !matches!(attr.meta, syn::Meta::List(_)) {
169            continue;
170        }
171        // Best effort: Tauri validates its own attribute (and `async` is a keyword
172        // `parse_nested_meta` refuses), so a parse failure here is never an error.
173        let _ = attr.parse_nested_meta(|meta| {
174            if meta.path.is_ident("rename_all") {
175                out.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?);
176            } else if meta.path.is_ident("rename") {
177                out.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
178            } else if meta.input.peek(syn::Token![=]) {
179                // Other options (`root = "..."`) are Tauri's business.
180                let _: syn::Expr = meta.value()?.parse()?;
181            }
182            Ok(())
183        });
184    }
185    out
186}
187
188/// The IPC key Tauri derives for an argument name (tauri-macros `ArgumentCase`).
189fn invoke_key(name: &str, snake_case: bool) -> String {
190    if snake_case {
191        name.to_snake_case()
192    } else {
193        name.to_lower_camel_case()
194    }
195}
196
197fn parse_attrs(attr: TokenStream) -> syn::Result<InspectableAttrs> {
198    let mut attrs = InspectableAttrs {
199        description: None,
200        intent: None,
201        category: None,
202        examples: Vec::new(),
203        rename: None,
204        snake_case: None,
205    };
206
207    let parser = syn::meta::parser(|meta| {
208        if meta.path.is_ident("description") {
209            attrs.description = Some(meta.value()?.parse::<syn::LitStr>()?.value());
210        } else if meta.path.is_ident("intent") {
211            attrs.intent = Some(meta.value()?.parse::<syn::LitStr>()?.value());
212        } else if meta.path.is_ident("category") {
213            attrs.category = Some(meta.value()?.parse::<syn::LitStr>()?.value());
214        } else if meta.path.is_ident("example") {
215            attrs
216                .examples
217                .push(meta.value()?.parse::<syn::LitStr>()?.value());
218        } else if meta.path.is_ident("rename") {
219            attrs.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
220        } else if meta.path.is_ident("rename_all") {
221            attrs.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?);
222        } else {
223            return Err(meta.error("unknown #[inspectable] attribute"));
224        }
225        Ok(())
226    });
227
228    syn::parse::Parser::parse(parser, attr)?;
229    Ok(attrs)
230}
231
232fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> {
233    sig.inputs
234        .iter()
235        .filter_map(|arg| {
236            if let syn::FnArg::Typed(pat_type) = arg {
237                let name = match &*pat_type.pat {
238                    syn::Pat::Ident(ident) => ident.ident.unraw().to_string(),
239                    _ => return None,
240                };
241
242                let ty = &*pat_type.ty;
243                if is_tauri_framework_type(ty) {
244                    return None;
245                }
246                let type_str = quote!(#ty).to_string();
247
248                let is_option = type_str.starts_with("Option")
249                    || type_str.starts_with("Option <")
250                    || type_str.contains(":: Option");
251                let type_name = type_str;
252
253                Some((name, type_name, !is_option))
254            } else {
255                None
256            }
257        })
258        .collect()
259}
260
261/// Whether Tauri injects this argument itself (the `CommandArg` impls that do not
262/// read the invoke payload), so it never appears in the frontend's arguments.
263fn is_tauri_framework_type(ty: &syn::Type) -> bool {
264    const FRAMEWORK_TYPES: &[&str] = &[
265        "AppHandle",
266        "State",
267        "Window",
268        "Webview",
269        "WebviewWindow",
270        "Request",
271        "CommandScope",
272        "GlobalScope",
273    ];
274    let ty = match ty {
275        syn::Type::Reference(r) => &*r.elem,
276        syn::Type::Paren(p) => &*p.elem,
277        syn::Type::Group(g) => &*g.elem,
278        other => other,
279    };
280    // Judge by the path's LAST segment, never by splitting its tokens as a string:
281    // `State<'_, my::Pool>` has a `::` inside its generics.
282    let syn::Type::Path(path) = ty else {
283        return false;
284    };
285    path.path
286        .segments
287        .last()
288        .is_some_and(|seg| FRAMEWORK_TYPES.iter().any(|t| seg.ident == t))
289}
290
291fn extract_return_type(sig: &syn::Signature) -> String {
292    match &sig.output {
293        syn::ReturnType::Default => "()".to_string(),
294        syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(),
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn fn_attrs(src: &str) -> Vec<syn::Attribute> {
303        syn::parse_str::<ItemFn>(src).unwrap().attrs
304    }
305
306    #[test]
307    fn reads_rename_options_from_a_tauri_command_attribute() {
308        let o = tauri_command_options(&fn_attrs(
309            "#[tauri::command(rename_all = \"snake_case\", rename = \"x\")] fn f() {}",
310        ));
311        assert_eq!(o.snake_case, Some(true));
312        assert_eq!(o.rename.as_deref(), Some("x"));
313    }
314
315    #[test]
316    fn tolerates_the_rest_of_tauris_attribute_grammar() {
317        for src in [
318            "#[tauri::command(async)] fn f() {}",
319            "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}",
320            "#[tauri::command] fn f() {}",
321            "#[other::command(rename = \"no\")] fn f() {}",
322        ] {
323            let o = tauri_command_options(&fn_attrs(src));
324            assert!(o.rename.is_none() || src.contains("root"), "{src}");
325        }
326        let o = tauri_command_options(&fn_attrs(
327            "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}",
328        ));
329        assert_eq!(o.snake_case, Some(true));
330    }
331
332    #[test]
333    fn keys_match_tauri_argument_case() {
334        assert_eq!(invoke_key("size_kb", false), "sizeKb");
335        assert_eq!(invoke_key("a1_b", false), "a1B");
336        assert_eq!(invoke_key("name", false), "name");
337        assert_eq!(invoke_key("size_kb", true), "size_kb");
338    }
339}