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 = "...", env(K = "v"))]` | Zero (compiled) | `md-tmpl` |
44/// | `#[llm_tool(prompt_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(prompt = "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(prompt_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(prompt_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(prompt_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(prompt_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/// - `prompt = "inline text"` — static inline description
185/// - `prompt_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 `prompt_file_path`).
192    prompt_inline: Option<LitStr>,
193    /// Path to a `.tmpl.md` file (mutually exclusive with `prompt_inline`).
194    prompt_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_PROMPT: &str = "prompt";
214const ATTR_PROMPT_FILE: &str = "prompt_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    prompt_inline: Option<syn::LitStr>,
228    prompt_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_PROMPT {
249            let _: syn::Token![=] = input.parse()?;
250            self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
251        } else if ident == ATTR_PROMPT_FILE {
252            let _: syn::Token![=] = input.parse()?;
253            self.prompt_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 `prompt`, `prompt_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.prompt_inline.as_ref(),
363            builder.prompt_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            prompt_inline: builder.prompt_inline,
387            prompt_file_path: builder.prompt_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    prompt_inline: Option<&LitStr>,
406    prompt_file_path: Option<&LitStr>,
407    has_inline_params: bool,
408    has_context_fn: bool,
409    has_env: bool,
410) -> syn::Result<()> {
411    // Mutual exclusion: prompt vs prompt_file.
412    if prompt_inline.is_some() && prompt_file_path.is_some() {
413        return Err(syn::Error::new(
414            proc_macro2::Span::call_site(),
415            "`prompt` and `prompt_file` are mutually exclusive",
416        ));
417    }
418
419    // params/context require a template source (prompt_file or prompt).
420    if prompt_file_path.is_none() && prompt_inline.is_none() && has_inline_params {
421        return Err(syn::Error::new(
422            proc_macro2::Span::call_site(),
423            "`params(...)` requires `prompt_file = \"...\"` or `prompt = \"...\"`",
424        ));
425    }
426    if prompt_file_path.is_none() && prompt_inline.is_none() && has_context_fn {
427        return Err(syn::Error::new(
428            proc_macro2::Span::call_site(),
429            "`context = ...` requires `prompt_file = \"...\"` or `prompt = \"...\"`",
430        ));
431    }
432
433    // env() requires a template source (prompt_file or prompt with frontmatter).
434    if has_env && prompt_file_path.is_none() && prompt_inline.is_none() {
435        return Err(syn::Error::new(
436            proc_macro2::Span::call_site(),
437            "`env(...)` requires `prompt_file = \"...\"` or `prompt = \"...\"`",
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 prompt or prompt_file (unless only response_file
461    // is set, in which case doc comments serve as the description).
462    if prompt_inline.is_none()
463        && prompt_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    let tool_name_str = fn_name.to_string();
498    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
499    let params_name = format_ident!("{}Params", struct_name);
500
501    // Resolve description: template file OR doc comment.
502    let DescriptionInfo {
503        static_description,
504        helper_tokens,
505        description_method,
506        dep_tracking,
507    } = resolve_description(func, attr)?;
508
509    // Resolve response template (if provided).
510    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
511
512    // Extract parameters, separating ToolContext from regular params.
513    let all_params = extract_params(func)?;
514    let ctx_param = all_params.iter().find(|p| p.is_context);
515    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
516
517    // Enforce doc comments on every non-ToolContext parameter.
518    for param in &params {
519        if param.doc_attrs.is_empty() {
520            return Err(syn::Error::new_spanned(
521                &param.name,
522                format!(
523                    "#[llm_tool] parameter `{}` must have a doc comment \
524                      (used as the parameter description in the JSON schema)",
525                    param.name
526                ),
527            ));
528        }
529    }
530
531    // Parse return type: either Result<T, E> or bare T.
532    let return_info = parse_return_type(func)?;
533
534    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
535    let param_descriptions: Vec<String> = params
536        .iter()
537        .map(|p| extract_doc_string(&p.doc_attrs))
538        .collect();
539
540    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
541    let serde_defaults = build_serde_defaults(&params);
542    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
543
544    let vis = &func.vis;
545
546    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
547    let struct_doc = format!(
548        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
549    );
550
551    // If the user's function takes a ToolContext parameter, bind it from the
552    // `_ctx` reference provided by the RustTool::call signature.
553    let ctx_binding = if let Some(cp) = ctx_param {
554        let ctx_name = &cp.name;
555        quote! { let #ctx_name = _ctx; }
556    } else {
557        quote! {}
558    };
559
560    let response_dep_tracking = &response_info.dep_tracking;
561    let response_helper_tokens = &response_info.helper_tokens;
562
563    Ok(quote! {
564        #dep_tracking
565        #response_dep_tracking
566        #helper_tokens
567        #response_helper_tokens
568
569        #[doc = #params_doc]
570        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
571        #vis struct #params_name {
572            #(
573                #[schemars(description = #param_descriptions)]
574                #serde_defaults
575                pub #param_names: #param_struct_types,
576            )*
577        }
578
579        #[doc = #struct_doc]
580        #vis struct #struct_name;
581
582        impl #crate_path::RustTool for #struct_name {
583            type Params = #params_name;
584            const NAME: &'static str = #tool_name_str;
585            const DESCRIPTION: &'static str = #static_description;
586
587            #description_method
588
589
590            #[allow(unknown_lints, clippy::unused_async_trait_impl)]
591            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
592
593                // Import the fallback trait so `Wrap<T>::__convert()` resolves
594                // for `T: Serialize` types that lack an inherent `__convert`.
595                use #crate_path::__private::SerializeFallback as _;
596                // Destructure params into local bindings matching the original
597                // function signature.
598                let #params_name { #( #param_names, )* } = params;
599                // Auto-borrow &str params from their owned String fields.
600                #( #borrow_bindings )*
601                #ctx_binding
602                #body_tokens
603            }
604        }
605    })
606}
607
608// ── Description Resolution ──────────────────────────────────────────────────
609
610/// Structured output from description resolution.
611struct DescriptionInfo {
612    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
613    static_description: String,
614    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
615    helper_tokens: proc_macro2::TokenStream,
616    /// Implementation of the `description(&self)` method if dynamic.
617    description_method: Option<proc_macro2::TokenStream>,
618    /// Cargo dependency-tracking tokens.
619    dep_tracking: proc_macro2::TokenStream,
620}
621
622pub(crate) mod desc;
623pub(crate) mod helpers;
624#[allow(clippy::wildcard_imports)]
625pub(crate) use desc::*;
626#[allow(clippy::wildcard_imports)]
627pub(crate) use helpers::*;