#![forbid(unsafe_code)]
use heck::{ToLowerCamelCase, ToSnakeCase};
use proc_macro::TokenStream;
use quote::quote;
use syn::ext::IdentExt;
use syn::{ItemFn, parse_macro_input};
#[proc_macro_attribute]
pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemFn);
let attrs = match parse_attrs(attr) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
let command_options = tauri_command_options(&input.attrs);
let fn_name = &input.sig.ident;
let schema_fn_name = syn::Ident::new(&format!("{fn_name}__schema"), fn_name.span());
let command_name = attrs
.rename
.clone()
.or(command_options.rename)
.unwrap_or_else(|| fn_name.unraw().to_string());
let is_async = input.sig.asyncness.is_some();
let description = attrs
.description
.unwrap_or_else(|| command_name.replace('_', " "));
let snake_case = attrs
.snake_case
.or(command_options.snake_case)
.unwrap_or(false);
let args_info = extract_args(&input.sig);
let arg_tokens: Vec<_> = args_info
.iter()
.map(|(name, type_str, required)| {
let key = invoke_key(name, snake_case);
quote! {
victauri_core::registry::CommandArg::new(#name, #type_str, #required)
.with_key(#key)
}
})
.collect();
let return_type = extract_return_type(&input.sig);
let intent_token = attrs.intent.as_ref().map(|i| quote! { .with_intent(#i) });
let category_token = attrs
.category
.as_ref()
.map(|c| quote! { .with_category(#c) });
let example_tokens: Vec<_> = attrs
.examples
.iter()
.map(|e| quote! { #e.to_string() })
.collect();
let expanded = quote! {
#input
#[allow(dead_code, non_snake_case)]
fn #schema_fn_name() -> victauri_core::registry::CommandInfo {
victauri_core::registry::CommandInfo::new(#command_name)
.with_description(#description)
.with_args(vec![#(#arg_tokens),*])
.with_return_type(#return_type)
.with_async(#is_async)
#intent_token
#category_token
.with_examples(vec![#(#example_tokens),*])
}
victauri_core::inventory::submit! {
victauri_core::registry::CommandInfoFactory(#schema_fn_name)
}
};
TokenStream::from(expanded)
}
struct InspectableAttrs {
description: Option<String>,
intent: Option<String>,
category: Option<String>,
examples: Vec<String>,
rename: Option<String>,
snake_case: Option<bool>,
}
#[derive(Default)]
struct CommandOptions {
rename: Option<String>,
snake_case: Option<bool>,
}
fn parse_rename_all(lit: &syn::LitStr) -> syn::Result<bool> {
match lit.value().as_str() {
"snake_case" => Ok(true),
"camelCase" => Ok(false),
_ => Err(syn::Error::new(
lit.span(),
"expected \"camelCase\" or \"snake_case\"",
)),
}
}
fn tauri_command_options(fn_attrs: &[syn::Attribute]) -> CommandOptions {
let mut out = CommandOptions::default();
for attr in fn_attrs {
let path = attr.path();
let is_command = path.segments.last().is_some_and(|s| s.ident == "command")
&& (path.segments.len() == 1 || path.segments[0].ident == "tauri");
if !is_command || !matches!(attr.meta, syn::Meta::List(_)) {
continue;
}
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename_all") {
out.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?);
} else if meta.path.is_ident("rename") {
out.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.input.peek(syn::Token![=]) {
let _: syn::Expr = meta.value()?.parse()?;
}
Ok(())
});
}
out
}
fn invoke_key(name: &str, snake_case: bool) -> String {
if snake_case {
name.to_snake_case()
} else {
name.to_lower_camel_case()
}
}
fn parse_attrs(attr: TokenStream) -> syn::Result<InspectableAttrs> {
let mut attrs = InspectableAttrs {
description: None,
intent: None,
category: None,
examples: Vec::new(),
rename: None,
snake_case: None,
};
let parser = syn::meta::parser(|meta| {
if meta.path.is_ident("description") {
attrs.description = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.path.is_ident("intent") {
attrs.intent = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.path.is_ident("category") {
attrs.category = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.path.is_ident("example") {
attrs
.examples
.push(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.path.is_ident("rename") {
attrs.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.path.is_ident("rename_all") {
attrs.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?);
} else {
return Err(meta.error("unknown #[inspectable] attribute"));
}
Ok(())
});
syn::parse::Parser::parse(parser, attr)?;
Ok(attrs)
}
fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> {
sig.inputs
.iter()
.filter_map(|arg| {
if let syn::FnArg::Typed(pat_type) = arg {
let name = match &*pat_type.pat {
syn::Pat::Ident(ident) => ident.ident.unraw().to_string(),
_ => return None,
};
let ty = &*pat_type.ty;
if is_tauri_framework_type(ty) {
return None;
}
let type_str = quote!(#ty).to_string();
let is_option = type_str.starts_with("Option")
|| type_str.starts_with("Option <")
|| type_str.contains(":: Option");
let type_name = type_str;
Some((name, type_name, !is_option))
} else {
None
}
})
.collect()
}
fn is_tauri_framework_type(ty: &syn::Type) -> bool {
const FRAMEWORK_TYPES: &[&str] = &[
"AppHandle",
"State",
"Window",
"Webview",
"WebviewWindow",
"Request",
"CommandScope",
"GlobalScope",
];
let ty = match ty {
syn::Type::Reference(r) => &*r.elem,
syn::Type::Paren(p) => &*p.elem,
syn::Type::Group(g) => &*g.elem,
other => other,
};
let syn::Type::Path(path) = ty else {
return false;
};
path.path
.segments
.last()
.is_some_and(|seg| FRAMEWORK_TYPES.iter().any(|t| seg.ident == t))
}
fn extract_return_type(sig: &syn::Signature) -> String {
match &sig.output {
syn::ReturnType::Default => "()".to_string(),
syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fn_attrs(src: &str) -> Vec<syn::Attribute> {
syn::parse_str::<ItemFn>(src).unwrap().attrs
}
#[test]
fn reads_rename_options_from_a_tauri_command_attribute() {
let o = tauri_command_options(&fn_attrs(
"#[tauri::command(rename_all = \"snake_case\", rename = \"x\")] fn f() {}",
));
assert_eq!(o.snake_case, Some(true));
assert_eq!(o.rename.as_deref(), Some("x"));
}
#[test]
fn tolerates_the_rest_of_tauris_attribute_grammar() {
for src in [
"#[tauri::command(async)] fn f() {}",
"#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}",
"#[tauri::command] fn f() {}",
"#[other::command(rename = \"no\")] fn f() {}",
] {
let o = tauri_command_options(&fn_attrs(src));
assert!(o.rename.is_none() || src.contains("root"), "{src}");
}
let o = tauri_command_options(&fn_attrs(
"#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}",
));
assert_eq!(o.snake_case, Some(true));
}
#[test]
fn keys_match_tauri_argument_case() {
assert_eq!(invoke_key("size_kb", false), "sizeKb");
assert_eq!(invoke_key("a1_b", false), "a1B");
assert_eq!(invoke_key("name", false), "name");
assert_eq!(invoke_key("size_kb", true), "size_kb");
}
}