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///
52/// # Release builds
53///
54/// Everything Victauri-specific is gated on `debug_assertions`, evaluated in YOUR crate's
55/// profile (the Victauri plugin is a no-op in release, so nothing could read it):
56///
57/// - the auto-discovery registration (what `VictauriBuilder::auto_discover()` collects) is not
58///   emitted, so a release build has no startup constructor for it;
59/// - the `<fn>__schema()` companion still exists — code passing it to
60///   `VictauriBuilder::commands(...)` or `register_commands!` keeps compiling — but returns
61///   only the command name (and whether it is async): the description, intent, category,
62///   examples and argument/return types are not compiled in.
63///
64/// So none of the metadata strings ship in a release binary.
65#[proc_macro_attribute]
66pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream {
67    let input = parse_macro_input!(item as ItemFn);
68    let attrs = match parse_attrs(attr) {
69        Ok(a) => a,
70        Err(e) => return e.to_compile_error().into(),
71    };
72    TokenStream::from(expand(input, attrs))
73}
74
75/// The expansion of `#[inspectable]` (split out so it can be unit-tested with `proc_macro2`).
76fn expand(input: ItemFn, attrs: InspectableAttrs) -> proc_macro2::TokenStream {
77    let command_options = tauri_command_options(&input.attrs);
78
79    let fn_name = &input.sig.ident;
80    let schema_fn_name = syn::Ident::new(&format!("{fn_name}__schema"), fn_name.span());
81    // The name the frontend invokes: Tauri's `rename`, else the bare identifier
82    // (`r#type` is invoked as `type`).
83    let command_name = attrs
84        .rename
85        .clone()
86        .or(command_options.rename)
87        .unwrap_or_else(|| fn_name.unraw().to_string());
88    let is_async = input.sig.asyncness.is_some();
89
90    let description = attrs
91        .description
92        .unwrap_or_else(|| command_name.replace('_', " "));
93
94    let snake_case = attrs
95        .snake_case
96        .or(command_options.snake_case)
97        .unwrap_or(false);
98    let args_info = extract_args(&input.sig);
99    let arg_tokens: Vec<_> = args_info
100        .iter()
101        .map(|(name, type_str, required)| {
102            let key = invoke_key(name, snake_case);
103            quote! {
104                victauri_core::registry::CommandArg::new(#name, #type_str, #required)
105                    .with_key(#key)
106            }
107        })
108        .collect();
109
110    let return_type = extract_return_type(&input.sig);
111
112    let intent_token = attrs.intent.as_ref().map(|i| quote! { .with_intent(#i) });
113
114    let category_token = attrs
115        .category
116        .as_ref()
117        .map(|c| quote! { .with_category(#c) });
118
119    let example_tokens: Vec<_> = attrs
120        .examples
121        .iter()
122        .map(|e| quote! { #e.to_string() })
123        .collect();
124
125    let expanded = quote! {
126        #input
127
128        #[allow(dead_code, non_snake_case)]
129        fn #schema_fn_name() -> victauri_core::registry::CommandInfo {
130            // Built via constructors, not a struct literal: `CommandInfo` and
131            // `CommandArg` are `#[non_exhaustive]`, and this code expands in the
132            // user's crate.
133            #[cfg(debug_assertions)]
134            let info = victauri_core::registry::CommandInfo::new(#command_name)
135                .with_description(#description)
136                .with_args(vec![#(#arg_tokens),*])
137                .with_return_type(#return_type)
138                .with_async(#is_async)
139                #intent_token
140                #category_token
141                .with_examples(vec![#(#example_tokens),*]);
142            // Release (the consumer's profile): the name only, so none of the metadata
143            // strings ship — see "Release builds" above.
144            #[cfg(not(debug_assertions))]
145            let info = victauri_core::registry::CommandInfo::new(#command_name)
146                .with_async(#is_async);
147            info
148        }
149
150        // Debug builds only, in the consumer's profile — see "Release builds" above.
151        #[cfg(debug_assertions)]
152        victauri_core::inventory::submit! {
153            victauri_core::registry::CommandInfoFactory(#schema_fn_name)
154        }
155    };
156
157    expanded
158}
159
160#[derive(Default)]
161struct InspectableAttrs {
162    description: Option<String>,
163    intent: Option<String>,
164    category: Option<String>,
165    examples: Vec<String>,
166    /// `rename = "..."`: the name the command is invoked by.
167    rename: Option<String>,
168    /// `rename_all = "snake_case" | "camelCase"`: the argument key case.
169    snake_case: Option<bool>,
170}
171
172/// The options of a `#[tauri::command(...)]` still present on the function, which
173/// happens only when `#[inspectable]` is written above it.
174#[derive(Default)]
175struct CommandOptions {
176    rename: Option<String>,
177    snake_case: Option<bool>,
178}
179
180fn parse_rename_all(lit: &syn::LitStr) -> syn::Result<bool> {
181    match lit.value().as_str() {
182        "snake_case" => Ok(true),
183        "camelCase" => Ok(false),
184        _ => Err(syn::Error::new(
185            lit.span(),
186            "expected \"camelCase\" or \"snake_case\"",
187        )),
188    }
189}
190
191fn tauri_command_options(fn_attrs: &[syn::Attribute]) -> CommandOptions {
192    let mut out = CommandOptions::default();
193    for attr in fn_attrs {
194        let path = attr.path();
195        let is_command = path.segments.last().is_some_and(|s| s.ident == "command")
196            && (path.segments.len() == 1 || path.segments[0].ident == "tauri");
197        if !is_command || !matches!(attr.meta, syn::Meta::List(_)) {
198            continue;
199        }
200        // Best effort: Tauri validates its own attribute (and `async` is a keyword
201        // `parse_nested_meta` refuses), so a parse failure here is never an error.
202        let _ = attr.parse_nested_meta(|meta| {
203            if meta.path.is_ident("rename_all") {
204                out.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?);
205            } else if meta.path.is_ident("rename") {
206                out.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
207            } else if meta.input.peek(syn::Token![=]) {
208                // Other options (`root = "..."`) are Tauri's business.
209                let _: syn::Expr = meta.value()?.parse()?;
210            }
211            Ok(())
212        });
213    }
214    out
215}
216
217/// The IPC key Tauri derives for an argument name (tauri-macros `ArgumentCase`).
218fn invoke_key(name: &str, snake_case: bool) -> String {
219    if snake_case {
220        name.to_snake_case()
221    } else {
222        name.to_lower_camel_case()
223    }
224}
225
226fn parse_attrs(attr: TokenStream) -> syn::Result<InspectableAttrs> {
227    let mut attrs = InspectableAttrs::default();
228
229    let parser = syn::meta::parser(|meta| {
230        if meta.path.is_ident("description") {
231            attrs.description = Some(meta.value()?.parse::<syn::LitStr>()?.value());
232        } else if meta.path.is_ident("intent") {
233            attrs.intent = Some(meta.value()?.parse::<syn::LitStr>()?.value());
234        } else if meta.path.is_ident("category") {
235            attrs.category = Some(meta.value()?.parse::<syn::LitStr>()?.value());
236        } else if meta.path.is_ident("example") {
237            attrs
238                .examples
239                .push(meta.value()?.parse::<syn::LitStr>()?.value());
240        } else if meta.path.is_ident("rename") {
241            attrs.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
242        } else if meta.path.is_ident("rename_all") {
243            attrs.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?);
244        } else {
245            return Err(meta.error("unknown #[inspectable] attribute"));
246        }
247        Ok(())
248    });
249
250    syn::parse::Parser::parse(parser, attr)?;
251    Ok(attrs)
252}
253
254fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> {
255    sig.inputs
256        .iter()
257        .filter_map(|arg| {
258            if let syn::FnArg::Typed(pat_type) = arg {
259                let name = match &*pat_type.pat {
260                    syn::Pat::Ident(ident) => ident.ident.unraw().to_string(),
261                    _ => return None,
262                };
263
264                let ty = &*pat_type.ty;
265                if is_tauri_framework_type(ty) {
266                    return None;
267                }
268                let type_str = quote!(#ty).to_string();
269
270                let is_option = type_str.starts_with("Option")
271                    || type_str.starts_with("Option <")
272                    || type_str.contains(":: Option");
273                let type_name = type_str;
274
275                Some((name, type_name, !is_option))
276            } else {
277                None
278            }
279        })
280        .collect()
281}
282
283/// Whether Tauri injects this argument itself (the `CommandArg` impls that do not
284/// read the invoke payload), so it never appears in the frontend's arguments.
285fn is_tauri_framework_type(ty: &syn::Type) -> bool {
286    const FRAMEWORK_TYPES: &[&str] = &[
287        "AppHandle",
288        "State",
289        "Window",
290        "Webview",
291        "WebviewWindow",
292        "Request",
293        "CommandScope",
294        "GlobalScope",
295    ];
296    let ty = match ty {
297        syn::Type::Reference(r) => &*r.elem,
298        syn::Type::Paren(p) => &*p.elem,
299        syn::Type::Group(g) => &*g.elem,
300        other => other,
301    };
302    // Judge by the path's LAST segment, never by splitting its tokens as a string:
303    // `State<'_, my::Pool>` has a `::` inside its generics.
304    let syn::Type::Path(path) = ty else {
305        return false;
306    };
307    path.path
308        .segments
309        .last()
310        .is_some_and(|seg| FRAMEWORK_TYPES.iter().any(|t| seg.ident == t))
311}
312
313fn extract_return_type(sig: &syn::Signature) -> String {
314    match &sig.output {
315        syn::ReturnType::Default => "()".to_string(),
316        syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(),
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn fn_attrs(src: &str) -> Vec<syn::Attribute> {
325        syn::parse_str::<ItemFn>(src).unwrap().attrs
326    }
327
328    #[test]
329    fn reads_rename_options_from_a_tauri_command_attribute() {
330        let o = tauri_command_options(&fn_attrs(
331            "#[tauri::command(rename_all = \"snake_case\", rename = \"x\")] fn f() {}",
332        ));
333        assert_eq!(o.snake_case, Some(true));
334        assert_eq!(o.rename.as_deref(), Some("x"));
335    }
336
337    #[test]
338    fn tolerates_the_rest_of_tauris_attribute_grammar() {
339        for src in [
340            "#[tauri::command(async)] fn f() {}",
341            "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}",
342            "#[tauri::command] fn f() {}",
343            "#[other::command(rename = \"no\")] fn f() {}",
344        ] {
345            let o = tauri_command_options(&fn_attrs(src));
346            assert!(o.rename.is_none() || src.contains("root"), "{src}");
347        }
348        let o = tauri_command_options(&fn_attrs(
349            "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}",
350        ));
351        assert_eq!(o.snake_case, Some(true));
352    }
353
354    /// The inventory registration (and with it every description/intent/example string and the
355    /// startup constructor) must exist only in debug builds of the CONSUMER, while the
356    /// `__schema()` companion stays available in every profile.
357    #[test]
358    fn registration_is_gated_on_debug_assertions_but_schema_fn_is_not() {
359        let item =
360            syn::parse_str::<ItemFn>("#[tauri::command] fn greet(name: String) -> String { name }")
361                .unwrap();
362        let attrs = InspectableAttrs {
363            description: Some("Greet a person by name".into()),
364            ..InspectableAttrs::default()
365        };
366        let file: syn::File = syn::parse2(expand(item, attrs)).unwrap();
367        let debug_only = |attrs: &[syn::Attribute]| {
368            attrs.iter().any(|a| {
369                a.path().is_ident("cfg")
370                    && a.parse_args::<syn::Ident>()
371                        .is_ok_and(|i| i == "debug_assertions")
372            })
373        };
374
375        let mut saw_schema_fn = false;
376        let mut saw_submit = false;
377        for item in &file.items {
378            match item {
379                syn::Item::Fn(f) if f.sig.ident == "greet__schema" => {
380                    saw_schema_fn = true;
381                    assert!(
382                        !debug_only(&f.attrs),
383                        "the schema companion must exist in every profile"
384                    );
385                    // The metadata is built only in the debug `let`; the release one has no
386                    // description string.
387                    let lets: Vec<&syn::Local> = f
388                        .block
389                        .stmts
390                        .iter()
391                        .filter_map(|s| match s {
392                            syn::Stmt::Local(l) => Some(l),
393                            _ => None,
394                        })
395                        .collect();
396                    assert_eq!(lets.len(), 2, "one debug and one release `let info`");
397                    for local in lets {
398                        let text = quote!(#local).to_string();
399                        if debug_only(&local.attrs) {
400                            assert!(text.contains("Greet a person by name"), "{text}");
401                        } else {
402                            assert!(
403                                local.attrs.iter().any(|a| quote!(#a)
404                                    .to_string()
405                                    .contains("not (debug_assertions)")),
406                                "{text}"
407                            );
408                            assert!(!text.contains("Greet a person by name"), "{text}");
409                            assert!(!text.contains("with_description"), "{text}");
410                        }
411                    }
412                }
413                syn::Item::Macro(m)
414                    if m.mac
415                        .path
416                        .segments
417                        .last()
418                        .is_some_and(|s| s.ident == "submit") =>
419                {
420                    saw_submit = true;
421                    assert!(
422                        debug_only(&m.attrs),
423                        "inventory::submit! must be #[cfg(debug_assertions)]"
424                    );
425                }
426                _ => {}
427            }
428        }
429        assert!(saw_schema_fn, "no greet__schema fn in the expansion");
430        assert!(saw_submit, "no inventory::submit! in the expansion");
431    }
432
433    #[test]
434    fn keys_match_tauri_argument_case() {
435        assert_eq!(invoke_key("size_kb", false), "sizeKb");
436        assert_eq!(invoke_key("a1_b", false), "a1B");
437        assert_eq!(invoke_key("name", false), "name");
438        assert_eq!(invoke_key("size_kb", true), "size_kb");
439    }
440}