use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{format_ident, quote};
use syn::ext::IdentExt;
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::{Error, Field, FieldsNamed, GenericArgument, Meta, PathArguments, Type};
pub(crate) fn get_crate_path(original_name: &str) -> TokenStream {
let ident = format_ident!("{}", original_name.replace('-', "_"));
quote! { ::#ident }
}
pub(crate) enum LocationLookup {
Found {
index: usize,
needs_stack_attr: bool,
},
NotFound,
}
pub(crate) fn lookup_location_field(
fields: &FieldsNamed,
location_attr_hint: &str,
) -> Result<LocationLookup, Error> {
let mut marked: Vec<(usize, Span)> = Vec::new();
for (i, field) in fields.named.iter().enumerate() {
if let Some(attr_span) = has_stack_location_attr(field)? {
marked.push((i, attr_span));
}
}
match marked.len() {
1 => {
return Ok(LocationLookup::Found {
index: marked[0].0,
needs_stack_attr: false,
});
}
2.. => {
let mut err = Error::new(
marked[1].1,
"multiple #[stack(location)] fields; only one is allowed per struct/variant",
);
err.combine(Error::new(
marked[0].1,
"first occurrence of #[stack(location)] is here",
));
return Err(err);
}
0 => {}
}
let location_typed: Vec<(usize, Span)> = fields
.named
.iter()
.enumerate()
.filter(|(_, f)| looks_like_location_type(&f.ty))
.map(|(i, f)| (i, f.ty.span()))
.collect();
match location_typed.len() {
1 => {
return Ok(LocationLookup::Found {
index: location_typed[0].0,
needs_stack_attr: true,
});
}
2.. => {
let mut err = Error::new(
location_typed[1].1,
format!(
"multiple fields with type name ending in `Location` found; \
use {location_attr_hint} to specify the correct one. \
Note: detection uses the last path segment, so types like \
`geo::Location` also match."
),
);
err.combine(Error::new(
location_typed[0].1,
"first Location-typed field found here",
));
return Err(err);
}
0 => {}
}
if let Some(field) = fields
.named
.iter()
.find(|f| f.ident.as_ref().is_some_and(|i| i == "location"))
{
return Err(Error::new(
field.span(),
format!(
"field 'location' exists but is not of type Location; \
rename it or use {location_attr_hint} on the correct field"
),
));
}
Ok(LocationLookup::NotFound)
}
pub(crate) fn find_location_field(fields: &FieldsNamed) -> Result<&Field, Error> {
match lookup_location_field(fields, "#[stack(location)]")? {
LocationLookup::Found { index, .. } => {
let field = &fields.named[index];
if !looks_like_location_type(&field.ty) {
return Err(Error::new(
field.ty.span(),
"#[stack(location)] field must be of type `suzunari_error::Location`",
));
}
Ok(field)
}
LocationLookup::NotFound => Err(Error::new(
fields.span(),
"StackError requires a Location field. Use #[suzunari_error] to auto-inject, \
or add a field of type Location manually.",
)),
}
}
pub(crate) fn has_stack_location_attr(field: &Field) -> Result<Option<Span>, Error> {
let mut found: Option<Span> = None;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("stack")) {
let Meta::List(meta_list) = &attr.meta else {
return Err(Error::new(
attr.span(),
"#[stack] requires arguments, e.g., #[stack(location)]",
));
};
let nested =
meta_list.parse_args_with(Punctuated::<Meta, syn::Token![,]>::parse_terminated)?;
if nested.is_empty() {
return Err(Error::new(
attr.span(),
"#[stack()] requires arguments, e.g., #[stack(location)]",
));
}
if let Some(unknown) = nested
.iter()
.find(|meta| !matches!(meta, Meta::Path(p) if p.is_ident("location")))
{
return Err(Error::new(
unknown.span(),
"unknown #[stack(...)] argument; only `location` is supported",
));
}
if nested.len() > 1 {
return Err(Error::new(
nested[1].span(),
"duplicate `location` in #[stack(...)]; specify it only once",
));
}
if let Some(prev_span) = found {
let mut err = Error::new(
attr.span(),
"duplicate #[stack(location)] on the same field; specify it only once",
);
err.combine(Error::new(
prev_span,
"first occurrence of #[stack(location)] is here",
));
return Err(err);
}
found = Some(attr.span());
}
Ok(found)
}
pub(crate) fn extract_display_error_inner(ty: &Type) -> Option<&Type> {
let Type::Path(type_path) = ty else {
return None;
};
let segment = type_path.path.segments.last()?;
if segment.ident != "DisplayError" {
return None;
}
let PathArguments::AngleBracketed(args) = &segment.arguments else {
return None;
};
if args.args.len() != 1 {
return None;
}
match &args.args[0] {
GenericArgument::Type(inner) => Some(inner),
_ => None,
}
}
pub(crate) fn looks_like_location_type(ty: &Type) -> bool {
match ty {
Type::Path(p) => p
.path
.segments
.last()
.is_some_and(|s| s.ident == "Location"),
_ => false,
}
}
pub(crate) fn find_source_field(fields: &FieldsNamed) -> Option<&Field> {
fields.named.iter().find(|field| is_source_field(field))
}
fn is_source_field(field: &Field) -> bool {
let is_named_source = field.ident.as_ref().is_some_and(|ident| ident == "source");
let snafu_source = field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("snafu"))
.filter_map(|attr| {
let Meta::List(meta_list) = &attr.meta else {
return None;
};
let nested = meta_list
.parse_args_with(Punctuated::<Meta, syn::Token![,]>::parse_terminated)
.ok()?;
nested
.iter()
.filter_map(|meta| match meta {
Meta::Path(path) if path.is_ident("source") => Some(true),
Meta::List(list) if list.path.is_ident("source") => {
let is_disabled = list
.parse_args_with(Ident::parse_any)
.is_ok_and(|ident| ident == "false");
Some(!is_disabled)
}
_ => None,
})
.last()
})
.last();
snafu_source.unwrap_or(is_named_source)
}
pub(crate) fn has_snafu_keyword(attrs: &[syn::Attribute], keyword: &str) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("snafu") {
return false;
}
let Meta::List(meta_list) = &attr.meta else {
return false;
};
snafu_tokens_contain_keyword(&meta_list.tokens, keyword)
})
}
pub(crate) fn ensure_snafu_implicit(field: &mut Field) {
if !has_snafu_keyword(&field.attrs, "implicit") {
field.attrs.push(syn::parse_quote!(#[snafu(implicit)]));
}
}
fn snafu_tokens_contain_keyword(tokens: &TokenStream, keyword: &str) -> bool {
let mut at_start = true;
for tt in tokens.clone() {
match &tt {
TokenTree::Ident(ident) if at_start && *ident == keyword => return true,
TokenTree::Punct(p) if p.as_char() == ',' => at_start = true,
_ => at_start = false,
}
}
false
}
pub(crate) fn combine_errors(errors: Vec<Error>) -> Result<(), Error> {
let mut iter = errors.into_iter();
let Some(mut combined) = iter.next() else {
return Ok(());
};
for e in iter {
combined.combine(e);
}
Err(combined)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snafu_tokens_contain_keyword_basic() {
let tokens: TokenStream = "source".parse().unwrap();
assert!(snafu_tokens_contain_keyword(&tokens, "source"));
}
#[test]
fn test_snafu_tokens_contain_keyword_comma_separated() {
let tokens: TokenStream = "display(\"msg\"), source".parse().unwrap();
assert!(snafu_tokens_contain_keyword(&tokens, "source"));
}
#[test]
fn test_snafu_tokens_contain_keyword_not_found() {
let tokens: TokenStream = "implicit".parse().unwrap();
assert!(!snafu_tokens_contain_keyword(&tokens, "source"));
}
#[test]
fn test_snafu_tokens_display_source_no_false_positive() {
let tokens: TokenStream = "display(\"source\")".parse().unwrap();
assert!(
!snafu_tokens_contain_keyword(&tokens, "source"),
"display(\"source\") should not match top-level `source` keyword"
);
}
#[test]
fn test_snafu_tokens_source_with_args() {
let tokens: TokenStream = "source(from(T, f))".parse().unwrap();
assert!(snafu_tokens_contain_keyword(&tokens, "source"));
}
#[test]
fn test_snafu_tokens_keyword_inside_group_not_matched() {
let tokens: TokenStream = "wrapper(source)".parse().unwrap();
assert!(!snafu_tokens_contain_keyword(&tokens, "source"));
}
}