Skip to content
Merged

447 #460

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions masterror-derive/src/display/enum_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

use proc_macro2::{Ident, TokenStream};
use quote::quote;
use syn::Error;
use syn::{Error, ext::IdentExt};

use super::{
format_args::FormatArgumentsEnv,
Expand Down Expand Up @@ -392,10 +392,12 @@ pub fn variant_named_placeholder(
}
match &placeholder.identifier {
TemplateIdentifierSpec::Named(name) => {
if let Some(index) = fields
.iter()
.position(|field| field.ident.as_ref().is_some_and(|ident| ident == name))
{
if let Some(index) = fields.iter().position(|field| {
field
.ident
.as_ref()
.is_some_and(|ident| ident.unraw() == name.as_str())
}) {
let binding = &bindings[index];
Ok(ResolvedPlaceholderExpr::with(
quote!(#binding),
Expand Down
81 changes: 67 additions & 14 deletions masterror-derive/src/input/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
//! Handles top-level parsing of struct and enum error definitions,
//! coordinating attribute extraction and validation.

use syn::{Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, Ident, spanned::Spanned};
use syn::{
Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, ExprPath, Ident, spanned::Spanned
};

use super::{
parse_attr::{extract_app_error_spec, extract_display_spec, extract_masterror_spec},
types::{ErrorData, ErrorInput, Fields, FormatArgsSpec, StructData, VariantData},
parse_attr::{
extract_app_error_spec, extract_display_spec, extract_display_spec_optional,
extract_enum_fmt_spec, extract_masterror_spec
},
types::{DisplaySpec, ErrorData, ErrorInput, Fields, FormatArgsSpec, StructData, VariantData},
utils::{
collect_errors, path_is, validate_backtrace_usage, validate_from_usage,
validate_transparent
Expand Down Expand Up @@ -77,28 +82,28 @@
}

/// Parses enum error definition.
///
/// An enum-level `#[error(fmt = ...)]` attribute provides a shared formatter
/// for variants that do not declare their own `#[error]` attribute.
fn parse_enum(
attrs: &[Attribute],
data: DataEnum,
errors: &mut Vec<Error>
) -> Result<ErrorData, ()> {
for attr in attrs {
if path_is(attr, "error") {
errors.push(Error::new_spanned(
attr,
"type-level #[error] attributes are not supported"
));
}
}
let shared_fmt = extract_enum_fmt_spec(attrs, errors)?;
let mut variants = Vec::new();
for variant in data.variants {
variants.push(parse_variant(variant, errors)?);
variants.push(parse_variant(variant, shared_fmt.as_ref(), errors)?);
}
Ok(ErrorData::Enum(variants))
}

/// Parses single enum variant.
fn parse_variant(variant: syn::Variant, errors: &mut Vec<Error>) -> Result<VariantData, ()> {
fn parse_variant(
variant: syn::Variant,
shared_fmt: Option<&ExprPath>,
errors: &mut Vec<Error>
) -> Result<VariantData, ()> {
let span = variant.span();
for attr in &variant.attrs {
if path_is(attr, "from") {
Expand All @@ -108,7 +113,19 @@
));
}
}
let display = extract_display_spec(&variant.attrs, span, errors)?;
let display = match extract_display_spec_optional(&variant.attrs, errors)? {
Some(spec) => spec,
None => match shared_fmt {
Some(path) => DisplaySpec::FormatterPath {
path: path.clone(),
args: FormatArgsSpec::default()
},
None => {
errors.push(Error::new(span, "missing #[error(...)] attribute"));
return Err(());
}
}
};
let app_error = extract_app_error_spec(&variant.attrs, errors)?;
let masterror = extract_masterror_spec(&variant.attrs, errors)?;
let fields = Fields::from_syn(&variant.fields, errors);
Expand Down Expand Up @@ -182,6 +199,42 @@
assert!(result.is_err());
}

#[test]
fn parse_enum_level_fmt_fallback() {
let input: DeriveInput = parse_quote! {
#[error(fmt = crate::render)]
enum TestError {
A,
#[error("explicit")]
B
}
};
let result = parse_input(input);
let parsed = result.expect("enum-level fmt fallback");
let ErrorData::Enum(variants) = parsed.data else {
panic!("expected enum data");

Check warning on line 215 in masterror-derive/src/input/parse.rs

View check run for this annotation

Codecov / codecov/patch

masterror-derive/src/input/parse.rs#L215

Added line #L215 was not covered by tests
};
assert!(matches!(
variants[0].display,
super::super::types::DisplaySpec::FormatterPath { .. }
));
assert!(matches!(
variants[1].display,
super::super::types::DisplaySpec::Template(_)
));
}

#[test]
fn parse_enum_variant_missing_error_without_fallback() {
let input: DeriveInput = parse_quote! {
enum TestError {
A
}
};
let result = parse_input(input);
assert!(result.is_err());
}

#[test]
fn parse_variant_from_attr() {
let input: DeriveInput = parse_quote! {
Expand Down
150 changes: 145 additions & 5 deletions masterror-derive/src/input/parse_attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@
missing_span: Span,
errors: &mut Vec<Error>
) -> Result<DisplaySpec, ()> {
match extract_display_spec_optional(attrs, errors)? {
Some(spec) => Ok(spec),
None => {
errors.push(Error::new(missing_span, "missing #[error(...)] attribute"));
Err(())
}
}
}

/// Extracts display specification when the `#[error]` attribute is optional.
///
/// Returns `Ok(None)` when no `#[error]` attribute is present, allowing the
/// caller to fall back to an enum-level formatter.
pub(crate) fn extract_display_spec_optional(
attrs: &[Attribute],
errors: &mut Vec<Error>
) -> Result<Option<DisplaySpec>, ()> {
let mut display = None;
let mut saw_error_attribute = false;
for attr in attrs {
Expand All @@ -108,14 +125,63 @@
}
}
match display {
Some(spec) => Ok(spec),
None => {
if !saw_error_attribute {
errors.push(Error::new(missing_span, "missing #[error(...)] attribute"));
Some(spec) => Ok(Some(spec)),
None if saw_error_attribute => Err(()),
None => Ok(None)
}
}

/// Extracts an enum-level `#[error(fmt = ...)]` formatter path.
///
/// The enum-level attribute serves as a shared formatter for variants that
/// do not carry their own `#[error]` attribute. Any other enum-level display
/// form (template or `transparent`) is rejected.
pub(crate) fn extract_enum_fmt_spec(
attrs: &[Attribute],
errors: &mut Vec<Error>
) -> Result<Option<ExprPath>, ()> {
let mut fmt = None;
let mut had_error = false;
for attr in attrs {
if !path_is(attr, "error") {
continue;
}
if fmt.is_some() {
errors.push(Error::new_spanned(
attr,
"duplicate enum-level #[error] attribute"
));
had_error = true;
continue;
}
match parse_error_attribute(attr) {
Ok(DisplaySpec::FormatterPath {
path,
args
}) if args.args.is_empty() => fmt = Some(path),
Ok(DisplaySpec::FormatterPath {
..
}) => {
errors.push(Error::new_spanned(
attr,
"enum-level #[error(fmt = ...)] does not accept format arguments"
));
had_error = true;
}
Ok(_) => {
errors.push(Error::new_spanned(
attr,
"enum-level #[error] attributes support only `fmt = ...`"
));
had_error = true;
}
Err(err) => {
errors.push(err);
had_error = true;

Check warning on line 180 in masterror-derive/src/input/parse_attr.rs

View check run for this annotation

Codecov / codecov/patch

masterror-derive/src/input/parse_attr.rs#L178-L180

Added lines #L178 - L180 were not covered by tests
}
Err(())
}
}
if had_error { Err(()) } else { Ok(fmt) }
}

/// Parses #[app_error(...)] attribute contents.
Expand Down Expand Up @@ -1233,6 +1299,80 @@
assert!(result.is_err());
}

#[test]
fn extract_display_spec_optional_missing() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[derive(Debug)] }];
let mut errors = Vec::new();
let result = extract_display_spec_optional(&attrs, &mut errors);
assert!(matches!(result, Ok(None)));
assert!(errors.is_empty());
}

#[test]
fn extract_display_spec_optional_present() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[error("message")] }];
let mut errors = Vec::new();
let result = extract_display_spec_optional(&attrs, &mut errors);
assert!(matches!(result, Ok(Some(_))));
}

#[test]
fn extract_enum_fmt_spec_valid() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[error(fmt = crate::render)] }];
let mut errors = Vec::new();
let result = extract_enum_fmt_spec(&attrs, &mut errors);
assert!(matches!(result, Ok(Some(_))));
assert!(errors.is_empty());
}

#[test]
fn extract_enum_fmt_spec_missing() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[derive(Debug)] }];
let mut errors = Vec::new();
let result = extract_enum_fmt_spec(&attrs, &mut errors);
assert!(matches!(result, Ok(None)));
assert!(errors.is_empty());
}

#[test]
fn extract_enum_fmt_spec_rejects_template() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[error("template")] }];
let mut errors = Vec::new();
let result = extract_enum_fmt_spec(&attrs, &mut errors);
assert!(result.is_err());
assert!(!errors.is_empty());
}

#[test]
fn extract_enum_fmt_spec_rejects_transparent() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[error(transparent)] }];
let mut errors = Vec::new();
let result = extract_enum_fmt_spec(&attrs, &mut errors);
assert!(result.is_err());
assert!(!errors.is_empty());
}

#[test]
fn extract_enum_fmt_spec_rejects_arguments() {
let attrs: Vec<Attribute> = vec![parse_quote! { #[error(fmt = render, arg)] }];
let mut errors = Vec::new();
let result = extract_enum_fmt_spec(&attrs, &mut errors);
assert!(result.is_err());
assert!(!errors.is_empty());
}

#[test]
fn extract_enum_fmt_spec_rejects_duplicate() {
let attrs: Vec<Attribute> = vec![
parse_quote! { #[error(fmt = first)] },
parse_quote! { #[error(fmt = second)] },
];
let mut errors = Vec::new();
let result = extract_enum_fmt_spec(&attrs, &mut errors);
assert!(result.is_err());
assert!(!errors.is_empty());
}

#[test]
fn parse_provide_attribute_missing_comma() {
let attr: Attribute = parse_quote! { #[provide(ref = A value = B)] };
Expand Down
40 changes: 35 additions & 5 deletions masterror-derive/src/input/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
use proc_macro2::Span;
use syn::{
AngleBracketedGenericArguments, Attribute, Error, Expr, ExprPath, Field as SynField,
Fields as SynFields, Ident, LitStr, Token, TypePath, punctuated::Punctuated, spanned::Spanned,
token::Paren
Fields as SynFields, Ident, LitStr, Token, TypePath, ext::IdentExt, punctuated::Punctuated,
spanned::Spanned, token::Paren
};

use super::{parse_attr::parse_provide_attribute, utils::is_backtrace_storage};
Expand Down Expand Up @@ -138,11 +138,17 @@ impl Fields {
}

/// Finds a named field by identifier.
///
/// Raw identifiers are matched by their unraw form, so a template
/// placeholder `{source}` resolves a field named `r#source`.
pub fn get_named(&self, name: &str) -> Option<&Field> {
match self {
Self::Named(fields) => fields
.iter()
.find(|field| field.ident.as_ref().is_some_and(|ident| ident == name)),
Self::Named(fields) => fields.iter().find(|field| {
field
.ident
.as_ref()
.is_some_and(|ident| ident.unraw() == name)
}),
_ => None
}
}
Expand Down Expand Up @@ -567,6 +573,14 @@ mod tests {
assert!(parsed.get_named("y").is_some());
}

#[test]
fn fields_get_named_raw_ident_unraw_match() {
let fields: syn::FieldsNamed = parse_quote! { { r#source: String } };
let mut errors = Vec::new();
let parsed = Fields::from_syn(&syn::Fields::Named(fields), &mut errors);
assert!(parsed.get_named("source").is_some());
}

#[test]
fn fields_get_named_not_found() {
let fields: syn::FieldsNamed = parse_quote! { { x: i32 } };
Expand Down Expand Up @@ -706,6 +720,22 @@ mod tests {
assert!(parsed.attrs.has_source());
}

#[test]
fn field_attrs_raw_source_not_inferred() {
let field: SynField = parse_quote! { r#source: String };
let mut errors = Vec::new();
let parsed = Field::from_syn(&field, 0, &mut errors);
assert!(!parsed.attrs.has_source());
}

#[test]
fn field_attrs_raw_source_explicit_attribute() {
let field: SynField = parse_quote! { #[source] r#source: io::Error };
let mut errors = Vec::new();
let parsed = Field::from_syn(&field, 0, &mut errors);
assert!(parsed.attrs.has_source());
}

#[test]
fn field_attrs_has_source_from() {
let field: SynField = parse_quote! { #[from] e: io::Error };
Expand Down
Loading
Loading