1#[cfg(feature = "md-tmpl")]
12mod response_struct_gen;
13#[cfg(feature = "md-tmpl")]
14mod template_codegen;
15#[cfg(feature = "md-tmpl")]
16mod template_compile;
17
18use convert_case::{Case, Casing};
19use proc_macro::TokenStream;
20use quote::{format_ident, quote};
21#[cfg(feature = "md-tmpl")]
22use syn::Ident;
23use syn::{
24 FnArg, GenericArgument, ItemFn, LitStr, Pat, PatType, PathArguments, Type, parse_macro_input,
25};
26
27#[proc_macro_attribute]
109pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
110 let func = parse_macro_input!(item as ItemFn);
111 let tool_attr = if attr.is_empty() {
112 None
113 } else {
114 match syn::parse::<ToolAttr>(attr) {
115 Ok(parsed) => Some(parsed),
116 Err(err) => return err.to_compile_error().into(),
117 }
118 };
119 match tool_impl(&func, tool_attr.as_ref()) {
120 Ok(tokens) => tokens.into(),
121 Err(err) => err.to_compile_error().into(),
122 }
123}
124
125struct ToolAttr {
136 prompt_inline: Option<LitStr>,
138 prompt_file_path: Option<LitStr>,
140 response_file_path: Option<LitStr>,
142 response_inline: Option<LitStr>,
144 #[cfg(feature = "md-tmpl")]
147 inline_params: Vec<(Ident, LitStr)>,
148 #[cfg(feature = "md-tmpl")]
150 context_fn: Option<syn::Path>,
151 has_inline_params: bool,
152 has_context_fn: bool,
153}
154
155const ATTR_PROMPT: &str = "prompt";
156const ATTR_PROMPT_FILE: &str = "prompt_file";
157const ATTR_RESPONSE_FILE: &str = "response_file";
158const ATTR_RESPONSE: &str = "response";
159const ATTR_PARAMS: &str = "params";
160const ATTR_CONTEXT: &str = "context";
161const TYPE_OPTION: &str = "Option";
162const TYPE_TOOL_CONTEXT: &str = "ToolContext";
163const TYPE_STR: &str = "str";
164const ATTR_LLM_TOOL: &str = "llm_tool";
165
166#[derive(Default)]
167struct ToolAttrBuilder {
168 prompt_inline: Option<syn::LitStr>,
169 prompt_file_path: Option<syn::LitStr>,
170 response_file_path: Option<syn::LitStr>,
171 response_inline: Option<syn::LitStr>,
172 #[cfg(feature = "md-tmpl")]
173 inline_params: Vec<(syn::Ident, syn::LitStr)>,
174 #[cfg(feature = "md-tmpl")]
175 context_fn: Option<syn::Path>,
176 #[cfg(not(feature = "md-tmpl"))]
177 has_inline_params: bool,
178 #[cfg(not(feature = "md-tmpl"))]
179 has_context_fn: bool,
180}
181
182impl ToolAttrBuilder {
183 fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
184 let ident: syn::Ident = input.parse()?;
185 if ident == ATTR_PROMPT {
186 let _: syn::Token![=] = input.parse()?;
187 self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
188 } else if ident == ATTR_PROMPT_FILE {
189 let _: syn::Token![=] = input.parse()?;
190 self.prompt_file_path = Some(input.parse::<syn::LitStr>()?);
191 } else if ident == ATTR_RESPONSE_FILE {
192 let _: syn::Token![=] = input.parse()?;
193 self.response_file_path = Some(input.parse::<syn::LitStr>()?);
194 } else if ident == ATTR_RESPONSE {
195 let _: syn::Token![=] = input.parse()?;
196 self.response_inline = Some(input.parse::<syn::LitStr>()?);
197 } else if ident == ATTR_PARAMS {
198 let content;
199 syn::parenthesized!(content in input);
200 while !content.is_empty() {
201 let key: syn::Ident = content.parse()?;
202 let _: syn::Token![=] = content.parse()?;
203 let value: syn::LitStr = content.parse()?;
204 #[cfg(feature = "md-tmpl")]
205 self.inline_params.push((key, value));
206 #[cfg(not(feature = "md-tmpl"))]
207 {
208 drop(key);
209 drop(value);
210 }
211 if !content.is_empty() {
212 let _: syn::Token![,] = content.parse()?;
213 }
214 }
215 #[cfg(not(feature = "md-tmpl"))]
216 {
217 self.has_inline_params = true;
218 }
219 } else if ident == ATTR_CONTEXT {
220 let _: syn::Token![=] = input.parse()?;
221 #[cfg(feature = "md-tmpl")]
222 {
223 self.context_fn = Some(input.parse::<syn::Path>()?);
224 }
225 #[cfg(not(feature = "md-tmpl"))]
226 {
227 let _path: syn::Path = input.parse()?;
228 self.has_context_fn = true;
229 }
230 } else {
231 return Err(syn::Error::new(
232 ident.span(),
233 "expected `prompt`, `prompt_file`, `response`, `response_file`, `params`, or `context`",
234 ));
235 }
236 Ok(())
237 }
238}
239
240impl syn::parse::Parse for ToolAttr {
241 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
242 let mut builder = ToolAttrBuilder::default();
243
244 while !input.is_empty() {
245 builder.parse_single(input)?;
246 if !input.is_empty() {
247 let _: syn::Token![,] = input.parse()?;
248 }
249 }
250
251 #[cfg(feature = "md-tmpl")]
252 let (has_inline_params, has_context_fn) = (
253 !builder.inline_params.is_empty(),
254 builder.context_fn.is_some(),
255 );
256 #[cfg(not(feature = "md-tmpl"))]
257 let (has_inline_params, has_context_fn) =
258 (builder.has_inline_params, builder.has_context_fn);
259
260 validate_tool_attr(
261 builder.prompt_inline.as_ref(),
262 builder.prompt_file_path.as_ref(),
263 has_inline_params,
264 has_context_fn,
265 )?;
266
267 if builder.response_inline.is_some() && builder.response_file_path.is_some() {
268 return Err(syn::Error::new(
269 proc_macro2::Span::call_site(),
270 "cannot specify both `response` and `response_file`",
271 ));
272 }
273
274 #[cfg(not(feature = "md-tmpl"))]
276 if builder.response_file_path.is_some() || builder.response_inline.is_some() {
277 return Err(syn::Error::new(
278 proc_macro2::Span::call_site(),
279 "the `md-tmpl` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
280 ));
281 }
282
283 Ok(Self {
284 prompt_inline: builder.prompt_inline,
285 prompt_file_path: builder.prompt_file_path,
286 response_file_path: builder.response_file_path,
287 response_inline: builder.response_inline,
288 #[cfg(feature = "md-tmpl")]
289 inline_params: builder.inline_params,
290 #[cfg(feature = "md-tmpl")]
291 context_fn: builder.context_fn,
292 has_inline_params,
293 has_context_fn,
294 })
295 }
296}
297
298fn validate_tool_attr(
301 prompt_inline: Option<&LitStr>,
302 prompt_file_path: Option<&LitStr>,
303 has_inline_params: bool,
304 has_context_fn: bool,
305) -> syn::Result<()> {
306 if prompt_inline.is_some() && prompt_file_path.is_some() {
308 return Err(syn::Error::new(
309 proc_macro2::Span::call_site(),
310 "`prompt` and `prompt_file` are mutually exclusive",
311 ));
312 }
313
314 if prompt_file_path.is_none() && has_inline_params {
316 return Err(syn::Error::new(
317 proc_macro2::Span::call_site(),
318 "`params(...)` requires `prompt_file = \"...\"`",
319 ));
320 }
321 if prompt_file_path.is_none() && has_context_fn {
322 return Err(syn::Error::new(
323 proc_macro2::Span::call_site(),
324 "`context = ...` requires `prompt_file = \"...\"`",
325 ));
326 }
327
328 if has_inline_params && has_context_fn {
330 return Err(syn::Error::new(
331 proc_macro2::Span::call_site(),
332 "`params(...)` and `context = ...` are mutually exclusive; \
333 use `params` for compile-time values or `context` for runtime values",
334 ));
335 }
336
337 if prompt_inline.is_none()
340 && prompt_file_path.is_none()
341 && !has_inline_params
342 && !has_context_fn
343 {
344 }
346
347 Ok(())
348}
349
350struct ParamInfo {
354 name: syn::Ident,
355 ty: Box<syn::Type>,
356 doc_attrs: Vec<syn::Attribute>,
357 is_context: bool,
358}
359
360enum ReturnInfo {
362 ResultType {
364 ok_type: Box<syn::Type>,
365 err_type: Box<syn::Type>,
366 },
367 BareType,
369}
370
371fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
372 let crate_path = quote! { ::llm_tool };
373 let fn_name = &func.sig.ident;
374 let tool_name_str = fn_name.to_string();
375 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
376 let params_name = format_ident!("{}Params", struct_name);
377
378 let DescriptionInfo {
380 static_description,
381 helper_tokens,
382 description_method,
383 dep_tracking,
384 } = resolve_description(func, attr)?;
385
386 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
388
389 let all_params = extract_params(func)?;
391 let ctx_param = all_params.iter().find(|p| p.is_context);
392 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
393
394 for param in ¶ms {
396 if param.doc_attrs.is_empty() {
397 return Err(syn::Error::new_spanned(
398 ¶m.name,
399 format!(
400 "#[llm_tool] parameter `{}` must have a doc comment \
401 (used as the parameter description in the JSON schema)",
402 param.name
403 ),
404 ));
405 }
406 }
407
408 let return_info = parse_return_type(func)?;
410
411 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
412 let param_descriptions: Vec<String> = params
413 .iter()
414 .map(|p| extract_doc_string(&p.doc_attrs))
415 .collect();
416
417 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
418 let serde_defaults = build_serde_defaults(¶ms);
419 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
420
421 let vis = &func.vis;
422
423 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
424 let struct_doc = format!(
425 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
426 );
427
428 let ctx_binding = if let Some(cp) = ctx_param {
431 let ctx_name = &cp.name;
432 quote! { let #ctx_name = _ctx; }
433 } else {
434 quote! {}
435 };
436
437 let response_dep_tracking = &response_info.dep_tracking;
438 let response_helper_tokens = &response_info.helper_tokens;
439
440 Ok(quote! {
441 #dep_tracking
442 #response_dep_tracking
443 #helper_tokens
444 #response_helper_tokens
445
446 #[doc = #params_doc]
447 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
448 #vis struct #params_name {
449 #(
450 #[schemars(description = #param_descriptions)]
451 #serde_defaults
452 pub #param_names: #param_struct_types,
453 )*
454 }
455
456 #[doc = #struct_doc]
457 #vis struct #struct_name;
458
459 impl #crate_path::RustTool for #struct_name {
460 type Params = #params_name;
461 const NAME: &'static str = #tool_name_str;
462 const DESCRIPTION: &'static str = #static_description;
463
464 #description_method
465
466 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
467 use #crate_path::__private::SerializeFallback as _;
470 let #params_name { #( #param_names, )* } = params;
473 #( #borrow_bindings )*
475 #ctx_binding
476 #body_tokens
477 }
478 }
479 })
480}
481
482struct DescriptionInfo {
486 static_description: String,
488 helper_tokens: proc_macro2::TokenStream,
490 description_method: Option<proc_macro2::TokenStream>,
492 dep_tracking: proc_macro2::TokenStream,
494}
495
496fn resolve_description(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<DescriptionInfo> {
498 match attr {
499 Some(
501 tool_attr @ ToolAttr {
502 prompt_inline: Some(_),
503 ..
504 },
505 ) => resolve_inline_description(tool_attr),
506 Some(
508 tool_attr @ ToolAttr {
509 prompt_file_path: Some(_),
510 ..
511 },
512 ) => resolve_template_description(tool_attr),
513 _ => {
515 let desc = extract_doc_string(&func.attrs);
516 if desc.is_empty() {
517 return Err(syn::Error::new_spanned(
518 &func.sig.ident,
519 "#[llm_tool] functions must have a doc comment \
520 (used as the tool description), or use \
521 #[llm_tool(prompt = \"...\")]",
522 ));
523 }
524 Ok(DescriptionInfo {
525 static_description: desc,
526 helper_tokens: quote! {},
527 description_method: None,
528 dep_tracking: quote! {},
529 })
530 }
531 }
532}
533
534fn resolve_inline_description(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
536 #[cfg(not(feature = "md-tmpl"))]
537 {
538 let span = attr
539 .prompt_inline
540 .as_ref()
541 .map_or(proc_macro2::Span::call_site(), LitStr::span);
542 if attr.has_inline_params || attr.has_context_fn {
543 return Err(syn::Error::new(
544 span,
545 "the `md-tmpl` feature must be enabled to use dynamic inline prompts",
546 ));
547 }
548 let desc = attr.prompt_inline.as_ref().unwrap().value();
549 Ok(DescriptionInfo {
550 static_description: desc,
551 helper_tokens: quote! {},
552 description_method: None,
553 dep_tracking: quote! {},
554 })
555 }
556
557 #[cfg(feature = "md-tmpl")]
558 resolve_inline_description_impl(attr)
559}
560
561fn resolve_template_description(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
563 #[cfg(not(feature = "md-tmpl"))]
564 {
565 let span = attr
566 .prompt_file_path
567 .as_ref()
568 .map_or(proc_macro2::Span::call_site(), LitStr::span);
569 Err(syn::Error::new(
570 span,
571 "the `md-tmpl` feature must be enabled to use \
572 `#[llm_tool(prompt_file = \"...\")]`. \
573 Add `features = [\"md-tmpl\"]` to your llm-tool dependency.",
574 ))
575 }
576
577 #[cfg(feature = "md-tmpl")]
578 resolve_template_description_impl(attr)
579}
580
581#[cfg(feature = "md-tmpl")]
588fn resolve_template_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
589 let template_lit = attr
590 .prompt_file_path
591 .as_ref()
592 .expect("prompt_file_path validated");
593 let rel_path = template_lit.value();
594 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
595 let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
596
597 let source = std::fs::read_to_string(&full_path).map_err(|e| {
598 syn::Error::new(
599 template_lit.span(),
600 format!("failed to read template '{}': {e}", full_path.display()),
601 )
602 })?;
603 let source = template_compile::normalize_and_validate_syntax(&source, &rel_path)
604 .map_err(|e| syn::Error::new(template_lit.span(), e))?;
605
606 let cur_dir = template_compile::REL_PREFIX_CUR.trim_end_matches(template_compile::CHAR_SLASH);
607 let base_dir = full_path.parent().unwrap_or(std::path::Path::new(cur_dir));
608 let (fm, body) = md_tmpl::parse_frontmatter_with_base_dir(&source, base_dir).map_err(|e| {
609 syn::Error::new(
610 template_lit.span(),
611 format!("template '{rel_path}' error: {e}"),
612 )
613 })?;
614
615 let body_str = body.trim().to_string();
616 let path_str = full_path.to_string_lossy().to_string();
617
618 let dep_tracking = quote! {
621 const _: &str = include_str!(#path_str);
622 };
623
624 let has_params = !attr.inline_params.is_empty();
625 let has_context = attr.context_fn.is_some();
626 let has_declarations = !fm.declarations.is_empty();
627
628 if !has_declarations && !has_params && !has_context {
629 Ok(DescriptionInfo {
631 static_description: body_str,
632 helper_tokens: quote! {},
633 description_method: None,
634 dep_tracking,
635 })
636 } else if has_params {
637 resolve_template_with_params(
639 attr,
640 &fm,
641 &source,
642 &rel_path,
643 template_lit.span(),
644 dep_tracking,
645 )
646 } else if has_context {
647 resolve_context_description(ResolveContextArgs {
649 attr,
650 rel_path: &rel_path,
651 template_lit,
652 source: &source,
653 full_path: &full_path,
654 body_str: &body_str,
655 has_declarations,
656 dep_tracking,
657 })
658 } else {
659 let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
661 Err(syn::Error::new(
662 template_lit.span(),
663 format!(
664 "template '{rel_path}' declares parameters ({}) but neither \
665 `params(...)` nor `context = ...` was provided",
666 declared.join(", ")
667 ),
668 ))
669 }
670}
671
672#[cfg(feature = "md-tmpl")]
674fn resolve_inline_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
675 let template_lit = attr
676 .prompt_inline
677 .as_ref()
678 .expect("prompt_inline validated");
679 let source = template_lit.value();
680 let trimmed = source.trim_start();
681 if !trimmed.starts_with(template_compile::FRONTMATTER_DELIM) {
682 return Ok(DescriptionInfo {
683 static_description: source,
684 helper_tokens: quote! {},
685 description_method: None,
686 dep_tracking: quote! {},
687 });
688 }
689
690 let source =
691 template_compile::normalize_and_validate_syntax(&source, template_compile::LABEL_INLINE)
692 .map_err(|e| syn::Error::new(template_lit.span(), e))?;
693 let (fm, body) = md_tmpl::parse_frontmatter(&source)
694 .map_err(|e| syn::Error::new(template_lit.span(), format!("inline template error: {e}")))?;
695
696 let body_str = body.trim().to_string();
697
698 let has_params = attr.has_inline_params;
699 let has_context = attr.has_context_fn;
700 let has_declarations = !fm.declarations.is_empty();
701
702 if !has_declarations && !has_params && !has_context {
703 Ok(DescriptionInfo {
705 static_description: body_str,
706 helper_tokens: quote! {},
707 description_method: None,
708 dep_tracking: quote! {},
709 })
710 } else if has_params {
711 resolve_template_with_params(
713 attr,
714 &fm,
715 &source,
716 "<inline>",
717 template_lit.span(),
718 quote! {},
719 )
720 } else if has_context {
721 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
723 let base_dir = std::path::Path::new(&manifest_dir);
724 let ast = template_compile::compile_template_to_ast(&source, base_dir).map_err(|e| {
725 syn::Error::new(
726 template_lit.span(),
727 format!("inline template compilation error: {e}"),
728 )
729 })?;
730 let tmpl_tokens = template_codegen::codegen_template(&ast);
731
732 let context_fn = attr.context_fn.as_ref().unwrap();
733
734 let description_method = quote! {
735 fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
736 static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__md_tmpl::Template> =
737 ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
738 let ctx = #context_fn(self);
739 let rendered = TEMPLATE.render_ctx(&ctx)
740 .expect("Failed to render tool description template");
741 ::llm_tool::__private::Cow::Owned(rendered)
742 }
743 };
744
745 Ok(DescriptionInfo {
746 static_description: body_str.clone(),
747 helper_tokens: quote! {},
748 description_method: Some(description_method),
749 dep_tracking: quote! {},
750 })
751 } else {
752 let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
753 Err(syn::Error::new(
754 template_lit.span(),
755 format!(
756 "inline template declares parameters ({}) but neither \
757 `params(...)` nor `context = ...` was provided",
758 declared.join(", ")
759 ),
760 ))
761 }
762}
763
764#[cfg(feature = "md-tmpl")]
765struct ResolveContextArgs<'a> {
766 attr: &'a ToolAttr,
767 rel_path: &'a str,
768 template_lit: &'a LitStr,
769 source: &'a str,
770 full_path: &'a std::path::Path,
771 body_str: &'a str,
772 has_declarations: bool,
773 dep_tracking: proc_macro2::TokenStream,
774}
775
776#[cfg(feature = "md-tmpl")]
782fn resolve_context_description(args: ResolveContextArgs<'_>) -> syn::Result<DescriptionInfo> {
783 let ResolveContextArgs {
784 attr,
785 rel_path,
786 template_lit,
787 source,
788 full_path,
789 body_str,
790 has_declarations,
791 dep_tracking,
792 } = args;
793 let context_fn = attr.context_fn.as_ref().ok_or_else(|| {
794 syn::Error::new(
795 template_lit.span(),
796 "internal error: resolve_context_description called without context_fn",
797 )
798 })?;
799
800 if !has_declarations {
801 return Err(syn::Error::new(
802 template_lit.span(),
803 format!(
804 "template '{rel_path}' has no declared parameters, \
805 so `context = ...` is unnecessary. Remove `context` \
806 or add params to the template."
807 ),
808 ));
809 }
810
811 let base_dir = full_path.parent().unwrap_or(std::path::Path::new("."));
812 let ast = template_compile::compile_template_to_ast(source, base_dir).map_err(|e| {
813 syn::Error::new(
814 template_lit.span(),
815 format!("template '{rel_path}' compilation error: {e}"),
816 )
817 })?;
818 let tmpl_tokens = template_codegen::codegen_template(&ast);
819
820 let description_method = quote! {
823 fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
824 static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__md_tmpl::Template> =
825 ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
826 let ctx = #context_fn(self);
827 let rendered = TEMPLATE.render_ctx(&ctx)
828 .expect("Failed to render tool description template");
829 ::llm_tool::__private::Cow::Owned(rendered)
830 }
831 };
832
833 Ok(DescriptionInfo {
834 static_description: body_str.to_string(),
835 helper_tokens: quote! {},
836 description_method: Some(description_method),
837 dep_tracking,
838 })
839}
840
841#[cfg(feature = "md-tmpl")]
848fn resolve_template_with_params(
849 attr: &ToolAttr,
850 fm: &md_tmpl::Frontmatter,
851 source: &str,
852 rel_path: &str,
853 span: proc_macro2::Span,
854 dep_tracking: proc_macro2::TokenStream,
855) -> syn::Result<DescriptionInfo> {
856 let mut expected_names = std::collections::HashSet::new();
857 let mut struct_fields: std::collections::HashMap<String, String> =
858 std::collections::HashMap::new();
859
860 for decl in &fm.declarations {
861 if let md_tmpl::VarType::Struct(fields) = &decl.var_type {
862 for f in fields {
863 expected_names.insert(f.name.as_str());
864 struct_fields.insert(f.name.clone(), decl.name.clone());
865 }
866 } else {
867 expected_names.insert(decl.name.as_str());
868 }
869 }
870
871 let provided_names: std::collections::HashSet<String> = attr
872 .inline_params
873 .iter()
874 .map(|(k, _)| k.to_string())
875 .collect();
876
877 let missing: Vec<&str> = expected_names
879 .iter()
880 .filter(|n| !provided_names.contains(**n))
881 .copied()
882 .collect();
883 if !missing.is_empty() {
884 return Err(syn::Error::new(
885 span,
886 format!(
887 "template '{rel_path}' declares parameters not provided in `params(...)`: {}",
888 missing.join(", ")
889 ),
890 ));
891 }
892
893 for (key, _) in &attr.inline_params {
895 let key_str = key.to_string();
896 if !expected_names.contains(key_str.as_str()) {
897 return Err(syn::Error::new(
898 key.span(),
899 format!(
900 "param `{key_str}` is not declared in template '{rel_path}'. \
901 Declared params: {}",
902 expected_names.into_iter().collect::<Vec<_>>().join(", ")
903 ),
904 ));
905 }
906 }
907
908 let template = md_tmpl::Template::from_source(source)
910 .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' parse error: {e}")))?;
911
912 let mut root_values: std::collections::HashMap<String, md_tmpl::Value> =
913 std::collections::HashMap::new();
914 let mut struct_maps: std::collections::HashMap<
915 String,
916 std::collections::HashMap<String, md_tmpl::Value>,
917 > = std::collections::HashMap::new();
918
919 for (key, value) in &attr.inline_params {
920 let key_str = key.to_string();
921 if let Some(parent_struct) = struct_fields.get(&key_str) {
922 struct_maps
923 .entry(parent_struct.clone())
924 .or_default()
925 .insert(key_str, md_tmpl::Value::Str(value.value()));
926 } else {
927 root_values.insert(key_str, md_tmpl::Value::Str(value.value()));
928 }
929 }
930
931 for (struct_name, s_map) in struct_maps {
932 root_values.insert(
933 struct_name,
934 md_tmpl::Value::Struct(std::sync::Arc::new(s_map.into_iter().collect())),
935 );
936 }
937
938 let mut ctx = md_tmpl::Context::new();
939 for (k, v) in root_values {
940 ctx.set(k, v);
941 }
942
943 let rendered = template
944 .render_ctx(&ctx)
945 .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' render error: {e}")))?;
946
947 Ok(DescriptionInfo {
948 static_description: rendered,
949 helper_tokens: quote! {},
950 description_method: None,
951 dep_tracking,
952 })
953}
954
955fn build_param_types_and_borrows(
957 params: &[&ParamInfo],
958) -> (Vec<proc_macro2::TokenStream>, Vec<proc_macro2::TokenStream>) {
959 params
960 .iter()
961 .map(|p| {
962 if is_str_ref(&p.ty) {
963 let name = &p.name;
965 (quote! { String }, quote! { let #name: &str = &#name; })
966 } else {
967 let ty = &p.ty;
968 (quote! { #ty }, quote! {})
969 }
970 })
971 .unzip()
972}
973
974fn build_serde_defaults(params: &[&ParamInfo]) -> Vec<proc_macro2::TokenStream> {
976 params
977 .iter()
978 .map(|p| {
979 if is_option_type(&p.ty) {
980 quote! { #[serde(default)] }
981 } else {
982 quote! {}
983 }
984 })
985 .collect()
986}
987
988fn build_body_tokens(
999 func: &ItemFn,
1000 return_info: &ReturnInfo,
1001 crate_path: &proc_macro2::TokenStream,
1002 response_info: &ResponseTemplateInfo,
1003) -> proc_macro2::TokenStream {
1004 let is_async = func.sig.asyncness.is_some();
1005 let body_stmts = &func.block.stmts;
1006
1007 match return_info {
1008 ReturnInfo::ResultType { ok_type, err_type } => {
1009 let inner = if is_async {
1010 quote! {
1011 let __r: ::core::result::Result<#ok_type, #err_type> = async move {
1012 #( #body_stmts )*
1013 }.await;
1014 }
1015 } else {
1016 quote! {
1017 let __r: ::core::result::Result<#ok_type, #err_type> = (|| { #( #body_stmts )* })();
1018 }
1019 };
1020 let ok_branch = build_ok_branch(crate_path, response_info);
1021 quote! {
1022 #inner
1023 match __r {
1024 ::core::result::Result::Ok(__v) => { #ok_branch },
1025 ::core::result::Result::Err(__e) => ::core::result::Result::Err(::core::convert::Into::into(__e)),
1026 }
1027 }
1028 }
1029 ReturnInfo::BareType => {
1030 let inner = if is_async {
1031 quote! {
1032 let __v = async move { #( #body_stmts )* }.await;
1033 }
1034 } else {
1035 quote! {
1036 let __v = (|| { #( #body_stmts )* })();
1037 }
1038 };
1039 let ok_branch = build_ok_branch(crate_path, response_info);
1040 quote! {
1041 #inner
1042 #ok_branch
1043 }
1044 }
1045 }
1046}
1047
1048fn build_ok_branch(
1051 crate_path: &proc_macro2::TokenStream,
1052 response_info: &ResponseTemplateInfo,
1053) -> proc_macro2::TokenStream {
1054 if let Some(ref render_tokens) = response_info.render_tokens {
1055 render_tokens.clone()
1056 } else {
1057 quote! { #crate_path::__private::Wrap(__v).__convert() }
1058 }
1059}
1060
1061struct ResponseTemplateInfo {
1065 dep_tracking: proc_macro2::TokenStream,
1067 helper_tokens: proc_macro2::TokenStream,
1069 render_tokens: Option<proc_macro2::TokenStream>,
1072}
1073
1074impl Default for ResponseTemplateInfo {
1075 fn default() -> Self {
1076 Self {
1077 dep_tracking: quote! {},
1078 helper_tokens: quote! {},
1079 render_tokens: None,
1080 }
1081 }
1082}
1083
1084#[allow(unused_variables)]
1085fn resolve_response_template(
1086 attr: Option<&ToolAttr>,
1087 struct_name: &syn::Ident,
1088 fn_name: &syn::Ident,
1089) -> syn::Result<ResponseTemplateInfo> {
1090 let Some(attr) = attr else {
1091 return Ok(ResponseTemplateInfo::default());
1092 };
1093
1094 if let Some(response_path) = &attr.response_file_path {
1095 #[cfg(not(feature = "md-tmpl"))]
1096 {
1097 return Err(syn::Error::new(
1098 response_path.span(),
1099 "the `md-tmpl` feature must be enabled to use `response_file`",
1100 ));
1101 }
1102 #[cfg(feature = "md-tmpl")]
1103 {
1104 return resolve_response_template_file(response_path, struct_name, fn_name);
1105 }
1106 }
1107 if let Some(response_inline) = &attr.response_inline {
1108 #[cfg(not(feature = "md-tmpl"))]
1109 {
1110 return Err(syn::Error::new(
1111 response_inline.span(),
1112 "the `md-tmpl` feature must be enabled to use `response`",
1113 ));
1114 }
1115 #[cfg(feature = "md-tmpl")]
1116 {
1117 return resolve_response_template_inline(response_inline, struct_name, fn_name);
1118 }
1119 }
1120 Ok(ResponseTemplateInfo::default())
1121}
1122
1123#[cfg(feature = "md-tmpl")]
1125fn resolve_response_template_file(
1126 response_path: &LitStr,
1127 struct_name: &syn::Ident,
1128 fn_name: &syn::Ident,
1129) -> syn::Result<ResponseTemplateInfo> {
1130 let rel_path = response_path.value();
1131 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
1132 let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
1133 let path_str = full_path.to_string_lossy().to_string();
1134
1135 let source = std::fs::read_to_string(&full_path).map_err(|e| {
1137 syn::Error::new(
1138 response_path.span(),
1139 format!(
1140 "failed to read response template '{}': {e}",
1141 full_path.display()
1142 ),
1143 )
1144 })?;
1145 let source = template_compile::normalize_and_validate_syntax(&source, &rel_path)
1146 .map_err(|e| syn::Error::new(response_path.span(), e))?;
1147
1148 let dep_tracking = quote! {
1149 const _: &str = include_str!(#path_str);
1150 };
1151
1152 let cur_dir = template_compile::REL_PREFIX_CUR.trim_end_matches(template_compile::CHAR_SLASH);
1153 let base_dir = full_path.parent().unwrap_or(std::path::Path::new(cur_dir));
1154 let (fm, _) = md_tmpl::parse_frontmatter_with_base_dir(&source, base_dir).map_err(|e| {
1155 syn::Error::new(
1156 response_path.span(),
1157 format!("response template '{rel_path}' frontmatter error: {e}"),
1158 )
1159 })?;
1160
1161 let response_struct_name_str = format!("{struct_name}Response");
1162 let generated_idents = response_struct_gen::collect_generated_type_names(
1163 &response_struct_name_str,
1164 &fm.declarations,
1165 );
1166
1167 let response_struct_name = format_ident!("{}", response_struct_name_str);
1168 let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1169
1170 let helper_tokens = quote! {
1171 ::llm_tool::__md_tmpl_macros::template!(
1172 #source as #response_struct_name => #response_mod_name,
1173 crate = ::llm_tool::__md_tmpl
1174 );
1175 pub use #response_mod_name::{ #( #generated_idents ),* };
1176 };
1177
1178 let render_tokens = quote! {
1179 {
1180 let __rendered = #response_mod_name::template().render(&__v)
1181 .map_err(|e| ::llm_tool::ToolError::new(
1182 format!("response template render error: {e}")
1183 ))?;
1184 ::llm_tool::ToolOutput::new(__rendered)
1185 .with_metadata(&__v)
1186 .map_err(|e| ::llm_tool::ToolError::new(
1187 format!("response metadata error: {e}")
1188 ))
1189 }
1190 };
1191
1192 Ok(ResponseTemplateInfo {
1193 dep_tracking,
1194 helper_tokens,
1195 render_tokens: Some(render_tokens),
1196 })
1197}
1198
1199#[cfg(feature = "md-tmpl")]
1201fn resolve_response_template_inline(
1202 response_inline: &LitStr,
1203 struct_name: &syn::Ident,
1204 fn_name: &syn::Ident,
1205) -> syn::Result<ResponseTemplateInfo> {
1206 let source = response_inline.value();
1207 let source = template_compile::normalize_and_validate_syntax(
1208 &source,
1209 template_compile::LABEL_INLINE_RESP,
1210 )
1211 .map_err(|e| syn::Error::new(response_inline.span(), e))?;
1212
1213 let fm = match md_tmpl::parse_frontmatter(&source) {
1215 Ok((fm, _)) => fm,
1216 Err(e) => {
1217 return Err(syn::Error::new(
1218 response_inline.span(),
1219 format!("inline response template error: {e}"),
1220 ));
1221 }
1222 };
1223
1224 let response_struct_name_str = format!("{struct_name}Response");
1225 let generated_idents = response_struct_gen::collect_generated_type_names(
1226 &response_struct_name_str,
1227 &fm.declarations,
1228 );
1229
1230 let response_struct_name = format_ident!("{}", response_struct_name_str);
1231 let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1232
1233 let helper_tokens = quote! {
1234 ::llm_tool::__md_tmpl_macros::template!(
1235 #source as #response_struct_name => #response_mod_name,
1236 crate = ::llm_tool::__md_tmpl
1237 );
1238 pub use #response_mod_name::{ #( #generated_idents ),* };
1239 };
1240
1241 let render_tokens = quote! {
1242 {
1243 let __rendered = #response_mod_name::template().render(&__v)
1244 .map_err(|e| ::llm_tool::ToolError::new(
1245 format!("response template render error: {e}")
1246 ))?;
1247 ::llm_tool::ToolOutput::new(__rendered)
1248 .with_metadata(&__v)
1249 .map_err(|e| ::llm_tool::ToolError::new(
1250 format!("response metadata error: {e}")
1251 ))
1252 }
1253 };
1254
1255 Ok(ResponseTemplateInfo {
1256 dep_tracking: quote! {},
1257 helper_tokens,
1258 render_tokens: Some(render_tokens),
1259 })
1260}
1261
1262fn is_option_type(ty: &syn::Type) -> bool {
1264 let Type::Path(type_path) = ty else {
1265 return false;
1266 };
1267 let Some(last_seg) = type_path.path.segments.last() else {
1268 return false;
1269 };
1270 if last_seg.ident != TYPE_OPTION {
1271 return false;
1272 }
1273 matches!(&last_seg.arguments, PathArguments::AngleBracketed(args)
1274 if args.args.len() == 1
1275 && matches!(args.args.first(), Some(GenericArgument::Type(_))))
1276}
1277
1278fn is_tool_context_type(ty: &syn::Type) -> bool {
1281 let inner = match ty {
1282 Type::Reference(r) => r.elem.as_ref(),
1283 other => other,
1284 };
1285 let Type::Path(type_path) = inner else {
1286 return false;
1287 };
1288 type_path
1289 .path
1290 .segments
1291 .last()
1292 .is_some_and(|seg| seg.ident == TYPE_TOOL_CONTEXT)
1293}
1294
1295fn is_str_ref(ty: &syn::Type) -> bool {
1297 let Type::Reference(ref_type) = ty else {
1298 return false;
1299 };
1300 if ref_type.mutability.is_some() {
1301 return false;
1302 }
1303 let Type::Path(type_path) = ref_type.elem.as_ref() else {
1304 return false;
1305 };
1306 type_path
1307 .path
1308 .segments
1309 .last()
1310 .is_some_and(|seg| seg.ident == TYPE_STR && seg.arguments.is_none())
1311}
1312
1313fn is_explicit_context_attr(attr: &syn::Attribute) -> syn::Result<bool> {
1314 if !attr.path().is_ident(ATTR_LLM_TOOL) {
1315 return Ok(false);
1316 }
1317 let mut is_context = false;
1318 attr.parse_nested_meta(|meta| {
1319 if meta.path.is_ident(ATTR_CONTEXT) {
1320 is_context = true;
1321 Ok(())
1322 } else {
1323 Err(meta.error("unsupported llm_tool attribute"))
1324 }
1325 })?;
1326 Ok(is_context)
1327}
1328
1329fn extract_params(func: &ItemFn) -> syn::Result<Vec<ParamInfo>> {
1330 let mut params = Vec::new();
1331 for arg in &func.sig.inputs {
1332 match arg {
1333 FnArg::Receiver(r) => {
1334 return Err(syn::Error::new_spanned(
1335 r,
1336 "#[llm_tool] functions must be free functions (no `self`)",
1337 ));
1338 }
1339 FnArg::Typed(PatType { pat, ty, attrs, .. }) => {
1340 let name = match pat.as_ref() {
1341 Pat::Ident(ident) => ident.ident.clone(),
1342 other => {
1343 return Err(syn::Error::new_spanned(
1344 other,
1345 "#[llm_tool] parameters must be simple identifiers",
1346 ));
1347 }
1348 };
1349
1350 let mut has_context_attr = false;
1351 for a in attrs {
1352 has_context_attr |= is_explicit_context_attr(a)?;
1353 }
1354 let is_tool_context = is_tool_context_type(ty);
1355 let is_context = has_context_attr || is_tool_context;
1356
1357 if is_tool_context && !matches!(ty.as_ref(), syn::Type::Reference(_)) {
1358 return Err(syn::Error::new_spanned(
1359 ty,
1360 "ToolContext parameter must be a reference type (e.g., `&ToolContext` or `&'a ToolContext`)",
1361 ));
1362 }
1363
1364 let doc_attrs: Vec<syn::Attribute> = attrs
1365 .iter()
1366 .filter(|a| a.path().is_ident("doc"))
1367 .cloned()
1368 .collect();
1369 params.push(ParamInfo {
1370 name,
1371 ty: ty.clone(),
1372 doc_attrs,
1373 is_context,
1374 });
1375 }
1376 }
1377 }
1378 Ok(params)
1379}
1380
1381fn extract_doc_string(attrs: &[syn::Attribute]) -> String {
1382 let lines: Vec<String> = attrs
1383 .iter()
1384 .filter_map(|attr| {
1385 if !attr.path().is_ident("doc") {
1386 return None;
1387 }
1388 if let syn::Meta::NameValue(nv) = &attr.meta
1389 && let syn::Expr::Lit(lit) = &nv.value
1390 && let syn::Lit::Str(s) = &lit.lit
1391 {
1392 return Some(s.value());
1393 }
1394 None
1395 })
1396 .collect();
1397 lines
1398 .iter()
1399 .map(|l| l.trim())
1400 .collect::<Vec<_>>()
1401 .join("\n")
1402 .trim()
1403 .to_string()
1404}
1405
1406fn parse_return_type(func: &ItemFn) -> syn::Result<ReturnInfo> {
1408 let syn::ReturnType::Type(_, ty) = &func.sig.output else {
1409 return Err(syn::Error::new_spanned(
1410 &func.sig,
1411 "#[llm_tool] functions must have an explicit return type",
1412 ));
1413 };
1414
1415 if let Some(result_types) = try_extract_result_types(ty) {
1417 return Ok(result_types);
1418 }
1419
1420 Ok(ReturnInfo::BareType)
1422}
1423
1424fn try_extract_result_types(ty: &syn::Type) -> Option<ReturnInfo> {
1427 let Type::Path(type_path) = ty else {
1428 return None;
1429 };
1430
1431 let last_seg = type_path.path.segments.last()?;
1432
1433 if last_seg.ident != "Result" {
1434 return None;
1435 }
1436
1437 let PathArguments::AngleBracketed(args) = &last_seg.arguments else {
1438 return None;
1439 };
1440
1441 if args.args.len() != 2 {
1442 return None;
1443 }
1444
1445 let GenericArgument::Type(ok_type) = &args.args[0] else {
1446 return None;
1447 };
1448
1449 let GenericArgument::Type(err_type) = &args.args[1] else {
1450 return None;
1451 };
1452
1453 Some(ReturnInfo::ResultType {
1454 ok_type: Box::new(ok_type.clone()),
1455 err_type: Box::new(err_type.clone()),
1456 })
1457}