1#![forbid(unsafe_code)]
2use heck::{ToLowerCamelCase, ToSnakeCase};
6use proc_macro::TokenStream;
7use quote::quote;
8use syn::ext::IdentExt;
9use syn::{ItemFn, parse_macro_input};
10
11#[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 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 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: Option<String>,
139 snake_case: Option<bool>,
141}
142
143#[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 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 let _: syn::Expr = meta.value()?.parse()?;
181 }
182 Ok(())
183 });
184 }
185 out
186}
187
188fn 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
261fn 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 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}