Skip to main content

lc_tools_derive/
lib.rs

1#![warn(missing_docs)]
2// lc-tools-derive/src/lib.rs
3//! Procedural macro for deriving BaseTool implementations from functions.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use lc_tools::{tool, BaseTool, Tool, ToolError};
9//!
10//! #[tool(description = "Useful for arithmetic calculations")]
11//! fn calculator(
12//!     #[param(desc = "The mathematical expression to evaluate")]
13//!     expression: String,
14//! ) -> Result<f64, ToolError> {
15//!     expression
16//!         .parse::<f64>()
17//!         .map_err(|e| ToolError::ExecutionFailed(e.to_string()))
18//! }
19//! ```
20//!
21//! This expands to:
22//! - `CalculatorTool` struct
23//! - `CalculatorInput` struct with `Deserialize` + `JsonSchema`
24//! - `impl BaseTool for CalculatorTool`
25//! - `impl Tool for CalculatorTool`
26//! - The original `calculator` function is preserved
27
28use proc_macro::TokenStream;
29use proc_macro2::TokenStream as TokenStream2;
30use quote::{format_ident, quote};
31use syn::{
32    parse_macro_input, Attribute, Expr, ExprLit, FnArg, Ident, ItemFn, Lit, Meta, MetaNameValue,
33    Pat, PatType, Result, Signature, Type,
34};
35
36/// Attribute for individual parameters.
37const PARAM_ATTR: &str = "param";
38
39/// The `#[tool]` procedural macro.
40///
41/// Transforms a function into a full Tool implementation.
42///
43/// # Attributes
44///
45/// - `#[tool(description = "...")]` — Required. The tool description shown to the LLM.
46/// - `#[param(desc = "...")]` — Optional per-parameter. Adds description to the JSON schema.
47///
48/// # Parameter Rules
49///
50/// - `String`, `i64`, `f64`, `bool` → required in schema
51/// - `Option<T>` → optional in schema
52/// - `Vec<T>` → array in schema
53#[proc_macro_attribute]
54pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
55    let func = parse_macro_input!(item as ItemFn);
56
57    // Parse the attribute as `description = "..."`
58    let description = match parse_tool_attr(attr) {
59        Ok(d) => d,
60        Err(err) => return err.to_compile_error().into(),
61    };
62
63    match tool_impl(description, func) {
64        Ok(tokens) => tokens.into(),
65        Err(err) => err.to_compile_error().into(),
66    }
67}
68
69/// Parse `#[tool(description = "...")]` attribute tokens.
70fn parse_tool_attr(attr: TokenStream) -> Result<String> {
71    if attr.is_empty() {
72        return Err(syn::Error::new(
73            proc_macro2::Span::call_site(),
74            "#[tool(description = \"...\")] is required",
75        ));
76    }
77
78    // Parse as `description = "..."`
79    let meta: Meta = syn::parse(attr)?;
80    if let Meta::NameValue(MetaNameValue { path, value, .. }) = &meta {
81        if path.is_ident("description") {
82            if let Expr::Lit(ExprLit {
83                lit: Lit::Str(lit), ..
84            }) = value
85            {
86                return Ok(lit.value());
87            }
88        }
89    }
90
91    Err(syn::Error::new(
92        proc_macro2::Span::call_site(),
93        "expected #[tool(description = \"...\")]",
94    ))
95}
96
97fn tool_impl(description: String, mut func: ItemFn) -> Result<TokenStream2> {
98    // 1. Extract all information from the function BEFORE mutating it
99    let func_name_str = func.sig.ident.to_string();
100    // J3:先校验 PascalCase 种子能作为合法标识符前缀,再交给 `format_ident!`。
101    // 输入本是合法的 Rust fn 名(首字符必为字母/下划线),理论不会触发,但按防御性
102    // 修正,让它成为可诊断的 `compile_error!` 而不是过程宏内部的 panic。
103    let pascal = to_pascal_case(&func_name_str);
104    if !is_valid_ident_seed(&pascal) {
105        return Err(syn::Error::new_spanned(
106            &func.sig.ident,
107            format!(
108                "cannot derive `{pascal}Tool`/`{pascal}Input` from function name `{func_name_str}`: \
109                 generated identifiers must start with an alphabetic or underscore character"
110            ),
111        ));
112    }
113    let tool_struct_name = format_ident!("{}Tool", pascal);
114    let input_struct_name = format_ident!("{}Input", pascal);
115    let func_name = func.sig.ident.clone();
116
117    // J7:self(`const`/`async`/`unsafe` etc.)为 async 时不支持——`invoke`/`run` 内
118    // 以同步求值调用 `fn(...)`,直接展开会把 future 当同步值用而生成坏代码。给明确
119    // 的「不支持」错误,而非静默展开成类型错误。
120    if func.sig.asyncness.is_some() {
121        return Err(syn::Error::new_spanned(
122            &func.sig.ident,
123            "async tool functions are not supported by #[tool]: make the function synchronous \
124             (the derived Tool::invoke / BaseTool::run are already async)",
125        ));
126    }
127
128    // 2. Extract parameters from function signature
129    let params = extract_params(&func.sig)?;
130    let field_names: Vec<Ident> = params.iter().map(|p| p.name.clone()).collect();
131
132    // 3. Determine the output type from the function return type
133    let output_type = match &func.sig.output {
134        syn::ReturnType::Default => quote! { () },
135        syn::ReturnType::Type(_, ty) => {
136            // If it's Result<T, E>, extract T; otherwise the type as-is.
137            if let Some(inner) = extract_result_ok(&func.sig.output) {
138                quote! { #inner }
139            } else {
140                quote! { #ty }
141            }
142        }
143    };
144
145    // 3b. F5:函数返回 `Result<_, ToolError>` 时,`invoke` 直接透传原错误
146    // (参数错 / 业务错原样保留,不再统一压平成 `ExecutionFailed`);返回
147    // 其他错误类型时才包 `ExecutionFailed`。此为 breaking:错误语义变化。
148    let invoke_body = if return_type_is_tool_error(&func.sig.output) {
149        quote! { #func_name(#(#field_names),*) }
150    } else {
151        quote! { #func_name(#(#field_names),*).map_err(|e| ::lc_core::tools::ToolError::ExecutionFailed(e.to_string())) }
152    };
153
154    // 4. Generate Input struct fields
155    let input_fields = generate_input_fields(&params);
156
157    // 5. Generate field-level schemars attributes for descriptions
158    let input_field_attrs = generate_field_attrs(&params);
159
160    // 6. Remove #[param] attributes from the original function so the compiler
161    //    doesn't complain about unknown attributes
162    strip_param_attrs(&mut func);
163
164    // 7. Generate the full expanded code
165    let expanded = quote! {
166        // Preserve the original function (with #[param] attrs stripped)
167        #func
168
169        /// Auto-generated Tool struct.
170        #[derive(Debug, Clone)]
171        pub struct #tool_struct_name;
172
173        impl ::std::default::Default for #tool_struct_name {
174            fn default() -> Self {
175                Self
176            }
177        }
178
179        impl #tool_struct_name {
180            pub fn new() -> Self {
181                Self
182            }
183        }
184
185        /// Auto-generated Input struct.
186        #[derive(serde::Deserialize, schemars::JsonSchema)]
187        pub struct #input_struct_name {
188            #(#input_field_attrs)*
189            #(#input_fields)*
190        }
191
192        // Implement Tool trait (type-safe version)
193        #[::async_trait::async_trait]
194        impl ::lc_core::tools::Tool for #tool_struct_name {
195            type Input = #input_struct_name;
196            type Output = #output_type;
197
198            async fn invoke(&self, input: Self::Input) -> ::std::result::Result<Self::Output, ::lc_core::tools::ToolError> {
199                let #input_struct_name { #(#field_names),* } = input;
200                #invoke_body
201            }
202        }
203
204        // Implement BaseTool trait (string version, for Agent)
205        #[::async_trait::async_trait]
206        impl ::lc_core::tools::BaseTool for #tool_struct_name {
207            fn name(&self) -> &str {
208                #func_name_str
209            }
210
211            fn description(&self) -> &str {
212                #description
213            }
214
215            async fn run(&self, input: ::std::string::String) -> ::std::result::Result<::std::string::String, ::lc_core::tools::ToolError> {
216                let parsed: #input_struct_name = ::serde_json::from_str(&input)
217                    .map_err(|e| ::lc_core::tools::ToolError::InvalidInput(format!("JSON parse error: {}", e)))?;
218                let #input_struct_name { #(#field_names),* } = parsed;
219                let result = #func_name(#(#field_names),*)
220                    .map_err(|e| ::lc_core::tools::ToolError::ExecutionFailed(e.to_string()))?;
221                // F5:序列化失败不再静默回退 Debug 文本(会把 Rust 内部结构喂给 LLM),
222                // 而是返回 ExecutionFailed 错误,让上层明确感知输出无法序列化。
223                let serialized = ::serde_json::to_string(&result)
224                    .map_err(|e| ::lc_core::tools::ToolError::ExecutionFailed(format!("Failed to serialize tool output: {}", e)))?;
225                Ok(serialized)
226            }
227
228            fn args_schema(&self) -> ::std::option::Option<::serde_json::Value> {
229                use ::schemars::schema_for;
230                // J9:schema 序列化失败不再 `.ok()` 占位空 `None`——Input 必 derive
231                // JsonSchema,schema 自描述必可序列化,真失败是内部错误,直接 panic 报出。
232                Some(
233                    ::serde_json::to_value(schema_for!(#input_struct_name)).expect(
234                        "[lc-tools-derive] internal error: generated Input schema failed to serialize \
235                         (Input must derive schemars::JsonSchema)",
236                    ),
237                )
238            }
239        }
240    };
241
242    Ok(expanded)
243}
244
245/// Parameter info extracted from function signature.
246struct ParamInfo {
247    name: Ident,
248    ty: Type,
249    desc: Option<String>,
250}
251
252/// Extract parameter info from the function signature.
253fn extract_params(sig: &Signature) -> Result<Vec<ParamInfo>> {
254    let mut params = Vec::new();
255
256    for arg in &sig.inputs {
257        // Skip self parameter
258        if let FnArg::Receiver(_) = arg {
259            continue;
260        }
261
262        if let FnArg::Typed(PatType { pat, ty, attrs, .. }) = arg {
263            let name = match pat.as_ref() {
264                Pat::Ident(ident) => ident.ident.clone(),
265                // J8:非 ident 参数不再静默丢弃——丢弃会让生成代码静默少一个字段,用户
266                // 无从得知宏为何没展开该参数,改为明确的宏错误(类型标注/tuple/wildcard
267                // 等绑定模式均不支持)。
268                other => {
269                    return Err(syn::Error::new_spanned(
270                        other,
271                        "tool parameters must be plain identifiers \
272                         (ascription / tuple / wildcard patterns are not supported)",
273                    ));
274                }
275            };
276
277            // Extract #[param(desc = "...")] attribute
278            let desc = extract_param_desc(attrs)?;
279
280            params.push(ParamInfo {
281                name,
282                ty: (*(*ty)).clone(),
283                desc,
284            });
285        }
286    }
287
288    Ok(params)
289}
290
291/// 从 `Result<ok, err>` 提取两个泛型参数。
292///
293/// 唯一识别 Result 的入口:按路径 **最后一个** 段匹配 `Result`(J4),因此裸 `Result`
294/// 与 `std::result::Result` 一致命中——消除旧 `extract_result_ok` 只认单段、而
295/// `return_type_is_tool_error` 认末段,导致两者对 `std::result::Result` 判决不一致
296/// 而产生双包装的问题。
297fn result_generics(ret: &syn::ReturnType) -> Option<(Type, Type)> {
298    let syn::ReturnType::Type(_, ty) = ret else {
299        return None;
300    };
301    let Type::Path(type_path) = &**ty else {
302        return None;
303    };
304    let seg = type_path.path.segments.last()?;
305    if seg.ident != "Result" {
306        return None;
307    }
308    let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
309        return None;
310    };
311    let mut generic = args.args.iter().filter_map(|a| match a {
312        syn::GenericArgument::Type(t) => Some(t),
313        _ => None,
314    });
315    Some((generic.next()?.clone(), generic.next()?.clone()))
316}
317
318/// Extract `T` from `Result<T, E>`. Returns None if not a Result type.
319fn extract_result_ok(ret: &syn::ReturnType) -> Option<Type> {
320    result_generics(ret).map(|(ok, _)| ok)
321}
322
323/// 判断函数返回类型是否为 `Result<_, ToolError>`。F5:宏据此决定 `invoke` 是否直接
324/// 透传原错误。
325///
326/// J4+J6:与 `extract_result_ok` 统一走 [`result_generics`] 识别 Result;错误类型不再
327/// 宽泛匹配任意 `ToolError` 结尾,限定为裸 `ToolError`(len==1)或
328/// `…::…::tools::ToolError` 路径(`lc_core::tools::ToolError` / `tools::ToolError`)。
329/// 这样既命中真实用例(裸 `ToolError` 经 `use` 引入的常见写法),又不把 `MyToolError`、
330/// `other::ToolError` 误判为库错误而透传。
331fn return_type_is_tool_error(ret: &syn::ReturnType) -> bool {
332    let Some((_, err)) = result_generics(ret) else {
333        return false;
334    };
335    let Type::Path(err_path) = err else {
336        return false;
337    };
338    let segs = err_path.path.segments;
339    let Some(last) = segs.last() else {
340        return false;
341    };
342    if last.ident != "ToolError" {
343        return false;
344    }
345    // 裸 `ToolError`(len==1)直接放行;多段要求倒数第二段为 `tools`。
346    segs.len() == 1
347        || segs
348            .get(segs.len().saturating_sub(2))
349            .is_some_and(|s| s.ident == "tools")
350}
351
352/// J3:校验种子字符串能否作为合法标识符前缀(非空,首字符为字母或 `_`)。
353fn is_valid_ident_seed(s: &str) -> bool {
354    match s.chars().next() {
355        Some(c) => c == '_' || c.is_ascii_alphabetic(),
356        None => false,
357    }
358}
359
360/// Extract `desc` from `#[param(desc = "...")]`.
361///
362/// J5:返回 `Result`,`#[param]` 解析/求值失败不再静默吞(原 `.ok()?`),改为向上抛
363/// `syn::Error` 变成干净的 `compile_error!`,避免描述静默丢失。
364fn extract_param_desc(attrs: &[Attribute]) -> Result<Option<String>> {
365    for attr in attrs {
366        if attr.path().is_ident(PARAM_ATTR) {
367            let meta: Meta = attr.parse_args().map_err(|e| {
368                syn::Error::new_spanned(
369                    attr,
370                    format!("failed to parse `#[{PARAM_ATTR}(...)]`: {e}"),
371                )
372            })?;
373            if let Meta::NameValue(MetaNameValue { path, value, .. }) = &meta {
374                if path.is_ident("desc") {
375                    if let Expr::Lit(ExprLit {
376                        lit: Lit::Str(lit), ..
377                    }) = value
378                    {
379                        return Ok(Some(lit.value()));
380                    }
381                }
382            }
383        }
384    }
385    Ok(None)
386}
387
388/// Generate Input struct fields.
389fn generate_input_fields(params: &[ParamInfo]) -> Vec<TokenStream2> {
390    params
391        .iter()
392        .map(|p| {
393            let name = &p.name;
394            let ty = &p.ty;
395            quote! {
396                pub #name: #ty,
397            }
398        })
399        .collect()
400}
401
402/// Generate schemars field attributes for descriptions.
403///
404/// Uses `#[doc = "..."]` instead of `#[schemars(description = "...")]` because
405/// schemars 0.8 automatically extracts descriptions from doc comments, and using
406/// `#[schemars(description)]` alongside the derive causes "duplicate attribute" errors
407/// when there are multiple fields.
408fn generate_field_attrs(params: &[ParamInfo]) -> Vec<TokenStream2> {
409    params
410        .iter()
411        .map(|p| {
412            if let Some(desc) = &p.desc {
413                quote! {
414                    #[doc = #desc]
415                }
416            } else {
417                quote! {}
418            }
419        })
420        .collect()
421}
422
423/// Convert snake_case to PascalCase.
424fn to_pascal_case(s: &str) -> String {
425    s.split('_')
426        .map(|word| {
427            let mut chars = word.chars();
428            match chars.next() {
429                None => String::new(),
430                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
431            }
432        })
433        .collect()
434}
435
436/// Remove all `#[param(...)]` attributes from function parameters.
437/// This prevents the compiler from complaining about unknown attributes
438/// when the original function is emitted.
439fn strip_param_attrs(func: &mut ItemFn) {
440    for arg in &mut func.sig.inputs {
441        if let FnArg::Typed(pat_type) = arg {
442            pat_type
443                .attrs
444                .retain(|attr| !attr.path().is_ident(PARAM_ATTR));
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use quote::quote;
453    use syn::ReturnType;
454
455    fn rt(src: &str) -> ReturnType {
456        syn::parse_str(src).unwrap()
457    }
458
459    fn ty_str(ret: &ReturnType) -> Option<String> {
460        extract_result_ok(ret).map(|t| quote! { #t }.to_string())
461    }
462
463    /// J4:裸 `Result` 与 `std::result::Result` 一致识别为同一 ok 类型,消双包装。
464    #[test]
465    fn result_generics_bare_and_qualified_agree() {
466        assert_eq!(ty_str(&rt("-> Result<f64, String>")), Some("f64".into()));
467        assert_eq!(
468            ty_str(&rt("-> std::result::Result<f64, String>")),
469            Some("f64".into())
470        );
471        assert_eq!(ty_str(&rt("-> f64")), None);
472        assert_eq!(ty_str(&rt("-> Result<f64>")), None); // 单泛型参数不是 Result<T,E>
473    }
474
475    /// J6:裸 `ToolError` 与 `…::tools::ToolError` 命中;`MyToolError`/`other::ToolError`/
476    /// 非工具错误不命中,不再按名字宽泛过度匹配。
477    #[test]
478    fn return_type_is_tool_error_qualified_forms_only() {
479        assert!(return_type_is_tool_error(&rt("-> Result<String, ToolError>")));
480        assert!(return_type_is_tool_error(&rt("-> Result<String, lc_core::tools::ToolError>")));
481        assert!(return_type_is_tool_error(&rt("-> Result<String, tools::ToolError>")));
482        assert!(!return_type_is_tool_error(&rt("-> Result<String, MyToolError>")));
483        assert!(!return_type_is_tool_error(&rt("-> Result<String, other::ToolError>")));
484        assert!(!return_type_is_tool_error(&rt("-> Result<String, anyhow::Error>")));
485        assert!(!return_type_is_tool_error(&rt("-> String")));
486    }
487
488    /// J3:标识符种子校验拒绝数字开头/空串。
489    #[test]
490    fn ident_seed_validation() {
491        assert!(is_valid_ident_seed("Calculator"));
492        assert!(is_valid_ident_seed("_private"));
493        assert!(!is_valid_ident_seed("9lives"));
494        assert!(!is_valid_ident_seed(""));
495    }
496}