Skip to main content

llm_tool_macros/
lib.rs

1//! Proc-macro crate for `llm-tool`.
2//!
3//! Provides the `#[llm_tool]` attribute macro that transforms a plain function
4//! into a strongly-typed [`RustTool`](https://docs.rs/llm-tool/latest/llm_tool/trait.RustTool.html)
5//! implementation.
6//!
7//! With the `md-tmpl` feature enabled, tool descriptions can be
8//! loaded from `.tmpl.md` template files via `prompt_file = "..."`, and tool
9//! responses can be auto-rendered through templates via
10//! `response_file = "..."`.
11mod prompt_macro;
12mod resource_macro;
13#[cfg(feature = "md-tmpl")]
14mod response_struct_gen;
15
16use convert_case::{Case, Casing};
17use proc_macro::TokenStream;
18use quote::{format_ident, quote};
19#[cfg(feature = "md-tmpl")]
20use syn::Ident;
21use syn::{ItemFn, LitStr, parse_macro_input};
22
23/// Transforms a function into a `RustTool` implementation.
24///
25/// The macro generates:
26/// - A `{FnName}Params` struct deriving `Deserialize` and `JsonSchema`
27/// - A `{FnName}` unit struct (`PascalCase`) implementing `RustTool`
28///
29/// The tool **name** is the function name (`snake_case`).
30/// The tool **description** comes from one of the sources below.
31/// Parameter names and types come from the function signature.
32/// Doc comments on parameters become schema descriptions.
33///
34/// # Description sources (in priority order)
35///
36/// | Syntax | Cost | Feature |
37/// |--------|------|---------|
38/// | `#[llm_tool]` + doc comment | Zero (static `&str`) | — |
39/// | `#[llm_tool(prompt = "inline text")]` | Zero (static `&str`) | — |
40/// | `#[llm_tool(response_file = "...")]` | Runtime render | `md-tmpl` |
41/// | `#[llm_tool(prompt_file = "tools/x.tmpl.md")]` | Zero (compiled) | `md-tmpl` |
42/// | `#[llm_tool(prompt_file = "...", params(k = "v"))]` | Zero (compiled) | `md-tmpl` |
43/// | `#[llm_tool(prompt_file = "...", context = fn)]` | Runtime `Cow::Owned` | `md-tmpl` |
44///
45/// ## Inline description
46///
47/// Override or replace the doc comment with an inline string:
48///
49/// ```text
50/// #[llm_tool(prompt = "Get the current weather for a city.")]
51/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
52/// ```
53///
54/// ## Template descriptions (feature: `md-tmpl`)
55///
56/// Load the description from a `.tmpl.md` file:
57///
58/// ```text
59/// #[llm_tool(prompt_file = "tools/weather.tmpl.md")]
60/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
61/// ```
62///
63/// For templates with variables, provide **compile-time** key-value pairs:
64///
65/// ```text
66/// #[llm_tool(prompt_file = "tools/weather.tmpl.md", params(api = "v3", env = "prod"))]
67/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
68/// ```
69///
70/// The macro reads the template, validates all declared variables are
71/// provided, renders the description, and embeds the result as a static
72/// string — **zero runtime cost**.
73///
74/// For **runtime** context (e.g. values from config), provide a context function:
75///
76/// ```text
77/// #[llm_tool(prompt_file = "tools/weather.tmpl.md", context = build_ctx)]
78/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
79/// ```
80///
81/// The context function signature is `fn(&ToolStruct) -> Context`.
82/// Templates are parsed once at startup via `LazyLock`.
83///
84/// # Typed parameters
85///
86/// Parameters may use `&str` — the generated params struct stores an owned
87/// `String` and the macro auto-borrows it before passing to your function body.
88///
89/// # Response templates
90///
91/// When `response_file = "path/to/response.tmpl.md"` is provided, the
92/// tool's return value (`T: Serialize`) is used to build a template context
93/// via `Context::from_serialize`, rendered through the template, and returned
94/// as `ToolOutput`. The struct is also attached as metadata.
95///
96/// # Return types
97///
98/// The return type can be `Result<T, E>` or just `T` (infallible):
99///
100/// - **`T`**: `String` (wrapped as-is), `ToolOutput` (passed through), any
101///   `T: Serialize` (auto-serialized to JSON), or any `T: Into<ToolOutput>`
102/// - **`E`**: any `E: Into<ToolError>` — built-in for `String`, `ToolError`,
103///   `std::io::Error`, `serde_json::Error`
104#[proc_macro_attribute]
105pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
106    let func = parse_macro_input!(item as ItemFn);
107    let tool_attr = if attr.is_empty() {
108        None
109    } else {
110        match syn::parse::<ToolAttr>(attr) {
111            Ok(parsed) => Some(parsed),
112            Err(err) => return err.to_compile_error().into(),
113        }
114    };
115    match tool_impl(&func, tool_attr.as_ref()) {
116        Ok(tokens) => tokens.into(),
117        Err(err) => err.to_compile_error().into(),
118    }
119}
120
121/// Transforms a function into a `RustPrompt` implementation.
122#[proc_macro_attribute]
123pub fn llm_prompt(attr: TokenStream, item: TokenStream) -> TokenStream {
124    let func = parse_macro_input!(item as ItemFn);
125    let tool_attr = if attr.is_empty() {
126        None
127    } else {
128        match syn::parse::<ToolAttr>(attr) {
129            Ok(parsed) => Some(parsed),
130            Err(err) => return err.to_compile_error().into(),
131        }
132    };
133    match prompt_macro::prompt_impl(&func, tool_attr.as_ref()) {
134        Ok(tokens) => tokens.into(),
135        Err(err) => err.to_compile_error().into(),
136    }
137}
138
139/// Transforms a function into a `RustResource` implementation.
140#[proc_macro_attribute]
141pub fn llm_resource(attr: TokenStream, item: TokenStream) -> TokenStream {
142    let func = parse_macro_input!(item as ItemFn);
143    let res_attr = match syn::parse::<resource_macro::ResourceAttr>(attr) {
144        Ok(parsed) => parsed,
145        Err(err) => return err.to_compile_error().into(),
146    };
147    match resource_macro::resource_impl(&func, &res_attr) {
148        Ok(tokens) => tokens.into(),
149        Err(err) => err.to_compile_error().into(),
150    }
151}
152
153// ── Attribute Parsing ───────────────────────────────────────────────────────
154
155/// Parsed `#[llm_tool(...)]` attribute.
156///
157/// Supports:
158/// - `prompt = "inline text"` — static inline description
159/// - `prompt_file = "path.tmpl.md"` — template file (requires `md-tmpl`)
160/// - `params(key = "value", ...)` — compile-time template variables
161/// - `context = path::to::fn` — runtime template context function
162/// - `response_file = "path.tmpl.md"` — response rendering template
163struct ToolAttr {
164    /// Inline description string (mutually exclusive with `prompt_file_path`).
165    prompt_inline: Option<LitStr>,
166    /// Path to a `.tmpl.md` file (mutually exclusive with `prompt_inline`).
167    prompt_file_path: Option<LitStr>,
168    /// Path to a response `.tmpl.md` file for auto-rendering tool output.
169    response_file_path: Option<LitStr>,
170    /// Inline response template string (mutually exclusive with `response_file_path`).
171    response_inline: Option<LitStr>,
172    /// Compile-time key-value pairs for template rendering.
173    /// Mutually exclusive with `context_fn`.
174    #[cfg(feature = "md-tmpl")]
175    inline_params: Vec<(Ident, LitStr)>,
176    /// Runtime context function (mutually exclusive with `inline_params`).
177    #[cfg(feature = "md-tmpl")]
178    context_fn: Option<syn::Path>,
179    has_inline_params: bool,
180    has_context_fn: bool,
181}
182
183const ATTR_PROMPT: &str = "prompt";
184const ATTR_PROMPT_FILE: &str = "prompt_file";
185const ATTR_RESPONSE_FILE: &str = "response_file";
186const ATTR_RESPONSE: &str = "response";
187const ATTR_PARAMS: &str = "params";
188const ATTR_CONTEXT: &str = "context";
189const TYPE_OPTION: &str = "Option";
190const TYPE_TOOL_CONTEXT: &str = "ToolContext";
191const TYPE_STR: &str = "str";
192const ATTR_LLM_TOOL: &str = "llm_tool";
193
194#[derive(Default)]
195struct ToolAttrBuilder {
196    prompt_inline: Option<syn::LitStr>,
197    prompt_file_path: Option<syn::LitStr>,
198    response_file_path: Option<syn::LitStr>,
199    response_inline: Option<syn::LitStr>,
200    #[cfg(feature = "md-tmpl")]
201    inline_params: Vec<(syn::Ident, syn::LitStr)>,
202    #[cfg(feature = "md-tmpl")]
203    context_fn: Option<syn::Path>,
204    #[cfg(not(feature = "md-tmpl"))]
205    has_inline_params: bool,
206    #[cfg(not(feature = "md-tmpl"))]
207    has_context_fn: bool,
208}
209
210impl ToolAttrBuilder {
211    fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
212        let ident: syn::Ident = input.parse()?;
213        if ident == ATTR_PROMPT {
214            let _: syn::Token![=] = input.parse()?;
215            self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
216        } else if ident == ATTR_PROMPT_FILE {
217            let _: syn::Token![=] = input.parse()?;
218            self.prompt_file_path = Some(input.parse::<syn::LitStr>()?);
219        } else if ident == ATTR_RESPONSE_FILE {
220            let _: syn::Token![=] = input.parse()?;
221            self.response_file_path = Some(input.parse::<syn::LitStr>()?);
222        } else if ident == ATTR_RESPONSE {
223            let _: syn::Token![=] = input.parse()?;
224            self.response_inline = Some(input.parse::<syn::LitStr>()?);
225        } else if ident == ATTR_PARAMS {
226            let content;
227            syn::parenthesized!(content in input);
228            while !content.is_empty() {
229                let key: syn::Ident = content.parse()?;
230                let _: syn::Token![=] = content.parse()?;
231                let value: syn::LitStr = content.parse()?;
232                #[cfg(feature = "md-tmpl")]
233                self.inline_params.push((key, value));
234                #[cfg(not(feature = "md-tmpl"))]
235                {
236                    drop(key);
237                    drop(value);
238                }
239                if !content.is_empty() {
240                    let _: syn::Token![,] = content.parse()?;
241                }
242            }
243            #[cfg(not(feature = "md-tmpl"))]
244            {
245                self.has_inline_params = true;
246            }
247        } else if ident == ATTR_CONTEXT {
248            let _: syn::Token![=] = input.parse()?;
249            #[cfg(feature = "md-tmpl")]
250            {
251                self.context_fn = Some(input.parse::<syn::Path>()?);
252            }
253            #[cfg(not(feature = "md-tmpl"))]
254            {
255                let _path: syn::Path = input.parse()?;
256                self.has_context_fn = true;
257            }
258        } else {
259            return Err(syn::Error::new(
260                ident.span(),
261                "expected `prompt`, `prompt_file`, `response`, `response_file`, `params`, or `context`",
262            ));
263        }
264        Ok(())
265    }
266}
267
268impl syn::parse::Parse for ToolAttr {
269    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
270        let mut builder = ToolAttrBuilder::default();
271
272        while !input.is_empty() {
273            builder.parse_single(input)?;
274            if !input.is_empty() {
275                let _: syn::Token![,] = input.parse()?;
276            }
277        }
278
279        #[cfg(feature = "md-tmpl")]
280        let (has_inline_params, has_context_fn) = (
281            !builder.inline_params.is_empty(),
282            builder.context_fn.is_some(),
283        );
284        #[cfg(not(feature = "md-tmpl"))]
285        let (has_inline_params, has_context_fn) =
286            (builder.has_inline_params, builder.has_context_fn);
287
288        validate_tool_attr(
289            builder.prompt_inline.as_ref(),
290            builder.prompt_file_path.as_ref(),
291            has_inline_params,
292            has_context_fn,
293        )?;
294
295        if builder.response_inline.is_some() && builder.response_file_path.is_some() {
296            return Err(syn::Error::new(
297                proc_macro2::Span::call_site(),
298                "cannot specify both `response` and `response_file`",
299            ));
300        }
301
302        // Validate response_file requires md-tmpl feature.
303        #[cfg(not(feature = "md-tmpl"))]
304        if builder.response_file_path.is_some() || builder.response_inline.is_some() {
305            return Err(syn::Error::new(
306                proc_macro2::Span::call_site(),
307                "the `md-tmpl` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
308            ));
309        }
310
311        Ok(Self {
312            prompt_inline: builder.prompt_inline,
313            prompt_file_path: builder.prompt_file_path,
314            response_file_path: builder.response_file_path,
315            response_inline: builder.response_inline,
316            #[cfg(feature = "md-tmpl")]
317            inline_params: builder.inline_params,
318            #[cfg(feature = "md-tmpl")]
319            context_fn: builder.context_fn,
320            has_inline_params,
321            has_context_fn,
322        })
323    }
324}
325
326/// Validate mutual-exclusion and presence constraints for parsed `#[llm_tool(...)]`
327/// attribute fields.
328fn validate_tool_attr(
329    prompt_inline: Option<&LitStr>,
330    prompt_file_path: Option<&LitStr>,
331    has_inline_params: bool,
332    has_context_fn: bool,
333) -> syn::Result<()> {
334    // Mutual exclusion: prompt vs prompt_file.
335    if prompt_inline.is_some() && prompt_file_path.is_some() {
336        return Err(syn::Error::new(
337            proc_macro2::Span::call_site(),
338            "`prompt` and `prompt_file` are mutually exclusive",
339        ));
340    }
341
342    // params/context only make sense with prompt_file.
343    if prompt_file_path.is_none() && has_inline_params {
344        return Err(syn::Error::new(
345            proc_macro2::Span::call_site(),
346            "`params(...)` requires `prompt_file = \"...\"`",
347        ));
348    }
349    if prompt_file_path.is_none() && has_context_fn {
350        return Err(syn::Error::new(
351            proc_macro2::Span::call_site(),
352            "`context = ...` requires `prompt_file = \"...\"`",
353        ));
354    }
355
356    // params and context are mutually exclusive.
357    if has_inline_params && has_context_fn {
358        return Err(syn::Error::new(
359            proc_macro2::Span::call_site(),
360            "`params(...)` and `context = ...` are mutually exclusive; \
361             use `params` for compile-time values or `context` for runtime values",
362        ));
363    }
364
365    // Must have at least prompt or prompt_file (unless only response_file
366    // is set, in which case doc comments serve as the description).
367    if prompt_inline.is_none()
368        && prompt_file_path.is_none()
369        && !has_inline_params
370        && !has_context_fn
371    {
372        // This is fine — doc comments will be used as fallback.
373    }
374
375    Ok(())
376}
377
378// ── Implementation ──────────────────────────────────────────────────────────
379
380/// Parsed information about a single function parameter.
381struct ParamInfo {
382    name: syn::Ident,
383    ty: Box<syn::Type>,
384    doc_attrs: Vec<syn::Attribute>,
385    is_context: bool,
386}
387
388/// Information about the function's return type.
389enum ReturnInfo {
390    /// `Result<T, E>` — fallible tool.
391    ResultType {
392        ok_type: Box<syn::Type>,
393        err_type: Box<syn::Type>,
394    },
395    /// Bare `T` — infallible tool.
396    BareType,
397}
398
399fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
400    let crate_path = quote! { ::llm_tool };
401    let fn_name = &func.sig.ident;
402    let tool_name_str = fn_name.to_string();
403    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
404    let params_name = format_ident!("{}Params", struct_name);
405
406    // Resolve description: template file OR doc comment.
407    let DescriptionInfo {
408        static_description,
409        helper_tokens,
410        description_method,
411        dep_tracking,
412    } = resolve_description(func, attr)?;
413
414    // Resolve response template (if provided).
415    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
416
417    // Extract parameters, separating ToolContext from regular params.
418    let all_params = extract_params(func)?;
419    let ctx_param = all_params.iter().find(|p| p.is_context);
420    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
421
422    // Enforce doc comments on every non-ToolContext parameter.
423    for param in &params {
424        if param.doc_attrs.is_empty() {
425            return Err(syn::Error::new_spanned(
426                &param.name,
427                format!(
428                    "#[llm_tool] parameter `{}` must have a doc comment \
429                      (used as the parameter description in the JSON schema)",
430                    param.name
431                ),
432            ));
433        }
434    }
435
436    // Parse return type: either Result<T, E> or bare T.
437    let return_info = parse_return_type(func)?;
438
439    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
440    let param_descriptions: Vec<String> = params
441        .iter()
442        .map(|p| extract_doc_string(&p.doc_attrs))
443        .collect();
444
445    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
446    let serde_defaults = build_serde_defaults(&params);
447    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
448
449    let vis = &func.vis;
450
451    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
452    let struct_doc = format!(
453        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
454    );
455
456    // If the user's function takes a ToolContext parameter, bind it from the
457    // `_ctx` reference provided by the RustTool::call signature.
458    let ctx_binding = if let Some(cp) = ctx_param {
459        let ctx_name = &cp.name;
460        quote! { let #ctx_name = _ctx; }
461    } else {
462        quote! {}
463    };
464
465    let response_dep_tracking = &response_info.dep_tracking;
466    let response_helper_tokens = &response_info.helper_tokens;
467
468    Ok(quote! {
469        #dep_tracking
470        #response_dep_tracking
471        #helper_tokens
472        #response_helper_tokens
473
474        #[doc = #params_doc]
475        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
476        #vis struct #params_name {
477            #(
478                #[schemars(description = #param_descriptions)]
479                #serde_defaults
480                pub #param_names: #param_struct_types,
481            )*
482        }
483
484        #[doc = #struct_doc]
485        #vis struct #struct_name;
486
487        impl #crate_path::RustTool for #struct_name {
488            type Params = #params_name;
489            const NAME: &'static str = #tool_name_str;
490            const DESCRIPTION: &'static str = #static_description;
491
492            #description_method
493
494            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
495                // Import the fallback trait so `Wrap<T>::__convert()` resolves
496                // for `T: Serialize` types that lack an inherent `__convert`.
497                use #crate_path::__private::SerializeFallback as _;
498                // Destructure params into local bindings matching the original
499                // function signature.
500                let #params_name { #( #param_names, )* } = params;
501                // Auto-borrow &str params from their owned String fields.
502                #( #borrow_bindings )*
503                #ctx_binding
504                #body_tokens
505            }
506        }
507    })
508}
509
510// ── Description Resolution ──────────────────────────────────────────────────
511
512/// Structured output from description resolution.
513struct DescriptionInfo {
514    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
515    static_description: String,
516    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
517    helper_tokens: proc_macro2::TokenStream,
518    /// Implementation of the `description(&self)` method if dynamic.
519    description_method: Option<proc_macro2::TokenStream>,
520    /// Cargo dependency-tracking tokens.
521    dep_tracking: proc_macro2::TokenStream,
522}
523
524pub(crate) mod desc;
525pub(crate) mod helpers;
526#[allow(clippy::wildcard_imports)]
527pub(crate) use desc::*;
528#[allow(clippy::wildcard_imports)]
529pub(crate) use helpers::*;