use darling::FromAttributes;
use darling::FromMeta;
use darling::ast::NestedMeta;
use proc_macro2::TokenStream;
use quote::quote;
use syn::Attribute;
use syn::FnArg;
use syn::GenericArgument;
use syn::ImplItem;
use syn::ItemImpl;
use syn::LitStr;
use syn::Pat;
use syn::PatType;
use syn::PathArguments;
use syn::ReturnType;
use syn::Type;
use syn::TypePath;
use syn::parse2;
use crate::model::*;
use syn::visit_mut::VisitMut;
use syn::visit_mut::{self};
pub fn parse_router(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
let impl_block = parse2::<ItemImpl>(item)?;
let router_attr = if attr.is_empty() {
RouterAttr::default()
} else {
let nested_metas = NestedMeta::parse_meta_list(attr.clone())
.map_err(|e| syn::Error::new_spanned(&attr, e))?;
RouterAttr::from_list(&nested_metas).map_err(|e| syn::Error::new_spanned(&attr, e))?
};
let router_def = parse_impl_to_router(&impl_block, router_attr)?;
if let Err(errors) = router_def.validate() {
let mut combined_error = None;
for error in errors {
let syn_error = syn::Error::new(error.span, &error.message);
match &mut combined_error {
None => combined_error = Some(syn_error),
Some(e) => e.combine(syn_error),
}
}
return Err(combined_error.unwrap());
}
let cli_methods = if cfg!(feature = "cli") && router_def.metadata.cli_config.is_some() {
crate::codegen::cli::generate_cli_methods(&router_def)
} else {
TokenStream::new() };
let mcp_methods = if cfg!(feature = "mcp") && router_def.metadata.mcp_config.is_some() {
crate::codegen::mcp::generate_mcp_methods(&router_def)
} else {
TokenStream::new()
};
let (rest_module, rest_methods) =
if cfg!(feature = "rest") && router_def.metadata.rest_config.is_some() {
crate::codegen::rest::generate_rest_methods_split(&router_def)
} else {
(TokenStream::new(), TokenStream::new())
};
let mut cleaned_impl_block = impl_block.clone();
strip_param_attributes(&mut cleaned_impl_block);
let output = quote! {
#rest_module
#cleaned_impl_block
#cli_methods
#rest_methods
#mcp_methods
};
if std::env::var("UTF_DEBUG").is_ok() {
eprintln!("Generated code:\n{output}");
}
Ok(output)
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct RouterAttr {
openapi_tag: Option<String>,
base_path: Option<String>,
cli: Option<RouterCliAttr>,
rest: Option<RouterRestAttr>,
mcp: Option<RouterMcpAttr>,
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct RouterCliAttr {
name: Option<String>,
description: Option<String>,
global_output_formats: Option<Vec<LitStr>>,
standard_global_args: Option<bool>,
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct RouterRestAttr {
prefix: Option<String>,
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct RouterMcpAttr {
name: Option<String>,
version: Option<String>,
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct ToolAttr {
name: Option<String>,
description: String,
short: Option<String>,
#[darling(default)]
rest: Option<RestAttr>,
#[darling(default)]
mcp: Option<McpAttr>,
#[darling(default)]
cli: Option<CliAttr>,
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct RestAttr {
path: Option<String>,
#[darling(default = "default_http_method")]
method: String,
}
fn default_http_method() -> String {
"POST".to_string()
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct McpAttr {
read_only: Option<bool>,
destructive: Option<bool>,
idempotent: Option<bool>,
open_world: Option<bool>,
output: Option<String>,
}
#[derive(Debug, Default, FromMeta)]
#[darling(default)]
struct CliAttr {
name: Option<String>,
#[darling(multiple)]
alias: Vec<String>,
#[darling(default)]
hidden: bool,
output_formats: Option<Vec<LitStr>>,
progress_style: Option<String>,
supports_stdin: Option<bool>,
supports_stdout: Option<bool>,
confirm: Option<String>,
interactive: Option<bool>,
command_path: Option<Vec<LitStr>>,
}
#[derive(Debug, Default, FromAttributes)]
#[darling(default, attributes(universal_tool_param))]
struct ParamAttr {
source: Option<String>,
description: Option<String>,
short: Option<char>,
long: Option<String>,
env: Option<String>,
default: Option<String>,
possible_values: Option<Vec<LitStr>>,
multiple: Option<bool>,
delimiter: Option<char>,
completions: Option<String>,
}
fn parse_impl_to_router(impl_block: &ItemImpl, router_attr: RouterAttr) -> syn::Result<RouterDef> {
let struct_type = match &*impl_block.self_ty {
Type::Path(type_path) => type_path.path.clone(),
_ => {
return Err(syn::Error::new_spanned(
&impl_block.self_ty,
"universal_tool_router can only be applied to named types",
));
}
};
let mut tools = Vec::new();
for item in &impl_block.items {
if let ImplItem::Fn(method) = item
&& let Some(tool) = parse_tool_method(method)?
{
tools.push(tool);
}
}
Ok(RouterDef {
struct_type,
generics: if impl_block.generics.params.is_empty() {
None
} else {
Some(impl_block.generics.clone())
},
tools,
metadata: RouterMetadata {
openapi_tag: router_attr.openapi_tag,
base_path: router_attr
.rest
.as_ref()
.and_then(|r| r.prefix.clone())
.or(router_attr.base_path),
cli_config: router_attr.cli.map(|c| crate::model::RouterCliConfig {
name: c.name,
description: c.description,
global_output_formats: c
.global_output_formats
.map(|v| v.into_iter().map(|lit| lit.value()).collect())
.unwrap_or_default(),
standard_global_args: c.standard_global_args.unwrap_or(false),
}),
mcp_config: router_attr.mcp.map(|m| crate::model::RouterMcpConfig {
name: m.name,
version: m.version,
}),
rest_config: router_attr
.rest
.map(|r| crate::model::RouterRestConfig { prefix: r.prefix }),
},
})
}
fn parse_tool_method(method: &syn::ImplItemFn) -> syn::Result<Option<ToolDef>> {
let tool_attr = match find_tool_attribute(&method.attrs)? {
Some(attr) => attr,
None => return Ok(None), };
let method_name = method.sig.ident.clone();
let tool_name = tool_attr.name.unwrap_or_else(|| method_name.to_string());
let mut params = parse_parameters(&method.sig.inputs)?;
let return_type = match &method.sig.output {
ReturnType::Default => {
return Err(syn::Error::new_spanned(
&method.sig,
"Tool methods must have a return type",
));
}
ReturnType::Type(_, ty) => (**ty).clone(),
};
validate_return_type(&return_type)?;
let description = if tool_attr.description.is_empty() {
extract_doc_comment(&method.attrs)
.unwrap_or_else(|| format!("Execute {tool_name} operation"))
} else {
tool_attr.description
};
let metadata = ToolMetadata {
description,
short_description: tool_attr.short,
rest_config: tool_attr.rest.map(|r| RestConfig {
path: r.path,
method: parse_http_method(&r.method).unwrap_or_default(),
}),
mcp_config: tool_attr.mcp.map(|m| {
let output_mode = match m.output.as_deref() {
None => None,
Some("text") => Some(crate::model::McpOutputMode::Text),
Some("json") => Some(crate::model::McpOutputMode::Json),
Some(other) => {
panic!(
"Invalid mcp(output) value: {}. Expected \"text\" or \"json\".",
other
);
}
};
McpConfig {
annotations: McpAnnotations {
read_only_hint: m.read_only,
destructive_hint: m.destructive,
idempotent_hint: m.idempotent,
open_world_hint: m.open_world,
},
output_mode,
}
}),
cli_config: tool_attr.cli.map(|c| CliConfig {
name: c.name,
aliases: c.alias,
hidden: c.hidden,
output_formats: c
.output_formats
.map(|v| v.into_iter().map(|lit| lit.value()).collect())
.unwrap_or_default(),
progress_style: c.progress_style,
examples: vec![], supports_stdin: c.supports_stdin.unwrap_or(false),
supports_stdout: c.supports_stdout.unwrap_or(false),
confirm: c.confirm,
interactive: c.interactive.unwrap_or(false),
command_path: c
.command_path
.map(|v| v.into_iter().map(|lit| lit.value()).collect())
.unwrap_or_default(),
}),
};
if let Some(rest_config) = &metadata.rest_config
&& let Some(path) = &rest_config.path
{
let path_param_names: Vec<String> = path
.split('/')
.filter(|segment| segment.starts_with(':'))
.map(|segment| segment[1..].to_string())
.collect();
for param in &mut params {
if path_param_names.contains(¶m.name.to_string()) {
param.source = ParamSource::Path;
}
}
}
Ok(Some(ToolDef {
method_name,
tool_name,
params,
return_type,
metadata,
is_async: method.sig.asyncness.is_some(),
visibility: method.vis.clone(),
}))
}
fn find_tool_attribute(attrs: &[Attribute]) -> syn::Result<Option<ToolAttr>> {
for attr in attrs {
if attr.path().is_ident("universal_tool") {
let meta = &attr.meta;
return Ok(Some(
ToolAttr::from_meta(meta).map_err(|e| syn::Error::new_spanned(attr, e))?,
));
}
}
Ok(None)
}
fn parse_parameters(
inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
) -> syn::Result<Vec<ParamDef>> {
let mut params = Vec::new();
for arg in inputs {
match arg {
FnArg::Receiver(_) => {
}
FnArg::Typed(pat_type) => {
params.push(parse_typed_param(pat_type)?);
}
}
}
Ok(params)
}
fn parse_typed_param(pat_type: &PatType) -> syn::Result<ParamDef> {
let name = match &*pat_type.pat {
Pat::Ident(pat_ident) => pat_ident.ident.clone(),
_ => {
return Err(syn::Error::new_spanned(
pat_type,
"Tool parameters must be simple identifiers",
));
}
};
let param_attr = ParamAttr::from_attributes(&pat_type.attrs)
.map_err(|e| syn::Error::new_spanned(pat_type, e))?;
let source = if let Some(source_str) = param_attr.source {
match source_str.as_str() {
"body" => ParamSource::Body,
"query" => ParamSource::Query,
"path" => ParamSource::Path,
"header" => ParamSource::Header,
_ => {
return Err(syn::Error::new_spanned(
pat_type,
format!(
"Invalid parameter source: {source_str}. Must be one of: body, query, path, header"
),
));
}
}
} else {
ParamSource::default()
};
let is_optional = is_option_type(&pat_type.ty);
Ok(ParamDef {
name,
ty: (*pat_type.ty).clone(),
source,
is_optional,
metadata: ParamMetadata {
description: param_attr.description,
short: param_attr.short,
long: param_attr.long,
env: param_attr.env,
default: param_attr.default,
possible_values: param_attr
.possible_values
.map(|v| v.into_iter().map(|lit| lit.value()).collect())
.unwrap_or_default(),
completions: param_attr.completions,
multiple: param_attr.multiple.unwrap_or(false),
delimiter: param_attr.delimiter,
},
})
}
fn is_option_type(ty: &Type) -> bool {
if let Type::Path(TypePath { path, .. }) = ty
&& let Some(segment) = path.segments.first()
{
return segment.ident == "Option";
}
false
}
fn validate_return_type(ty: &Type) -> syn::Result<()> {
if let Type::Path(TypePath { path, .. }) = ty
&& let Some(segment) = path.segments.last()
{
if segment.ident != "Result" {
return Err(syn::Error::new_spanned(
ty,
"Tool methods must return Result<T, ToolError>",
));
}
if let PathArguments::AngleBracketed(args) = &segment.arguments {
if args.args.len() != 2 {
return Err(syn::Error::new_spanned(
ty,
"Result must have exactly two type parameters: Result<T, ToolError>",
));
}
if let Some(GenericArgument::Type(error_type)) = args.args.iter().nth(1)
&& !is_tool_error_type(error_type)
{
return Err(syn::Error::new_spanned(
error_type,
"Tool methods must return Result<T, ToolError>. The error type must be ToolError.",
));
}
} else {
return Err(syn::Error::new_spanned(
ty,
"Result must have type parameters: Result<T, ToolError>",
));
}
return Ok(());
}
Err(syn::Error::new_spanned(
ty,
"Tool methods must return Result<T, ToolError>",
))
}
fn is_tool_error_type(ty: &Type) -> bool {
if let Type::Path(TypePath { path, .. }) = ty
&& let Some(segment) = path.segments.last()
{
return segment.ident == "ToolError";
}
false
}
fn parse_http_method(method: &str) -> Option<HttpMethod> {
match method.to_uppercase().as_str() {
"GET" => Some(HttpMethod::Get),
"POST" => Some(HttpMethod::Post),
"PUT" => Some(HttpMethod::Put),
"DELETE" => Some(HttpMethod::Delete),
"PATCH" => Some(HttpMethod::Patch),
_ => None,
}
}
fn extract_doc_comment(attrs: &[Attribute]) -> Option<String> {
let mut docs = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc")
&& let syn::Meta::NameValue(meta) = &attr.meta
&& let syn::Expr::Lit(lit) = &meta.value
&& let syn::Lit::Str(s) = &lit.lit
{
let line = s.value();
let line = line.strip_prefix(' ').unwrap_or(&line);
docs.push(line.to_string());
}
}
if docs.is_empty() {
None
} else {
Some(docs.join("\n"))
}
}
fn strip_param_attributes(impl_block: &mut ItemImpl) {
struct ParamAttributeStripper;
impl VisitMut for ParamAttributeStripper {
fn visit_fn_arg_mut(&mut self, arg: &mut FnArg) {
if let FnArg::Typed(pat_type) = arg {
pat_type
.attrs
.retain(|attr| !attr.path().is_ident("universal_tool_param"));
}
visit_mut::visit_fn_arg_mut(self, arg);
}
}
let mut stripper = ParamAttributeStripper;
stripper.visit_item_impl_mut(impl_block);
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn test_parse_simple_router() {
let input = quote! {
impl MyTools {
#[universal_tool(description = "Add two numbers")]
pub async fn add(&self, a: i32, b: i32) -> Result<i32, ToolError> {
Ok(a + b)
}
}
};
let result = parse_router(TokenStream::new(), input);
assert!(result.is_ok(), "Failed to parse simple router");
}
#[test]
fn test_validate_return_type() {
let valid_type: Type = syn::parse_quote!(Result<i32, ToolError>);
assert!(validate_return_type(&valid_type).is_ok());
let invalid_type: Type = syn::parse_quote!(i32);
assert!(validate_return_type(&invalid_type).is_err());
let wrong_error: Type = syn::parse_quote!(Result<i32, std::io::Error>);
assert!(validate_return_type(&wrong_error).is_err());
}
#[test]
fn test_is_option_type() {
let opt_type: Type = syn::parse_quote!(Option<String>);
assert!(is_option_type(&opt_type));
let non_opt_type: Type = syn::parse_quote!(String);
assert!(!is_option_type(&non_opt_type));
}
#[test]
fn test_parse_http_method() {
assert_eq!(parse_http_method("GET"), Some(HttpMethod::Get));
assert_eq!(parse_http_method("post"), Some(HttpMethod::Post));
assert_eq!(parse_http_method("PUT"), Some(HttpMethod::Put));
assert_eq!(parse_http_method("DELETE"), Some(HttpMethod::Delete));
assert_eq!(parse_http_method("PATCH"), Some(HttpMethod::Patch));
assert_eq!(parse_http_method("INVALID"), None);
}
#[test]
fn test_extract_doc_comment() {
let attrs: Vec<Attribute> = vec![
syn::parse_quote!(#[doc = " This is a doc comment"]),
syn::parse_quote!(#[doc = " with multiple lines"]),
];
let doc = extract_doc_comment(&attrs);
assert_eq!(
doc,
Some("This is a doc comment\nwith multiple lines".to_string())
);
}
}