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]
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
75fn 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 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 #[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 #[cfg(not(debug_assertions))]
145 let info = victauri_core::registry::CommandInfo::new(#command_name)
146 .with_async(#is_async);
147 info
148 }
149
150 #[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: Option<String>,
168 snake_case: Option<bool>,
170}
171
172#[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 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 let _: syn::Expr = meta.value()?.parse()?;
210 }
211 Ok(())
212 });
213 }
214 out
215}
216
217fn 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
283fn 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 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 #[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 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}