use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Data, DeriveInput, Fields, Ident, Type};
use crate::attr::{self, ContainerAttrs};
pub(crate) fn expand_derive_tool(input: DeriveInput) -> TokenStream2 {
let result = expand(&input);
drop(input);
match result {
Ok(tokens) => tokens,
Err(error) => error.into_compile_error(),
}
}
fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
let ident = &input.ident;
let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(named) => &named.named,
_ => {
return Err(syn::Error::new(
ident.span(),
"`Tool` can only be derived for structs with named fields",
));
}
},
_ => {
return Err(syn::Error::new(
ident.span(),
"`Tool` can only be derived on structs",
));
}
};
if !input.generics.params.is_empty() {
return Err(syn::Error::new(
ident.span(),
"`Tool` cannot be derived for generic structs; implement it manually",
));
}
let container = attr::parse_container(&input.attrs)?;
let description = container
.description
.clone()
.or_else(|| attr::doc_string(&input.attrs))
.ok_or_else(|| {
syn::Error::new(
ident.span(),
format!(
"`{ident}` has no description: set \
`#[tool(description = \"…\")]` or add a `///` doc comment"
),
)
})?;
let tool_name = container
.name
.clone()
.unwrap_or_else(|| to_snake_case(&ident.to_string()));
let rename_all = attr::serde_rename_all(&input.attrs);
let mut properties = Vec::<TokenStream2>::new();
let mut required = Vec::<String>::new();
for field in fields {
let Some(field_ident) = &field.ident else {
continue;
};
let field_attrs = attr::parse_field(&field.attrs)?;
if field_attrs.skip {
check_skip_validity(field_ident, &field.ty, &field.attrs)?;
continue;
}
let json_name = resolve_json_name(field_ident, &field_attrs, &field.attrs, rename_all)?;
let field_doc = field_attrs
.description
.clone()
.or_else(|| attr::doc_string(&field.attrs));
let (schema_value, optional) = type_schema(&field.ty)?;
let description_part = match field_doc {
Some(doc) => quote! { , "description": #doc },
None => quote! {},
};
properties.push(quote! {
#json_name: { #schema_value #description_part }
});
check_default_agreement(field_ident, &field_attrs, &field.attrs, optional)?;
if !optional && !field_attrs.default && !attr::has_serde_default(&field.attrs) {
required.push(json_name);
}
}
let additional = if container.allow_extra {
quote! {}
} else {
quote! { "additionalProperties": false, }
};
let required_tokens: Vec<&str> = required.iter().map(String::as_str).collect();
let handler_str = container.handler.as_deref().unwrap_or("run");
let handler = syn::parse_str::<Ident>(handler_str).map_err(|_| {
syn::Error::new(
proc_macro2::Span::call_site(),
format!("handler `{handler_str}` is not a valid identifier"),
)
})?;
let overrides = provided_overrides(&container);
Ok(quote! {
#[automatically_derived]
impl loopctl::tool::Tool for #ident {
fn name(&self) -> &str {
#tool_name
}
fn description(&self) -> &str {
#description
}
fn schema(&self) -> loopctl::tool::ToolSchema {
loopctl::tool::ToolSchema {
tool: #tool_name.to_string(),
description: #description.to_string(),
input_schema: loopctl::__private::serde_json::json!({
"type": "object",
#additional
"properties": { #(#properties),* },
"required": [#(#required_tokens),*]
}),
}
}
fn call(
&self,
input: loopctl::__private::serde_json::Value,
ctx: &loopctl::tool::ToolContext,
) -> std::pin::Pin<
std::boxed::Box<
dyn std::future::Future<
Output = Result<
loopctl::tool::ToolOutput,
loopctl::tool::ToolError,
>,
> + std::marker::Send
+ '_,
>,
> {
let ctx = std::clone::Clone::clone(ctx);
std::boxed::Box::pin(async move {
let parsed: Self = match loopctl::__private::serde_json::from_value(input) {
Ok(value) => value,
Err(err) => {
return Err(
loopctl::tool::ToolError::InvalidInput(
err.to_string(),
)
);
}
};
Self::#handler(self, parsed, &ctx).await
})
}
#overrides
}
})
}
fn provided_overrides(container: &ContainerAttrs) -> TokenStream2 {
let mut methods = Vec::<TokenStream2>::new();
if container.read_only {
methods.push(quote! {
fn is_read_only(&self) -> bool { true }
});
}
if container.concurrency_safe {
methods.push(quote! {
fn is_concurrency_safe(&self) -> bool { true }
});
}
if let Some(prompt) = &container.system_prompt {
methods.push(quote! {
fn system_prompt(&self) -> Option<String> {
Some(#prompt.to_string())
}
});
}
quote! { #(#methods)* }
}
fn resolve_json_name(
field_ident: &Ident,
field_attrs: &crate::attr::FieldAttrs,
field: &[syn::Attribute],
rename_all: Option<crate::attr::RenameAll>,
) -> syn::Result<String> {
let rust_name = field_ident.to_string();
let serde_name = attr::serde_rename(field).unwrap_or_else(|| match rename_all {
Some(strategy) => strategy.apply(&rust_name),
None => rust_name.clone(),
});
match &field_attrs.name {
Some(tool_name) => {
if *tool_name != serde_name {
return Err(syn::Error::new(
field_ident.span(),
format!(
"`#[tool(name = \"{tool_name}\")]` disagrees with serde's \
key `\"{serde_name}\"` — the model sends what the schema \
says, but deserialization looks for serde's key. Set \
`#[serde(rename = \"{tool_name}\")]` to match, or remove \
the `tool(name)` override."
),
));
}
Ok(tool_name.clone())
}
None => Ok(serde_name),
}
}
fn check_default_agreement(
field_ident: &Ident,
field_attrs: &crate::attr::FieldAttrs,
field: &[syn::Attribute],
optional: bool,
) -> syn::Result<()> {
if field_attrs.default && !optional && !attr::has_serde_default(field) {
return Err(syn::Error::new(
field_ident.span(),
"`#[tool(default)]` on a non-`Option` field also needs \
`#[serde(default)]` — without it the schema says optional \
but deserialization still fails when the model omits the field.",
));
}
Ok(())
}
fn check_skip_validity(
field_ident: &Ident,
ty: &Type,
attrs: &[syn::Attribute],
) -> syn::Result<()> {
if is_option(ty).is_some() || attr::has_serde_default(attrs) {
return Ok(());
}
Err(syn::Error::new(
field_ident.span(),
"`#[tool(skip)]` requires the field to be `Option<T>` or carry \
`#[serde(default)]` so deserialization succeeds when the \
property is absent",
))
}
fn type_schema(ty: &Type) -> syn::Result<(TokenStream2, bool)> {
if let Some(inner) = is_option(ty) {
let (schema, _) = type_schema(inner)?;
return Ok((schema, true));
}
let last = last_path_segment(ty);
let Some(seg) = last else {
return Err(unmappable(ty));
};
let ident = seg.ident.to_string();
match ident.as_str() {
"String" | "str" => Ok((quote! { "type": "string" }, false)),
"Cow" => {
let inner = generic_arg(seg)?;
let Some(inner_seg) = last_path_segment(inner) else {
return Err(unmappable(ty));
};
if matches!(inner_seg.ident.to_string().as_str(), "String" | "str") {
Ok((quote! { "type": "string" }, false))
} else {
Err(unmappable(ty))
}
}
"bool" => Ok((quote! { "type": "boolean" }, false)),
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
| "usize" => Ok((quote! { "type": "integer" }, false)),
"f32" | "f64" => Ok((quote! { "type": "number" }, false)),
"Vec" => {
let inner = generic_arg(seg)?;
let (schema, _) = type_schema(inner)?;
Ok((quote! { "type": "array", "items": { #schema } }, false))
}
"HashMap" | "BTreeMap" => {
let inner = second_generic_arg(seg)?;
let (schema, _) = type_schema(inner)?;
Ok((
quote! { "type": "object", "additionalProperties": { #schema } },
false,
))
}
_ => Err(unmappable(ty)),
}
}
fn unmappable(ty: &Type) -> syn::Error {
use quote::ToTokens;
let text = ty.to_token_stream().to_string();
syn::Error::new(
proc_macro2::Span::call_site(),
format!(
"the derive cannot map `{text}` to a JSON Schema type; \
implement `Tool` manually or use `#[tool(skip)]`"
),
)
}
fn last_path_segment(ty: &Type) -> Option<&syn::PathSegment> {
match ty {
Type::Path(path) => path.path.segments.last(),
Type::Reference(reference) => last_path_segment(&reference.elem),
_ => None,
}
}
fn is_option(ty: &Type) -> Option<&Type> {
let segment = last_path_segment(ty)?;
if segment.ident != "Option" {
return None;
}
match &segment.arguments {
syn::PathArguments::AngleBracketed(args) => args.args.first().and_then(|arg| {
if let syn::GenericArgument::Type(inner) = arg {
Some(inner)
} else {
None
}
}),
_ => None,
}
}
fn generic_arg(segment: &syn::PathSegment) -> syn::Result<&Type> {
match &segment.arguments {
syn::PathArguments::AngleBracketed(args) => {
let inner = args.args.iter().find_map(|arg| {
if let syn::GenericArgument::Type(ty) = arg {
Some(ty)
} else {
None
}
});
inner.ok_or_else(|| unmappable_of(segment))
}
_ => Err(unmappable_of(segment)),
}
}
fn second_generic_arg(segment: &syn::PathSegment) -> syn::Result<&Type> {
match &segment.arguments {
syn::PathArguments::AngleBracketed(args) => {
let inner = args
.args
.iter()
.filter_map(|arg| {
if let syn::GenericArgument::Type(ty) = arg {
Some(ty)
} else {
None
}
})
.nth(1);
inner.ok_or_else(|| unmappable_of(segment))
}
_ => Err(unmappable_of(segment)),
}
}
fn unmappable_of(segment: &syn::PathSegment) -> syn::Error {
syn::Error::new(
segment.ident.span(),
"the derive cannot map this type's generic arguments",
)
}
fn to_snake_case(name: &str) -> String {
let mut out = String::new();
for (index, ch) in name.chars().enumerate() {
if ch.is_uppercase() {
if index != 0 {
out.push('_');
}
out.extend(ch.to_lowercase());
} else {
out.push(ch);
}
}
out
}