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]
105pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
106 let func = parse_macro_input!(item as ItemFn);
107 let tool_attr = if attr.is_empty() {
108 None
109 } else {
110 match syn::parse::<ToolAttr>(attr) {
111 Ok(parsed) => Some(parsed),
112 Err(err) => return err.to_compile_error().into(),
113 }
114 };
115 match tool_impl(&func, tool_attr.as_ref()) {
116 Ok(tokens) => tokens.into(),
117 Err(err) => err.to_compile_error().into(),
118 }
119}
120
121#[proc_macro_attribute]
123pub fn llm_prompt(attr: TokenStream, item: TokenStream) -> TokenStream {
124 let func = parse_macro_input!(item as ItemFn);
125 let tool_attr = if attr.is_empty() {
126 None
127 } else {
128 match syn::parse::<ToolAttr>(attr) {
129 Ok(parsed) => Some(parsed),
130 Err(err) => return err.to_compile_error().into(),
131 }
132 };
133 match prompt_macro::prompt_impl(&func, tool_attr.as_ref()) {
134 Ok(tokens) => tokens.into(),
135 Err(err) => err.to_compile_error().into(),
136 }
137}
138
139#[proc_macro_attribute]
141pub fn llm_resource(attr: TokenStream, item: TokenStream) -> TokenStream {
142 let func = parse_macro_input!(item as ItemFn);
143 let res_attr = match syn::parse::<resource_macro::ResourceAttr>(attr) {
144 Ok(parsed) => parsed,
145 Err(err) => return err.to_compile_error().into(),
146 };
147 match resource_macro::resource_impl(&func, &res_attr) {
148 Ok(tokens) => tokens.into(),
149 Err(err) => err.to_compile_error().into(),
150 }
151}
152
153struct ToolAttr {
164 prompt_inline: Option<LitStr>,
166 prompt_file_path: Option<LitStr>,
168 response_file_path: Option<LitStr>,
170 response_inline: Option<LitStr>,
172 #[cfg(feature = "md-tmpl")]
175 inline_params: Vec<(Ident, LitStr)>,
176 #[cfg(feature = "md-tmpl")]
178 context_fn: Option<syn::Path>,
179 has_inline_params: bool,
180 has_context_fn: bool,
181}
182
183const ATTR_PROMPT: &str = "prompt";
184const ATTR_PROMPT_FILE: &str = "prompt_file";
185const ATTR_RESPONSE_FILE: &str = "response_file";
186const ATTR_RESPONSE: &str = "response";
187const ATTR_PARAMS: &str = "params";
188const ATTR_CONTEXT: &str = "context";
189const TYPE_OPTION: &str = "Option";
190const TYPE_TOOL_CONTEXT: &str = "ToolContext";
191const TYPE_STR: &str = "str";
192const ATTR_LLM_TOOL: &str = "llm_tool";
193
194#[derive(Default)]
195struct ToolAttrBuilder {
196 prompt_inline: Option<syn::LitStr>,
197 prompt_file_path: Option<syn::LitStr>,
198 response_file_path: Option<syn::LitStr>,
199 response_inline: Option<syn::LitStr>,
200 #[cfg(feature = "md-tmpl")]
201 inline_params: Vec<(syn::Ident, syn::LitStr)>,
202 #[cfg(feature = "md-tmpl")]
203 context_fn: Option<syn::Path>,
204 #[cfg(not(feature = "md-tmpl"))]
205 has_inline_params: bool,
206 #[cfg(not(feature = "md-tmpl"))]
207 has_context_fn: bool,
208}
209
210impl ToolAttrBuilder {
211 fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
212 let ident: syn::Ident = input.parse()?;
213 if ident == ATTR_PROMPT {
214 let _: syn::Token![=] = input.parse()?;
215 self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
216 } else if ident == ATTR_PROMPT_FILE {
217 let _: syn::Token![=] = input.parse()?;
218 self.prompt_file_path = Some(input.parse::<syn::LitStr>()?);
219 } else if ident == ATTR_RESPONSE_FILE {
220 let _: syn::Token![=] = input.parse()?;
221 self.response_file_path = Some(input.parse::<syn::LitStr>()?);
222 } else if ident == ATTR_RESPONSE {
223 let _: syn::Token![=] = input.parse()?;
224 self.response_inline = Some(input.parse::<syn::LitStr>()?);
225 } else if ident == ATTR_PARAMS {
226 let content;
227 syn::parenthesized!(content in input);
228 while !content.is_empty() {
229 let key: syn::Ident = content.parse()?;
230 let _: syn::Token![=] = content.parse()?;
231 let value: syn::LitStr = content.parse()?;
232 #[cfg(feature = "md-tmpl")]
233 self.inline_params.push((key, value));
234 #[cfg(not(feature = "md-tmpl"))]
235 {
236 drop(key);
237 drop(value);
238 }
239 if !content.is_empty() {
240 let _: syn::Token![,] = content.parse()?;
241 }
242 }
243 #[cfg(not(feature = "md-tmpl"))]
244 {
245 self.has_inline_params = true;
246 }
247 } else if ident == ATTR_CONTEXT {
248 let _: syn::Token![=] = input.parse()?;
249 #[cfg(feature = "md-tmpl")]
250 {
251 self.context_fn = Some(input.parse::<syn::Path>()?);
252 }
253 #[cfg(not(feature = "md-tmpl"))]
254 {
255 let _path: syn::Path = input.parse()?;
256 self.has_context_fn = true;
257 }
258 } else {
259 return Err(syn::Error::new(
260 ident.span(),
261 "expected `prompt`, `prompt_file`, `response`, `response_file`, `params`, or `context`",
262 ));
263 }
264 Ok(())
265 }
266}
267
268impl syn::parse::Parse for ToolAttr {
269 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
270 let mut builder = ToolAttrBuilder::default();
271
272 while !input.is_empty() {
273 builder.parse_single(input)?;
274 if !input.is_empty() {
275 let _: syn::Token![,] = input.parse()?;
276 }
277 }
278
279 #[cfg(feature = "md-tmpl")]
280 let (has_inline_params, has_context_fn) = (
281 !builder.inline_params.is_empty(),
282 builder.context_fn.is_some(),
283 );
284 #[cfg(not(feature = "md-tmpl"))]
285 let (has_inline_params, has_context_fn) =
286 (builder.has_inline_params, builder.has_context_fn);
287
288 validate_tool_attr(
289 builder.prompt_inline.as_ref(),
290 builder.prompt_file_path.as_ref(),
291 has_inline_params,
292 has_context_fn,
293 )?;
294
295 if builder.response_inline.is_some() && builder.response_file_path.is_some() {
296 return Err(syn::Error::new(
297 proc_macro2::Span::call_site(),
298 "cannot specify both `response` and `response_file`",
299 ));
300 }
301
302 #[cfg(not(feature = "md-tmpl"))]
304 if builder.response_file_path.is_some() || builder.response_inline.is_some() {
305 return Err(syn::Error::new(
306 proc_macro2::Span::call_site(),
307 "the `md-tmpl` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
308 ));
309 }
310
311 Ok(Self {
312 prompt_inline: builder.prompt_inline,
313 prompt_file_path: builder.prompt_file_path,
314 response_file_path: builder.response_file_path,
315 response_inline: builder.response_inline,
316 #[cfg(feature = "md-tmpl")]
317 inline_params: builder.inline_params,
318 #[cfg(feature = "md-tmpl")]
319 context_fn: builder.context_fn,
320 has_inline_params,
321 has_context_fn,
322 })
323 }
324}
325
326fn validate_tool_attr(
329 prompt_inline: Option<&LitStr>,
330 prompt_file_path: Option<&LitStr>,
331 has_inline_params: bool,
332 has_context_fn: bool,
333) -> syn::Result<()> {
334 if prompt_inline.is_some() && prompt_file_path.is_some() {
336 return Err(syn::Error::new(
337 proc_macro2::Span::call_site(),
338 "`prompt` and `prompt_file` are mutually exclusive",
339 ));
340 }
341
342 if prompt_file_path.is_none() && has_inline_params {
344 return Err(syn::Error::new(
345 proc_macro2::Span::call_site(),
346 "`params(...)` requires `prompt_file = \"...\"`",
347 ));
348 }
349 if prompt_file_path.is_none() && has_context_fn {
350 return Err(syn::Error::new(
351 proc_macro2::Span::call_site(),
352 "`context = ...` requires `prompt_file = \"...\"`",
353 ));
354 }
355
356 if has_inline_params && has_context_fn {
358 return Err(syn::Error::new(
359 proc_macro2::Span::call_site(),
360 "`params(...)` and `context = ...` are mutually exclusive; \
361 use `params` for compile-time values or `context` for runtime values",
362 ));
363 }
364
365 if prompt_inline.is_none()
368 && prompt_file_path.is_none()
369 && !has_inline_params
370 && !has_context_fn
371 {
372 }
374
375 Ok(())
376}
377
378struct ParamInfo {
382 name: syn::Ident,
383 ty: Box<syn::Type>,
384 doc_attrs: Vec<syn::Attribute>,
385 is_context: bool,
386}
387
388enum ReturnInfo {
390 ResultType {
392 ok_type: Box<syn::Type>,
393 err_type: Box<syn::Type>,
394 },
395 BareType,
397}
398
399fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
400 let crate_path = quote! { ::llm_tool };
401 let fn_name = &func.sig.ident;
402 let tool_name_str = fn_name.to_string();
403 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
404 let params_name = format_ident!("{}Params", struct_name);
405
406 let DescriptionInfo {
408 static_description,
409 helper_tokens,
410 description_method,
411 dep_tracking,
412 } = resolve_description(func, attr)?;
413
414 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
416
417 let all_params = extract_params(func)?;
419 let ctx_param = all_params.iter().find(|p| p.is_context);
420 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
421
422 for param in ¶ms {
424 if param.doc_attrs.is_empty() {
425 return Err(syn::Error::new_spanned(
426 ¶m.name,
427 format!(
428 "#[llm_tool] parameter `{}` must have a doc comment \
429 (used as the parameter description in the JSON schema)",
430 param.name
431 ),
432 ));
433 }
434 }
435
436 let return_info = parse_return_type(func)?;
438
439 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
440 let param_descriptions: Vec<String> = params
441 .iter()
442 .map(|p| extract_doc_string(&p.doc_attrs))
443 .collect();
444
445 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
446 let serde_defaults = build_serde_defaults(¶ms);
447 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
448
449 let vis = &func.vis;
450
451 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
452 let struct_doc = format!(
453 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
454 );
455
456 let ctx_binding = if let Some(cp) = ctx_param {
459 let ctx_name = &cp.name;
460 quote! { let #ctx_name = _ctx; }
461 } else {
462 quote! {}
463 };
464
465 let response_dep_tracking = &response_info.dep_tracking;
466 let response_helper_tokens = &response_info.helper_tokens;
467
468 Ok(quote! {
469 #dep_tracking
470 #response_dep_tracking
471 #helper_tokens
472 #response_helper_tokens
473
474 #[doc = #params_doc]
475 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
476 #vis struct #params_name {
477 #(
478 #[schemars(description = #param_descriptions)]
479 #serde_defaults
480 pub #param_names: #param_struct_types,
481 )*
482 }
483
484 #[doc = #struct_doc]
485 #vis struct #struct_name;
486
487 impl #crate_path::RustTool for #struct_name {
488 type Params = #params_name;
489 const NAME: &'static str = #tool_name_str;
490 const DESCRIPTION: &'static str = #static_description;
491
492 #description_method
493
494 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
495 use #crate_path::__private::SerializeFallback as _;
498 let #params_name { #( #param_names, )* } = params;
501 #( #borrow_bindings )*
503 #ctx_binding
504 #body_tokens
505 }
506 }
507 })
508}
509
510struct DescriptionInfo {
514 static_description: String,
516 helper_tokens: proc_macro2::TokenStream,
518 description_method: Option<proc_macro2::TokenStream>,
520 dep_tracking: proc_macro2::TokenStream,
522}
523
524pub(crate) mod desc;
525pub(crate) mod helpers;
526#[allow(clippy::wildcard_imports)]
527pub(crate) use desc::*;
528#[allow(clippy::wildcard_imports)]
529pub(crate) use helpers::*;