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().unwrap_or_default();
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)]
295struct 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
378 match key {
379 ToolAttrKey::Description => {
380 let _: syn::Token![=] = input.parse()?;
381 if self.description_inline.is_some() {
382 return Err(syn::Error::new(
383 ident.span(),
384 format!("duplicate `{}` attribute", key.as_str()),
385 ));
386 }
387 self.description_inline = Some(input.parse::<syn::LitStr>()?);
388 }
389 ToolAttrKey::DescriptionFile => {
390 let _: syn::Token![=] = input.parse()?;
391 if self.description_file_path.is_some() {
392 return Err(syn::Error::new(
393 ident.span(),
394 format!("duplicate `{}` attribute", key.as_str()),
395 ));
396 }
397 self.description_file_path = Some(input.parse::<syn::LitStr>()?);
398 }
399 ToolAttrKey::ResponseFile => {
400 let _: syn::Token![=] = input.parse()?;
401 if self.response_file_path.is_some() {
402 return Err(syn::Error::new(
403 ident.span(),
404 format!("duplicate `{}` attribute", key.as_str()),
405 ));
406 }
407 self.response_file_path = Some(input.parse::<syn::LitStr>()?);
408 }
409 ToolAttrKey::Response => {
410 let _: syn::Token![=] = input.parse()?;
411 if self.response_inline.is_some() {
412 return Err(syn::Error::new(
413 ident.span(),
414 format!("duplicate `{}` attribute", key.as_str()),
415 ));
416 }
417 self.response_inline = Some(input.parse::<syn::LitStr>()?);
418 }
419 ToolAttrKey::Params => {
420 self.parse_params_attr(input)?;
421 }
422 ToolAttrKey::Env => {
423 self.parse_env_attr(input)?;
424 }
425 ToolAttrKey::Context => {
426 let _: syn::Token![=] = input.parse()?;
427 #[cfg(feature = "md-tmpl")]
428 {
429 if self.context_fn.is_some() {
430 return Err(syn::Error::new(
431 ident.span(),
432 format!("duplicate `{}` attribute", key.as_str()),
433 ));
434 }
435 self.context_fn = Some(input.parse::<syn::Path>()?);
436 }
437 #[cfg(not(feature = "md-tmpl"))]
438 {
439 let _path: syn::Path = input.parse()?;
440 if self.has_context_fn {
441 return Err(syn::Error::new(
442 ident.span(),
443 format!("duplicate `{}` attribute", key.as_str()),
444 ));
445 }
446 self.has_context_fn = true;
447 }
448 }
449 }
450 Ok(())
451 }
452}
453
454impl syn::parse::Parse for ToolAttr {
455 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
456 let mut builder = ToolAttrBuilder::default();
457
458 while !input.is_empty() {
459 builder.parse_single(input)?;
460 if !input.is_empty() {
461 let _: syn::Token![,] = input.parse()?;
462 }
463 }
464
465 #[cfg(feature = "md-tmpl")]
466 let has_inline_params = !builder.inline_params.is_empty();
467 #[cfg(not(feature = "md-tmpl"))]
468 let has_inline_params = builder.has_inline_params;
469
470 #[cfg(feature = "md-tmpl")]
471 let has_context_fn = builder.context_fn.is_some();
472 #[cfg(not(feature = "md-tmpl"))]
473 let has_context_fn = builder.has_context_fn;
474
475 validate_tool_attr(&builder)?;
476
477 Ok(Self {
478 description_inline: builder.description_inline,
479 description_file_path: builder.description_file_path,
480 response_file_path: builder.response_file_path,
481 response_inline: builder.response_inline,
482 #[cfg(feature = "md-tmpl")]
483 inline_params: builder.inline_params,
484 #[cfg(feature = "md-tmpl")]
485 env_vars: builder.env_vars,
486 #[cfg(feature = "md-tmpl")]
487 context_fn: builder.context_fn,
488 has_inline_params,
489 has_context_fn,
490 })
491 }
492}
493
494fn validate_tool_attr(builder: &ToolAttrBuilder) -> syn::Result<()> {
495 if builder.description_inline.is_some() && builder.description_file_path.is_some() {
496 return Err(syn::Error::new(
497 proc_macro2::Span::call_site(),
498 "`description` and `description_file` are mutually exclusive",
499 ));
500 }
501
502 if builder.response_file_path.is_some() && builder.response_inline.is_some() {
503 return Err(syn::Error::new(
504 proc_macro2::Span::call_site(),
505 "`response` and `response_file` are mutually exclusive",
506 ));
507 }
508
509 #[cfg(feature = "md-tmpl")]
510 let has_inline_params = !builder.inline_params.is_empty();
511 #[cfg(not(feature = "md-tmpl"))]
512 let has_inline_params = builder.has_inline_params;
513
514 #[cfg(feature = "md-tmpl")]
515 let has_context_fn = builder.context_fn.is_some();
516 #[cfg(not(feature = "md-tmpl"))]
517 let has_context_fn = builder.has_context_fn;
518
519 #[cfg(feature = "md-tmpl")]
520 let has_env = !builder.env_vars.is_empty();
521 #[cfg(not(feature = "md-tmpl"))]
522 let has_env = builder.has_env;
523
524 if has_inline_params && has_context_fn {
525 return Err(syn::Error::new(
526 proc_macro2::Span::call_site(),
527 "`params(...)` and `context = ...` are mutually exclusive; \
528 use `params` for compile-time values or `context` for runtime values",
529 ));
530 }
531
532 if has_inline_params
533 && builder.description_file_path.is_none()
534 && builder.description_inline.is_none()
535 {
536 return Err(syn::Error::new(
537 proc_macro2::Span::call_site(),
538 "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
539 ));
540 }
541
542 if has_context_fn
543 && builder.description_file_path.is_none()
544 && builder.description_inline.is_none()
545 {
546 return Err(syn::Error::new(
547 proc_macro2::Span::call_site(),
548 "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
549 ));
550 }
551
552 if has_env && builder.description_file_path.is_none() && builder.description_inline.is_none() {
553 return Err(syn::Error::new(
554 proc_macro2::Span::call_site(),
555 "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
556 ));
557 }
558
559 #[cfg(not(feature = "md-tmpl"))]
560 if builder.description_file_path.is_some() {
561 return Err(syn::Error::new(
562 proc_macro2::Span::call_site(),
563 "`description_file` requires the `md-tmpl` feature of `llm-tool`",
564 ));
565 }
566
567 #[cfg(not(feature = "md-tmpl"))]
568 if builder.response_file_path.is_some() {
569 return Err(syn::Error::new(
570 proc_macro2::Span::call_site(),
571 "`response_file` requires the `md-tmpl` feature of `llm-tool`",
572 ));
573 }
574
575 #[cfg(not(feature = "md-tmpl"))]
576 if builder.response_inline.is_some() {
577 return Err(syn::Error::new(
578 proc_macro2::Span::call_site(),
579 "`response` requires the `md-tmpl` feature of `llm-tool`",
580 ));
581 }
582
583 Ok(())
584}
585
586struct ParamInfo {
590 name: syn::Ident,
591 ty: Box<syn::Type>,
592 doc_attrs: Vec<syn::Attribute>,
593 is_context: bool,
594 is_mut: bool,
595}
596
597enum ReturnInfo {
599 ResultType {
601 ok_type: Box<syn::Type>,
602 err_type: Box<syn::Type>,
603 },
604 BareType,
606}
607
608fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
609 let crate_path = quote! { ::llm_tool };
610 let fn_name = &func.sig.ident;
611 reject_generic_signature(func, MACRO_LLM_TOOL)?;
612 let tool_name_str = fn_name.to_string();
613 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
614 let params_name = format_ident!("{}Params", struct_name);
615
616 let DescriptionInfo {
618 static_description,
619 helper_tokens,
620 description_method,
621 dep_tracking,
622 } = resolve_description(func, attr)?;
623
624 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
626
627 let all_params = extract_params(func, MACRO_LLM_TOOL)?;
629 let ctx_count = all_params.iter().filter(|p| p.is_context).count();
630 if ctx_count > 1 {
631 return Err(syn::Error::new_spanned(
632 &func.sig,
633 "#[llm_tool] functions can accept at most one ToolContext parameter",
634 ));
635 }
636 let ctx_param = all_params.iter().find(|p| p.is_context);
637 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
638
639 for param in ¶ms {
641 if param.doc_attrs.is_empty() {
642 return Err(syn::Error::new_spanned(
643 ¶m.name,
644 format!(
645 "#[llm_tool] parameter `{}` must have a doc comment \
646 (used as the parameter description in the JSON schema)",
647 param.name
648 ),
649 ));
650 }
651 }
652
653 let return_info = parse_return_type(func, MACRO_LLM_TOOL)?;
655
656 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
657 let param_descriptions: Vec<String> = params
658 .iter()
659 .map(|p| extract_doc_string(&p.doc_attrs))
660 .collect();
661
662 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
663 let serde_defaults = build_serde_defaults(¶ms);
664 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
665
666 let vis = &func.vis;
667
668 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
669 let struct_doc = format!(
670 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
671 );
672
673 let ctx_binding = if let Some(cp) = ctx_param {
676 let ctx_name = &cp.name;
677 quote! { let #ctx_name = _ctx; }
678 } else {
679 quote! {}
680 };
681
682 let mut_tokens: Vec<proc_macro2::TokenStream> = params
683 .iter()
684 .map(|p| {
685 if p.is_mut {
686 quote! { mut }
687 } else {
688 quote! {}
689 }
690 })
691 .collect();
692
693 let response_dep_tracking = &response_info.dep_tracking;
694 let response_helper_tokens = &response_info.helper_tokens;
695
696 Ok(quote! {
697 #dep_tracking
698 #response_dep_tracking
699 #helper_tokens
700 #response_helper_tokens
701
702 #[doc = #params_doc]
703 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
704 #vis struct #params_name {
705 #(
706 #[schemars(description = #param_descriptions)]
707 #serde_defaults
708 pub #param_names: #param_struct_types,
709 )*
710 }
711
712 #[doc = #struct_doc]
713 #vis struct #struct_name;
714
715 impl #crate_path::RustTool for #struct_name {
716 type Params = #params_name;
717 const NAME: &'static str = #tool_name_str;
718 const DESCRIPTION: &'static str = #static_description;
719
720 #description_method
721
722
723 #[allow(unknown_lints, clippy::unused_async_trait_impl)]
725 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
726
727 use #crate_path::__private::SerializeFallback as _;
730 let #params_name { #( #mut_tokens #param_names, )* } = params;
733 #( #borrow_bindings )*
735 #ctx_binding
736 #body_tokens
737 }
738 }
739 })
740}
741
742struct DescriptionInfo {
746 static_description: String,
748 helper_tokens: proc_macro2::TokenStream,
750 description_method: Option<proc_macro2::TokenStream>,
752 dep_tracking: proc_macro2::TokenStream,
754}
755
756pub(crate) mod desc;
757pub(crate) mod helpers;
758pub(crate) use desc::resolve_description;
759pub(crate) use helpers::{
760 build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
761 extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
762};