diff --git a/CHANGELOG.md b/CHANGELOG.md index f2000f7..d6b07fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file. ### Added - _Nothing yet._ +## [0.6.0] - 2025-10-08 + +### Added +- Recognised empty placeholder bodies (`{}` / `{:?}`) as implicit positional + identifiers, numbering them by appearance and exposing the new + `TemplateIdentifier::Implicit` variant in the template API. +- Propagated the implicit identifier metadata through + `template_support::TemplateIdentifierSpec`, ensuring derive-generated display + implementations resolve tuple fields in placeholder order. + +### Fixed +- Preserved `TemplateError::EmptyPlaceholder` diagnostics for whitespace-only + placeholders, matching previous error reporting for invalid bodies. + +### Tests +- Added parser regressions covering implicit placeholder sequencing and the + whitespace-only error path. + ## [0.5.15] - 2025-10-07 ### Added diff --git a/Cargo.lock b/Cargo.lock index ff99c99..a5f5d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.5.15" +version = "0.6.0" dependencies = [ "actix-web", "axum", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.1.7" +version = "0.2.0" dependencies = [ "masterror-template", "proc-macro2", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "masterror-template" -version = "0.1.4" +version = "0.2.0" [[package]] name = "matchit" diff --git a/Cargo.toml b/Cargo.toml index a9064e9..824820c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.5.15" +version = "0.6.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -49,8 +49,8 @@ turnkey = [] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.1.7", path = "masterror-derive" } -masterror-template = { version = "0.1.4", path = "masterror-template" } +masterror-derive = { version = "0.2.0", path = "masterror-derive" } +masterror-template = { version = "0.2.0", path = "masterror-template" } [dependencies] masterror-derive = { workspace = true } diff --git a/README.md b/README.md index efd3cb2..85674db 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.5.15", default-features = false } +masterror = { version = "0.6.0", default-features = false } # or with features: -# masterror = { version = "0.5.15", features = [ +# masterror = { version = "0.6.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -66,10 +66,10 @@ masterror = { version = "0.5.15", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.5.15", default-features = false } +masterror = { version = "0.6.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.5.15", features = [ +# masterror = { version = "0.6.0", 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.15", default-features = false } +masterror = { version = "0.6.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.5.15", features = [ +masterror = { version = "0.6.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -398,7 +398,7 @@ masterror = { version = "0.5.15", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.5.15", features = [ +masterror = { version = "0.6.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 8021e91..80a1c5d 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.7" +version = "0.2.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 bc4f641..6bf327a 100644 --- a/masterror-derive/src/display.rs +++ b/masterror-derive/src/display.rs @@ -249,6 +249,10 @@ fn struct_placeholder_expr( } } TemplateIdentifierSpec::Positional(index) => fields + .get_positional(*index) + .map(|field| struct_field_expr(field, placeholder.formatter)) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)), + TemplateIdentifierSpec::Implicit(index) => fields .get_positional(*index) .map(|field| struct_field_expr(field, placeholder.formatter)) .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)) @@ -298,6 +302,15 @@ fn variant_tuple_placeholder( Err(placeholder_error(placeholder.span, &placeholder.identifier)) } TemplateIdentifierSpec::Positional(index) => bindings + .get(*index) + .map(|binding| { + ResolvedPlaceholderExpr::with( + quote!(#binding), + needs_pointer_value(placeholder.formatter) + ) + }) + .ok_or_else(|| placeholder_error(placeholder.span, &placeholder.identifier)), + TemplateIdentifierSpec::Implicit(index) => bindings .get(*index) .map(|binding| { ResolvedPlaceholderExpr::with( @@ -338,6 +351,10 @@ fn variant_named_placeholder( TemplateIdentifierSpec::Positional(index) => Err(placeholder_error( placeholder.span, &TemplateIdentifierSpec::Positional(*index) + )), + TemplateIdentifierSpec::Implicit(index) => Err(placeholder_error( + placeholder.span, + &TemplateIdentifierSpec::Implicit(*index) )) } } diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 56c6523..86cf612 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -826,5 +826,8 @@ pub fn placeholder_error(span: Span, identifier: &TemplateIdentifierSpec) -> Err TemplateIdentifierSpec::Positional(index) => { Error::new(span, format!("field `{}` is not available", index)) } + TemplateIdentifierSpec::Implicit(index) => { + Error::new(span, format!("field `{}` is not available", index)) + } } } diff --git a/masterror-derive/src/template_support.rs b/masterror-derive/src/template_support.rs index 77d0bbc..71fb1fe 100644 --- a/masterror-derive/src/template_support.rs +++ b/masterror-derive/src/template_support.rs @@ -27,7 +27,8 @@ pub struct TemplatePlaceholderSpec { #[derive(Debug, Clone)] pub enum TemplateIdentifierSpec { Named(String), - Positional(usize) + Positional(usize), + Implicit(usize) } pub fn parse_display_template(lit: LitStr) -> Result { @@ -49,6 +50,7 @@ pub fn parse_display_template(lit: LitStr) -> Result { TemplateIdentifier::Positional(index) => { TemplateIdentifierSpec::Positional(*index) } + TemplateIdentifier::Implicit(index) => TemplateIdentifierSpec::Implicit(*index) }; segments.push(TemplateSegmentSpec::Placeholder(TemplatePlaceholderSpec { diff --git a/masterror-template/Cargo.toml b/masterror-template/Cargo.toml index d400a6d..50caa5f 100644 --- a/masterror-template/Cargo.toml +++ b/masterror-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror-template" -version = "0.1.4" +version = "0.2.0" rust-version = "1.90" edition = "2024" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-template/src/template.rs b/masterror-template/src/template.rs index 74e6fca..469471e 100644 --- a/masterror-template/src/template.rs +++ b/masterror-template/src/template.rs @@ -139,6 +139,9 @@ impl<'a> TemplatePlaceholder<'a> { /// Placeholder identifier parsed from the template. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TemplateIdentifier<'a> { + /// Implicit positional index inferred from the placeholder order (`{}` / + /// `{:?}` / etc.). + Implicit(usize), /// Positional index (`{0}` / `{1:?}` / etc.). Positional(usize), /// Named field (`{name}` / `{kind:?}` / etc.). @@ -150,7 +153,7 @@ impl<'a> TemplateIdentifier<'a> { pub const fn as_str(&self) -> Option<&'a str> { match self { Self::Named(value) => Some(value), - Self::Positional(_) => None + Self::Positional(_) | Self::Implicit(_) => None } } } @@ -569,6 +572,32 @@ mod tests { assert_eq!(placeholders[1].identifier(), &named("message")); } + #[test] + fn parses_implicit_identifiers() { + let template = ErrorTemplate::parse("{}, {:?}, {name}, {}").expect("parse"); + let mut placeholders = template.placeholders(); + + let first = placeholders.next().expect("first placeholder"); + assert_eq!(first.identifier(), &TemplateIdentifier::Implicit(0)); + assert_eq!(first.formatter(), TemplateFormatter::Display); + + let second = placeholders.next().expect("second placeholder"); + assert_eq!(second.identifier(), &TemplateIdentifier::Implicit(1)); + assert_eq!( + second.formatter(), + TemplateFormatter::Debug { + alternate: false + } + ); + + let third = placeholders.next().expect("third placeholder"); + assert_eq!(third.identifier(), &named("name")); + + let fourth = placeholders.next().expect("fourth placeholder"); + assert_eq!(fourth.identifier(), &TemplateIdentifier::Implicit(2)); + assert!(placeholders.next().is_none()); + } + #[test] fn parses_debug_formatter() { let template = ErrorTemplate::parse("{0:#?}").expect("parse"); diff --git a/masterror-template/src/template/parser.rs b/masterror-template/src/template/parser.rs index c438720..6f8c749 100644 --- a/masterror-template/src/template/parser.rs +++ b/masterror-template/src/template/parser.rs @@ -9,6 +9,7 @@ pub fn parse_template<'a>(source: &'a str) -> Result>, T let mut segments = Vec::new(); let mut iter = source.char_indices().peekable(); let mut literal_start = 0usize; + let mut implicit_counter = 0usize; while let Some((index, ch)) = iter.next() { match ch { @@ -36,7 +37,7 @@ pub fn parse_template<'a>(source: &'a str) -> Result>, T segments.push(TemplateSegment::Literal(&source[literal_start..index])); } - let parsed = parse_placeholder(source, index)?; + let parsed = parse_placeholder(source, index, &mut implicit_counter)?; segments.push(TemplateSegment::Placeholder(parsed.placeholder)); literal_start = parsed.after; @@ -86,14 +87,15 @@ struct ParsedPlaceholder<'a> { fn parse_placeholder<'a>( source: &'a str, - start: usize + start: usize, + implicit_counter: &mut usize ) -> Result, TemplateError> { for (offset, ch) in source[start + 1..].char_indices() { let absolute = start + 1 + offset; match ch { '}' => { let end = absolute; - let placeholder = build_placeholder(source, start, end)?; + let placeholder = build_placeholder(source, start, end, implicit_counter)?; return Ok(ParsedPlaceholder { placeholder, after: end + 1 @@ -116,10 +118,21 @@ fn parse_placeholder<'a>( fn build_placeholder<'a>( source: &'a str, start: usize, - end: usize + end: usize, + implicit_counter: &mut usize ) -> Result, TemplateError> { let span = start..(end + 1); let body = &source[start + 1..end]; + + if body.is_empty() { + let identifier = next_implicit_identifier(implicit_counter, &span)?; + return Ok(TemplatePlaceholder { + span, + identifier, + formatter: TemplateFormatter::Display + }); + } + let trimmed = body.trim(); if trimmed.is_empty() { @@ -128,7 +141,7 @@ fn build_placeholder<'a>( }); } - let (identifier, formatter) = split_placeholder(trimmed, span.clone())?; + let (identifier, formatter) = split_placeholder(trimmed, span.clone(), implicit_counter)?; Ok(TemplatePlaceholder { span, @@ -139,12 +152,13 @@ fn build_placeholder<'a>( fn split_placeholder<'a>( body: &'a str, - span: Range + span: Range, + implicit_counter: &mut usize ) -> Result<(TemplateIdentifier<'a>, TemplateFormatter), TemplateError> { let mut parts = body.splitn(2, ':'); let identifier_text = parts.next().unwrap_or("").trim(); - let identifier = parse_identifier(identifier_text, span.clone())?; + let identifier = parse_identifier(identifier_text, span.clone(), implicit_counter)?; let formatter = match parts.next().map(str::trim) { None => TemplateFormatter::Display, @@ -222,12 +236,11 @@ fn detect_alternate_flag(prefix: &str) -> Option { fn parse_identifier<'a>( text: &'a str, - span: Range + span: Range, + implicit_counter: &mut usize ) -> Result, TemplateError> { if text.is_empty() { - return Err(TemplateError::EmptyPlaceholder { - start: span.start - }); + return next_implicit_identifier(implicit_counter, &span); } if text.chars().all(|ch| ch.is_ascii_digit()) { @@ -251,6 +264,20 @@ fn parse_identifier<'a>( }) } +fn next_implicit_identifier<'a>( + implicit_counter: &mut usize, + span: &Range +) -> Result, TemplateError> { + let index = *implicit_counter; + *implicit_counter = index + .checked_add(1) + .ok_or_else(|| TemplateError::InvalidIdentifier { + span: span.clone() + })?; + + Ok(TemplateIdentifier::Implicit(index)) +} + #[cfg(test)] mod tests { use super::*; @@ -449,4 +476,63 @@ mod tests { ); } } + + #[test] + fn parses_empty_braces_as_implicit_display() { + let segments = parse_template("{}").expect("template parsed"); + let placeholder = match segments.first() { + Some(TemplateSegment::Placeholder(placeholder)) => placeholder, + other => panic!("unexpected segments for empty braces: {other:?}") + }; + + assert_eq!(placeholder.identifier(), &TemplateIdentifier::Implicit(0)); + assert_eq!(placeholder.formatter(), TemplateFormatter::Display); + } + + #[test] + fn increments_implicit_indices_across_placeholders() { + let segments = parse_template("{}, {value}, {:?}, {}").expect("template parsed"); + let placeholders: Vec<_> = segments + .iter() + .filter_map(|segment| match segment { + TemplateSegment::Placeholder(placeholder) => Some(placeholder), + TemplateSegment::Literal(_) => None + }) + .collect(); + + assert_eq!(placeholders.len(), 4); + assert_eq!( + placeholders[0].identifier(), + &TemplateIdentifier::Implicit(0) + ); + assert_eq!( + placeholders[1].identifier(), + &TemplateIdentifier::Named("value") + ); + assert_eq!( + placeholders[2].identifier(), + &TemplateIdentifier::Implicit(1) + ); + assert_eq!( + placeholders[2].formatter(), + TemplateFormatter::Debug { + alternate: false + } + ); + assert_eq!( + placeholders[3].identifier(), + &TemplateIdentifier::Implicit(2) + ); + } + + #[test] + fn rejects_whitespace_only_placeholders() { + let err = parse_template("{ }").expect_err("should fail"); + assert!(matches!( + err, + TemplateError::EmptyPlaceholder { + start: 0 + } + )); + } }