1mod 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#[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#[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#[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
179struct ToolAttr {
191 description_inline: Option<LitStr>,
193 description_file_path: Option<LitStr>,
195 response_file_path: Option<LitStr>,
197 response_inline: Option<LitStr>,
199 #[cfg(feature = "md-tmpl")]
202 inline_params: Vec<(Ident, LitStr)>,
203 #[cfg(feature = "md-tmpl")]
205 env_vars: Vec<(Ident, syn::Lit)>,
206 #[cfg(feature = "md-tmpl")]
208 context_fn: Option<syn::Path>,
209 has_inline_params: bool,
210 has_context_fn: bool,
211}
212
213pub(crate) const MACRO_LLM_TOOL: &str = "llm_tool";
214pub(crate) const MACRO_LLM_PROMPT: &str = "llm_prompt";
215pub(crate) const MACRO_LLM_RESOURCE: &str = "llm_resource";
216
217pub(crate) const ATTR_DESCRIPTION: &str = "description";
218pub(crate) const ATTR_DESCRIPTION_FILE: &str = "description_file";
219pub(crate) const ATTR_RESPONSE_FILE: &str = "response_file";
220pub(crate) const ATTR_RESPONSE: &str = "response";
221pub(crate) const ATTR_PARAMS: &str = "params";
222pub(crate) const ATTR_CONTEXT: &str = "context";
223pub(crate) const ATTR_ENV: &str = "env";
224pub(crate) const ATTR_DOC: &str = "doc";
225
226pub(crate) const TYPE_OPTION: &str = "Option";
227pub(crate) const TYPE_TOOL_CONTEXT: &str = "ToolContext";
228pub(crate) const TYPE_STR: &str = "str";
229pub(crate) const TYPE_RESULT: &str = "Result";
230
231#[derive(Copy, Clone, PartialEq, Eq, Debug)]
232pub(crate) enum ToolAttrKey {
233 Description,
234 DescriptionFile,
235 ResponseFile,
236 Response,
237 Params,
238 Env,
239 Context,
240}
241
242impl ToolAttrKey {
243 pub(crate) const ALL: &'static [Self] = &[
244 Self::Description,
245 Self::DescriptionFile,
246 Self::Response,
247 Self::ResponseFile,
248 Self::Params,
249 Self::Env,
250 Self::Context,
251 ];
252
253 pub(crate) const fn as_str(self) -> &'static str {
254 match self {
255 Self::Description => ATTR_DESCRIPTION,
256 Self::DescriptionFile => ATTR_DESCRIPTION_FILE,
257 Self::ResponseFile => ATTR_RESPONSE_FILE,
258 Self::Response => ATTR_RESPONSE,
259 Self::Params => ATTR_PARAMS,
260 Self::Env => ATTR_ENV,
261 Self::Context => ATTR_CONTEXT,
262 }
263 }
264
265 pub(crate) fn expected_keys_error(span: proc_macro2::Span) -> syn::Error {
266 let mut parts: Vec<String> = Self::ALL
267 .iter()
268 .map(|k| format!("`{}`", k.as_str()))
269 .collect();
270 let last = parts.pop().expect("ToolAttrKey::ALL is non-empty");
271 let formatted = if parts.is_empty() {
272 last
273 } else {
274 format!("{}, or {last}", parts.join(", "))
275 };
276 syn::Error::new(span, format!("expected {formatted}"))
277 }
278}
279
280impl TryFrom<&syn::Ident> for ToolAttrKey {
281 type Error = syn::Error;
282
283 fn try_from(ident: &syn::Ident) -> Result<Self, Self::Error> {
284 let s = ident.to_string();
285 for &variant in Self::ALL {
286 if s == variant.as_str() {
287 return Ok(variant);
288 }
289 }
290 Err(Self::expected_keys_error(ident.span()))
291 }
292}
293
294#[derive(Default)]
295pub(crate) struct ToolAttrBuilder {
296 description_inline: Option<syn::LitStr>,
297 description_file_path: Option<syn::LitStr>,
298 response_file_path: Option<syn::LitStr>,
299 response_inline: Option<syn::LitStr>,
300 #[cfg(feature = "md-tmpl")]
301 inline_params: Vec<(syn::Ident, syn::LitStr)>,
302 #[cfg(feature = "md-tmpl")]
303 env_vars: Vec<(syn::Ident, syn::Lit)>,
304 #[cfg(feature = "md-tmpl")]
305 context_fn: Option<syn::Path>,
306 #[cfg(not(feature = "md-tmpl"))]
307 has_inline_params: bool,
308 #[cfg(not(feature = "md-tmpl"))]
309 has_context_fn: bool,
310 #[cfg(not(feature = "md-tmpl"))]
311 has_env: bool,
312}
313
314impl ToolAttrBuilder {
315 fn parse_params_attr(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
316 let content;
317 syn::parenthesized!(content in input);
318 while !content.is_empty() {
319 let key: syn::Ident = content.parse()?;
320 let _: syn::Token![=] = content.parse()?;
321 let value: syn::LitStr = content.parse()?;
322 #[cfg(feature = "md-tmpl")]
323 self.inline_params.push((key, value));
324 #[cfg(not(feature = "md-tmpl"))]
325 {
326 drop(key);
327 drop(value);
328 }
329 if !content.is_empty() {
330 let _: syn::Token![,] = content.parse()?;
331 }
332 }
333 #[cfg(not(feature = "md-tmpl"))]
334 {
335 self.has_inline_params = true;
336 }
337 Ok(())
338 }
339
340 fn parse_env_attr(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
341 let content;
342 syn::parenthesized!(content in input);
343 while !content.is_empty() {
344 let key: syn::Ident = content.parse()?;
345 let _: syn::Token![=] = content.parse()?;
346 let value: syn::Lit = content.parse()?;
347 match &value {
348 syn::Lit::Str(_) | syn::Lit::Int(_) | syn::Lit::Float(_) | syn::Lit::Bool(_) => {}
349 other => {
350 return Err(syn::Error::new(
351 other.span(),
352 "env values must be string, integer, float, or bool literals",
353 ));
354 }
355 }
356 #[cfg(feature = "md-tmpl")]
357 self.env_vars.push((key, value));
358 #[cfg(not(feature = "md-tmpl"))]
359 {
360 drop(key);
361 drop(value);
362 }
363 if !content.is_empty() {
364 let _: syn::Token![,] = content.parse()?;
365 }
366 }
367 #[cfg(not(feature = "md-tmpl"))]
368 {
369 self.has_env = true;
370 }
371 Ok(())
372 }
373
374 fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
375 let ident: syn::Ident = input.parse()?;
376 let key = ToolAttrKey::try_from(&ident)?;
377 self.parse_by_key(&ident, key, input)
378 }
379
380 pub(crate) fn parse_by_key(
381 &mut self,
382 ident: &syn::Ident,
383 key: ToolAttrKey,
384 input: syn::parse::ParseStream,
385 ) -> syn::Result<()> {
386 match key {
387 ToolAttrKey::Description => {
388 let _: syn::Token![=] = input.parse()?;
389 if self.description_inline.is_some() {
390 return Err(syn::Error::new(
391 ident.span(),
392 format!("duplicate `{}` attribute", key.as_str()),
393 ));
394 }
395 self.description_inline = Some(input.parse::<syn::LitStr>()?);
396 }
397 ToolAttrKey::DescriptionFile => {
398 let _: syn::Token![=] = input.parse()?;
399 if self.description_file_path.is_some() {
400 return Err(syn::Error::new(
401 ident.span(),
402 format!("duplicate `{}` attribute", key.as_str()),
403 ));
404 }
405 self.description_file_path = Some(input.parse::<syn::LitStr>()?);
406 }
407 ToolAttrKey::ResponseFile => {
408 let _: syn::Token![=] = input.parse()?;
409 if self.response_file_path.is_some() {
410 return Err(syn::Error::new(
411 ident.span(),
412 format!("duplicate `{}` attribute", key.as_str()),
413 ));
414 }
415 self.response_file_path = Some(input.parse::<syn::LitStr>()?);
416 }
417 ToolAttrKey::Response => {
418 let _: syn::Token![=] = input.parse()?;
419 if self.response_inline.is_some() {
420 return Err(syn::Error::new(
421 ident.span(),
422 format!("duplicate `{}` attribute", key.as_str()),
423 ));
424 }
425 self.response_inline = Some(input.parse::<syn::LitStr>()?);
426 }
427 ToolAttrKey::Params => {
428 self.parse_params_attr(input)?;
429 }
430 ToolAttrKey::Env => {
431 self.parse_env_attr(input)?;
432 }
433 ToolAttrKey::Context => {
434 let _: syn::Token![=] = input.parse()?;
435 #[cfg(feature = "md-tmpl")]
436 {
437 if self.context_fn.is_some() {
438 return Err(syn::Error::new(
439 ident.span(),
440 format!("duplicate `{}` attribute", key.as_str()),
441 ));
442 }
443 self.context_fn = Some(input.parse::<syn::Path>()?);
444 }
445 #[cfg(not(feature = "md-tmpl"))]
446 {
447 let _path: syn::Path = input.parse()?;
448 if self.has_context_fn {
449 return Err(syn::Error::new(
450 ident.span(),
451 format!("duplicate `{}` attribute", key.as_str()),
452 ));
453 }
454 self.has_context_fn = true;
455 }
456 }
457 }
458 Ok(())
459 }
460
461 pub(crate) fn build(self) -> syn::Result<ToolAttr> {
462 #[cfg(feature = "md-tmpl")]
463 let has_inline_params = !self.inline_params.is_empty();
464 #[cfg(not(feature = "md-tmpl"))]
465 let has_inline_params = self.has_inline_params;
466
467 #[cfg(feature = "md-tmpl")]
468 let has_context_fn = self.context_fn.is_some();
469 #[cfg(not(feature = "md-tmpl"))]
470 let has_context_fn = self.has_context_fn;
471
472 validate_tool_attr(&self)?;
473
474 Ok(ToolAttr {
475 description_inline: self.description_inline,
476 description_file_path: self.description_file_path,
477 response_file_path: self.response_file_path,
478 response_inline: self.response_inline,
479 #[cfg(feature = "md-tmpl")]
480 inline_params: self.inline_params,
481 #[cfg(feature = "md-tmpl")]
482 env_vars: self.env_vars,
483 #[cfg(feature = "md-tmpl")]
484 context_fn: self.context_fn,
485 has_inline_params,
486 has_context_fn,
487 })
488 }
489}
490
491impl syn::parse::Parse for ToolAttr {
492 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
493 let mut builder = ToolAttrBuilder::default();
494
495 while !input.is_empty() {
496 builder.parse_single(input)?;
497 if !input.is_empty() {
498 let _: syn::Token![,] = input.parse()?;
499 }
500 }
501
502 builder.build()
503 }
504}
505
506fn validate_tool_attr(builder: &ToolAttrBuilder) -> syn::Result<()> {
507 if builder.description_inline.is_some() && builder.description_file_path.is_some() {
508 return Err(syn::Error::new(
509 proc_macro2::Span::call_site(),
510 "`description` and `description_file` are mutually exclusive",
511 ));
512 }
513
514 if builder.response_file_path.is_some() && builder.response_inline.is_some() {
515 return Err(syn::Error::new(
516 proc_macro2::Span::call_site(),
517 "`response` and `response_file` are mutually exclusive",
518 ));
519 }
520
521 #[cfg(feature = "md-tmpl")]
522 let has_inline_params = !builder.inline_params.is_empty();
523 #[cfg(not(feature = "md-tmpl"))]
524 let has_inline_params = builder.has_inline_params;
525
526 #[cfg(feature = "md-tmpl")]
527 let has_context_fn = builder.context_fn.is_some();
528 #[cfg(not(feature = "md-tmpl"))]
529 let has_context_fn = builder.has_context_fn;
530
531 #[cfg(feature = "md-tmpl")]
532 let has_env = !builder.env_vars.is_empty();
533 #[cfg(not(feature = "md-tmpl"))]
534 let has_env = builder.has_env;
535
536 if has_inline_params && has_context_fn {
537 return Err(syn::Error::new(
538 proc_macro2::Span::call_site(),
539 "`params(...)` and `context = ...` are mutually exclusive; \
540 use `params` for compile-time values or `context` for runtime values",
541 ));
542 }
543
544 if has_inline_params
545 && builder.description_file_path.is_none()
546 && builder.description_inline.is_none()
547 {
548 return Err(syn::Error::new(
549 proc_macro2::Span::call_site(),
550 "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
551 ));
552 }
553
554 if has_context_fn
555 && builder.description_file_path.is_none()
556 && builder.description_inline.is_none()
557 {
558 return Err(syn::Error::new(
559 proc_macro2::Span::call_site(),
560 "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
561 ));
562 }
563
564 if has_env && builder.description_file_path.is_none() && builder.description_inline.is_none() {
565 return Err(syn::Error::new(
566 proc_macro2::Span::call_site(),
567 "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
568 ));
569 }
570
571 #[cfg(not(feature = "md-tmpl"))]
572 if builder.description_file_path.is_some() {
573 return Err(syn::Error::new(
574 proc_macro2::Span::call_site(),
575 "`description_file` requires the `md-tmpl` feature of `llm-tool`",
576 ));
577 }
578
579 #[cfg(not(feature = "md-tmpl"))]
580 if builder.response_file_path.is_some() {
581 return Err(syn::Error::new(
582 proc_macro2::Span::call_site(),
583 "`response_file` requires the `md-tmpl` feature of `llm-tool`",
584 ));
585 }
586
587 #[cfg(not(feature = "md-tmpl"))]
588 if builder.response_inline.is_some() {
589 return Err(syn::Error::new(
590 proc_macro2::Span::call_site(),
591 "`response` requires the `md-tmpl` feature of `llm-tool`",
592 ));
593 }
594
595 Ok(())
596}
597
598struct ParamInfo {
602 name: syn::Ident,
603 ty: Box<syn::Type>,
604 doc_attrs: Vec<syn::Attribute>,
605 is_context: bool,
606 is_mut: bool,
607}
608
609enum ReturnInfo {
611 ResultType {
613 ok_type: Box<syn::Type>,
614 err_type: Box<syn::Type>,
615 },
616 BareType,
618}
619
620fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
621 let crate_path = quote! { ::llm_tool };
622 let fn_name = &func.sig.ident;
623 reject_generic_signature(func, MACRO_LLM_TOOL)?;
624 let tool_name_str = fn_name.to_string();
625 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
626 let params_name = format_ident!("{}Params", struct_name);
627
628 let DescriptionInfo {
630 static_description,
631 helper_tokens,
632 description_method,
633 dep_tracking,
634 } = resolve_description(func, attr)?;
635
636 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
638
639 let all_params = extract_params(func, MACRO_LLM_TOOL)?;
641 let ctx_count = all_params.iter().filter(|p| p.is_context).count();
642 if ctx_count > 1 {
643 return Err(syn::Error::new_spanned(
644 &func.sig,
645 "#[llm_tool] functions can accept at most one ToolContext parameter",
646 ));
647 }
648 let ctx_param = all_params.iter().find(|p| p.is_context);
649 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
650
651 for param in ¶ms {
653 if param.doc_attrs.is_empty() {
654 return Err(syn::Error::new_spanned(
655 ¶m.name,
656 format!(
657 "#[llm_tool] parameter `{}` must have a doc comment \
658 (used as the parameter description in the JSON schema)",
659 param.name
660 ),
661 ));
662 }
663 }
664
665 let return_info = parse_return_type(func, MACRO_LLM_TOOL)?;
667
668 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
669 let param_descriptions: Vec<String> = params
670 .iter()
671 .map(|p| extract_doc_string(&p.doc_attrs))
672 .collect();
673
674 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
675 let serde_defaults = build_serde_defaults(¶ms);
676 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
677
678 let vis = &func.vis;
679
680 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
681 let struct_doc = format!(
682 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
683 );
684
685 let ctx_binding = if let Some(cp) = ctx_param {
688 let ctx_name = &cp.name;
689 quote! { let #ctx_name = _ctx; }
690 } else {
691 quote! {}
692 };
693
694 let mut_tokens: Vec<proc_macro2::TokenStream> = params
695 .iter()
696 .map(|p| {
697 if p.is_mut {
698 quote! { mut }
699 } else {
700 quote! {}
701 }
702 })
703 .collect();
704
705 let response_dep_tracking = &response_info.dep_tracking;
706 let response_helper_tokens = &response_info.helper_tokens;
707
708 Ok(quote! {
709 #dep_tracking
710 #response_dep_tracking
711 #helper_tokens
712 #response_helper_tokens
713
714 #[doc = #params_doc]
715 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
716 #vis struct #params_name {
717 #(
718 #[schemars(description = #param_descriptions)]
719 #serde_defaults
720 pub #param_names: #param_struct_types,
721 )*
722 }
723
724 #[doc = #struct_doc]
725 #vis struct #struct_name;
726
727 impl #crate_path::RustTool for #struct_name {
728 type Params = #params_name;
729 const NAME: &'static str = #tool_name_str;
730 const DESCRIPTION: &'static str = #static_description;
731
732 #description_method
733
734
735 #[allow(unknown_lints, clippy::unused_async_trait_impl)]
737 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
738
739 use #crate_path::__private::SerializeFallback as _;
742 let #params_name { #( #mut_tokens #param_names, )* } = params;
745 #( #borrow_bindings )*
747 #ctx_binding
748 #body_tokens
749 }
750 }
751 })
752}
753
754struct DescriptionInfo {
758 static_description: String,
760 helper_tokens: proc_macro2::TokenStream,
762 description_method: Option<proc_macro2::TokenStream>,
764 dep_tracking: proc_macro2::TokenStream,
766}
767
768pub(crate) mod desc;
769pub(crate) mod helpers;
770pub(crate) use desc::resolve_description;
771pub(crate) use helpers::{
772 build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
773 extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
774};