From d35bfbdaceb7c2a8c2612ffa478611b5329a70c5 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 19 Sep 2025 20:42:32 +0700 Subject: [PATCH] Parse #[error] format arguments and fmt handlers --- CHANGELOG.md | 17 ++ Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 14 +- README.ru.md | 5 +- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/input.rs | 179 ++++++++++++++++-- tests/ui/formatter/fail/duplicate_fmt.rs | 14 ++ tests/ui/formatter/fail/duplicate_fmt.stderr | 11 ++ .../ui/transparent/arguments_not_supported.rs | 7 + .../arguments_not_supported.stderr | 11 ++ 11 files changed, 237 insertions(+), 31 deletions(-) create mode 100644 tests/ui/formatter/fail/duplicate_fmt.rs create mode 100644 tests/ui/formatter/fail/duplicate_fmt.stderr create mode 100644 tests/ui/transparent/arguments_not_supported.rs create mode 100644 tests/ui/transparent/arguments_not_supported.stderr diff --git a/CHANGELOG.md b/CHANGELOG.md index 1effc6a..f2000f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project will be documented in this file. ### Added - _Nothing yet._ +## [0.5.15] - 2025-10-07 + +### Added +- Parse `#[error("...")]` attribute arguments into structured `FormatArg` + entries, tracking named bindings and positional indices for future + `format_args!` integration. +- Recognise `#[error(fmt = )]` handlers, capturing the formatter path and + associated arguments while guarding against duplicate `fmt` specifications. + +### Fixed +- Produce dedicated diagnostics when unsupported combinations are used, such as + providing format arguments alongside `#[error(transparent)]`. + +### Tests +- Extend the `trybuild` suite with regression cases covering duplicate `fmt` + handlers and transparent attributes that erroneously include arguments. + ## [0.5.14] - 2025-10-06 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5ab7d3b..ff99c99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.5.14" +version = "0.5.15" dependencies = [ "actix-web", "axum", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.1.6" +version = "0.1.7" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f0e40c6..a9064e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.5.14" +version = "0.5.15" 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.1.6", path = "masterror-derive" } +masterror-derive = { version = "0.1.7", path = "masterror-derive" } masterror-template = { version = "0.1.4", path = "masterror-template" } [dependencies] diff --git a/README.md b/README.md index ad01f98..efd3cb2 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.5.14", default-features = false } +masterror = { version = "0.5.15", default-features = false } # or with features: -# masterror = { version = "0.5.14", features = [ +# masterror = { version = "0.5.15", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -66,10 +66,10 @@ masterror = { version = "0.5.14", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.5.14", default-features = false } +masterror = { version = "0.5.15", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.5.14", features = [ +# masterror = { version = "0.5.15", 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.5.14", default-features = false } +masterror = { version = "0.5.15", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.5.14", features = [ +masterror = { version = "0.5.15", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -398,7 +398,7 @@ masterror = { version = "0.5.14", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.5.14", features = [ +masterror = { version = "0.5.15", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 77d308e..12666e3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -27,9 +27,10 @@ ~~~toml [dependencies] -masterror = { version = "0.5.14", default-features = false } +# минимальное ядро +masterror = { version = "0.5.15", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.5.14", features = [ +# masterror = { version = "0.5.15", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 61e72bd..8021e91 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.6" +version = "0.1.7" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 0b47449..56c6523 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -1,7 +1,12 @@ +use std::collections::HashSet; + use proc_macro2::{Span, TokenStream}; +use quote::{ToTokens, quote}; use syn::{ - Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, Expr, Field as SynField, - Fields as SynFields, GenericArgument, Ident, LitStr, Path, spanned::Spanned + Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, Expr, ExprPath, Field as SynField, + Fields as SynFields, GenericArgument, Ident, LitStr, Token, + parse::{Parse, ParseStream}, + spanned::Spanned }; use crate::template_support::{DisplayTemplate, TemplateIdentifierSpec, parse_display_template}; @@ -260,7 +265,7 @@ pub enum DisplaySpec { }, #[allow(dead_code)] FormatterPath { - path: Path, + path: ExprPath, args: FormatArgsSpec } } @@ -285,7 +290,7 @@ pub struct FormatArg { pub enum FormatBindingKind { Named(Ident), Positional(usize), - Implicit + Implicit(usize) } pub fn parse_input(input: DeriveInput) -> Result { @@ -426,43 +431,183 @@ fn extract_display_spec( } fn parse_error_attribute(attr: &Attribute) -> Result { - attr.parse_args_with(|input: syn::parse::ParseStream| { + mod kw { + syn::custom_keyword!(transparent); + syn::custom_keyword!(fmt); + } + + attr.parse_args_with(|input: ParseStream| { if input.peek(LitStr) { let lit: LitStr = input.parse()?; + let template = parse_display_template(lit)?; + let args = parse_format_args(input)?; + if !input.is_empty() { return Err(Error::new( input.span(), - "unexpected tokens after string literal" + "unexpected tokens after format arguments" )); } - let template = parse_display_template(lit)?; - Ok(DisplaySpec::Template(template)) - } else if input.peek(Ident) { - let ident: Ident = input.parse()?; - if ident != "transparent" { - return Err(Error::new( - ident.span(), - "expected string literal or `transparent`" - )); + + if args.args.is_empty() { + Ok(DisplaySpec::Template(template)) + } else { + Ok(DisplaySpec::TemplateWithArgs { + template, + args + }) } + } else if input.peek(kw::transparent) { + let _: kw::transparent = input.parse()?; + if !input.is_empty() { return Err(Error::new( input.span(), - "unexpected tokens after `transparent`" + "format arguments are not supported with #[error(transparent)]" )); } + Ok(DisplaySpec::Transparent { attribute: Box::new(attr.clone()) }) + } else if input.peek(kw::fmt) { + input.parse::()?; + input.parse::()?; + let path: ExprPath = input.parse()?; + let args = parse_format_args(input)?; + + for arg in &args.args { + if let FormatBindingKind::Named(ident) = &arg.kind + && ident == "fmt" + { + return Err(Error::new(arg.span, "duplicate `fmt` handler specified")); + } + } + + if !input.is_empty() { + return Err(Error::new( + input.span(), + "`fmt = ...` cannot be combined with additional arguments" + )); + } + + Ok(DisplaySpec::FormatterPath { + path, + args + }) } else { Err(Error::new( input.span(), - "expected string literal or `transparent`" + "expected string literal, `transparent`, or `fmt = ...`" )) } }) } +fn parse_format_args(input: ParseStream) -> Result { + let mut args = FormatArgsSpec::default(); + + if input.is_empty() { + return Ok(args); + } + + let leading_comma = if input.peek(Token![,]) { + let comma: Token![,] = input.parse()?; + Some(comma.span) + } else { + None + }; + + if input.is_empty() { + if let Some(span) = leading_comma { + return Err(Error::new(span, "expected format argument after comma")); + } + return Ok(args); + } + + let parsed = syn::punctuated::Punctuated::::parse_terminated(input)?; + + let mut seen_named = HashSet::new(); + + for (index, raw) in parsed.into_iter().enumerate() { + match raw { + RawFormatArg::Named { + ident, + expr, + span + } => { + let name_key = ident.to_string(); + if !seen_named.insert(name_key) { + return Err(Error::new( + ident.span(), + format!("duplicate format argument `{ident}`") + )); + } + + let tokens = quote!(#ident = #expr); + args.args.push(FormatArg { + tokens, + expr, + kind: FormatBindingKind::Named(ident), + span + }); + } + RawFormatArg::Positional { + expr, + span + } => { + let tokens = expr.to_token_stream(); + args.args.push(FormatArg { + tokens, + expr, + kind: FormatBindingKind::Positional(index), + span + }); + } + } + } + + Ok(args) +} + +enum RawFormatArg { + Named { + ident: Ident, + expr: Expr, + span: Span + }, + Positional { + expr: Expr, + span: Span + } +} + +impl Parse for RawFormatArg { + fn parse(input: ParseStream) -> syn::Result { + if input.peek(Ident) && input.peek2(Token![=]) { + let ident: Ident = input.parse()?; + input.parse::()?; + let expr: Expr = input.parse()?; + let span = ident + .span() + .join(expr.span()) + .unwrap_or_else(|| ident.span()); + Ok(Self::Named { + ident, + expr, + span + }) + } else { + let expr: Expr = input.parse()?; + let span = expr.span(); + Ok(Self::Positional { + expr, + span + }) + } + } +} + fn validate_from_usage(fields: &Fields, display: &DisplaySpec, errors: &mut Vec) { let mut from_fields = fields.iter().filter(|field| field.attrs.from.is_some()); let first = from_fields.next(); diff --git a/tests/ui/formatter/fail/duplicate_fmt.rs b/tests/ui/formatter/fail/duplicate_fmt.rs new file mode 100644 index 0000000..f9aa4c9 --- /dev/null +++ b/tests/ui/formatter/fail/duplicate_fmt.rs @@ -0,0 +1,14 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error(fmt = crate::format_error, fmt = crate::format_error)] +struct DuplicateFmt; + +fn format_error( + _error: &DuplicateFmt, + f: &mut core::fmt::Formatter<'_> +) -> core::fmt::Result { + f.write_str("duplicate") +} + +fn main() {} diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr new file mode 100644 index 0000000..82869b9 --- /dev/null +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -0,0 +1,11 @@ +error: duplicate `fmt` handler specified + --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 + | +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/transparent/arguments_not_supported.rs b/tests/ui/transparent/arguments_not_supported.rs new file mode 100644 index 0000000..1245e17 --- /dev/null +++ b/tests/ui/transparent/arguments_not_supported.rs @@ -0,0 +1,7 @@ +use masterror::Error; + +#[derive(Debug, Error)] +#[error(transparent, code = 42)] +struct TransparentWithArgs(#[from] std::io::Error); + +fn main() {} diff --git a/tests/ui/transparent/arguments_not_supported.stderr b/tests/ui/transparent/arguments_not_supported.stderr new file mode 100644 index 0000000..84651ea --- /dev/null +++ b/tests/ui/transparent/arguments_not_supported.stderr @@ -0,0 +1,11 @@ +error: format arguments are not supported with #[error(transparent)] + --> tests/ui/transparent/arguments_not_supported.rs:4:20 + | +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); + | ^^^^^^^^^^^^^^^^^^^