use proc_macro2::TokenStream;
use quote::{ToTokens, format_ident, quote};
use syn::{ItemEnum, Token, parse::Parse, parse_quote};
pub(crate) fn variant_id(name: &str) -> u64 {
name.bytes().fold(0xcbf29ce484222325, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x100000001b3)
})
}
pub fn schema(input: &syn::DeriveInput) -> syn::Result<TokenStream> {
if !input.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&input.generics,
"Rejection composition supports monomorphic enums; use concrete payload types or a concrete enum facade",
));
}
let syn::Data::Enum(data) = &input.data else {
return Ok(TokenStream::new());
};
let name = &input.ident;
let unique = variant_id(&format!(
"{} {:?}",
input.to_token_stream(),
input.ident.span()
));
let helper = format_ident!("__errlanes_schema_{name}_{unique}");
let exported = format_ident!("{name}Schema");
let vis = &input.vis;
let mut fields = TokenStream::new();
let mut variants = data.variants.clone();
for variant in &mut variants {
let id = variant_id(&variant.ident.to_string());
let mut origin = format!("{}::{}", helper, variant.ident);
let mut delegate = false;
let mut from = false;
for attr in &variant.attrs {
if attr.path().is_ident("rejection") {
attr.parse_nested_meta(|m| {
if m.path.is_ident("origin") {
origin = m.value()?.parse::<syn::LitStr>()?.value();
} else if m.path.is_ident("delegate") {
delegate = true;
} else if m.path.is_ident("from") {
from = true;
} else if m.input.peek(Token![=]) {
let _: syn::Expr = m.value()?.parse()?;
}
Ok(())
})?;
}
}
let payload_index = if delegate || from {
Some(
crate::classify::payload_field(&variant.fields, &variant.ident, "this variant")?
.index,
)
} else {
None
};
variant
.attrs
.retain(|a| !a.path().is_ident("lift") && !a.path().is_ident("rejection"));
variant
.attrs
.push(parse_quote!(#[rejection(origin = #origin)]));
for (index, field) in variant.fields.iter_mut().enumerate() {
let ty = &field.ty;
fields.extend(quote! {
impl errlanes::RejectionField<#id, #index> for #name { type Type = #ty; }
});
if payload_index == Some(index)
&& !field.attrs.iter().any(|a| a.path().is_ident("source"))
{
field.attrs.push(parse_quote!(#[source]));
}
field.ty = syn::parse2(
quote!(<__ErrlanesSource as errlanes::RejectionField<#id, #index>>::Type),
)?;
}
}
let schema = quote!(#variants).to_string();
Ok(quote! {
#fields
#[doc(hidden)]
#[macro_export]
macro_rules! #helper {
($callback:path, [$($context:tt)*], $source:ty) => {
$callback! { [$($context)*] [$source] #schema }
};
}
#[doc(hidden)]
#vis use #helper as #exported;
})
}
fn is_placeholder(variant: &syn::Variant) -> bool {
variant.attrs.iter().any(|a| a.path().is_ident("compose"))
}
pub fn expand(mut item: ItemEnum) -> syn::Result<TokenStream> {
for variant in &item.variants {
let mut flatten = false;
for attr in &variant.attrs {
if attr.path().is_ident("flatten") {
return Err(syn::Error::new_spanned(
attr,
"use #[compose(flatten)] and the placeholder name as prefix; use explicit lifts for custom names",
));
}
if attr.path().is_ident("compose") {
let option = attr.parse_args::<syn::Ident>().map_err(|_| {
syn::Error::new_spanned(
attr,
"expected #[compose(flatten)]; the placeholder name supplies the prefix, with no prefix or rename arguments",
)
})?;
if option != "flatten" || flatten {
return Err(syn::Error::new_spanned(
attr,
"expected exactly one #[compose(flatten)] on a source placeholder",
));
}
flatten = true;
}
}
}
let Some(index) = item.variants.iter().position(is_placeholder) else {
let mut attrs = Vec::new();
for attr in item.attrs {
if attr.path().is_ident("derive") {
let derives = attr.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, Token![,]>::parse_terminated,
)?;
let derives: Vec<_> = derives
.into_iter()
.filter(|path| {
!path.segments.last().is_some_and(|segment| {
segment.ident == "Rejection" || segment.ident == "Lift"
})
})
.collect();
if !derives.is_empty() {
attrs.push(parse_quote!(#[derive(#(#derives),*)]));
}
} else {
attrs.push(attr);
}
}
item.attrs = attrs;
item.attrs.insert(
0,
parse_quote!(#[derive(errlanes::Rejection, errlanes::Lift)]),
);
return Ok(quote!(#item));
};
let variant = &item.variants[index];
let syn::Fields::Unnamed(fields) = &variant.fields else {
return Err(syn::Error::new_spanned(
variant,
"compose(flatten) requires one source family: Name(Source)",
));
};
if fields.unnamed.len() != 1 {
return Err(syn::Error::new_spanned(
fields,
"compose(flatten) requires one source family: Name(Source)",
));
}
let source = &fields.unnamed[0].ty;
let syn::Type::Path(source_path) = source else {
return Err(syn::Error::new_spanned(
source,
"compose(flatten) requires a named source enum",
));
};
if source_path.qself.is_some() {
return Err(syn::Error::new_spanned(
source,
"compose(flatten) requires a named source enum, not an associated type",
));
}
for attr in &item.attrs {
if attr.path().is_ident("lift") {
let registration = attr.parse_args::<crate::lift::Registration>()?;
if registration.source.to_token_stream().to_string()
== source.to_token_stream().to_string()
{
return Err(syn::Error::new_spanned(
variant,
"source family already mapped; choose whole-family #[compose(flatten)] or explicit #[lift] mappings for that source",
));
}
}
}
let mut helper = source_path.path.clone();
let last = helper.segments.last_mut().unwrap();
if !matches!(last.arguments, syn::PathArguments::None) {
return Err(syn::Error::new_spanned(
source,
"generic rejection composition is unsupported; use a concrete enum",
));
}
last.ident = format_ident!("{}Schema", last.ident);
Ok(quote! { #helper!(errlanes::__compose_rejection, [#item], #source); })
}
struct Callback {
item: ItemEnum,
source: syn::Type,
schema: syn::LitStr,
}
impl Parse for Callback {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let item;
syn::bracketed!(item in input);
let item = item.parse()?;
let source;
syn::bracketed!(source in input);
Ok(Self {
item,
source: source.parse()?,
schema: input.parse()?,
})
}
}
pub fn callback(input: TokenStream) -> syn::Result<TokenStream> {
let Callback {
mut item,
source,
schema,
} = syn::parse2(input)?;
let index = item.variants.iter().position(is_placeholder).unwrap();
let placeholder = &item.variants[index];
let prefix = placeholder.ident.to_string();
fn substitute(tokens: TokenStream, source: &syn::Type) -> TokenStream {
use proc_macro2::{Group, TokenTree};
tokens
.into_iter()
.flat_map(|token| match token {
TokenTree::Ident(name) if name == "__ErrlanesSource" => source.to_token_stream(),
TokenTree::Group(group) => {
let mut result =
Group::new(group.delimiter(), substitute(group.stream(), source));
result.set_span(group.span());
TokenStream::from(TokenTree::Group(result))
}
other => TokenStream::from(other),
})
.collect()
}
let variants = substitute(schema.value().parse()?, &source);
let parsed: ItemEnum = syn::parse2(quote!(enum Schema { #variants }))?;
let mut names: std::collections::HashSet<String> = item
.variants
.iter()
.enumerate()
.filter(|(i, _)| *i != index)
.map(|(_, v)| v.ident.to_string())
.collect();
fn origin(variant: &syn::Variant) -> syn::Result<Option<String>> {
let mut origin = None;
for attr in &variant.attrs {
if attr.path().is_ident("rejection") {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("origin") {
origin = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.input.peek(Token![=]) {
let _: syn::Expr = meta.value()?.parse()?;
}
Ok(())
})?;
}
}
Ok(origin)
}
let mut origins = std::collections::HashSet::new();
for variant in &item.variants {
if let Some(origin) = origin(variant)? {
origins.insert(origin);
}
}
let mut imported = Vec::new();
for mut variant in parsed.variants {
if let Some(origin) = origin(&variant)?
&& !origins.insert(origin)
{
return Err(syn::Error::new_spanned(
placeholder,
"the same rejection leaf arrives through multiple imports; narrow the source families or map explicitly to a canonical destination",
));
}
let original = variant.ident.clone();
variant.ident = format_ident!("{prefix}{original}");
if !names.insert(variant.ident.to_string()) {
return Err(syn::Error::new_spanned(
&variant.ident,
"composed variant name collision; change the placeholder name or use explicit lifts",
));
}
variant
.attrs
.push(parse_quote!(#[lift(#source::#original)]));
variant
.attrs
.push(parse_quote!(#[rejection(forward = #source::#original)]));
imported.push(variant);
}
let mut variants = syn::punctuated::Punctuated::new();
for (i, variant) in item.variants.into_iter().enumerate() {
if i == index {
variants.extend(imported.clone());
} else {
variants.push(variant);
}
}
item.variants = variants;
item.attrs.push(parse_quote!(#[lift(#source, strict)]));
expand(item)
}