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}