From 292975943d80046bea07ff6b9f86729cda891d04 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:44:52 -0400 Subject: [PATCH 1/2] feat: parse ISO ordinal and week dates --- src/pkg.generated.mbti | 2 ++ src/tempo.mbt | 31 +++++++++++++++++++++ src/tempo_test.mbt | 62 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/src/pkg.generated.mbti b/src/pkg.generated.mbti index 530726e..3c81789 100644 --- a/src/pkg.generated.mbti +++ b/src/pkg.generated.mbti @@ -55,6 +55,8 @@ pub fn Date::next(Self, Weekday) -> Self pub fn Date::next_or_same(Self, Weekday) -> Self pub fn Date::nth_weekday_of_month(Int, Int, Weekday, Int) -> Self raise TempoError pub fn Date::parse(String) -> Self raise TempoError +pub fn Date::parse_iso_week(String) -> Self raise TempoError +pub fn Date::parse_ordinal(String) -> Self raise TempoError pub fn Date::previous(Self, Weekday) -> Self pub fn Date::previous_or_same(Self, Weekday) -> Self pub fn Date::quarter(Self) -> Int diff --git a/src/tempo.mbt b/src/tempo.mbt index 602a130..f8e3ac5 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -1263,6 +1263,37 @@ pub fn Date::parse(s : String) -> Date raise TempoError { Date::new(year, month, day) } +///| +/// Parse an ISO 8601 ordinal date in 'YYYY-DDD' format. +pub fn Date::parse_ordinal(s : String) -> Date raise TempoError { + let v = s.view() + let (year, v) = parse_digits(v, 4) + let v = consume(v, '-') + let (day_of_year, v) = parse_digits(v, 3) + match v { + [] => () + _ => raise TempoError("unexpected trailing characters") + } + Date::from_ordinal(year, day_of_year) +} + +///| +/// Parse an ISO 8601 week date in 'YYYY-Www-D' format. +pub fn Date::parse_iso_week(s : String) -> Date raise TempoError { + let v = s.view() + let (week_year, v) = parse_digits(v, 4) + let v = consume(v, '-') + let v = consume(v, 'W') + let (week, v) = parse_digits(v, 2) + let v = consume(v, '-') + let (weekday, v) = parse_digits(v, 1) + match v { + [] => () + _ => raise TempoError("unexpected trailing characters") + } + Date::from_iso_week(week_year, week, weekday) +} + ///| /// Parse a calendar year-month in 'YYYY-MM' format. pub fn YearMonth::parse(s : String) -> YearMonth raise TempoError { diff --git a/src/tempo_test.mbt b/src/tempo_test.mbt index c7bf5bb..f7305ea 100644 --- a/src/tempo_test.mbt +++ b/src/tempo_test.mbt @@ -811,6 +811,39 @@ test "Date::from_iso_week raises on residual Int year overflow" { assert_true((try? @tempo.Date::from_iso_week(@int.MIN_VALUE, 1, 1)) is Err(_)) } +///| +test "Date::parse_iso_week valid and round-trips" { + assert_eq( + @tempo.Date::parse_iso_week("2020-W53-5"), + @tempo.Date::new(2021, 1, 1), + ) + let new_year_monday = @tempo.Date::new(2024, 1, 1) + assert_eq( + @tempo.Date::parse_iso_week(new_year_monday.format_iso_week()), + new_year_monday, + ) + let prior_week_year = @tempo.Date::new(2021, 1, 1) + assert_eq( + @tempo.Date::parse_iso_week(prior_week_year.format_iso_week()), + prior_week_year, + ) + let week_53_end = @tempo.Date::new(2026, 12, 31) + assert_eq( + @tempo.Date::parse_iso_week(week_53_end.format_iso_week()), + week_53_end, + ) +} + +///| +test "Date::parse_iso_week rejects malformed and out-of-range input" { + assert_true((try? @tempo.Date::parse_iso_week("2024-W54-1")) is Err(_)) + assert_true((try? @tempo.Date::parse_iso_week("2024-01-1")) is Err(_)) + assert_true((try? @tempo.Date::parse_iso_week("2024-W1-1")) is Err(_)) + assert_true((try? @tempo.Date::parse_iso_week("2024-W01")) is Err(_)) + assert_true((try? @tempo.Date::parse_iso_week("2024-W01-8")) is Err(_)) + assert_true((try? @tempo.Date::parse_iso_week("2024-W01-1Z")) is Err(_)) +} + ///| test "Weekday conversion helpers" { assert_true(@tempo.Weekday::from_int(1) == Some(@tempo.Monday)) @@ -1213,6 +1246,35 @@ test "Date::format_ordinal" { ) } +///| +test "Date::parse_ordinal valid and round-trips" { + assert_eq( + @tempo.Date::parse_ordinal("2024-060"), + @tempo.Date::new(2024, 2, 29), + ) + let jan1 = @tempo.Date::new(2024, 1, 1) + assert_eq(@tempo.Date::parse_ordinal(jan1.format_ordinal()), jan1) + let leap_day = @tempo.Date::new(2024, 2, 29) + assert_eq(@tempo.Date::parse_ordinal(leap_day.format_ordinal()), leap_day) + let leap_dec31 = @tempo.Date::new(2024, 12, 31) + assert_eq(@tempo.Date::parse_ordinal(leap_dec31.format_ordinal()), leap_dec31) + let common_dec31 = @tempo.Date::new(2023, 12, 31) + assert_eq( + @tempo.Date::parse_ordinal(common_dec31.format_ordinal()), + common_dec31, + ) +} + +///| +test "Date::parse_ordinal rejects malformed and out-of-range input" { + assert_true((try? @tempo.Date::parse_ordinal("2023-366")) is Err(_)) + assert_true((try? @tempo.Date::parse_ordinal("2024-000")) is Err(_)) + assert_true((try? @tempo.Date::parse_ordinal("2024-60")) is Err(_)) + assert_true((try? @tempo.Date::parse_ordinal("2024-0060")) is Err(_)) + assert_true((try? @tempo.Date::parse_ordinal("2024/060")) is Err(_)) + assert_true((try? @tempo.Date::parse_ordinal("2024-060Z")) is Err(_)) +} + ///| test "Date::parse roundtrip" { let s = "2000-02-29" From ef6cc847620a819948beb12239db4e90ef74d7af Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:48:33 -0400 Subject: [PATCH 2/2] docs: clarify strict ISO date parsers --- src/tempo.mbt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/tempo.mbt b/src/tempo.mbt index f8e3ac5..23a84b9 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -1265,6 +1265,10 @@ pub fn Date::parse(s : String) -> Date raise TempoError { ///| /// Parse an ISO 8601 ordinal date in 'YYYY-DDD' format. +/// +/// This parser is strict: it accepts only an unsigned 4-digit year, a hyphen, +/// exactly 3 digits for the day-of-year, and end-of-input. Expanded-year output +/// from `Date::format_ordinal` is not accepted. pub fn Date::parse_ordinal(s : String) -> Date raise TempoError { let v = s.view() let (year, v) = parse_digits(v, 4) @@ -1279,6 +1283,12 @@ pub fn Date::parse_ordinal(s : String) -> Date raise TempoError { ///| /// Parse an ISO 8601 week date in 'YYYY-Www-D' format. +/// +/// The leading year is the ISO week-numbering year, which can differ from the +/// resulting calendar year near New Year. This parser is strict: it accepts +/// only an unsigned 4-digit week-year, a literal `W`, a 2-digit week, a 1-digit +/// weekday, and end-of-input. Expanded-year output from `Date::format_iso_week` +/// is not accepted. pub fn Date::parse_iso_week(s : String) -> Date raise TempoError { let v = s.view() let (week_year, v) = parse_digits(v, 4)