Skip to main content

victauri_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{ItemFn, parse_macro_input};
4
5/// Marks a `#[tauri::command]` as inspectable by Victauri.
6///
7/// Generates:
8/// - A companion `<fn_name>__schema()` function returning the command's JSON schema
9/// - Runtime telemetry (duration, result status) emitted as tracing spans
10/// - Auto-registration in the global command registry
11///
12/// # Example
13///
14/// ```rust,ignore
15/// #[tauri::command]
16/// #[inspectable(description = "Save API key for a provider")]
17/// async fn save_api_key(provider: String, key: String) -> Result<(), String> {
18///     // ...
19/// }
20/// ```
21#[proc_macro_attribute]
22pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream {
23    let input = parse_macro_input!(item as ItemFn);
24    let attrs = parse_attrs(attr);
25
26    let fn_name = &input.sig.ident;
27    let fn_name_str = fn_name.to_string();
28    let schema_fn_name = syn::Ident::new(&format!("{fn_name_str}__schema"), fn_name.span());
29    let is_async = input.sig.asyncness.is_some();
30
31    let description = attrs
32        .description
33        .unwrap_or_else(|| fn_name_str.replace('_', " "));
34
35    let args_info = extract_args(&input.sig);
36    let arg_tokens: Vec<_> = args_info
37        .iter()
38        .map(|(name, type_str, required)| {
39            quote! {
40                victauri_core::registry::CommandArg {
41                    name: #name.to_string(),
42                    type_name: #type_str.to_string(),
43                    required: #required,
44                    schema: None,
45                }
46            }
47        })
48        .collect();
49
50    let return_type = extract_return_type(&input.sig);
51
52    let intent_token = match &attrs.intent {
53        Some(i) => quote! { Some(#i.to_string()) },
54        None => quote! { None },
55    };
56
57    let category_token = match &attrs.category {
58        Some(c) => quote! { Some(#c.to_string()) },
59        None => quote! { None },
60    };
61
62    let example_tokens: Vec<_> = attrs
63        .examples
64        .iter()
65        .map(|e| quote! { #e.to_string() })
66        .collect();
67
68    let expanded = quote! {
69        #input
70
71        #[allow(dead_code, non_snake_case)]
72        fn #schema_fn_name() -> victauri_core::registry::CommandInfo {
73            victauri_core::registry::CommandInfo {
74                name: #fn_name_str.to_string(),
75                plugin: None,
76                description: Some(#description.to_string()),
77                args: vec![#(#arg_tokens),*],
78                return_type: Some(#return_type.to_string()),
79                is_async: #is_async,
80                intent: #intent_token,
81                category: #category_token,
82                examples: vec![#(#example_tokens),*],
83            }
84        }
85    };
86
87    TokenStream::from(expanded)
88}
89
90struct InspectableAttrs {
91    description: Option<String>,
92    intent: Option<String>,
93    category: Option<String>,
94    examples: Vec<String>,
95}
96
97fn parse_attrs(attr: TokenStream) -> InspectableAttrs {
98    let mut attrs = InspectableAttrs {
99        description: None,
100        intent: None,
101        category: None,
102        examples: Vec::new(),
103    };
104
105    let parser = syn::meta::parser(|meta| {
106        if meta.path.is_ident("description") {
107            attrs.description = Some(meta.value()?.parse::<syn::LitStr>()?.value());
108        } else if meta.path.is_ident("intent") {
109            attrs.intent = Some(meta.value()?.parse::<syn::LitStr>()?.value());
110        } else if meta.path.is_ident("category") {
111            attrs.category = Some(meta.value()?.parse::<syn::LitStr>()?.value());
112        } else if meta.path.is_ident("example") {
113            attrs
114                .examples
115                .push(meta.value()?.parse::<syn::LitStr>()?.value());
116        } else {
117            return Err(meta.error("unknown #[inspectable] attribute"));
118        }
119        Ok(())
120    });
121
122    syn::parse::Parser::parse(parser, attr).expect("failed to parse #[inspectable] attributes");
123    attrs
124}
125
126fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> {
127    sig.inputs
128        .iter()
129        .filter_map(|arg| {
130            if let syn::FnArg::Typed(pat_type) = arg {
131                let name = match &*pat_type.pat {
132                    syn::Pat::Ident(ident) => ident.ident.to_string(),
133                    _ => return None,
134                };
135
136                // Skip tauri framework types
137                let ty = &*pat_type.ty;
138                let type_str = quote!(#ty).to_string();
139                if type_str.contains("AppHandle")
140                    || type_str.contains("State")
141                    || type_str.contains("Window")
142                    || type_str.contains("Webview")
143                {
144                    return None;
145                }
146
147                let is_option = type_str.contains("Option");
148                let type_name = type_str;
149
150                Some((name, type_name, !is_option))
151            } else {
152                None
153            }
154        })
155        .collect()
156}
157
158fn extract_return_type(sig: &syn::Signature) -> String {
159    match &sig.output {
160        syn::ReturnType::Default => "()".to_string(),
161        syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(),
162    }
163}