Skip to main content

tool_attr_macro/
lib.rs

1//! Procedural macro for defining structured tool functions.
2
3use proc_macro::TokenStream;
4use proc_macro2::Span;
5use quote::{ToTokens, quote};
6use std::collections::HashMap;
7use syn::parse_macro_input;
8
9/// # `#[tool]` Attribute Macro
10///
11/// Generates a `Tool` implementation for annotated functions:
12///
13/// 1. Creates struct wrapper with function
14/// 2. Implements `Tool` trait with:
15///    - `name()`: Function name
16///    - `arguments()`: JSON schema from doc comments
17///    - `call()`: Type-safe argument parsing
18///
19/// ## Example
20///
21/// ```rust
22/// use promptrs::{tool, Tooling};
23///
24/// #[tool]
25/// /// Calculator tool
26/// /// a: First number
27/// /// b: Second number
28/// /// operation: +, -, *, /
29/// fn calculate(a: f64, b: f64, operation: String) -> String {
30///     match operation.as_str() {
31///         "+" => (a + b).to_string(),
32///         "-" => (a - b).to_string(),
33///         "*" => (a * b).to_string(),
34///         "/" => a / b > 0.0 ? (a / b).to_string() : "Division by zero".into(),
35///         _ => "Invalid operation".into(),
36///     }
37/// }
38///
39/// let tooling = Tooling::new().add(calculate);
40/// let args = Arguments::from_value(json!({
41///     "a": 5.5,
42///     "b": 2.5,
43///     "operation": "*"
44/// }));
45///
46/// let result = tooling.call("calculate", &args).unwrap();
47/// println!("Result: {}", result); // 13.75
48/// ```
49///
50/// ## Documentation Comments
51///
52/// Use `/// name: Description` to:
53/// 1. Create parameter documentation in JSON schema
54/// 2. Provide user-facing parameter descriptions
55/// 3. Enable parameter auto-completion in development tools
56#[proc_macro_attribute]
57pub fn tool(_attr: TokenStream, item: TokenStream) -> TokenStream {
58	let input = parse_macro_input!(item as syn::ItemFn);
59	let doc_comments = input.attrs.iter().filter_map(|attr| {
60		let meta = attr.meta.require_name_value().ok()?;
61		meta.path.is_ident("doc").then_some(())?;
62		let syn::Expr::Lit(expr_lit) = &meta.value else {
63			return None;
64		};
65		let syn::Lit::Str(lit_str) = &expr_lit.lit else {
66			return None;
67		};
68		Some(lit_str.value())
69	});
70	let mut pycomments = String::new();
71	let mut prev = None;
72	let mut description = String::new();
73	let mut attr_map = HashMap::<String, String>::new();
74	for mut line in doc_comments {
75		pycomments.push_str("#");
76		pycomments.push_str(&line);
77		pycomments.push_str("\n");
78		line = line.split_off(line.find(line.trim_start()).unwrap_or(0));
79		if let Some(i) = line.find(':') {
80			if let Some((param, desc)) = prev {
81				attr_map.insert(param, desc);
82			}
83
84			let mut desc = line.split_off(i);
85			desc = desc.split_off(1);
86			desc = desc.split_off(desc.find(desc.trim_start()).unwrap_or(0));
87
88			prev = Some((line, desc));
89		} else if let Some((_, desc)) = &mut prev {
90			desc.push('\n');
91			desc.push_str(&line);
92		} else {
93			description.push('\n');
94			description.push_str(&line);
95		}
96	}
97	if let Some((param, desc)) = prev {
98		attr_map.insert(param, desc);
99	}
100
101	let mut params = Vec::new();
102	let mut pyparams = Vec::new();
103	let mut required_params = Vec::new();
104	let mut args = Vec::new();
105	let mut call_stmts = Vec::new();
106	let mut state_arg = quote!();
107	let mut state_ty = quote!();
108	let (mut i, mut j) = (0, 0);
109	for arg in &input.sig.inputs {
110		if let syn::FnArg::Typed(syn::PatType { pat, ty, .. }) = arg {
111			if let syn::Type::Reference(syn::TypeReference { elem, .. }) = &**ty {
112				let ident = syn::Ident::new("state", Span::call_site());
113				state_arg = quote!(#ident : #ty,);
114				state_ty = quote!(type State = #elem;);
115				args.push(ident);
116				continue;
117			}
118			if let syn::Pat::Ident(pat_ident) = &**pat {
119				let ident = &pat_ident.ident;
120				let name = ident.to_string();
121				let name_ref = &name;
122				let index = if name.starts_with("_") {
123					j += 1;
124					(j - 1).to_string() + "_"
125				} else {
126					i += 1;
127					(i - 1).to_string()
128				};
129				let desc = attr_map.get(&name).cloned().unwrap_or_default();
130				let (schema, required) = jsonschema(ty.clone(), desc);
131				let pyschema = pythonschema(&ty);
132
133				args.push(ident.clone());
134				if required {
135					call_stmts.push(quote! {
136						let #ident : #ty = ::promptrs::serde_json::from_value(
137							arguments.remove(#name_ref).or_else(|| arguments.remove(#index)).ok_or(::promptrs::serde::de::Error::missing_field(#name_ref))?
138						)?;
139					});
140				} else {
141					call_stmts.push(quote! {
142						let #ident : #ty = ::promptrs::serde_json::from_value(
143							arguments.remove(#name_ref).or_else(|| arguments.remove(#index)).unwrap_or(::promptrs::serde_json::Value::Null)
144						)?;
145					});
146				}
147				if name.starts_with('_') {
148					continue;
149				}
150
151				params.push(format!(r#""{}": {}"#, name, schema));
152				pyparams.push(format!("{}: {}", name, pyschema));
153				if required {
154					required_params.push(name);
155				}
156			}
157		}
158	}
159	let arguments = format!(
160		r#"{{ "description": "{}", "properties": {{ {} }}, "required": ["{}"] }}"#,
161		description,
162		params.join(", "),
163		required_params.join(r#"", ""#)
164	);
165
166	let syn::ItemFn {
167		vis,
168		sig: syn::Signature {
169			ident,
170			inputs,
171			output,
172			generics,
173			..
174		},
175		block,
176		..
177	} = input;
178	let name = ident.to_string();
179	let pydef = format!("{}def {}({})", pycomments, name, pyparams.join(", "));
180	let output = quote! {
181		#[allow(non_camel_case_types)]
182		struct #ident;
183
184		impl #ident {
185			#vis fn run #generics (#inputs) #output {
186				#block
187			}
188		}
189
190		impl ::promptrs::Tool for #ident {
191			#state_ty
192
193			fn name(&self) -> &str {
194				#name
195			}
196
197			fn arguments(&self) -> &str {
198				#arguments
199			}
200
201			fn pydef(&self) -> &str {
202				#pydef
203			}
204
205			fn call(&self, #state_arg arguments: &::promptrs::Arguments) -> ::promptrs::serde_json::Result<String> {
206				let mut arguments = arguments.clone();
207				#(#call_stmts)*
208				let result = #ident::run(#(#args,)*);
209				Ok(::std::string::ToString::to_string(&result))
210			}
211		}
212	};
213
214	output.into()
215}
216
217fn jsonschema(mut ty: Box<syn::Type>, description: String) -> (String, bool) {
218	if let syn::Type::Reference(syn::TypeReference { elem, .. }) = &*ty {
219		ty = elem.clone();
220	}
221	let syn::Type::Path(syn::TypePath { path, .. }) = &*ty else {
222		return ("unknown".into(), true);
223	};
224	if let Some(syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
225		args,
226		..
227	})) = path.segments.last().and_then(|l| {
228		l.ident
229			.to_string()
230			.as_str()
231			.eq("Option")
232			.then(|| &l.arguments)
233	}) {
234		if let Some(syn::GenericArgument::Type(ty)) = args.first() {
235			return (
236				format!(
237					r#"{}, "description": "{}" }}"#,
238					_jsonschema(ty),
239					description
240				),
241				false,
242			);
243		}
244	}
245	(
246		format!(
247			r#"{}, "description": "{}" }}"#,
248			_jsonschema(&ty),
249			description
250		),
251		true,
252	)
253}
254
255fn _jsonschema(ty: &syn::Type) -> String {
256	match ty {
257		syn::Type::Array(syn::TypeArray { elem, .. }) => {
258			format!(r#"{{ "type": "array", "items": {} }}"#, _jsonschema(&elem))
259		}
260		syn::Type::Reference(syn::TypeReference { elem, .. }) => _jsonschema(&elem),
261		syn::Type::Slice(syn::TypeSlice { elem, .. }) => {
262			let inner = _jsonschema(&elem);
263			if &inner == "str" {
264				return "string".into();
265			}
266			format!(r#"{{ "type": "array", "items": {} }}"#, inner)
267		}
268		syn::Type::Path(syn::TypePath { path, .. }) => {
269			let (ident, arguments) = match path.segments.last() {
270				Some(syn::PathSegment {
271					ident, arguments, ..
272				}) => (ident.into_token_stream().to_string(), arguments),
273				None => return "".into(),
274			};
275			let ty = match ident.as_str() {
276				"String" | "str" | "char" => "string".into(),
277				"f32" | "f64" => "number".into(),
278				"u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" => "integer".into(),
279				"bool" => "boolean".into(),
280				"Vec" => {
281					let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
282						args,
283						..
284					}) = arguments
285					else {
286						return "".into();
287					};
288					let Some(syn::GenericArgument::Type(ty)) = args.first() else {
289						return "".into();
290					};
291					format!(r#"{{ "type": "array", "items": {} }}"#, _jsonschema(&ty))
292				}
293				_ => "".into(),
294			};
295			format!(r#"{{ "type": "{}""#, ty)
296		}
297		_ => "unknown".into(),
298	}
299}
300
301fn pythonschema(ty: &syn::Type) -> String {
302	match ty {
303		syn::Type::Array(syn::TypeArray { elem, .. }) => {
304			format!("list[{}]", pythonschema(&elem))
305		}
306		syn::Type::Reference(syn::TypeReference { elem, .. }) => pythonschema(&elem),
307		syn::Type::Slice(syn::TypeSlice { elem, .. }) => {
308			let inner = pythonschema(&elem);
309			if &inner == "str" {
310				return "str".into();
311			}
312			format!("list[{}]", pythonschema(&elem))
313		}
314		syn::Type::Path(syn::TypePath { path, .. }) => {
315			let (ident, arguments) = match path.segments.last() {
316				Some(syn::PathSegment {
317					ident, arguments, ..
318				}) => (ident.into_token_stream().to_string(), arguments),
319				None => return "".into(),
320			};
321			match ident.as_str() {
322				"String" | "str" | "char" => "str".into(),
323				"f32" | "f64" => "float".into(),
324				"u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" => "int".into(),
325				"bool" => "bool".into(),
326				"Option" => {
327					let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
328						args,
329						..
330					}) = arguments
331					else {
332						return "".into();
333					};
334					let Some(syn::GenericArgument::Type(ty)) = args.first() else {
335						return "".into();
336					};
337					format!("Optional[{}]", pythonschema(ty))
338				}
339				"Vec" => {
340					let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
341						args,
342						..
343					}) = arguments
344					else {
345						return "".into();
346					};
347					let Some(syn::GenericArgument::Type(ty)) = args.first() else {
348						return "".into();
349					};
350					format!("list[{}]", pythonschema(ty))
351				}
352				_ => "".into(),
353			}
354		}
355		_ => "unknown".into(),
356	}
357}