use proc_macro2::TokenStream;
use quote::quote;
use syn::{
FnArg, GenericArgument, GenericParam, Ident, ItemFn, LitStr, Pat, PathArguments, ReturnType,
Type, parse_quote, spanned::Spanned,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Component,
Modal,
}
impl Kind {
fn ctx_name(self) -> &'static str {
match self {
Kind::Component => "ComponentCtx",
Kind::Modal => "ModalCtx",
}
}
fn attribute(self) -> &'static str {
match self {
Kind::Component => "component",
Kind::Modal => "modal",
}
}
}
const MAX_PREFIX_LEN: usize = 100;
pub fn expand(kind: Kind, args: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
let prefix: LitStr = syn::parse2(args).map_err(|e| {
syn::Error::new(
e.span(),
format!(
"expected the custom_id prefix: `#[{}(\"prefix\")]`",
kind.attribute()
),
)
})?;
validate_prefix(&prefix)?;
let mut function: ItemFn = syn::parse2(item)?;
let ident = function.sig.ident.clone();
if matches!(function.sig.output, ReturnType::Default) {
return Err(syn::Error::new(
function.sig.span(),
"handlers must return a response, e.g. `-> String` or `-> Update<String>`",
));
}
let state_param = state_param(&function)?;
let mut ctx: Option<Option<Type>> = None;
let mut form: Option<(Ident, Type)> = None;
let mut state_args: Vec<(Ident, Type)> = Vec::new();
for (index, input) in function.sig.inputs.iter().enumerate() {
let FnArg::Typed(param) = input else {
return Err(syn::Error::new(input.span(), "handlers cannot take `self`"));
};
if let Some(state) = ctx_state(¶m.ty, kind.ctx_name()) {
if index != 0 {
return Err(syn::Error::new(
param.ty.span(),
format!("`{}` must be the first parameter", kind.ctx_name()),
));
}
ctx = Some(state);
continue;
}
if is_ctx(¶m.ty, "Ctx") {
return Err(syn::Error::new(
param.ty.span(),
format!(
"`Ctx` is for commands; a {} handler takes `{}`",
kind.attribute(),
kind.ctx_name()
),
));
}
let Pat::Ident(pat) = &*param.pat else {
return Err(syn::Error::new(
param.pat.span(),
"parameters must be plain names",
));
};
if matches!(*param.ty, Type::Reference(_)) {
return Err(syn::Error::new(
param.ty.span(),
"custom_id state is decoded into owned values: use `String`, not `&str`",
));
}
let arg = (pat.ident.clone(), (*param.ty).clone());
if kind == Kind::Modal && form.is_none() {
form = Some(arg);
} else {
state_args.push(arg);
}
}
let form = match (kind, form) {
(Kind::Modal, None) => {
return Err(syn::Error::new(
function.sig.span(),
"modal handlers take the form (a `#[derive(Modal)]` type) after `ctx`",
));
}
(_, form) => form,
};
let has_ctx = ctx.is_some();
let (state, impl_generics, where_clause) =
resolve_state(&mut function, ctx, state_param, kind.ctx_name())?;
let vis = &function.vis;
let names: Vec<_> = state_args.iter().map(|(name, _)| name).collect();
let types: Vec<_> = state_args.iter().map(|(_, ty)| ty).collect();
let ctx_arg = has_ctx.then(|| quote!(ctx,));
let form_arg = form.as_ref().map(|(name, _)| quote!(#name,));
let call = if function.sig.asyncness.is_some() {
quote! { #ident(#ctx_arg #form_arg #(#names),*).await }
} else {
quote! { #ident(#ctx_arg #form_arg #(#names),*) }
};
let (definition, handler, data) = match kind {
Kind::Component => (
quote!(ComponentDefinition),
quote!(ComponentHandler),
quote!(MessageComponentData),
),
Kind::Modal => (
quote!(ModalDefinition),
quote!(ModalHandler),
quote!(ModalSubmitData),
),
};
let extract_form = form.as_ref().map(|(name, ty)| {
quote! {
let #name = <#ty as ::composure::command::Modal>::extract(ctx.data())?;
}
});
let show = form.as_ref().map(|(_, ty)| {
quote! {
#[allow(dead_code)]
#vis fn show(
#(#names: #types),*
) -> ::core::result::Result<
::composure::types::InteractionResponse,
::composure::command::custom_id::CustomIdError,
> {
::core::result::Result::Ok(<#ty as ::composure::command::Modal>::response(
Self::custom_id(#(#names),*)?,
))
}
}
});
let docs: Vec<_> = function
.attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.collect();
let route = crate::app::handler_route(&ident, matches!(kind, Kind::Modal));
Ok(quote! {
#function
#(#docs)*
#[allow(non_camel_case_types)]
#[derive(Debug, Clone, Copy, Default)]
#vis struct #ident {}
impl #ident {
#[allow(dead_code)]
#vis fn custom_id(
#(#names: #types),*
) -> ::core::result::Result<
::std::string::String,
::composure::command::custom_id::CustomIdError,
> {
::composure::command::custom_id::encode(#prefix, &(#(&#names,)*))
}
#show
}
impl ::composure::command::#definition for #ident {
const PREFIX: &'static str = #prefix;
}
impl #impl_generics ::composure::command::#handler<#state> for #ident #where_clause {
async fn invoke(
ctx: ::composure::command::Ctx<'_, #state, ::composure::types::#data>,
fields: ::core::option::Option<&str>,
) -> ::core::result::Result<
::composure::types::InteractionResponse,
::composure::command::HandlerError,
> {
let _ = ctx;
let (#(#names,)*): (#(#types,)*) =
::composure::command::decode_state(#prefix, fields)?;
#extract_form
::composure::command::CommandOutput::into_result(#call)
}
}
#route
})
}
pub(crate) fn resolve_state(
function: &mut ItemFn,
ctx: Option<Option<Type>>,
state_param: Option<Ident>,
ctx_name: &str,
) -> syn::Result<(Type, TokenStream, TokenStream)> {
let has_ctx = ctx.is_some();
let state: Type = match ctx {
Some(None) => {
if let Some(param) = &state_param {
return Err(syn::Error::new(
param.span(),
format!(
"use the type parameter as the State: `ctx: {}<S>`",
ctx_name
),
));
}
function.sig.generics.params.push(parse_quote!(__S));
parse_quote!(__S)
}
Some(Some(Type::ImplTrait(bounds))) => {
let bounds = bounds.bounds;
function
.sig
.generics
.params
.push(parse_quote!(__S: #bounds));
parse_quote!(__S)
}
Some(Some(ty)) => ty,
None => {
if let Some(param) = &state_param {
return Err(syn::Error::new(
param.span(),
format!(
"handlers can only be generic over their State: add `ctx: {}<{param}>`",
ctx_name
),
));
}
parse_quote!(__S)
}
};
if has_ctx
&& let Some(FnArg::Typed(param)) = function.sig.inputs.first_mut()
&& let Type::Path(path) = &mut *param.ty
&& let Some(segment) = path.path.segments.last_mut()
{
segment.arguments = PathArguments::AngleBracketed(parse_quote!(<'_, #state>));
}
let generic = has_ctx && function.sig.generics.type_params().next().is_some();
let impl_generics = if generic {
let (impl_generics, _, _) = function.sig.generics.split_for_impl();
quote!(#impl_generics)
} else if has_ctx {
quote!()
} else {
quote!(<__S>)
};
let predicates = function
.sig
.generics
.where_clause
.iter()
.flat_map(|clause| clause.predicates.iter());
let where_clause = quote!(where #(#predicates,)* #state: ::core::marker::Sync);
Ok((state, impl_generics, where_clause))
}
fn validate_prefix(prefix: &LitStr) -> syn::Result<()> {
let value = prefix.value();
if value.is_empty() || value.chars().count() > MAX_PREFIX_LEN {
return Err(syn::Error::new(
prefix.span(),
"the prefix must be 1 to 100 characters",
));
}
if value.contains([':', '\\']) {
return Err(syn::Error::new(
prefix.span(),
"the prefix cannot contain `:` or `\\`",
));
}
Ok(())
}
pub(crate) fn state_param(function: &ItemFn) -> syn::Result<Option<Ident>> {
let mut param = None;
for generic in &function.sig.generics.params {
match generic {
GenericParam::Type(ty) if param.is_none() => param = Some(ty.ident.clone()),
_ => {
return Err(syn::Error::new(
generic.span(),
"handlers can only be generic over their State",
));
}
}
}
Ok(param)
}
pub(crate) fn is_ctx(ty: &Type, name: &str) -> bool {
matches!(ty, Type::Path(path) if path.path.segments.last().is_some_and(|s| s.ident == name))
}
pub(crate) fn ctx_state(ty: &Type, name: &str) -> Option<Option<Type>> {
if !is_ctx(ty, name) {
return None;
}
let Type::Path(path) = ty else { return None };
let segment = path.path.segments.last()?;
let PathArguments::AngleBracketed(args) = &segment.arguments else {
return Some(None);
};
Some(args.args.iter().find_map(|arg| match arg {
GenericArgument::Type(ty) => Some(ty.clone()),
_ => None,
}))
}