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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ All notable changes to this project will be documented in this file.
### Added
- _Nothing yet._

## [0.5.10] - 2025-10-02

### Changed
- Template parser now recognises formatter traits even when alignment, sign or
width flags precede the type specifier, constructing the matching
`TemplateFormatter` variant and keeping alternate (`#`) detection aligned with
`thiserror`.

### Tests
- Extended parser unit tests to cover complex formatter specifiers and
additional malformed cases to guard diagnostic accuracy.

## [0.5.9] - 2025-10-01

### 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.9"
version = "0.5.10"
rust-version = "1.90"
edition = "2024"
license = "MIT OR Apache-2.0"
Expand Down Expand Up @@ -50,7 +50,7 @@ openapi = ["dep:utoipa"]

[workspace.dependencies]
masterror-derive = { version = "0.1.4", path = "masterror-derive" }
masterror-template = { version = "0.1.3", path = "masterror-template" }
masterror-template = { version = "0.1.4", path = "masterror-template" }

[dependencies]
masterror-derive = { workspace = true }
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.9", default-features = false }
masterror = { version = "0.5.10", default-features = false }
# or with features:
# masterror = { version = "0.5.9", features = [
# masterror = { version = "0.5.10", 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.9", default-features = false }
~~~toml
[dependencies]
# lean core
masterror = { version = "0.5.9", default-features = false }
masterror = { version = "0.5.10", default-features = false }

# with Axum/Actix + JSON + integrations
# masterror = { version = "0.5.9", features = [
# masterror = { version = "0.5.10", features = [
# "axum", "actix", "openapi", "serde_json",
# "sqlx", "sqlx-migrate", "reqwest", "redis",
# "validator", "config", "tokio", "multipart",
Expand Down Expand Up @@ -349,13 +349,13 @@ assert_eq!(resp.status, 401);
Minimal core:

~~~toml
masterror = { version = "0.5.9", default-features = false }
masterror = { version = "0.5.10", default-features = false }
~~~

API (Axum + JSON + deps):

~~~toml
masterror = { version = "0.5.9", features = [
masterror = { version = "0.5.10", features = [
"axum", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
Expand All @@ -364,7 +364,7 @@ masterror = { version = "0.5.9", features = [
API (Actix + JSON + deps):

~~~toml
masterror = { version = "0.5.9", features = [
masterror = { version = "0.5.10", features = [
"actix", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
Expand Down
2 changes: 1 addition & 1 deletion masterror-template/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "masterror-template"
version = "0.1.3"
version = "0.1.4"
rust-version = "1.90"
edition = "2024"
repository = "https://github.com/RAprogramm/masterror"
Expand Down
16 changes: 1 addition & 15 deletions masterror-template/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,21 +410,7 @@ impl TemplateFormatter {
}

pub(crate) fn parse_specifier(spec: &str) -> Option<Self> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return None;
}

let (last_index, ty) = trimmed.char_indices().next_back()?;
let prefix = &trimmed[..last_index];
let alternate = match prefix {
"" => false,
"#" => true,
_ => return None
};

let kind = TemplateFormatterKind::from_specifier(ty)?;
Some(Self::from_kind(kind, alternate))
parser::parse_formatter_spec(spec)
}

/// Returns `true` when alternate formatting (`#`) was requested.
Expand Down
139 changes: 132 additions & 7 deletions masterror-template/src/template/parser.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use core::ops::Range;

use super::{
TemplateError, TemplateFormatter, TemplateIdentifier, TemplatePlaceholder, TemplateSegment
TemplateError, TemplateFormatter, TemplateFormatterKind, TemplateIdentifier,
TemplatePlaceholder, TemplateSegment
};

pub fn parse_template<'a>(source: &'a str) -> Result<Vec<TemplateSegment<'a>>, TemplateError> {
Expand Down Expand Up @@ -152,16 +153,73 @@ fn split_placeholder<'a>(
span
});
}
Some(spec) => {
TemplateFormatter::parse_specifier(spec).ok_or(TemplateError::InvalidFormatter {
span
})?
}
Some(spec) => parse_formatter(spec, span.clone())?
};

Ok((identifier, formatter))
}

fn parse_formatter(spec: &str, span: Range<usize>) -> Result<TemplateFormatter, TemplateError> {
parse_formatter_spec(spec).ok_or(TemplateError::InvalidFormatter {
span
})
}

pub(super) fn parse_formatter_spec(spec: &str) -> Option<TemplateFormatter> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return None;
}

let (last_index, ty) = trimmed.char_indices().next_back()?;
let prefix = &trimmed[..last_index];
let kind = TemplateFormatterKind::from_specifier(ty)?;
let alternate = detect_alternate_flag(prefix)?;

Some(TemplateFormatter::from_kind(kind, alternate))
}

fn detect_alternate_flag(prefix: &str) -> Option<bool> {
let mut rest = prefix;

if rest.len() >= 2 {
let mut iter = rest.char_indices();
if let (Some((_, _)), Some((second_index, second))) = (iter.next(), iter.next())
&& matches!(second, '<' | '>' | '^' | '=')
{
let skip = second_index + second.len_utf8();
rest = &rest[skip..];
}
}

if let Some(first) = rest.chars().next()
&& matches!(first, '<' | '>' | '^' | '=')
{
rest = &rest[first.len_utf8()..];
}

loop {
let mut chars = rest.chars();
let Some(ch) = chars.next() else {
return Some(false);
};

match ch {
'+' | '-' | ' ' => {
rest = &rest[ch.len_utf8()..];
}
'#' => {
rest = &rest[ch.len_utf8()..];
if rest.chars().any(|value| value == '#') {
return None;
}
return Some(true);
}
_ => return Some(false)
}
Comment on lines +201 to +219

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reject # flag that appears after width or zero padding

The new alternate-flag detector returns Some(false) as soon as it encounters any character other than +, - or space and never inspects the remainder of the prefix. As a result, format strings such as {value:0#x} or {value:>8#x}—which the Rust formatter rejects because the # flag must precede 0/width—are now parsed as valid hex formatters with alternate: false. Earlier versions correctly produced TemplateError::InvalidFormatter. This regression means invalid templates are accepted by the macro while the subsequent format! invocation will still fail to compile. The function should scan the whole prefix and return None if a # appears after width or padding characters.

Useful? React with 👍 / 👎.

}
}

fn parse_identifier<'a>(
text: &'a str,
span: Range<usize>
Expand Down Expand Up @@ -212,24 +270,54 @@ mod tests {
alternate: true
}
),
(
"{value:*>#?}",
TemplateFormatter::Debug {
alternate: true
}
),
(
"{value:#>8?}",
TemplateFormatter::Debug {
alternate: false
}
),
(
"{value:x}",
TemplateFormatter::LowerHex {
alternate: false
}
),
(
"{value:>08x}",
TemplateFormatter::LowerHex {
alternate: false
}
),
(
"{value:#x}",
TemplateFormatter::LowerHex {
alternate: true
}
),
(
"{value:*<#x}",
TemplateFormatter::LowerHex {
alternate: true
}
),
(
"{value:X}",
TemplateFormatter::UpperHex {
alternate: false
}
),
(
"{value:*>#X}",
TemplateFormatter::UpperHex {
alternate: true
}
),
(
"{value:#X}",
TemplateFormatter::UpperHex {
Expand All @@ -242,6 +330,12 @@ mod tests {
alternate: false
}
),
(
"{value:>+#18p}",
TemplateFormatter::Pointer {
alternate: true
}
),
(
"{value:#p}",
TemplateFormatter::Pointer {
Expand All @@ -254,6 +348,12 @@ mod tests {
alternate: false
}
),
(
"{value:#08b}",
TemplateFormatter::Binary {
alternate: true
}
),
(
"{value:#b}",
TemplateFormatter::Binary {
Expand All @@ -266,6 +366,12 @@ mod tests {
alternate: false
}
),
(
"{value:+#o}",
TemplateFormatter::Octal {
alternate: true
}
),
(
"{value:#o}",
TemplateFormatter::Octal {
Expand All @@ -278,6 +384,12 @@ mod tests {
alternate: false
}
),
(
"{value:#0e}",
TemplateFormatter::LowerExp {
alternate: true
}
),
(
"{value:#e}",
TemplateFormatter::LowerExp {
Expand All @@ -290,6 +402,12 @@ mod tests {
alternate: false
}
),
(
"{value:#^10E}",
TemplateFormatter::UpperExp {
alternate: false
}
),
(
"{value:#E}",
TemplateFormatter::UpperExp {
Expand All @@ -315,7 +433,14 @@ mod tests {

#[test]
fn rejects_malformed_formatters() {
let cases = ["{value:}", "{value:#}", "{value:0x}"];
let cases = [
"{value:}",
"{value:#}",
"{value:>8q}",
"{value:#>}",
"{value:*>}",
"{value:##x}"
];

for source in &cases {
let err = parse_template(source).expect_err("expected formatter error");
Expand Down
Loading