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 prompt_inline: Option<LitStr>,
193 prompt_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_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 #[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
402fn 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 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 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 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 #[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 prompt_inline.is_none()
463 && prompt_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 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 let DescriptionInfo {
503 static_description,
504 helper_tokens,
505 description_method,
506 dep_tracking,
507 } = resolve_description(func, attr)?;
508
509 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
511
512 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 for param in ¶ms {
519 if param.doc_attrs.is_empty() {
520 return Err(syn::Error::new_spanned(
521 ¶m.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 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(¶ms);
541 let serde_defaults = build_serde_defaults(¶ms);
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 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 use #crate_path::__private::SerializeFallback as _;
596 let #params_name { #( #param_names, )* } = params;
599 #( #borrow_bindings )*
601 #ctx_binding
602 #body_tokens
603 }
604 }
605 })
606}
607
608struct DescriptionInfo {
612 static_description: String,
614 helper_tokens: proc_macro2::TokenStream,
616 description_method: Option<proc_macro2::TokenStream>,
618 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::*;