From 62b3cf0a6e207240ad974975541aab0afa4cb3e5 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 20 Sep 2025 12:41:40 +0700 Subject: [PATCH] Fix projection clippy lints and update tests --- CHANGELOG.md | 17 ++ Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 112 +++++----- README.template.md | 48 +++++ masterror-derive/Cargo.toml | 2 +- masterror-derive/src/display.rs | 188 +++++++++++++---- masterror-derive/src/error_trait.rs | 63 +++--- masterror-derive/src/input.rs | 197 +++++++++++++++--- tests/error_derive.rs | 76 +++++++ .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/formatter/pass/nested_projection.rs | 43 ++++ 19 files changed, 617 insertions(+), 168 deletions(-) create mode 100644 tests/ui/formatter/pass/nested_projection.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb6121..745deb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.9.0] - 2025-10-20 + +### Added +- Parsed dot-prefixed display shorthands into a projection AST so `.limits.lo`, + `.0.data`, and chained method calls like `.suggestion.as_ref().map_or_else(...)` + resolve against struct fields and variant bindings. +- Extended the `error_derive` integration suite and trybuild fixtures with + regressions covering nested projections for named and tuple variants. + +### Changed +- Shorthand resolution now builds expressions from the projection AST, preserving + raw identifiers, tuple indices, and method invocations when generating code. + +### Documentation +- Documented the richer shorthand projection support in the README and template + so downstream users know complex field/method chains are available. + ## [0.8.0] - 2025-10-14 ### Added diff --git a/Cargo.lock b/Cargo.lock index 9152a19..304716e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.8.0" +version = "0.9.0" dependencies = [ "actix-web", "axum", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.4.0" +version = "0.5.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 262e39a..37cccda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.8.0" +version = "0.9.0" 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.4.0", path = "masterror-derive" } +masterror-derive = { version = "0.5.0", path = "masterror-derive" } masterror-template = { version = "0.2.0", path = "masterror-template" } [dependencies] diff --git a/README.md b/README.md index 9b62ef7..9e2667e 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.8.0", default-features = false } +masterror = { version = "0.9.0", default-features = false } # or with features: -# masterror = { version = "0.8.0", features = [ +# masterror = { version = "0.9.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -66,10 +66,10 @@ masterror = { version = "0.8.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.8.0", default-features = false } +masterror = { version = "0.9.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.8.0", features = [ +# masterror = { version = "0.9.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -163,6 +163,54 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. +#### Display shorthand projections + +`#[error("...")]` supports the same shorthand syntax as `thiserror` for +referencing fields with `.field` or `.0`. The derive now understands chained +segments, so projections like `.limits.lo`, `.0.data` or +`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw +identifiers and tuple indices are preserved, ensuring keywords such as +`r#type` and tuple fields continue to work even when you call methods on the +projected value. + +~~~rust +use masterror::Error; + +#[derive(Debug)] +struct Limits { + lo: i32, + hi: i32, +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct RangeError { + limits: Limits, + suggestion: Option, +} + +#[derive(Debug)] +struct Payload { + data: &'static str, +} + +#[derive(Debug, Error)] +enum UiError { + #[error("tuple data {data}", data = .0.data)] + Tuple(Payload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option }, +} +~~~ + #### AppError conversions Annotating structs or enum variants with `#[app_error(...)]` captures the @@ -211,56 +259,6 @@ let as_app: AppError = missing.into(); assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); ~~~ -#### Provide custom telemetry - -Fields can expose structured telemetry to `std::error::Request` consumers by -annotating them with `#[provide(...)]`. The attribute accepts `ref = ` to -publish borrowed values via `Request::provide_ref` and `value = ` to clone -and forward owned data with `Request::provide_value`. Both specifiers are -optional, enabling one field to provide different representations. Optional -fields only emit telemetry when they contain a value, mirroring the -backtrace-handling behaviour of `thiserror`. - -When the crate is compiled with `--cfg error_generic_member_access` (required by -the current unstable `std::error::request_*` helpers), the derive generates a -`provide` implementation that forwards the `Request` to any `#[source]` field -before yielding the annotated telemetry: - -~~~rust -use masterror::Error; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct Snapshot(&'static str); - -#[derive(Debug, Error)] -#[error("snapshot {0:?}")] -struct SnapshotError( - #[provide(ref = Snapshot, value = Snapshot)] - Snapshot -); - -#[derive(Debug, Error)] -#[error("optional telemetry {0:?}")] -struct MaybeSnapshot( - #[provide(ref = Snapshot)] - Option -); - -#[cfg(error_generic_member_access)] -{ - let err = SnapshotError(Snapshot("trace")); - let borrowed = std::error::request_ref::(&err).unwrap(); - assert_eq!(borrowed, &Snapshot("trace")); - let owned = std::error::request_value::(&err).unwrap(); - assert_eq!(owned, Snapshot("trace")); - - let maybe = MaybeSnapshot(Some(Snapshot("span"))); - assert!(std::error::request_ref::(&maybe).is_some()); - let empty = MaybeSnapshot(None); - assert!(std::error::request_ref::(&empty).is_none()); -} -~~~ - #### Formatter traits Placeholders default to `Display` (`{value}`) but can opt into richer @@ -485,13 +483,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.8.0", default-features = false } +masterror = { version = "0.9.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.8.0", features = [ +masterror = { version = "0.9.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -500,7 +498,7 @@ masterror = { version = "0.8.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.8.0", features = [ +masterror = { version = "0.9.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.template.md b/README.template.md index 36dec3f..1b59176 100644 --- a/README.template.md +++ b/README.template.md @@ -157,6 +157,54 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. +#### Display shorthand projections + +`#[error("...")]` supports the same shorthand syntax as `thiserror` for +referencing fields with `.field` or `.0`. The derive now understands chained +segments, so projections like `.limits.lo`, `.0.data` or +`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw +identifiers and tuple indices are preserved, ensuring keywords such as +`r#type` and tuple fields continue to work even when you call methods on the +projected value. + +~~~rust +use masterror::Error; + +#[derive(Debug)] +struct Limits { + lo: i32, + hi: i32, +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct RangeError { + limits: Limits, + suggestion: Option, +} + +#[derive(Debug)] +struct Payload { + data: &'static str, +} + +#[derive(Debug, Error)] +enum UiError { + #[error("tuple data {data}", data = .0.data)] + Tuple(Payload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option }, +} +~~~ + #### AppError conversions Annotating structs or enum variants with `#[app_error(...)]` captures the diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index c253ce5..c52c717 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.4.0" +version = "0.5.0" 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 241367a..a58b42d 100644 --- a/masterror-derive/src/display.rs +++ b/masterror-derive/src/display.rs @@ -3,11 +3,12 @@ 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; +use syn::{Error, Index}; use crate::{ input::{ - DisplaySpec, ErrorData, ErrorInput, Field, Fields, FormatArg, FormatArgShorthand, + DisplaySpec, ErrorData, ErrorInput, Field, Fields, FormatArg, FormatArgProjection, + FormatArgProjectionMethodCall, FormatArgProjectionSegment, FormatArgShorthand, FormatArgValue, FormatArgsSpec, FormatBindingKind, StructData, VariantData, placeholder_error }, @@ -371,28 +372,18 @@ fn resolve_struct_shorthand( shorthand: &FormatArgShorthand, placeholder: &TemplatePlaceholderSpec ) -> Result { - match shorthand { - FormatArgShorthand::Named(ident) => { - let field = fields.get_named(&ident.to_string()).ok_or_else(|| { - Error::new( - ident.span(), - format!("unknown field `{}` in format arguments", ident) - ) - })?; - Ok(struct_field_expr(field, placeholder.formatter)) - } - FormatArgShorthand::Positional { - index, - span - } => { - let field = fields.get_positional(*index).ok_or_else(|| { - Error::new( - *span, - format!("field `{}` is not available in format arguments", index) - ) - })?; - Ok(struct_field_expr(field, placeholder.formatter)) - } + let FormatArgShorthand::Projection(projection) = shorthand; + + let (expr, first_field, has_tail) = struct_projection_expr(fields, projection)?; + + if !has_tail && let Some(field) = first_field { + return Ok(struct_field_expr(field, placeholder.formatter)); + } + + if needs_pointer_value(placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(expr, false)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&(#expr)))) } } @@ -402,8 +393,17 @@ fn resolve_variant_shorthand( shorthand: &FormatArgShorthand, placeholder: &TemplatePlaceholderSpec ) -> Result { - match shorthand { - FormatArgShorthand::Named(ident) => { + let FormatArgShorthand::Projection(projection) = shorthand; + + let Some(first_segment) = projection.segments.first() else { + return Err(Error::new( + projection.span, + "empty shorthand projection is not supported" + )); + }; + + match first_segment { + FormatArgProjectionSegment::Field(ident) => { let Fields::Named(named_fields) = fields else { return Err(Error::new( ident.span(), @@ -435,12 +435,24 @@ fn resolve_variant_shorthand( ) })?; - Ok(ResolvedPlaceholderExpr::with( - quote!(#binding), - needs_pointer_value(placeholder.formatter) - )) + let expr = if projection.segments.len() == 1 { + quote!(#binding) + } else { + append_projection_segments(quote!(#binding), &projection.segments[1..]) + }; + + if projection.segments.len() == 1 { + Ok(ResolvedPlaceholderExpr::with( + expr, + needs_pointer_value(placeholder.formatter) + )) + } else if needs_pointer_value(placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(expr, false)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&(#expr)))) + } } - FormatArgShorthand::Positional { + FormatArgProjectionSegment::Index { index, span } => { @@ -458,11 +470,117 @@ fn resolve_variant_shorthand( ) })?; - Ok(ResolvedPlaceholderExpr::with( - quote!(#binding), - needs_pointer_value(placeholder.formatter) - )) + let expr = if projection.segments.len() == 1 { + quote!(#binding) + } else { + append_projection_segments(quote!(#binding), &projection.segments[1..]) + }; + + if projection.segments.len() == 1 { + Ok(ResolvedPlaceholderExpr::with( + expr, + needs_pointer_value(placeholder.formatter) + )) + } else if needs_pointer_value(placeholder.formatter) { + Ok(ResolvedPlaceholderExpr::with(expr, false)) + } else { + Ok(ResolvedPlaceholderExpr::new(quote!(&(#expr)))) + } + } + FormatArgProjectionSegment::MethodCall(call) => Err(Error::new( + call.span, + "variant format projections must start with a field or index" + )) + } +} + +fn struct_projection_expr<'a>( + fields: &'a Fields, + projection: &'a FormatArgProjection +) -> Result<(TokenStream, Option<&'a Field>, bool), Error> { + let Some(first) = projection.segments.first() else { + return Err(Error::new( + projection.span, + "empty shorthand projection is not supported" + )); + }; + + let mut first_field = None; + let mut expr = match first { + FormatArgProjectionSegment::Field(ident) => { + let field = fields.get_named(&ident.to_string()).ok_or_else(|| { + Error::new( + ident.span(), + format!("unknown field `{}` in format arguments", ident) + ) + })?; + first_field = Some(field); + let member = &field.member; + quote!(self.#member) + } + FormatArgProjectionSegment::Index { + index, + span + } => { + let field = fields.get_positional(*index).ok_or_else(|| { + Error::new( + *span, + format!("field `{}` is not available in format arguments", index) + ) + })?; + first_field = Some(field); + let member = &field.member; + quote!(self.#member) + } + FormatArgProjectionSegment::MethodCall(call) => append_method_call(quote!(self), call) + }; + + if projection.segments.len() > 1 { + expr = append_projection_segments(expr, &projection.segments[1..]); + } + + Ok((expr, first_field, projection.segments.len() > 1)) +} + +fn append_projection_segments( + mut expr: TokenStream, + segments: &[FormatArgProjectionSegment] +) -> TokenStream { + for segment in segments { + expr = append_projection_segment(expr, segment); + } + expr +} + +fn append_projection_segment( + expr: TokenStream, + segment: &FormatArgProjectionSegment +) -> TokenStream { + match segment { + FormatArgProjectionSegment::Field(ident) => quote!((#expr).#ident), + FormatArgProjectionSegment::Index { + index, + span + } => { + let index_token = Index { + index: *index as u32, + span: *span + }; + quote!((#expr).#index_token) } + FormatArgProjectionSegment::MethodCall(call) => append_method_call(expr, call) + } +} + +fn append_method_call(expr: TokenStream, call: &FormatArgProjectionMethodCall) -> TokenStream { + let method = &call.method; + let args = &call.args; + if let Some(turbofish) = &call.turbofish { + let colon2 = turbofish.colon2_token; + let generics = &turbofish.generics; + quote!((#expr).#method #colon2 #generics (#args)) + } else { + quote!((#expr).#method(#args)) } } diff --git a/masterror-derive/src/error_trait.rs b/masterror-derive/src/error_trait.rs index 1eb6f34..0b5f7ad 100644 --- a/masterror-derive/src/error_trait.rs +++ b/masterror-derive/src/error_trait.rs @@ -334,18 +334,17 @@ fn struct_provide_method(fields: &Fields) -> Option { )); } - if let Some(backtrace) = backtrace { - if backtrace.stores_backtrace() - && !source_field.is_some_and(|source| source.index == backtrace.index()) - && !delegates_to_source - { - let member = &backtrace.field().member; - statements.push(provide_backtrace_tokens( - quote!(self.#member), - backtrace.field(), - &request - )); - } + if let Some(backtrace) = backtrace + && backtrace.stores_backtrace() + && source_field.is_none_or(|source| source.index != backtrace.index()) + && !delegates_to_source + { + let member = &backtrace.field().member; + statements.push(provide_backtrace_tokens( + quote!(self.#member), + backtrace.field(), + &request + )); } for field in fields.iter() { @@ -515,15 +514,17 @@ fn variant_provide_named_arm( )); } - if let Some(backtrace_field) = backtrace { - if backtrace_field.stores_backtrace() && !same_as_source && !delegates_to_source { - let binding = backtrace_binding.expect("backtrace binding"); - statements.push(provide_backtrace_tokens( - quote!(#binding), - backtrace_field.field(), - request - )); - } + if let Some(backtrace_field) = backtrace + && backtrace_field.stores_backtrace() + && !same_as_source + && !delegates_to_source + { + let binding = backtrace_binding.expect("backtrace binding"); + statements.push(provide_backtrace_tokens( + quote!(#binding), + backtrace_field.field(), + request + )); } for (binding, field) in provide_bindings { @@ -606,15 +607,17 @@ fn variant_provide_unnamed_arm( )); } - if let Some(backtrace_field) = backtrace { - if backtrace_field.stores_backtrace() && !same_as_source && !delegates_to_source { - let binding = backtrace_binding.expect("backtrace binding"); - statements.push(provide_backtrace_tokens( - quote!(#binding), - backtrace_field.field(), - request - )); - } + if let Some(backtrace_field) = backtrace + && backtrace_field.stores_backtrace() + && !same_as_source + && !delegates_to_source + { + let binding = backtrace_binding.expect("backtrace binding"); + statements.push(provide_backtrace_tokens( + quote!(#binding), + backtrace_field.field(), + request + )); } for (binding, field) in provide_bindings { diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 43593ba..73408b8 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -2,11 +2,14 @@ use std::collections::HashSet; use proc_macro2::Span; use syn::{ - Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, Expr, ExprPath, Field as SynField, - Fields as SynFields, GenericArgument, Ident, LitBool, LitInt, LitStr, Token, TypePath, + AngleBracketedGenericArguments, Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, + Expr, ExprPath, Field as SynField, Fields as SynFields, GenericArgument, Ident, LitBool, + LitInt, LitStr, Token, TypePath, ext::IdentExt, parse::{Parse, ParseStream}, - spanned::Spanned + punctuated::Punctuated, + spanned::Spanned, + token::Paren }; use crate::template_support::{DisplayTemplate, TemplateIdentifierSpec, parse_display_template}; @@ -421,10 +424,54 @@ pub enum FormatArgValue { #[allow(dead_code)] #[derive(Debug)] pub enum FormatArgShorthand { - Named(Ident), - Positional { index: usize, span: Span } + Projection(FormatArgProjection) +} + +#[derive(Debug)] +pub struct FormatArgProjection { + pub segments: Vec, + pub span: Span +} + +#[derive(Debug)] +pub enum FormatArgProjectionSegment { + Field(Ident), + Index { index: usize, span: Span }, + MethodCall(FormatArgProjectionMethodCall) +} + +impl FormatArgProjectionSegment { + fn span(&self) -> Span { + match self { + Self::Field(ident) => ident.span(), + Self::Index { + span, .. + } => *span, + Self::MethodCall(call) => call.span + } + } +} + +#[derive(Debug)] +pub struct FormatArgProjectionMethodCall { + pub method: Ident, + pub turbofish: Option, + pub args: Punctuated, + pub span: Span +} + +#[derive(Debug)] +pub struct FormatArgMethodTurbofish { + pub colon2_token: Token![::], + pub generics: AngleBracketedGenericArguments } +type MethodCallSuffix = Option<( + Option, + Paren, + Punctuated +)>; + #[allow(dead_code)] #[derive(Debug)] pub enum FormatBindingKind { @@ -869,36 +916,134 @@ impl Parse for RawFormatArg { fn parse_format_arg_value(input: ParseStream) -> syn::Result { if input.peek(Token![.]) { let dot: Token![.] = input.parse()?; - - if input.peek(Ident) { - let ident: Ident = input.parse()?; - Ok(FormatArgValue::Shorthand(FormatArgShorthand::Named(ident))) - } else if input.peek(LitInt) { - let literal: LitInt = input.parse()?; - let index = literal.base10_parse::()?; - Ok(FormatArgValue::Shorthand(FormatArgShorthand::Positional { - index, - span: literal.span() - })) - } else { - Err(syn::Error::new( - dot.span, - "expected field name or index after `.`" - )) - } + let projection = parse_projection_segments(input, dot.span)?; + Ok(FormatArgValue::Shorthand(FormatArgShorthand::Projection( + projection + ))) } else { let expr: Expr = input.parse()?; Ok(FormatArgValue::Expr(expr)) } } +fn parse_projection_segments( + input: ParseStream, + dot_span: Span +) -> syn::Result { + let first = parse_projection_segment(input, true)?; + let mut segments = vec![first]; + + while input.peek(Token![.]) { + input.parse::()?; + segments.push(parse_projection_segment(input, false)?); + } + + let mut span = join_spans(dot_span, segments[0].span()); + for segment in segments.iter().skip(1) { + span = join_spans(span, segment.span()); + } + + Ok(FormatArgProjection { + segments, + span + }) +} + +fn parse_projection_segment( + input: ParseStream, + first: bool +) -> syn::Result { + if input.peek(LitInt) { + let literal: LitInt = input.parse()?; + let index = literal.base10_parse::()?; + return Ok(FormatArgProjectionSegment::Index { + index, + span: literal.span() + }); + } + + if input.peek(Ident) { + let ident: Ident = input.parse()?; + if let Some((turbofish, paren_token, args)) = parse_method_call_suffix(input)? { + let span = method_call_span(&ident, turbofish.as_ref(), &paren_token); + return Ok(FormatArgProjectionSegment::MethodCall( + FormatArgProjectionMethodCall { + method: ident, + turbofish, + args, + span + } + )); + } + + return Ok(FormatArgProjectionSegment::Field(ident)); + } + + let span = input.span(); + if first { + Err(syn::Error::new( + span, + "expected field, index, or method call after `.`" + )) + } else { + Err(syn::Error::new( + span, + "expected field, index, or method call in projection" + )) + } +} + +fn parse_method_call_suffix(input: ParseStream) -> syn::Result { + let ahead = input.fork(); + + let has_turbofish = ahead.peek(Token![::]); + if has_turbofish { + let _: Token![::] = ahead.parse()?; + let _: AngleBracketedGenericArguments = ahead.parse()?; + } + + if !ahead.peek(Paren) { + return Ok(None); + } + + let turbofish = if has_turbofish { + let colon2_token = input.parse::()?; + let generics = input.parse::()?; + Some(FormatArgMethodTurbofish { + colon2_token, + generics + }) + } else { + None + }; + + let content; + let paren_token = syn::parenthesized!(content in input); + let args = Punctuated::::parse_terminated(&content)?; + + Ok(Some((turbofish, paren_token, args))) +} + +fn method_call_span( + method: &Ident, + turbofish: Option<&FormatArgMethodTurbofish>, + paren_token: &Paren +) -> Span { + let mut span = method.span(); + if let Some(turbofish) = turbofish { + span = join_spans(span, turbofish.generics.gt_token.span); + } + join_spans(span, paren_token.span.close()) +} + +fn join_spans(lhs: Span, rhs: Span) -> Span { + lhs.join(rhs).unwrap_or(lhs) +} + fn format_arg_value_span(value: &FormatArgValue) -> Span { match value { FormatArgValue::Expr(expr) => expr.span(), - FormatArgValue::Shorthand(FormatArgShorthand::Named(ident)) => ident.span(), - FormatArgValue::Shorthand(FormatArgShorthand::Positional { - span, .. - }) => *span + FormatArgValue::Shorthand(FormatArgShorthand::Projection(projection)) => projection.span } } diff --git a/tests/error_derive.rs b/tests/error_derive.rs index 8398406..5c5f984 100644 --- a/tests/error_derive.rs +++ b/tests/error_derive.rs @@ -150,12 +150,14 @@ enum EnumWithBacktrace { Unit } +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] #[derive(Clone, Debug, PartialEq, Eq)] struct TelemetrySnapshot { name: &'static str, value: u64 } +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] #[derive(Debug, Error)] #[error("structured telemetry {snapshot:?}")] struct StructuredTelemetryError { @@ -163,6 +165,7 @@ struct StructuredTelemetryError { snapshot: TelemetrySnapshot } +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] #[derive(Debug, Error)] #[error("optional telemetry {telemetry:?}")] struct OptionalTelemetryError { @@ -170,6 +173,7 @@ struct OptionalTelemetryError { telemetry: Option } +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] #[derive(Debug, Error)] #[error("optional owned telemetry {telemetry:?}")] struct OptionalOwnedTelemetryError { @@ -177,6 +181,7 @@ struct OptionalOwnedTelemetryError { telemetry: Option } +#[cfg_attr(not(error_generic_member_access), allow(dead_code))] #[derive(Debug, Error)] enum EnumTelemetryError { #[error("named {label}")] @@ -317,6 +322,40 @@ struct FieldShortcutError { #[error("{}, {}", .0, .1)] struct TupleShortcutError(&'static str, &'static str); +#[derive(Debug)] +struct RangeLimits { + lo: i32, + hi: i32 +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct ProjectionStructError { + limits: RangeLimits, + suggestion: Option +} + +#[derive(Debug)] +struct TuplePayload { + data: &'static str +} + +#[derive(Debug, Error)] +enum ProjectionEnumError { + #[error("tuple data {data}", data = .0.data)] + Tuple(TuplePayload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option } +} + #[derive(Debug, Error)] #[error("{value}")] struct DisplayFormatterError { @@ -785,6 +824,43 @@ fn supports_display_and_debug_formatters() { assert!(StdError::source(&err).is_none()); } +#[test] +fn struct_projection_shorthand_handles_nested_segments() { + let err = ProjectionStructError { + limits: RangeLimits { + lo: 2, hi: 5 + }, + suggestion: Some("retry".to_string()) + }; + assert_eq!(err.to_string(), "range 2-5 suggestion retry"); + + let none = ProjectionStructError { + limits: RangeLimits { + lo: -1, hi: 3 + }, + suggestion: None + }; + assert_eq!(none.to_string(), "range -1-3 suggestion "); +} + +#[test] +fn enum_projection_shorthand_handles_nested_segments() { + let tuple = ProjectionEnumError::Tuple(TuplePayload { + data: "payload" + }); + assert_eq!(tuple.to_string(), "tuple data payload"); + + let named = ProjectionEnumError::Named { + suggestion: Some("escalate".to_string()) + }; + assert_eq!(named.to_string(), "named suggestion escalate"); + + let fallback = ProjectionEnumError::Named { + suggestion: None + }; + assert_eq!(fallback.to_string(), "named suggestion "); +} + #[test] fn struct_named_source_is_inferred() { let err = AutoSourceStruct { diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/pass/nested_projection.rs b/tests/ui/formatter/pass/nested_projection.rs new file mode 100644 index 0000000..cdaa703 --- /dev/null +++ b/tests/ui/formatter/pass/nested_projection.rs @@ -0,0 +1,43 @@ +use masterror::Error; + +#[derive(Debug)] +struct Limits { + lo: i32, + hi: i32, +} + +#[derive(Debug, Error)] +#[error( + "range {lo}-{hi} suggestion {suggestion}", + lo = .limits.lo, + hi = .limits.hi, + suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) +)] +struct StructProjection { + limits: Limits, + suggestion: Option, +} + +#[derive(Debug)] +struct Payload { + data: &'static str, +} + +#[derive(Debug, Error)] +enum EnumProjection { + #[error("tuple data {data}", data = .0.data)] + Tuple(Payload), + #[error( + "named suggestion {value}", + value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) + )] + Named { suggestion: Option }, +} + +fn main() { + let _ = StructProjection { + limits: Limits { lo: 0, hi: 3 }, + suggestion: Some(String::from("hint")), + }; + let _ = EnumProjection::Tuple(Payload { data: "payload" }); +}