Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <path>)]` 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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
] }
Expand All @@ -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"
] }
Expand Down
5 changes: 3 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion masterror-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
179 changes: 162 additions & 17 deletions masterror-derive/src/input.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -260,7 +265,7 @@ pub enum DisplaySpec {
},
#[allow(dead_code)]
FormatterPath {
path: Path,
path: ExprPath,
args: FormatArgsSpec
}
}
Expand All @@ -285,7 +290,7 @@ pub struct FormatArg {
pub enum FormatBindingKind {
Named(Ident),
Positional(usize),
Implicit
Implicit(usize)
}

pub fn parse_input(input: DeriveInput) -> Result<ErrorInput, Error> {
Expand Down Expand Up @@ -426,43 +431,183 @@ fn extract_display_spec(
}

fn parse_error_attribute(attr: &Attribute) -> Result<DisplaySpec, Error> {
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::<kw::fmt>()?;
input.parse::<Token![=]>()?;
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<FormatArgsSpec, Error> {
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::<RawFormatArg, Token![,]>::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<Self> {
if input.peek(Ident) && input.peek2(Token![=]) {
let ident: Ident = input.parse()?;
input.parse::<Token![=]>()?;
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<Error>) {
let mut from_fields = fields.iter().filter(|field| field.attrs.from.is_some());
let first = from_fields.next();
Expand Down
14 changes: 14 additions & 0 deletions tests/ui/formatter/fail/duplicate_fmt.rs
Original file line number Diff line number Diff line change
@@ -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() {}
11 changes: 11 additions & 0 deletions tests/ui/formatter/fail/duplicate_fmt.stderr
Original file line number Diff line number Diff line change
@@ -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;
| ^^^^^^^^^^^^
7 changes: 7 additions & 0 deletions tests/ui/transparent/arguments_not_supported.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use masterror::Error;

#[derive(Debug, Error)]
#[error(transparent, code = 42)]
struct TransparentWithArgs(#[from] std::io::Error);

fn main() {}
Loading
Loading