From 279441e01afdb1c71c0d697383a1bb9acf2fa023 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:30:10 +0700 Subject: [PATCH] Add extended template formatters --- CHANGELOG.md | 4 + Cargo.lock | 6 +- Cargo.toml | 6 +- README.md | 14 +- masterror-derive/Cargo.toml | 4 +- masterror-derive/src/display.rs | 189 ++++++++++++++++--- masterror-template/Cargo.toml | 2 +- masterror-template/src/template.rs | 217 +++++++++++++++++++++- masterror-template/src/template/parser.rs | 12 +- tests/error_derive.rs | 34 ++++ 10 files changed, 436 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05858e1..3fdc49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. `#[error("...")]` strings and a formatter hook for future custom derives. - Internal `masterror-derive` crate powering the native `masterror::Error` derive macro. +- Template placeholders now accept the same formatter traits as `thiserror` + (`:?`, `:x`, `:X`, `:p`, `:b`, `:o`, `:e`, `:E`) so existing derives keep + compiling when hexadecimal, binary, pointer or exponential formatting is + requested. ### Changed - `masterror::Error` now uses the in-tree derive, removing the dependency on diff --git a/Cargo.lock b/Cargo.lock index 6920431..d8c9b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1409,7 +1409,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.5.2" +version = "0.5.3" dependencies = [ "actix-web", "axum", @@ -1438,7 +1438,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.1.0" +version = "0.1.1" dependencies = [ "masterror-template", "proc-macro2", @@ -1448,7 +1448,7 @@ dependencies = [ [[package]] name = "masterror-template" -version = "0.1.0" +version = "0.1.1" [[package]] name = "matchit" diff --git a/Cargo.toml b/Cargo.toml index 86398bd..7d2c9d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.5.2" +version = "0.5.3" rust-version = "1.90" edition = "2024" description = "Application error types and response mapping" @@ -35,8 +35,8 @@ turnkey = [] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.1", path = "masterror-derive" } -masterror-template = { version = "0.1", path = "masterror-template" } +masterror-derive = { version = "0.1.1", path = "masterror-derive" } +masterror-template = { version = "0.1.1", path = "masterror-template" } [dependencies] # masterror-derive = { path = "masterror-derive" } diff --git a/README.md b/README.md index 911b8ae..321e786 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.5.2", default-features = false } +masterror = { version = "0.5.3", default-features = false } # or with features: -# masterror = { version = "0.5.2", features = [ +# masterror = { version = "0.5.3", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "reqwest", "redis", "validator", # "config", "tokio", "multipart", "teloxide", @@ -66,10 +66,10 @@ masterror = { version = "0.5.2", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.5.2", default-features = false } +masterror = { version = "0.5.3", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.5.2", features = [ +# masterror = { version = "0.5.3", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "reqwest", "redis", "validator", # "config", "tokio", "multipart", "teloxide", @@ -261,13 +261,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.5.2", default-features = false } +masterror = { version = "0.5.3", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.5.2", features = [ +masterror = { version = "0.5.3", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -276,7 +276,7 @@ masterror = { version = "0.5.2", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.5.2", features = [ +masterror = { version = "0.5.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 9724372..3f8e190 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.1.0" +version = "0.1.1" edition = "2024" license = "MIT OR Apache-2.0" @@ -12,4 +12,4 @@ proc-macro = true proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "extra-traits"] } -masterror-template = { path = "../masterror-template", version = "0.1" } +masterror-template = { path = "../masterror-template", version = "0.1.1" } diff --git a/masterror-derive/src/display.rs b/masterror-derive/src/display.rs index b5180d0..b1627c1 100644 --- a/masterror-derive/src/display.rs +++ b/masterror-derive/src/display.rs @@ -85,6 +85,29 @@ fn render_variant(variant: &VariantData) -> Result { } } +#[derive(Debug)] +struct ResolvedPlaceholderExpr { + expr: TokenStream, + pointer_value: bool +} + +impl ResolvedPlaceholderExpr { + fn new(expr: TokenStream) -> Self { + Self::with(expr, false) + } + + fn pointer(expr: TokenStream) -> Self { + Self::with(expr, true) + } + + fn with(expr: TokenStream, pointer_value: bool) -> Self { + Self { + expr, + pointer_value + } + } +} + fn render_variant_transparent(variant: &VariantData) -> Result { let variant_ident = &variant.ident; @@ -172,7 +195,7 @@ fn render_variant_template( fn render_template(template: &DisplayTemplate, resolver: F) -> Result where - F: Fn(&TemplatePlaceholderSpec) -> Result + F: Fn(&TemplatePlaceholderSpec) -> Result { let mut pieces = Vec::new(); for segment in &template.segments { @@ -181,8 +204,8 @@ where pieces.push(quote! { f.write_str(#text)?; }); } TemplateSegmentSpec::Placeholder(placeholder) => { - let expr = resolver(placeholder)?; - pieces.push(format_placeholder(expr, placeholder.formatter)); + let resolved = resolver(placeholder)?; + pieces.push(format_placeholder(resolved, placeholder.formatter)); } } } @@ -196,40 +219,78 @@ where fn struct_placeholder_expr( fields: &Fields, placeholder: &TemplatePlaceholderSpec -) -> Result { +) -> Result { match &placeholder.identifier { - TemplateIdentifierSpec::Named(name) if name == "self" => Ok(quote!(self)), + TemplateIdentifierSpec::Named(name) if name == "self" => { + Ok(ResolvedPlaceholderExpr::with( + quote!(self), + needs_pointer_value(placeholder.formatter) + )) + } TemplateIdentifierSpec::Named(name) => { if let Some(field) = fields.get_named(name) { - let member = &field.member; - Ok(quote!(&self.#member)) - } else { - Err(placeholder_error(placeholder.span, &placeholder.identifier)) - } - } - TemplateIdentifierSpec::Positional(index) => { - if let Some(field) = fields.get_positional(*index) { - let member = &field.member; - Ok(quote!(&self.#member)) + Ok(struct_field_expr(field, placeholder.formatter)) } else { Err(placeholder_error(placeholder.span, &placeholder.identifier)) } } + TemplateIdentifierSpec::Positional(index) => fields + .get_positional(*index) + .map(|field| struct_field_expr(field, placeholder.formatter)) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)) + } +} + +fn struct_field_expr(field: &Field, formatter: TemplateFormatter) -> ResolvedPlaceholderExpr { + let member = &field.member; + + if needs_pointer_value(formatter) && pointer_prefers_value(&field.ty) { + ResolvedPlaceholderExpr::pointer(quote!(self.#member)) + } else { + ResolvedPlaceholderExpr::new(quote!(&self.#member)) + } +} + +fn needs_pointer_value(formatter: TemplateFormatter) -> bool { + matches!(formatter, TemplateFormatter::Pointer { .. }) +} + +fn pointer_prefers_value(ty: &syn::Type) -> bool { + match ty { + syn::Type::Ptr(_) => true, + syn::Type::Reference(reference) => reference.mutability.is_none(), + syn::Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident == "NonNull") + .unwrap_or(false), + _ => false } } fn variant_tuple_placeholder( bindings: &[Ident], placeholder: &TemplatePlaceholderSpec -) -> Result { +) -> Result { match &placeholder.identifier { - TemplateIdentifierSpec::Named(name) if name == "self" => Ok(quote!(self)), + TemplateIdentifierSpec::Named(name) if name == "self" => { + Ok(ResolvedPlaceholderExpr::with( + quote!(self), + needs_pointer_value(placeholder.formatter) + )) + } TemplateIdentifierSpec::Named(_) => { Err(placeholder_error(placeholder.span, &placeholder.identifier)) } TemplateIdentifierSpec::Positional(index) => bindings .get(*index) - .map(|binding| quote!(#binding)) + .map(|binding| { + ResolvedPlaceholderExpr::with( + quote!(#binding), + needs_pointer_value(placeholder.formatter) + ) + }) .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)) } } @@ -238,16 +299,24 @@ fn variant_named_placeholder( fields: &[Field], bindings: &[Ident], placeholder: &TemplatePlaceholderSpec -) -> Result { +) -> Result { match &placeholder.identifier { - TemplateIdentifierSpec::Named(name) if name == "self" => Ok(quote!(self)), + TemplateIdentifierSpec::Named(name) if name == "self" => { + Ok(ResolvedPlaceholderExpr::with( + quote!(self), + needs_pointer_value(placeholder.formatter) + )) + } TemplateIdentifierSpec::Named(name) => { if let Some(index) = fields .iter() .position(|field| field.ident.as_ref().is_some_and(|ident| ident == name)) { let binding = &bindings[index]; - Ok(quote!(#binding)) + Ok(ResolvedPlaceholderExpr::with( + quote!(#binding), + needs_pointer_value(placeholder.formatter) + )) } else { Err(placeholder_error(placeholder.span, &placeholder.identifier)) } @@ -259,7 +328,15 @@ fn variant_named_placeholder( } } -fn format_placeholder(expr: TokenStream, formatter: TemplateFormatter) -> TokenStream { +fn format_placeholder( + resolved: ResolvedPlaceholderExpr, + formatter: TemplateFormatter +) -> TokenStream { + let ResolvedPlaceholderExpr { + expr, + pointer_value + } = resolved; + match formatter { TemplateFormatter::Display => quote! { core::fmt::Display::fmt(#expr, f)?; @@ -273,6 +350,74 @@ fn format_placeholder(expr: TokenStream, formatter: TemplateFormatter) -> TokenS alternate: true } => quote! { write!(f, "{:#?}", #expr)?; + }, + TemplateFormatter::LowerHex { + alternate + } => { + if alternate { + quote! { write!(f, "{:#x}", #expr)?; } + } else { + quote! { core::fmt::LowerHex::fmt(#expr, f)?; } + } + } + TemplateFormatter::UpperHex { + alternate + } => { + if alternate { + quote! { write!(f, "{:#X}", #expr)?; } + } else { + quote! { core::fmt::UpperHex::fmt(#expr, f)?; } + } + } + TemplateFormatter::Pointer { + alternate + } => { + if alternate { + quote! { write!(f, "{:#p}", #expr)?; } + } else if pointer_value { + quote! {{ + let value = #expr; + core::fmt::Pointer::fmt(&value, f)?; + }} + } else { + quote! { core::fmt::Pointer::fmt(#expr, f)?; } + } + } + TemplateFormatter::Binary { + alternate + } => { + if alternate { + quote! { write!(f, "{:#b}", #expr)?; } + } else { + quote! { core::fmt::Binary::fmt(#expr, f)?; } + } + } + TemplateFormatter::Octal { + alternate + } => { + if alternate { + quote! { write!(f, "{:#o}", #expr)?; } + } else { + quote! { core::fmt::Octal::fmt(#expr, f)?; } + } + } + TemplateFormatter::LowerExp { + alternate + } => { + if alternate { + quote! { write!(f, "{:#e}", #expr)?; } + } else { + quote! { core::fmt::LowerExp::fmt(#expr, f)?; } + } + } + TemplateFormatter::UpperExp { + alternate + } => { + if alternate { + quote! { write!(f, "{:#E}", #expr)?; } + } else { + quote! { core::fmt::UpperExp::fmt(#expr, f)?; } + } } } } diff --git a/masterror-template/Cargo.toml b/masterror-template/Cargo.toml index e30dfbb..3aa3600 100644 --- a/masterror-template/Cargo.toml +++ b/masterror-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror-template" -version = "0.1.0" +version = "0.1.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/masterror-template/src/template.rs b/masterror-template/src/template.rs index 9127168..e3dad78 100644 --- a/masterror-template/src/template.rs +++ b/masterror-template/src/template.rs @@ -164,18 +164,129 @@ pub enum TemplateFormatter { Debug { /// Whether `{value:#?}` (alternate debug) was requested. alternate: bool + }, + /// Lower-hexadecimal formatting (`{value:x}` / `{value:#x}`). + LowerHex { + /// Whether alternate formatting (`{value:#x}`) was requested. + alternate: bool + }, + /// Upper-hexadecimal formatting (`{value:X}` / `{value:#X}`). + UpperHex { + /// Whether alternate formatting (`{value:#X}`) was requested. + alternate: bool + }, + /// Pointer formatting (`{value:p}` / `{value:#p}`). + Pointer { + /// Whether alternate formatting (`{value:#p}`) was requested. + alternate: bool + }, + /// Binary formatting (`{value:b}` / `{value:#b}`). + Binary { + /// Whether alternate formatting (`{value:#b}`) was requested. + alternate: bool + }, + /// Octal formatting (`{value:o}` / `{value:#o}`). + Octal { + /// Whether alternate formatting (`{value:#o}`) was requested. + alternate: bool + }, + /// Lower exponential formatting (`{value:e}` / `{value:#e}`). + LowerExp { + /// Whether alternate formatting (`{value:#e}`) was requested. + alternate: bool + }, + /// Upper exponential formatting (`{value:E}` / `{value:#E}`). + UpperExp { + /// Whether alternate formatting (`{value:#E}`) was requested. + alternate: bool } } impl TemplateFormatter { - /// Returns `true` when debug formatting with `#?` was requested. + /// Parses a formatting specifier (the portion after `:`) into a formatter. + pub fn from_format_spec(spec: &str) -> Option { + match spec { + "?" => Some(Self::Debug { + alternate: false + }), + "#?" => Some(Self::Debug { + alternate: true + }), + "x" => Some(Self::LowerHex { + alternate: false + }), + "#x" => Some(Self::LowerHex { + alternate: true + }), + "X" => Some(Self::UpperHex { + alternate: false + }), + "#X" => Some(Self::UpperHex { + alternate: true + }), + "p" => Some(Self::Pointer { + alternate: false + }), + "#p" => Some(Self::Pointer { + alternate: true + }), + "b" => Some(Self::Binary { + alternate: false + }), + "#b" => Some(Self::Binary { + alternate: true + }), + "o" => Some(Self::Octal { + alternate: false + }), + "#o" => Some(Self::Octal { + alternate: true + }), + "e" => Some(Self::LowerExp { + alternate: false + }), + "#e" => Some(Self::LowerExp { + alternate: true + }), + "E" => Some(Self::UpperExp { + alternate: false + }), + "#E" => Some(Self::UpperExp { + alternate: true + }), + _ => None + } + } + + /// Returns `true` when alternate formatting (`#`) was requested. pub const fn is_alternate(&self) -> bool { - matches!( - self, + match self { + Self::Display => false, Self::Debug { - alternate: true + alternate } - ) + | Self::LowerHex { + alternate + } + | Self::UpperHex { + alternate + } + | Self::Pointer { + alternate + } + | Self::Binary { + alternate + } + | Self::Octal { + alternate + } + | Self::LowerExp { + alternate + } + | Self::UpperExp { + alternate + } => *alternate + } } } @@ -322,6 +433,102 @@ mod tests { assert!(placeholders[0].formatter().is_alternate()); } + #[test] + fn parses_extended_formatters() { + let cases = [ + ( + "{value:x}", + TemplateFormatter::LowerHex { + alternate: false + } + ), + ( + "{value:#x}", + TemplateFormatter::LowerHex { + alternate: true + } + ), + ( + "{value:X}", + TemplateFormatter::UpperHex { + alternate: false + } + ), + ( + "{value:#X}", + TemplateFormatter::UpperHex { + alternate: true + } + ), + ( + "{value:p}", + TemplateFormatter::Pointer { + alternate: false + } + ), + ( + "{value:#p}", + TemplateFormatter::Pointer { + alternate: true + } + ), + ( + "{value:b}", + TemplateFormatter::Binary { + alternate: false + } + ), + ( + "{value:#b}", + TemplateFormatter::Binary { + alternate: true + } + ), + ( + "{value:o}", + TemplateFormatter::Octal { + alternate: false + } + ), + ( + "{value:#o}", + TemplateFormatter::Octal { + alternate: true + } + ), + ( + "{value:e}", + TemplateFormatter::LowerExp { + alternate: false + } + ), + ( + "{value:#e}", + TemplateFormatter::LowerExp { + alternate: true + } + ), + ( + "{value:E}", + TemplateFormatter::UpperExp { + alternate: false + } + ), + ( + "{value:#E}", + TemplateFormatter::UpperExp { + alternate: true + } + ) + ]; + + for (template_str, expected) in &cases { + let template = ErrorTemplate::parse(template_str).expect("parse"); + let placeholder = template.placeholders().next().expect("placeholder present"); + assert_eq!(placeholder.formatter(), *expected, "case: {template_str}"); + } + } + #[test] fn handles_brace_escaping() { let template = ErrorTemplate::parse("{{}} -> {value}").expect("parse"); diff --git a/masterror-template/src/template/parser.rs b/masterror-template/src/template/parser.rs index d3f30d1..2a82691 100644 --- a/masterror-template/src/template/parser.rs +++ b/masterror-template/src/template/parser.rs @@ -147,21 +147,15 @@ fn split_placeholder<'a>( let formatter = match parts.next().map(str::trim) { None => TemplateFormatter::Display, - Some("?") => TemplateFormatter::Debug { - alternate: false - }, - Some("#?") => TemplateFormatter::Debug { - alternate: true - }, Some("") => { return Err(TemplateError::InvalidFormatter { span }); } - Some(_) => { - return Err(TemplateError::InvalidFormatter { + Some(spec) => { + TemplateFormatter::from_format_spec(spec).ok_or(TemplateError::InvalidFormatter { span - }); + })? } }; diff --git a/tests/error_derive.rs b/tests/error_derive.rs index 3ce1a20..d4b70bf 100644 --- a/tests/error_derive.rs +++ b/tests/error_derive.rs @@ -150,6 +150,18 @@ enum EnumWithBacktrace { Unit } +#[derive(Debug, Error)] +#[error( + "x={value:x} X={value:X} #x={value:#x} #X={value:#X} b={value:b} #b={value:#b} \ + o={value:o} #o={value:#o} e={float:e} #e={float:#e} E={float:E} #E={float:#E} \ + p={ptr:p} #p={ptr:#p}" +)] +struct FormatterShowcase { + value: u32, + float: f64, + ptr: *const u32 +} + #[cfg(error_generic_member_access)] fn assert_backtrace_interfaces(error: &E, expected: &std::backtrace::Backtrace) where @@ -348,3 +360,25 @@ fn enum_backtrace_field_is_returned() { assert!(std::error::Error::backtrace(&unit).is_none()); } } + +#[test] +fn supports_extended_formatters() { + let value = 0x5A5Au32; + let float = 1234.5_f64; + let ptr = core::ptr::null::(); + + let err = FormatterShowcase { + value, + float, + ptr + }; + + let expected = format!( + "x={value:x} X={value:X} #x={value:#x} #X={value:#X} b={value:b} #b={value:#b} \ + o={value:o} #o={value:#o} e={float:e} #e={float:#e} E={float:E} #E={float:#E} \ + p={ptr:p} #p={ptr:#p}" + ); + + assert_eq!(err.to_string(), expected); + assert!(StdError::source(&err).is_none()); +}