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 `description_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(description = "inline text")]` | Zero (static `&str`) | — |
40/// | `#[llm_tool(response_file = "...")]` | Runtime render | `md-tmpl` |
41/// | `#[llm_tool(description_file = "tools/x.tmpl.md")]` | Zero (compiled) | `md-tmpl` |
42/// | `#[llm_tool(description_file = "...", params(k = "v"))]` | Zero (compiled) | `md-tmpl` |
43/// | `#[llm_tool(description_file = "...", env(K = "v"))]` | Zero (compiled) | `md-tmpl` |
44/// | `#[llm_tool(description_file = "...", context = fn)]` | Runtime `Cow::Owned` | `md-tmpl` |
45///
46/// ## Inline description
47///
48/// Override or replace the doc comment with an inline string:
49///
50/// ```text
51/// #[llm_tool(description = "Get the current weather for a city.")]
52/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
53/// ```
54///
55/// ## Template descriptions (feature: `md-tmpl`)
56///
57/// Load the description from a `.tmpl.md` file:
58///
59/// ```text
60/// #[llm_tool(description_file = "tools/weather.tmpl.md")]
61/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
62/// ```
63///
64/// For templates with variables, provide **compile-time** key-value pairs:
65///
66/// ```text
67/// #[llm_tool(description_file = "tools/weather.tmpl.md", params(api = "v3", env = "prod"))]
68/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
69/// ```
70///
71/// The macro reads the template, validates all declared variables are
72/// provided, renders the description, and embeds the result as a static
73/// string — **zero runtime cost**.
74///
75/// For **runtime** context (e.g. values from config), provide a context function:
76///
77/// ```text
78/// #[llm_tool(description_file = "tools/weather.tmpl.md", context = build_ctx)]
79/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
80/// ```
81///
82/// The context function signature is `fn(&ToolStruct) -> Context`.
83/// Templates are parsed once at startup via `LazyLock`.
84///
85/// ## Environment variables (feature: `md-tmpl`)
86///
87/// Templates can declare `env:` variables in their frontmatter. These are
88/// separate from `params:` — they represent build-time configuration
89/// (deployment environment, API version, etc.) rather than template parameters.
90///
91/// In the template:
92/// ```text
93/// ---
94/// env:
95///   - API_VERSION = str
96///   - MAX_RETRIES = int := 3
97/// ---
98/// Uses API {{ API_VERSION }} with {{ MAX_RETRIES }} retries.
99/// ```
100///
101/// Supply values via the `env(...)` attribute:
102/// ```text
103/// #[llm_tool(description_file = "tools/api.tmpl.md", env(API_VERSION = "v5"))]
104/// fn query_api(/* … */) -> Result<String, ToolError> { /* … */ }
105/// ```
106///
107/// Env values are resolved at compile time, producing a zero-cost static
108/// description. They can be combined with `params(...)` or `context = fn`.
109///
110/// # Typed parameters
111///
112/// Parameters may use `&str` — the generated params struct stores an owned
113/// `String` and the macro auto-borrows it before passing to your function body.
114///
115/// # Response templates
116///
117/// When `response_file = "path/to/response.tmpl.md"` is provided, the
118/// tool's return value (`T: Serialize`) is used to build a template context
119/// via `Context::from_serialize`, rendered through the template, and returned
120/// as `ToolOutput`. The struct is also attached as metadata.
121///
122/// # Return types
123///
124/// The return type can be `Result<T, E>` or just `T` (infallible):
125///
126/// - **`T`**: `String` (wrapped as-is), `ToolOutput` (passed through), any
127///   `T: Serialize` (auto-serialized to JSON), or any `T: Into<ToolOutput>`
128/// - **`E`**: any `E: Into<ToolError>` — built-in for `String`, `ToolError`,
129///   `std::io::Error`, `serde_json::Error`
130#[proc_macro_attribute]
131pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
132    let func = parse_macro_input!(item as ItemFn);
133    let tool_attr = if attr.is_empty() {
134        None
135    } else {
136        match syn::parse::<ToolAttr>(attr) {
137            Ok(parsed) => Some(parsed),
138            Err(err) => return err.to_compile_error().into(),
139        }
140    };
141    match tool_impl(&func, tool_attr.as_ref()) {
142        Ok(tokens) => tokens.into(),
143        Err(err) => err.to_compile_error().into(),
144    }
145}
146
147/// Transforms a function into a `RustPrompt` implementation.
148#[proc_macro_attribute]
149pub fn llm_prompt(attr: TokenStream, item: TokenStream) -> TokenStream {
150    let func = parse_macro_input!(item as ItemFn);
151    let tool_attr = if attr.is_empty() {
152        None
153    } else {
154        match syn::parse::<ToolAttr>(attr) {
155            Ok(parsed) => Some(parsed),
156            Err(err) => return err.to_compile_error().into(),
157        }
158    };
159    match prompt_macro::prompt_impl(&func, tool_attr.as_ref()) {
160        Ok(tokens) => tokens.into(),
161        Err(err) => err.to_compile_error().into(),
162    }
163}
164
165/// Transforms a function into a `RustResource` implementation.
166#[proc_macro_attribute]
167pub fn llm_resource(attr: TokenStream, item: TokenStream) -> TokenStream {
168    let func = parse_macro_input!(item as ItemFn);
169    let res_attr = match syn::parse::<resource_macro::ResourceAttr>(attr) {
170        Ok(parsed) => parsed,
171        Err(err) => return err.to_compile_error().into(),
172    };
173    match resource_macro::resource_impl(&func, &res_attr) {
174        Ok(tokens) => tokens.into(),
175        Err(err) => err.to_compile_error().into(),
176    }
177}
178
179// ── Attribute Parsing ───────────────────────────────────────────────────────
180
181/// Parsed `#[llm_tool(...)]` attribute.
182///
183/// Supports:
184/// - `description = "inline text"` — static inline description
185/// - `description_file = "path.tmpl.md"` — template file (requires `md-tmpl`)
186/// - `params(key = "value", ...)` — compile-time template variables
187/// - `env(KEY = "value", ...)` — compile-time environment variables for `env:` frontmatter
188/// - `context = path::to::fn` — runtime template context function
189/// - `response_file = "path.tmpl.md"` — response rendering template
190struct ToolAttr {
191    /// Inline description string (mutually exclusive with `description_file_path`).
192    description_inline: Option<LitStr>,
193    /// Path to a `.tmpl.md` file (mutually exclusive with `description_inline`).
194    description_file_path: Option<LitStr>,
195    /// Path to a response `.tmpl.md` file for auto-rendering tool output.
196    response_file_path: Option<LitStr>,
197    /// Inline response template string (mutually exclusive with `response_file_path`).
198    response_inline: Option<LitStr>,
199    /// Compile-time key-value pairs for template rendering.
200    /// Mutually exclusive with `context_fn`.
201    #[cfg(feature = "md-tmpl")]
202    inline_params: Vec<(Ident, LitStr)>,
203    /// Compile-time environment variables for `env:` frontmatter declarations.
204    #[cfg(feature = "md-tmpl")]
205    env_vars: Vec<(Ident, syn::Lit)>,
206    /// Runtime context function (mutually exclusive with `inline_params`).
207    #[cfg(feature = "md-tmpl")]
208    context_fn: Option<syn::Path>,
209    has_inline_params: bool,
210    has_context_fn: bool,
211}
212
213const ATTR_DESCRIPTION: &str = "description";
214const ATTR_DESCRIPTION_FILE: &str = "description_file";
215const ATTR_RESPONSE_FILE: &str = "response_file";
216const ATTR_RESPONSE: &str = "response";
217const ATTR_PARAMS: &str = "params";
218const ATTR_CONTEXT: &str = "context";
219const ATTR_ENV: &str = "env";
220const TYPE_OPTION: &str = "Option";
221const TYPE_TOOL_CONTEXT: &str = "ToolContext";
222const TYPE_STR: &str = "str";
223const ATTR_LLM_TOOL: &str = "llm_tool";
224
225#[derive(Default)]
226struct ToolAttrBuilder {
227    description_inline: Option<syn::LitStr>,
228    description_file_path: Option<syn::LitStr>,
229    response_file_path: Option<syn::LitStr>,
230    response_inline: Option<syn::LitStr>,
231    #[cfg(feature = "md-tmpl")]
232    inline_params: Vec<(syn::Ident, syn::LitStr)>,
233    #[cfg(feature = "md-tmpl")]
234    env_vars: Vec<(syn::Ident, syn::Lit)>,
235    #[cfg(feature = "md-tmpl")]
236    context_fn: Option<syn::Path>,
237    #[cfg(not(feature = "md-tmpl"))]
238    has_inline_params: bool,
239    #[cfg(not(feature = "md-tmpl"))]
240    has_context_fn: bool,
241    #[cfg(not(feature = "md-tmpl"))]
242    has_env: bool,
243}
244
245impl ToolAttrBuilder {
246    fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
247        let ident: syn::Ident = input.parse()?;
248        if ident == ATTR_DESCRIPTION {
249            let _: syn::Token![=] = input.parse()?;
250            self.description_inline = Some(input.parse::<syn::LitStr>()?);
251        } else if ident == ATTR_DESCRIPTION_FILE {
252            let _: syn::Token![=] = input.parse()?;
253            self.description_file_path = Some(input.parse::<syn::LitStr>()?);
254        } else if ident == ATTR_RESPONSE_FILE {
255            let _: syn::Token![=] = input.parse()?;
256            self.response_file_path = Some(input.parse::<syn::LitStr>()?);
257        } else if ident == ATTR_RESPONSE {
258            let _: syn::Token![=] = input.parse()?;
259            self.response_inline = Some(input.parse::<syn::LitStr>()?);
260        } else if ident == ATTR_PARAMS {
261            let content;
262            syn::parenthesized!(content in input);
263            while !content.is_empty() {
264                let key: syn::Ident = content.parse()?;
265                let _: syn::Token![=] = content.parse()?;
266                let value: syn::LitStr = content.parse()?;
267                #[cfg(feature = "md-tmpl")]
268                self.inline_params.push((key, value));
269                #[cfg(not(feature = "md-tmpl"))]
270                {
271                    drop(key);
272                    drop(value);
273                }
274                if !content.is_empty() {
275                    let _: syn::Token![,] = content.parse()?;
276                }
277            }
278            #[cfg(not(feature = "md-tmpl"))]
279            {
280                self.has_inline_params = true;
281            }
282        } else if ident == ATTR_ENV {
283            let content;
284            syn::parenthesized!(content in input);
285            while !content.is_empty() {
286                let key: syn::Ident = content.parse()?;
287                let _: syn::Token![=] = content.parse()?;
288                let value: syn::Lit = content.parse()?;
289                match &value {
290                    syn::Lit::Str(_)
291                    | syn::Lit::Int(_)
292                    | syn::Lit::Float(_)
293                    | syn::Lit::Bool(_) => {}
294                    other => {
295                        return Err(syn::Error::new(
296                            other.span(),
297                            "env values must be string, integer, float, or bool literals",
298                        ));
299                    }
300                }
301                #[cfg(feature = "md-tmpl")]
302                self.env_vars.push((key, value));
303                #[cfg(not(feature = "md-tmpl"))]
304                {
305                    drop(key);
306                    drop(value);
307                }
308                if !content.is_empty() {
309                    let _: syn::Token![,] = content.parse()?;
310                }
311            }
312            #[cfg(not(feature = "md-tmpl"))]
313            {
314                self.has_env = true;
315            }
316        } else if ident == ATTR_CONTEXT {
317            let _: syn::Token![=] = input.parse()?;
318            #[cfg(feature = "md-tmpl")]
319            {
320                self.context_fn = Some(input.parse::<syn::Path>()?);
321            }
322            #[cfg(not(feature = "md-tmpl"))]
323            {
324                let _path: syn::Path = input.parse()?;
325                self.has_context_fn = true;
326            }
327        } else {
328            return Err(syn::Error::new(
329                ident.span(),
330                "expected `description`, `description_file`, `response`, `response_file`, `params`, `env`, or `context`",
331            ));
332        }
333        Ok(())
334    }
335}
336
337impl syn::parse::Parse for ToolAttr {
338    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
339        let mut builder = ToolAttrBuilder::default();
340
341        while !input.is_empty() {
342            builder.parse_single(input)?;
343            if !input.is_empty() {
344                let _: syn::Token![,] = input.parse()?;
345            }
346        }
347
348        #[cfg(feature = "md-tmpl")]
349        let (has_inline_params, has_context_fn, has_env) = (
350            !builder.inline_params.is_empty(),
351            builder.context_fn.is_some(),
352            !builder.env_vars.is_empty(),
353        );
354        #[cfg(not(feature = "md-tmpl"))]
355        let (has_inline_params, has_context_fn, has_env) = (
356            builder.has_inline_params,
357            builder.has_context_fn,
358            builder.has_env,
359        );
360
361        validate_tool_attr(
362            builder.description_inline.as_ref(),
363            builder.description_file_path.as_ref(),
364            has_inline_params,
365            has_context_fn,
366            has_env,
367        )?;
368
369        if builder.response_inline.is_some() && builder.response_file_path.is_some() {
370            return Err(syn::Error::new(
371                proc_macro2::Span::call_site(),
372                "cannot specify both `response` and `response_file`",
373            ));
374        }
375
376        // Validate response_file requires md-tmpl feature.
377        #[cfg(not(feature = "md-tmpl"))]
378        if builder.response_file_path.is_some() || builder.response_inline.is_some() {
379            return Err(syn::Error::new(
380                proc_macro2::Span::call_site(),
381                "the `md-tmpl` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
382            ));
383        }
384
385        Ok(Self {
386            description_inline: builder.description_inline,
387            description_file_path: builder.description_file_path,
388            response_file_path: builder.response_file_path,
389            response_inline: builder.response_inline,
390            #[cfg(feature = "md-tmpl")]
391            inline_params: builder.inline_params,
392            #[cfg(feature = "md-tmpl")]
393            env_vars: builder.env_vars,
394            #[cfg(feature = "md-tmpl")]
395            context_fn: builder.context_fn,
396            has_inline_params,
397            has_context_fn,
398        })
399    }
400}
401
402/// Validate mutual-exclusion and presence constraints for parsed `#[llm_tool(...)]`
403/// attribute fields.
404fn validate_tool_attr(
405    description_inline: Option<&LitStr>,
406    description_file_path: Option<&LitStr>,
407    has_inline_params: bool,
408    has_context_fn: bool,
409    has_env: bool,
410) -> syn::Result<()> {
411    // Mutual exclusion: description vs description_file.
412    if description_inline.is_some() && description_file_path.is_some() {
413        return Err(syn::Error::new(
414            proc_macro2::Span::call_site(),
415            "`description` and `description_file` are mutually exclusive",
416        ));
417    }
418
419    // params/context require a template source (description_file or description).
420    if description_file_path.is_none() && description_inline.is_none() && has_inline_params {
421        return Err(syn::Error::new(
422            proc_macro2::Span::call_site(),
423            "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
424        ));
425    }
426    if description_file_path.is_none() && description_inline.is_none() && has_context_fn {
427        return Err(syn::Error::new(
428            proc_macro2::Span::call_site(),
429            "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
430        ));
431    }
432
433    // env() requires a template source (description_file or description with frontmatter).
434    if has_env && description_file_path.is_none() && description_inline.is_none() {
435        return Err(syn::Error::new(
436            proc_macro2::Span::call_site(),
437            "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
438        ));
439    }
440
441    // env() requires the md-tmpl feature.
442    #[cfg(not(feature = "md-tmpl"))]
443    if has_env {
444        return Err(syn::Error::new(
445            proc_macro2::Span::call_site(),
446            "the `md-tmpl` feature must be enabled to use `env(...)`. \
447             Add `features = [\"md-tmpl\"]` to your llm-tool dependency.",
448        ));
449    }
450
451    // params and context are mutually exclusive.
452    if has_inline_params && has_context_fn {
453        return Err(syn::Error::new(
454            proc_macro2::Span::call_site(),
455            "`params(...)` and `context = ...` are mutually exclusive; \
456             use `params` for compile-time values or `context` for runtime values",
457        ));
458    }
459
460    // Must have at least description or description_file (unless only response_file
461    // is set, in which case doc comments serve as the description).
462    if description_inline.is_none()
463        && description_file_path.is_none()
464        && !has_inline_params
465        && !has_context_fn
466    {
467        // This is fine — doc comments will be used as fallback.
468    }
469
470    Ok(())
471}
472
473// ── Implementation ──────────────────────────────────────────────────────────
474
475/// Parsed information about a single function parameter.
476struct ParamInfo {
477    name: syn::Ident,
478    ty: Box<syn::Type>,
479    doc_attrs: Vec<syn::Attribute>,
480    is_context: bool,
481}
482
483/// Information about the function's return type.
484enum ReturnInfo {
485    /// `Result<T, E>` — fallible tool.
486    ResultType {
487        ok_type: Box<syn::Type>,
488        err_type: Box<syn::Type>,
489    },
490    /// Bare `T` — infallible tool.
491    BareType,
492}
493
494fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
495    let crate_path = quote! { ::llm_tool };
496    let fn_name = &func.sig.ident;
497    reject_generic_signature(func, ATTR_LLM_TOOL)?;
498    let tool_name_str = fn_name.to_string();
499    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
500    let params_name = format_ident!("{}Params", struct_name);
501
502    // Resolve description: template file OR doc comment.
503    let DescriptionInfo {
504        static_description,
505        helper_tokens,
506        description_method,
507        dep_tracking,
508    } = resolve_description(func, attr)?;
509
510    // Resolve response template (if provided).
511    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
512
513    // Extract parameters, separating ToolContext from regular params.
514    let all_params = extract_params(func)?;
515    let ctx_param = all_params.iter().find(|p| p.is_context);
516    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
517
518    // Enforce doc comments on every non-ToolContext parameter.
519    for param in &params {
520        if param.doc_attrs.is_empty() {
521            return Err(syn::Error::new_spanned(
522                &param.name,
523                format!(
524                    "#[llm_tool] parameter `{}` must have a doc comment \
525                      (used as the parameter description in the JSON schema)",
526                    param.name
527                ),
528            ));
529        }
530    }
531
532    // Parse return type: either Result<T, E> or bare T.
533    let return_info = parse_return_type(func)?;
534
535    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
536    let param_descriptions: Vec<String> = params
537        .iter()
538        .map(|p| extract_doc_string(&p.doc_attrs))
539        .collect();
540
541    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
542    let serde_defaults = build_serde_defaults(&params);
543    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
544
545    let vis = &func.vis;
546
547    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
548    let struct_doc = format!(
549        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
550    );
551
552    // If the user's function takes a ToolContext parameter, bind it from the
553    // `_ctx` reference provided by the RustTool::call signature.
554    let ctx_binding = if let Some(cp) = ctx_param {
555        let ctx_name = &cp.name;
556        quote! { let #ctx_name = _ctx; }
557    } else {
558        quote! {}
559    };
560
561    let response_dep_tracking = &response_info.dep_tracking;
562    let response_helper_tokens = &response_info.helper_tokens;
563
564    Ok(quote! {
565        #dep_tracking
566        #response_dep_tracking
567        #helper_tokens
568        #response_helper_tokens
569
570        #[doc = #params_doc]
571        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
572        #vis struct #params_name {
573            #(
574                #[schemars(description = #param_descriptions)]
575                #serde_defaults
576                pub #param_names: #param_struct_types,
577            )*
578        }
579
580        #[doc = #struct_doc]
581        #vis struct #struct_name;
582
583        impl #crate_path::RustTool for #struct_name {
584            type Params = #params_name;
585            const NAME: &'static str = #tool_name_str;
586            const DESCRIPTION: &'static str = #static_description;
587
588            #description_method
589
590
591            // NOLINT: macro-generated code — the impl may not be async depending on user's function
592            #[allow(unknown_lints, clippy::unused_async_trait_impl)]
593            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
594
595                // Import the fallback trait so `Wrap<T>::__convert()` resolves
596                // for `T: Serialize` types that lack an inherent `__convert`.
597                use #crate_path::__private::SerializeFallback as _;
598                // Destructure params into local bindings matching the original
599                // function signature.
600                let #params_name { #( #param_names, )* } = params;
601                // Auto-borrow &str params from their owned String fields.
602                #( #borrow_bindings )*
603                #ctx_binding
604                #body_tokens
605            }
606        }
607    })
608}
609
610// ── Description Resolution ──────────────────────────────────────────────────
611
612/// Structured output from description resolution.
613struct DescriptionInfo {
614    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
615    static_description: String,
616    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
617    helper_tokens: proc_macro2::TokenStream,
618    /// Implementation of the `description(&self)` method if dynamic.
619    description_method: Option<proc_macro2::TokenStream>,
620    /// Cargo dependency-tracking tokens.
621    dep_tracking: proc_macro2::TokenStream,
622}
623
624pub(crate) mod desc;
625pub(crate) mod helpers;
626pub(crate) use desc::resolve_description;
627pub(crate) use helpers::{
628    build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
629    extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
630};