use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::spanned::Spanned;
use syn::{Data, DeriveInput, Error, LitStr, parse_macro_input};
#[proc_macro_derive(Tool, attributes(tool))]
pub fn derive_tool(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
expand(input)
.unwrap_or_else(Error::into_compile_error)
.into()
}
const VALID_EFFECTS: [&str; 3] = ["read", "idempotent", "write"];
fn expand(input: DeriveInput) -> Result<TokenStream2, Error> {
match &input.data {
Data::Struct(_) => {}
Data::Enum(data) => {
return Err(Error::new(
data.enum_token.span,
"`#[derive(Tool)]` applies to a struct, not an enum",
));
}
Data::Union(data) => {
return Err(Error::new(
data.union_token.span,
"`#[derive(Tool)]` applies to a struct, not a union",
));
}
}
let attrs = ToolAttrs::parse(&input)?;
let ident = &input.ident;
let name = attrs
.name
.map(|lit| lit.value())
.unwrap_or_else(|| to_snake_case(&ident.to_string()));
let description = attrs.description.value();
let effect = attrs.effect;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! {
impl #impl_generics ::salvor_tools::ToolMeta for #ident #ty_generics #where_clause {
const NAME: &'static str = #name;
const DESCRIPTION: &'static str = #description;
const EFFECT: ::salvor_tools::Effect = #effect;
}
})
}
struct ToolAttrs {
name: Option<LitStr>,
description: LitStr,
effect: TokenStream2,
}
impl ToolAttrs {
fn parse(input: &DeriveInput) -> Result<Self, Error> {
let tool_attrs: Vec<_> = input
.attrs
.iter()
.filter(|attr| attr.path().is_ident("tool"))
.collect();
let Some(first) = tool_attrs.first() else {
return Err(Error::new(
input.ident.span(),
"`#[derive(Tool)]` needs a `#[tool(...)]` attribute with at least \
`description = \"...\"` and `effect = \"...\"`",
));
};
let anchor = first.span();
let mut name: Option<LitStr> = None;
let mut description: Option<LitStr> = None;
let mut effect: Option<TokenStream2> = None;
for attr in &tool_attrs {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
set_once(&mut name, &meta, meta.value()?.parse()?)
} else if meta.path.is_ident("description") {
set_once(&mut description, &meta, meta.value()?.parse()?)
} else if meta.path.is_ident("effect") {
let lit: LitStr = meta.value()?.parse()?;
set_once(&mut effect, &meta, parse_effect(&lit)?)
} else {
Err(meta.error(
"unknown `#[tool(...)]` key; expected one of \
`name`, `description`, `effect`",
))
}
})?;
}
let description = description.ok_or_else(|| {
Error::new(anchor, "`#[tool(...)]` is missing `description = \"...\"`")
})?;
let effect = effect.ok_or_else(|| {
Error::new(
anchor,
"`#[tool(...)]` is missing `effect = \"...\"`; expected one of \
\"read\", \"idempotent\", \"write\"",
)
})?;
Ok(Self {
name,
description,
effect,
})
}
}
fn set_once<T>(
slot: &mut Option<T>,
meta: &syn::meta::ParseNestedMeta,
value: T,
) -> Result<(), Error> {
if slot.is_some() {
let key = meta
.path
.get_ident()
.map(ToString::to_string)
.unwrap_or_default();
return Err(meta.error(format!("duplicate `#[tool(...)]` key `{key}`")));
}
*slot = Some(value);
Ok(())
}
fn parse_effect(lit: &LitStr) -> Result<TokenStream2, Error> {
match lit.value().as_str() {
"read" => Ok(quote! { ::salvor_tools::Effect::Read }),
"idempotent" => Ok(quote! { ::salvor_tools::Effect::Idempotent }),
"write" => Ok(quote! { ::salvor_tools::Effect::Write }),
other => Err(Error::new(
lit.span(),
format!(
"invalid effect `{other}`; expected one of {}",
VALID_EFFECTS
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ")
),
)),
}
}
fn to_snake_case(ident: &str) -> String {
let chars: Vec<char> = ident.chars().collect();
let mut out = String::with_capacity(chars.len() + 4);
for (i, &c) in chars.iter().enumerate() {
if c.is_uppercase() && i > 0 {
let prev = chars[i - 1];
let next = chars.get(i + 1).copied();
let after_lower = !prev.is_uppercase();
let acronym_tail = prev.is_uppercase() && next.is_some_and(char::is_lowercase);
if after_lower || acronym_tail {
out.push('_');
}
}
out.extend(c.to_lowercase());
}
out
}