From 9e03938d20574224b6fcd731d603838fcd15c772 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:46:39 -0400 Subject: [PATCH 1/3] feat: add DateTime format_with tokens --- src/pkg.generated.mbti | 1 + src/tempo.mbt | 63 ++++++++++++++++++++++++++++++++++++++++++ src/tempo_test.mbt | 28 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/src/pkg.generated.mbti b/src/pkg.generated.mbti index 530726e..6d57343 100644 --- a/src/pkg.generated.mbti +++ b/src/pkg.generated.mbti @@ -94,6 +94,7 @@ pub fn DateTime::end_of_month(Self) -> Self pub fn DateTime::end_of_year(Self) -> Self pub fn DateTime::epoch() -> Self pub fn DateTime::format(Self) -> String +pub fn DateTime::format_with(Self, String) -> String raise TempoError pub fn DateTime::from_unix_micros(Int64) -> Self pub fn DateTime::from_unix_millis(Int64) -> Self pub fn DateTime::from_unix_nanos(Int64) -> Self diff --git a/src/tempo.mbt b/src/tempo.mbt index 602a130..2b6d8be 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -2345,6 +2345,69 @@ pub fn DateTime::format(self : DateTime) -> String { format_datetime_without_zone(self) + "Z" } +///| +/// Format this DateTime with a brace-token pattern. +/// +/// Supported tokens are `{YYYY}`, `{MM}`, `{DD}`, `{HH}`, `{mm}`, `{ss}`, +/// `{fff}`, and `{nnnnnnnnn}`. Text outside tokens is copied literally. Use +/// `{{` and `}}` to emit literal braces. Unknown or unterminated `{...}` tokens +/// raise `TempoError`. +pub fn DateTime::format_with( + self : DateTime, + pattern : String, +) -> String raise TempoError { + let buf = StringBuilder() + let len = pattern.length() + let mut i = 0 + while i < len { + if pattern[i] == '{' { + if i + 1 < len && pattern[i + 1] == '{' { + buf.write_string("{") + i += 2 + } else { + let start = i + 1 + let mut end = start + while end < len && pattern[end] != '}' { + end += 1 + } + if end >= len { + raise TempoError("unterminated datetime format token") + } + let token = pattern[start:end].to_owned() + buf.write_string(format_datetime_pattern_token(self, token)) + i = end + 1 + } + } else if pattern[i] == '}' && i + 1 < len && pattern[i + 1] == '}' { + buf.write_string("}") + i += 2 + } else { + buf.write_string(pattern[i:i + 1].to_owned()) + i += 1 + } + } + buf.to_string() +} + +///| +fn format_datetime_pattern_token( + dt : DateTime, + token : String, +) -> String raise TempoError { + let d = dt.date + let t = dt.time + match token { + "YYYY" => pad4_year(d.year) + "MM" => pad2(d.month) + "DD" => pad2(d.day) + "HH" => pad2(t.hour) + "mm" => pad2(t.minute) + "ss" => pad2(t.second) + "fff" => pad3(t.nanosecond / 1_000_000) + "nnnnnnnnn" => pad9(t.nanosecond) + _ => raise TempoError("unknown datetime format token {\{token}}") + } +} + ///| fn format_datetime_without_zone(dt : DateTime) -> String { let d = dt.date diff --git a/src/tempo_test.mbt b/src/tempo_test.mbt index c7bf5bb..920e620 100644 --- a/src/tempo_test.mbt +++ b/src/tempo_test.mbt @@ -1560,6 +1560,34 @@ test "DateTime::format supports full Int year range" { ) } +///| +test "DateTime::format_with tokens and literals" { + let dt = @tempo.DateTime::parse("2024-03-15T12:34:56.123456789Z") + assert_eq(dt.format_with("{YYYY}-{MM}-{DD}"), "2024-03-15") + assert_eq(dt.format_with("{HH}:{mm}:{ss}"), "12:34:56") + assert_eq(dt.format_with("{YYYY}/{MM}/{DD} {HH}h{mm}"), "2024/03/15 12h34") + assert_eq(dt.format_with("{fff} {nnnnnnnnn}"), "123 123456789") + let padded = @tempo.DateTime::parse("2024-03-15T12:34:56.007000009Z") + assert_eq(padded.format_with("{fff}/{nnnnnnnnn}"), "007/007000009") + let expanded = @tempo.DateTime::new( + @tempo.Date::new(-44, 3, 15), + @tempo.Time::new(0, 0, 0, 0), + ) + assert_eq(expanded.format_with("{YYYY}-{MM}-{DD}"), "-0044-03-15") +} + +///| +test "DateTime::format_with rejects unknown token" { + let dt = @tempo.DateTime::parse("2024-03-15T12:34:56Z") + assert_true((try? dt.format_with("{ZZ}")) is Err(_)) +} + +///| +test "DateTime::format_with escapes braces" { + let dt = @tempo.DateTime::parse("2024-03-15T12:34:56Z") + assert_eq(dt.format_with("{{x}}"), "{x}") +} + ///| test "Time::format no sub-second" { let t = @tempo.Time::new(0, 0, 0, 0) From 1a2af521753c4ac752fb05a42d4b61f0b05bae05 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:51:15 -0400 Subject: [PATCH 2/3] fix: reject unmatched format braces --- src/tempo.mbt | 16 ++++++++++------ src/tempo_test.mbt | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/tempo.mbt b/src/tempo.mbt index 2b6d8be..2b8b4ab 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -2349,9 +2349,9 @@ pub fn DateTime::format(self : DateTime) -> String { /// Format this DateTime with a brace-token pattern. /// /// Supported tokens are `{YYYY}`, `{MM}`, `{DD}`, `{HH}`, `{mm}`, `{ss}`, -/// `{fff}`, and `{nnnnnnnnn}`. Text outside tokens is copied literally. Use -/// `{{` and `}}` to emit literal braces. Unknown or unterminated `{...}` tokens -/// raise `TempoError`. +/// `{fff}`, and `{nnnnnnnnn}`. Non-brace text outside tokens is copied +/// literally. Use `{{` and `}}` to emit literal braces. Unknown tokens and +/// unmatched braces raise `TempoError`. pub fn DateTime::format_with( self : DateTime, pattern : String, @@ -2377,9 +2377,13 @@ pub fn DateTime::format_with( buf.write_string(format_datetime_pattern_token(self, token)) i = end + 1 } - } else if pattern[i] == '}' && i + 1 < len && pattern[i + 1] == '}' { - buf.write_string("}") - i += 2 + } else if pattern[i] == '}' { + if i + 1 < len && pattern[i + 1] == '}' { + buf.write_string("}") + i += 2 + } else { + raise TempoError("unmatched closing brace in datetime format pattern") + } } else { buf.write_string(pattern[i:i + 1].to_owned()) i += 1 diff --git a/src/tempo_test.mbt b/src/tempo_test.mbt index 920e620..b4a08ea 100644 --- a/src/tempo_test.mbt +++ b/src/tempo_test.mbt @@ -1580,6 +1580,7 @@ test "DateTime::format_with tokens and literals" { test "DateTime::format_with rejects unknown token" { let dt = @tempo.DateTime::parse("2024-03-15T12:34:56Z") assert_true((try? dt.format_with("{ZZ}")) is Err(_)) + assert_true((try? dt.format_with("{YYYY}}")) is Err(_)) } ///| From 1a477fce444ec9e201814bf077052595e08964c6 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:53:26 -0400 Subject: [PATCH 3/3] test: pin format_with parser edges --- src/tempo_test.mbt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tempo_test.mbt b/src/tempo_test.mbt index b4a08ea..4f3b5ca 100644 --- a/src/tempo_test.mbt +++ b/src/tempo_test.mbt @@ -1577,9 +1577,17 @@ test "DateTime::format_with tokens and literals" { } ///| -test "DateTime::format_with rejects unknown token" { +test "DateTime::format_with rejects invalid patterns" { let dt = @tempo.DateTime::parse("2024-03-15T12:34:56Z") assert_true((try? dt.format_with("{ZZ}")) is Err(_)) + assert_true((try? dt.format_with("a{YYYY")) is Err(_)) + assert_true((try? dt.format_with("a{")) is Err(_)) + assert_true((try? dt.format_with("{YYY}")) is Err(_)) + assert_true((try? dt.format_with("{}")) is Err(_)) + assert_true((try? dt.format_with("{nnnnnnnn}")) is Err(_)) + assert_true((try? dt.format_with("{nnnnnnnnnn}")) is Err(_)) + assert_true((try? dt.format_with("{Mm}")) is Err(_)) + assert_true((try? dt.format_with("}")) is Err(_)) assert_true((try? dt.format_with("{YYYY}}")) is Err(_)) } @@ -1587,6 +1595,8 @@ test "DateTime::format_with rejects unknown token" { test "DateTime::format_with escapes braces" { let dt = @tempo.DateTime::parse("2024-03-15T12:34:56Z") assert_eq(dt.format_with("{{x}}"), "{x}") + assert_eq(dt.format_with("{{{YYYY}}}"), "{2024}") + assert_eq(dt.format_with("}}"), "}") } ///|