Skip to main content

salvor_tools_macros/
lib.rs

1//! The `#[derive(Tool)]` procedural macro for the Salvor agent runtime.
2//!
3//! This crate exists only to host the derive. It is re-exported by
4//! `salvor-tools` as `salvor_tools::Tool`, so a tool author depends on one crate
5//! and never names this one directly. See the derive's own documentation for
6//! what it generates.
7
8use proc_macro::TokenStream;
9use proc_macro2::TokenStream as TokenStream2;
10use quote::quote;
11use syn::spanned::Spanned;
12use syn::{Data, DeriveInput, Error, LitStr, parse_macro_input};
13
14/// Derives the [`ToolMeta`] half of the tool contract from struct attributes.
15///
16/// `ToolMeta` is a tool's static identity: the name a model calls it by, a
17/// human-readable description, and its side-effect class. This derive writes
18/// that `impl` and nothing else. The behavior half, `ToolHandler` (the typed
19/// input, output, and async `call`), is always written by hand; the derive
20/// never sees or reasons about it.
21///
22/// # Usage
23///
24/// ```ignore
25/// use salvor_tools::Tool;
26///
27/// #[derive(Tool)]
28/// #[tool(effect = "write", description = "Create a Jira ticket")]
29/// struct CreateTicket;
30/// ```
31///
32/// That expands to an `impl salvor_tools::ToolMeta for CreateTicket` with
33/// `NAME = "create_ticket"`, `DESCRIPTION = "Create a Jira ticket"`, and
34/// `EFFECT = Effect::Write`.
35///
36/// # The `#[tool(...)]` attribute
37///
38/// The derive reads a single `#[tool(...)]` attribute whose keys are:
39///
40/// - `description = "..."` (**required**): the [`DESCRIPTION`] constant.
41/// - `effect = "read" | "idempotent" | "write"` (**required**): the [`EFFECT`]
42///   constant. Any other string is a compile error that names the three valid
43///   values.
44/// - `name = "..."` (optional): overrides [`NAME`]. When omitted, the name is
45///   derived from the struct identifier (see below).
46///
47/// Keys may be split across more than one `#[tool(...)]` attribute or combined
48/// in one. Repeating a key, using an unknown key, or giving a key a value that
49/// is not a string literal is a compile error.
50///
51/// # The default name
52///
53/// With no `name = "..."`, the derive lowercases the struct identifier into
54/// `snake_case` by inserting an underscore at each word boundary:
55///
56/// - before an uppercase letter that follows a lowercase letter or a digit
57///   (`CreateTicket` becomes `create_ticket`), and
58/// - before an uppercase letter that both follows another uppercase letter and
59///   is followed by a lowercase letter, which is the end of an acronym run
60///   (`HTTPFetch` becomes `http_fetch`).
61///
62/// A single-word identifier is simply lowercased (`Ticket` becomes `ticket`).
63/// Override the result with `name = "..."` when this rule does not produce the
64/// name you want.
65///
66/// # Hygiene
67///
68/// The generated code names every path from the crate root: the trait as
69/// `::salvor_tools::ToolMeta` and the effect as `::salvor_tools::Effect`. A
70/// `use` in the calling module that shadows either name (the tool author's own
71/// `Effect` type, say) cannot change what the generated `impl` refers to.
72///
73/// # What it rejects
74///
75/// The derive applies only to a `struct`. Placing it on an `enum` or a `union`
76/// is a compile error, because a tool's identity is one name, not a choice
77/// among several.
78///
79/// [`ToolMeta`]: ../salvor_tools/trait.ToolMeta.html
80/// [`NAME`]: ../salvor_tools/trait.ToolMeta.html#associatedconstant.NAME
81/// [`DESCRIPTION`]: ../salvor_tools/trait.ToolMeta.html#associatedconstant.DESCRIPTION
82/// [`EFFECT`]: ../salvor_tools/trait.ToolMeta.html#associatedconstant.EFFECT
83#[proc_macro_derive(Tool, attributes(tool))]
84pub fn derive_tool(input: TokenStream) -> TokenStream {
85    let input = parse_macro_input!(input as DeriveInput);
86    expand(input)
87        .unwrap_or_else(Error::into_compile_error)
88        .into()
89}
90
91/// The valid `effect = "..."` values, named in one place so the derive's docs,
92/// its error messages, and the mapping below cannot drift apart.
93const VALID_EFFECTS: [&str; 3] = ["read", "idempotent", "write"];
94
95/// Builds the `ToolMeta` impl, or returns a spanned error to be turned into a
96/// `compile_error!` at the offending tokens.
97fn expand(input: DeriveInput) -> Result<TokenStream2, Error> {
98    // A tool's identity is a single name, so the derive is meaningful only on a
99    // struct. Reject an enum or a union at its defining keyword.
100    match &input.data {
101        Data::Struct(_) => {}
102        Data::Enum(data) => {
103            return Err(Error::new(
104                data.enum_token.span,
105                "`#[derive(Tool)]` applies to a struct, not an enum",
106            ));
107        }
108        Data::Union(data) => {
109            return Err(Error::new(
110                data.union_token.span,
111                "`#[derive(Tool)]` applies to a struct, not a union",
112            ));
113        }
114    }
115
116    let attrs = ToolAttrs::parse(&input)?;
117
118    let ident = &input.ident;
119    let name = attrs
120        .name
121        .map(|lit| lit.value())
122        .unwrap_or_else(|| to_snake_case(&ident.to_string()));
123    let description = attrs.description.value();
124    let effect = attrs.effect;
125
126    // Support a generic tool type, even though tools are typically unit structs.
127    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
128
129    Ok(quote! {
130        impl #impl_generics ::salvor_tools::ToolMeta for #ident #ty_generics #where_clause {
131            const NAME: &'static str = #name;
132            const DESCRIPTION: &'static str = #description;
133            const EFFECT: ::salvor_tools::Effect = #effect;
134        }
135    })
136}
137
138/// The three pieces of metadata read from `#[tool(...)]`, after validation.
139///
140/// `description` and `effect` are non-optional here because a parsed
141/// `ToolAttrs` only exists once both were supplied; `name` stays optional
142/// because the derive falls back to the struct identifier.
143struct ToolAttrs {
144    name: Option<LitStr>,
145    description: LitStr,
146    /// The effect as the token stream of a fully qualified `Effect` variant,
147    /// ready to splice into the generated constant.
148    effect: TokenStream2,
149}
150
151impl ToolAttrs {
152    /// Reads every `#[tool(...)]` attribute on the input, validating keys and
153    /// values and enforcing that the required keys are present.
154    fn parse(input: &DeriveInput) -> Result<Self, Error> {
155        let tool_attrs: Vec<_> = input
156            .attrs
157            .iter()
158            .filter(|attr| attr.path().is_ident("tool"))
159            .collect();
160
161        // With nothing to point at, anchor the "missing attribute" error on the
162        // struct name so the author sees where the attribute belongs.
163        let Some(first) = tool_attrs.first() else {
164            return Err(Error::new(
165                input.ident.span(),
166                "`#[derive(Tool)]` needs a `#[tool(...)]` attribute with at least \
167                 `description = \"...\"` and `effect = \"...\"`",
168            ));
169        };
170        let anchor = first.span();
171
172        let mut name: Option<LitStr> = None;
173        let mut description: Option<LitStr> = None;
174        let mut effect: Option<TokenStream2> = None;
175
176        for attr in &tool_attrs {
177            attr.parse_nested_meta(|meta| {
178                if meta.path.is_ident("name") {
179                    set_once(&mut name, &meta, meta.value()?.parse()?)
180                } else if meta.path.is_ident("description") {
181                    set_once(&mut description, &meta, meta.value()?.parse()?)
182                } else if meta.path.is_ident("effect") {
183                    let lit: LitStr = meta.value()?.parse()?;
184                    set_once(&mut effect, &meta, parse_effect(&lit)?)
185                } else {
186                    Err(meta.error(
187                        "unknown `#[tool(...)]` key; expected one of \
188                         `name`, `description`, `effect`",
189                    ))
190                }
191            })?;
192        }
193
194        let description = description.ok_or_else(|| {
195            Error::new(anchor, "`#[tool(...)]` is missing `description = \"...\"`")
196        })?;
197        let effect = effect.ok_or_else(|| {
198            Error::new(
199                anchor,
200                "`#[tool(...)]` is missing `effect = \"...\"`; expected one of \
201                 \"read\", \"idempotent\", \"write\"",
202            )
203        })?;
204
205        Ok(Self {
206            name,
207            description,
208            effect,
209        })
210    }
211}
212
213/// Stores a key's value, or errors at the key if it was already set. This is
214/// what makes a repeated key (`effect = "read", effect = "write"`) a compile
215/// error rather than a silent last-wins.
216fn set_once<T>(
217    slot: &mut Option<T>,
218    meta: &syn::meta::ParseNestedMeta,
219    value: T,
220) -> Result<(), Error> {
221    if slot.is_some() {
222        let key = meta
223            .path
224            .get_ident()
225            .map(ToString::to_string)
226            .unwrap_or_default();
227        return Err(meta.error(format!("duplicate `#[tool(...)]` key `{key}`")));
228    }
229    *slot = Some(value);
230    Ok(())
231}
232
233/// Maps an `effect = "..."` string literal to the token stream of the matching
234/// fully qualified [`Effect`] variant, or errors at the literal listing the
235/// three valid values.
236fn parse_effect(lit: &LitStr) -> Result<TokenStream2, Error> {
237    match lit.value().as_str() {
238        "read" => Ok(quote! { ::salvor_tools::Effect::Read }),
239        "idempotent" => Ok(quote! { ::salvor_tools::Effect::Idempotent }),
240        "write" => Ok(quote! { ::salvor_tools::Effect::Write }),
241        other => Err(Error::new(
242            lit.span(),
243            format!(
244                "invalid effect `{other}`; expected one of {}",
245                VALID_EFFECTS
246                    .iter()
247                    .map(|v| format!("\"{v}\""))
248                    .collect::<Vec<_>>()
249                    .join(", ")
250            ),
251        )),
252    }
253}
254
255/// Lowercases an identifier into `snake_case`, inserting an underscore at each
256/// word boundary. See the derive's documentation for the exact rule this
257/// implements.
258fn to_snake_case(ident: &str) -> String {
259    let chars: Vec<char> = ident.chars().collect();
260    let mut out = String::with_capacity(chars.len() + 4);
261
262    for (i, &c) in chars.iter().enumerate() {
263        if c.is_uppercase() && i > 0 {
264            let prev = chars[i - 1];
265            let next = chars.get(i + 1).copied();
266            // A boundary is either the start of a word after a lowercase letter
267            // or digit, or the tail of an acronym run: an uppercase letter
268            // preceded by another uppercase letter but followed by a lowercase
269            // one (the `F` in `HTTPFetch`).
270            let after_lower = !prev.is_uppercase();
271            let acronym_tail = prev.is_uppercase() && next.is_some_and(char::is_lowercase);
272            if after_lower || acronym_tail {
273                out.push('_');
274            }
275        }
276        out.extend(c.to_lowercase());
277    }
278
279    out
280}