From 8a351ef429f39281ec2a146bfd0ba3f09bcc536c Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 20 Sep 2025 08:04:24 +0700 Subject: [PATCH] Support fmt handlers and update diagnostics --- CHANGELOG.md | 14 ++++ Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 14 ++-- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/display.rs | 74 +++++++++++++++++-- masterror-derive/src/input.rs | 7 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 6 -- .../ui/formatter/fail/unsupported_flag.stderr | 6 -- .../fail/unsupported_formatter.stderr | 6 -- .../ui/formatter/fail/uppercase_binary.stderr | 6 -- .../formatter/fail/uppercase_pointer.stderr | 6 -- tests/ui/formatter/pass/fmt_path.rs | 53 +++++++++++++ .../arguments_not_supported.stderr | 6 -- 14 files changed, 154 insertions(+), 54 deletions(-) create mode 100644 tests/ui/formatter/pass/fmt_path.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 79593c5..544b993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.6.3] - 2025-10-10 + +### Added +- Invoke custom `#[error(fmt = )]` handlers for structs and enum variants, + borrowing fields and forwarding the formatter reference just like `thiserror`. + +### Changed +- Ensure duplicate `fmt` attributes report a single diagnostic without + suppressing the derived display implementation. + +### Tests +- Extend the formatter trybuild suite with success cases covering struct and + enum formatter paths. + ## [0.6.2] - 2025-10-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index d21c5bc..b019275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.6.2" +version = "0.6.3" dependencies = [ "actix-web", "axum", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.2.2" +version = "0.2.3" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 56d9be0..c5c6b15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.6.2" +version = "0.6.3" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -49,7 +49,7 @@ turnkey = [] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.2.2", path = "masterror-derive" } +masterror-derive = { version = "0.2.3", path = "masterror-derive" } masterror-template = { version = "0.2.0", path = "masterror-template" } [dependencies] diff --git a/README.md b/README.md index 67f90ab..ee9840f 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.6.2", default-features = false } +masterror = { version = "0.6.3", default-features = false } # or with features: -# masterror = { version = "0.6.2", features = [ +# masterror = { version = "0.6.3", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -66,10 +66,10 @@ masterror = { version = "0.6.2", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.6.2", default-features = false } +masterror = { version = "0.6.3", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.6.2", features = [ +# masterror = { version = "0.6.3", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -383,13 +383,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.6.2", default-features = false } +masterror = { version = "0.6.3", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.6.2", features = [ +masterror = { version = "0.6.3", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -398,7 +398,7 @@ masterror = { version = "0.6.2", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.6.2", features = [ +masterror = { version = "0.6.3", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index fc78455..37f0de9 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.2.2" +version = "0.2.3" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/display.rs b/masterror-derive/src/display.rs index abcb6eb..aa6df95 100644 --- a/masterror-derive/src/display.rs +++ b/masterror-derive/src/display.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use masterror_template::template::{TemplateFormatter, TemplateFormatterKind}; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::{format_ident, quote, quote_spanned}; -use syn::{Error, spanned::Spanned}; +use syn::Error; use crate::{ input::{ @@ -42,9 +42,7 @@ fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { - return Err(Error::new(path.span(), "`fmt = ...` is not supported yet")); - } + } => render_struct_formatter_path(&data.fields, path) }; let ident = &input.ident; @@ -93,6 +91,26 @@ fn render_struct_transparent(fields: &Fields) -> TokenStream { } } +fn struct_formatter_arguments(fields: &Fields) -> Vec { + match fields { + Fields::Unit => Vec::new(), + Fields::Named(fields) | Fields::Unnamed(fields) => fields + .iter() + .map(|field| { + let member = &field.member; + quote!(&self.#member) + }) + .collect() + } +} + +fn formatter_path_call(path: &syn::ExprPath, mut args: Vec) -> TokenStream { + args.push(quote!(f)); + quote! { + #path(#(#args),*) + } +} + fn render_variant(variant: &VariantData) -> Result { match &variant.display { DisplaySpec::Transparent { @@ -105,10 +123,15 @@ fn render_variant(variant: &VariantData) -> Result { } => render_variant_template(variant, template, Some(args)), DisplaySpec::FormatterPath { path, .. - } => Err(Error::new(path.span(), "`fmt = ...` is not supported yet")) + } => render_variant_formatter_path(variant, path) } } +fn render_struct_formatter_path(fields: &Fields, path: &syn::ExprPath) -> TokenStream { + let args = struct_formatter_arguments(fields); + formatter_path_call(path, args) +} + #[derive(Debug)] struct ResolvedPlaceholderExpr { expr: TokenStream, @@ -279,6 +302,47 @@ fn render_variant_transparent(variant: &VariantData) -> Result Result { + let variant_ident = &variant.ident; + match &variant.fields { + Fields::Unit => { + let call = formatter_path_call(path, Vec::new()); + Ok(quote! { + Self::#variant_ident => { + #call + } + }) + } + Fields::Unnamed(fields) => { + let bindings: Vec<_> = fields.iter().map(binding_ident).collect(); + let pattern = quote!(Self::#variant_ident(#(#bindings),*)); + let call = formatter_path_call(path, variant_formatter_arguments(&bindings)); + Ok(quote! { + #pattern => { + #call + } + }) + } + Fields::Named(fields) => { + let bindings: Vec<_> = fields.iter().map(binding_ident).collect(); + let pattern = quote!(Self::#variant_ident { #(#bindings),* }); + let call = formatter_path_call(path, variant_formatter_arguments(&bindings)); + Ok(quote! { + #pattern => { + #call + } + }) + } + } +} + +fn variant_formatter_arguments(bindings: &[Ident]) -> Vec { + bindings.iter().map(|binding| quote!(#binding)).collect() +} + fn render_variant_template( variant: &VariantData, template: &DisplayTemplate, diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index b628073..69a91db 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -404,12 +404,15 @@ fn extract_display_spec( errors: &mut Vec ) -> Result { let mut display = None; + let mut saw_error_attribute = false; for attr in attrs { if !path_is(attr, "error") { continue; } + saw_error_attribute = true; + if display.is_some() { errors.push(Error::new_spanned(attr, "duplicate #[error] attribute")); continue; @@ -424,7 +427,9 @@ fn extract_display_spec( match display { Some(spec) => Ok(spec), None => { - errors.push(Error::new(missing_span, "missing #[error(...)] attribute")); + if !saw_error_attribute { + errors.push(Error::new(missing_span, "missing #[error(...)] attribute")); + } Err(()) } } diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 82869b9..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -3,9 +3,3 @@ error: duplicate `fmt` handler specified | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] | ^^^ - -error: missing #[error(...)] attribute - --> tests/ui/formatter/fail/duplicate_fmt.rs:5:8 - | -5 | struct DuplicateFmt; - | ^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index c7b58b4..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -3,9 +3,3 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter | 4 | #[error("{value:##x}")] | ^^^^^^^^^^^^^ - -error: missing #[error(...)] attribute - --> tests/ui/formatter/fail/unsupported_flag.rs:5:8 - | -5 | struct UnsupportedFlag { - | ^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index aa11c64..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -3,9 +3,3 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter | 4 | #[error("{value:y}")] | ^^^^^^^^^^^ - -error: missing #[error(...)] attribute - --> tests/ui/formatter/fail/unsupported_formatter.rs:5:8 - | -5 | struct UnsupportedFormatter { - | ^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index f49c391..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -3,9 +3,3 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter | 4 | #[error("{value:B}")] | ^^^^^^^^^^^ - -error: missing #[error(...)] attribute - --> tests/ui/formatter/fail/uppercase_binary.rs:5:8 - | -5 | struct UppercaseBinary { - | ^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index b87f76d..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -3,9 +3,3 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter | 4 | #[error("{value:P}")] | ^^^^^^^^^^^ - -error: missing #[error(...)] attribute - --> tests/ui/formatter/fail/uppercase_pointer.rs:5:8 - | -5 | struct UppercasePointer { - | ^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/pass/fmt_path.rs b/tests/ui/formatter/pass/fmt_path.rs new file mode 100644 index 0000000..715dfba --- /dev/null +++ b/tests/ui/formatter/pass/fmt_path.rs @@ -0,0 +1,53 @@ +use masterror::Error; + +fn format_unit(f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("unit") +} + +fn format_pair(left: &i32, right: &i32, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "pair={left}:{right}") +} + +fn format_struct_fields( + count: &usize, + label: &&'static str, + f: &mut core::fmt::Formatter<'_> +) -> core::fmt::Result { + write!(f, "struct={count}:{label}") +} + +#[derive(Debug, Error)] +#[error(fmt = crate::format_struct_fields)] +struct StructFormatter { + count: usize, + label: &'static str, +} + +#[derive(Debug, Error)] +enum EnumFormatter { + #[error(fmt = crate::format_unit)] + Unit, + #[error(fmt = crate::format_pair)] + Tuple(i32, i32), + #[error(fmt = crate::format_pair)] + Named { left: i32, right: i32 }, + #[error(fmt = crate::format_struct_fields)] + Struct { count: usize, label: &'static str } +} + +fn main() { + let _ = StructFormatter { + count: 1, + label: "alpha" + } + .to_string(); + + let _ = EnumFormatter::Unit.to_string(); + let _ = EnumFormatter::Tuple(10, 20).to_string(); + let _ = EnumFormatter::Named { left: 5, right: 15 }.to_string(); + let _ = EnumFormatter::Struct { + count: 2, + label: "beta" + } + .to_string(); +} diff --git a/tests/ui/transparent/arguments_not_supported.stderr b/tests/ui/transparent/arguments_not_supported.stderr index 84651ea..72addf3 100644 --- a/tests/ui/transparent/arguments_not_supported.stderr +++ b/tests/ui/transparent/arguments_not_supported.stderr @@ -3,9 +3,3 @@ error: format arguments are not supported with #[error(transparent)] | 4 | #[error(transparent, code = 42)] | ^ - -error: missing #[error(...)] attribute - --> tests/ui/transparent/arguments_not_supported.rs:5:8 - | -5 | struct TransparentWithArgs(#[from] std::io::Error); - | ^^^^^^^^^^^^^^^^^^^