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
213const ATTR_DESCRIPTION: &str = "description";
214const ATTR_DESCRIPTION_FILE: &str = "description_file";
215const ATTR_RESPONSE_FILE: &str = "response_file";
216const ATTR_RESPONSE: &str = "response";
217const ATTR_PARAMS: &str = "params";
218const ATTR_CONTEXT: &str = "context";
219const ATTR_ENV: &str = "env";
220const TYPE_OPTION: &str = "Option";
221const TYPE_TOOL_CONTEXT: &str = "ToolContext";
222const TYPE_STR: &str = "str";
223const ATTR_LLM_TOOL: &str = "llm_tool";
224
225#[derive(Default)]
226struct ToolAttrBuilder {
227 description_inline: Option<syn::LitStr>,
228 description_file_path: Option<syn::LitStr>,
229 response_file_path: Option<syn::LitStr>,
230 response_inline: Option<syn::LitStr>,
231 #[cfg(feature = "md-tmpl")]
232 inline_params: Vec<(syn::Ident, syn::LitStr)>,
233 #[cfg(feature = "md-tmpl")]
234 env_vars: Vec<(syn::Ident, syn::Lit)>,
235 #[cfg(feature = "md-tmpl")]
236 context_fn: Option<syn::Path>,
237 #[cfg(not(feature = "md-tmpl"))]
238 has_inline_params: bool,
239 #[cfg(not(feature = "md-tmpl"))]
240 has_context_fn: bool,
241 #[cfg(not(feature = "md-tmpl"))]
242 has_env: bool,
243}
244
245impl ToolAttrBuilder {
246 fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
247 let ident: syn::Ident = input.parse()?;
248 if ident == ATTR_DESCRIPTION {
249 let _: syn::Token![=] = input.parse()?;
250 self.description_inline = Some(input.parse::<syn::LitStr>()?);
251 } else if ident == ATTR_DESCRIPTION_FILE {
252 let _: syn::Token![=] = input.parse()?;
253 self.description_file_path = Some(input.parse::<syn::LitStr>()?);
254 } else if ident == ATTR_RESPONSE_FILE {
255 let _: syn::Token![=] = input.parse()?;
256 self.response_file_path = Some(input.parse::<syn::LitStr>()?);
257 } else if ident == ATTR_RESPONSE {
258 let _: syn::Token![=] = input.parse()?;
259 self.response_inline = Some(input.parse::<syn::LitStr>()?);
260 } else if ident == ATTR_PARAMS {
261 let content;
262 syn::parenthesized!(content in input);
263 while !content.is_empty() {
264 let key: syn::Ident = content.parse()?;
265 let _: syn::Token![=] = content.parse()?;
266 let value: syn::LitStr = content.parse()?;
267 #[cfg(feature = "md-tmpl")]
268 self.inline_params.push((key, value));
269 #[cfg(not(feature = "md-tmpl"))]
270 {
271 drop(key);
272 drop(value);
273 }
274 if !content.is_empty() {
275 let _: syn::Token![,] = content.parse()?;
276 }
277 }
278 #[cfg(not(feature = "md-tmpl"))]
279 {
280 self.has_inline_params = true;
281 }
282 } else if ident == ATTR_ENV {
283 let content;
284 syn::parenthesized!(content in input);
285 while !content.is_empty() {
286 let key: syn::Ident = content.parse()?;
287 let _: syn::Token![=] = content.parse()?;
288 let value: syn::Lit = content.parse()?;
289 match &value {
290 syn::Lit::Str(_)
291 | syn::Lit::Int(_)
292 | syn::Lit::Float(_)
293 | syn::Lit::Bool(_) => {}
294 other => {
295 return Err(syn::Error::new(
296 other.span(),
297 "env values must be string, integer, float, or bool literals",
298 ));
299 }
300 }
301 #[cfg(feature = "md-tmpl")]
302 self.env_vars.push((key, value));
303 #[cfg(not(feature = "md-tmpl"))]
304 {
305 drop(key);
306 drop(value);
307 }
308 if !content.is_empty() {
309 let _: syn::Token![,] = content.parse()?;
310 }
311 }
312 #[cfg(not(feature = "md-tmpl"))]
313 {
314 self.has_env = true;
315 }
316 } else if ident == ATTR_CONTEXT {
317 let _: syn::Token![=] = input.parse()?;
318 #[cfg(feature = "md-tmpl")]
319 {
320 self.context_fn = Some(input.parse::<syn::Path>()?);
321 }
322 #[cfg(not(feature = "md-tmpl"))]
323 {
324 let _path: syn::Path = input.parse()?;
325 self.has_context_fn = true;
326 }
327 } else {
328 return Err(syn::Error::new(
329 ident.span(),
330 "expected `description`, `description_file`, `response`, `response_file`, `params`, `env`, or `context`",
331 ));
332 }
333 Ok(())
334 }
335}
336
337impl syn::parse::Parse for ToolAttr {
338 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
339 let mut builder = ToolAttrBuilder::default();
340
341 while !input.is_empty() {
342 builder.parse_single(input)?;
343 if !input.is_empty() {
344 let _: syn::Token![,] = input.parse()?;
345 }
346 }
347
348 #[cfg(feature = "md-tmpl")]
349 let (has_inline_params, has_context_fn, has_env) = (
350 !builder.inline_params.is_empty(),
351 builder.context_fn.is_some(),
352 !builder.env_vars.is_empty(),
353 );
354 #[cfg(not(feature = "md-tmpl"))]
355 let (has_inline_params, has_context_fn, has_env) = (
356 builder.has_inline_params,
357 builder.has_context_fn,
358 builder.has_env,
359 );
360
361 validate_tool_attr(
362 builder.description_inline.as_ref(),
363 builder.description_file_path.as_ref(),
364 has_inline_params,
365 has_context_fn,
366 has_env,
367 )?;
368
369 if builder.response_inline.is_some() && builder.response_file_path.is_some() {
370 return Err(syn::Error::new(
371 proc_macro2::Span::call_site(),
372 "cannot specify both `response` and `response_file`",
373 ));
374 }
375
376 #[cfg(not(feature = "md-tmpl"))]
378 if builder.response_file_path.is_some() || builder.response_inline.is_some() {
379 return Err(syn::Error::new(
380 proc_macro2::Span::call_site(),
381 "the `md-tmpl` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
382 ));
383 }
384
385 Ok(Self {
386 description_inline: builder.description_inline,
387 description_file_path: builder.description_file_path,
388 response_file_path: builder.response_file_path,
389 response_inline: builder.response_inline,
390 #[cfg(feature = "md-tmpl")]
391 inline_params: builder.inline_params,
392 #[cfg(feature = "md-tmpl")]
393 env_vars: builder.env_vars,
394 #[cfg(feature = "md-tmpl")]
395 context_fn: builder.context_fn,
396 has_inline_params,
397 has_context_fn,
398 })
399 }
400}
401
402fn validate_tool_attr(
405 description_inline: Option<&LitStr>,
406 description_file_path: Option<&LitStr>,
407 has_inline_params: bool,
408 has_context_fn: bool,
409 has_env: bool,
410) -> syn::Result<()> {
411 if description_inline.is_some() && description_file_path.is_some() {
413 return Err(syn::Error::new(
414 proc_macro2::Span::call_site(),
415 "`description` and `description_file` are mutually exclusive",
416 ));
417 }
418
419 if description_file_path.is_none() && description_inline.is_none() && has_inline_params {
421 return Err(syn::Error::new(
422 proc_macro2::Span::call_site(),
423 "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
424 ));
425 }
426 if description_file_path.is_none() && description_inline.is_none() && has_context_fn {
427 return Err(syn::Error::new(
428 proc_macro2::Span::call_site(),
429 "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
430 ));
431 }
432
433 if has_env && description_file_path.is_none() && description_inline.is_none() {
435 return Err(syn::Error::new(
436 proc_macro2::Span::call_site(),
437 "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
438 ));
439 }
440
441 #[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 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 if description_inline.is_none()
463 && description_file_path.is_none()
464 && !has_inline_params
465 && !has_context_fn
466 {
467 }
469
470 Ok(())
471}
472
473struct ParamInfo {
477 name: syn::Ident,
478 ty: Box<syn::Type>,
479 doc_attrs: Vec<syn::Attribute>,
480 is_context: bool,
481}
482
483enum ReturnInfo {
485 ResultType {
487 ok_type: Box<syn::Type>,
488 err_type: Box<syn::Type>,
489 },
490 BareType,
492}
493
494fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
495 let crate_path = quote! { ::llm_tool };
496 let fn_name = &func.sig.ident;
497 reject_generic_signature(func, ATTR_LLM_TOOL)?;
498 let tool_name_str = fn_name.to_string();
499 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
500 let params_name = format_ident!("{}Params", struct_name);
501
502 let DescriptionInfo {
504 static_description,
505 helper_tokens,
506 description_method,
507 dep_tracking,
508 } = resolve_description(func, attr)?;
509
510 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
512
513 let all_params = extract_params(func)?;
515 let ctx_param = all_params.iter().find(|p| p.is_context);
516 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
517
518 for param in ¶ms {
520 if param.doc_attrs.is_empty() {
521 return Err(syn::Error::new_spanned(
522 ¶m.name,
523 format!(
524 "#[llm_tool] parameter `{}` must have a doc comment \
525 (used as the parameter description in the JSON schema)",
526 param.name
527 ),
528 ));
529 }
530 }
531
532 let return_info = parse_return_type(func)?;
534
535 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
536 let param_descriptions: Vec<String> = params
537 .iter()
538 .map(|p| extract_doc_string(&p.doc_attrs))
539 .collect();
540
541 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
542 let serde_defaults = build_serde_defaults(¶ms);
543 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
544
545 let vis = &func.vis;
546
547 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
548 let struct_doc = format!(
549 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
550 );
551
552 let ctx_binding = if let Some(cp) = ctx_param {
555 let ctx_name = &cp.name;
556 quote! { let #ctx_name = _ctx; }
557 } else {
558 quote! {}
559 };
560
561 let response_dep_tracking = &response_info.dep_tracking;
562 let response_helper_tokens = &response_info.helper_tokens;
563
564 Ok(quote! {
565 #dep_tracking
566 #response_dep_tracking
567 #helper_tokens
568 #response_helper_tokens
569
570 #[doc = #params_doc]
571 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
572 #vis struct #params_name {
573 #(
574 #[schemars(description = #param_descriptions)]
575 #serde_defaults
576 pub #param_names: #param_struct_types,
577 )*
578 }
579
580 #[doc = #struct_doc]
581 #vis struct #struct_name;
582
583 impl #crate_path::RustTool for #struct_name {
584 type Params = #params_name;
585 const NAME: &'static str = #tool_name_str;
586 const DESCRIPTION: &'static str = #static_description;
587
588 #description_method
589
590
591 #[allow(unknown_lints, clippy::unused_async_trait_impl)]
593 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
594
595 use #crate_path::__private::SerializeFallback as _;
598 let #params_name { #( #param_names, )* } = params;
601 #( #borrow_bindings )*
603 #ctx_binding
604 #body_tokens
605 }
606 }
607 })
608}
609
610struct DescriptionInfo {
614 static_description: String,
616 helper_tokens: proc_macro2::TokenStream,
618 description_method: Option<proc_macro2::TokenStream>,
620 dep_tracking: proc_macro2::TokenStream,
622}
623
624pub(crate) mod desc;
625pub(crate) mod helpers;
626pub(crate) use desc::resolve_description;
627pub(crate) use helpers::{
628 build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
629 extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
630};