From 0d1e9ba352d500a2b90eede9f6fe223b885731ab Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Fri, 24 Jul 2026 10:31:33 -0400 Subject: [PATCH] feat(parse_iso_Date): Added timezone parsing. --- mustache/format.odin | 73 +++++++++++++++++++++++++++++++++++---- mustache/format_test.odin | 53 ++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/mustache/format.odin b/mustache/format.odin index 8c0773d..deaa7b3 100644 --- a/mustache/format.odin +++ b/mustache/format.odin @@ -7,12 +7,15 @@ import "core:time" import "core:time/datetime" Date_Components :: struct { - year: int, - month: int, - day: int, - hour: int, - minute: int, - second: int, + year: int, + month: int, + day: int, + hour: int, + minute: int, + second: int, + offset_seconds: int, + has_offset: bool, + tz_abbr: string, } // TODO: Use some kind of scanner interface @@ -38,6 +41,9 @@ parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) { c.second = parse_2_digits(iso, 17) } + parse_offset(iso, &c) + + c.tz_abbr = "UTC" return c, true } @@ -48,6 +54,54 @@ parse_2_digits :: proc(s: string, offset: int) -> int { return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30) } +// parse_offset parses the timezone suffix of an ISO 8601 string (Z, +// +HH:MM, +HHMM, -HH:MM, -HHMM). Skips fractional seconds if present. +// Does nothing if no recognizable offset is found. +parse_offset :: proc(iso: string, c: ^Date_Components) { + pos := 19 + if pos >= len(iso) { + return + } + + // Skip fractional seconds (e.g., .123) + if iso[pos] == '.' { + pos += 1 + for pos < len(iso) && iso[pos] >= '0' && iso[pos] <= '9' { + pos += 1 + } + } + + if pos >= len(iso) { + return + } + + switch iso[pos] { + case 'Z', 'z': + c.has_offset = true + case '+', '-': + sign := 1 if iso[pos] == '+' else -1 + pos += 1 + if pos + 1 >= len(iso) { + return + } + hours := parse_2_digits(iso, pos) + pos += 2 + + minutes := 0 + if pos < len(iso) && iso[pos] == ':' { + pos += 1 + } + if pos + 1 < len(iso) { + minutes = parse_2_digits(iso, pos) + } + + c.offset_seconds = sign * (hours * 3600 + minutes * 60) + c.has_offset = true + case: + // no recognizable offset + } +} + format_date :: proc( dt: Date_Components, fmt: string, @@ -80,7 +134,12 @@ match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int s, "2006", ) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4} - if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3} + if strings.has_prefix(s, "MST") { + abbr := dt.tz_abbr + if len(abbr) == 0 do abbr = "UTC" + strings.write_string(b, abbr) + return 3 + } if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3} if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3} if strings.has_prefix( diff --git a/mustache/format_test.odin b/mustache/format_test.odin index 0c57fb2..e8319dd 100644 --- a/mustache/format_test.odin +++ b/mustache/format_test.odin @@ -17,6 +17,9 @@ test_parse_iso_date_extracts_time :: proc(t: ^testing.T) { testing.expect_value(t, c.hour, 8) testing.expect_value(t, c.minute, 49) testing.expect_value(t, c.second, 54) + testing.expect_value(t, c.has_offset, true) + testing.expect_value(t, c.offset_seconds, -14400) + testing.expect_value(t, c.tz_abbr, "UTC") } @(test) @@ -26,6 +29,8 @@ test_parse_iso_date_lowercase_t :: proc(t: ^testing.T) { testing.expect_value(t, c.hour, 8) testing.expect_value(t, c.minute, 49) testing.expect_value(t, c.second, 54) + testing.expect_value(t, c.has_offset, true) + testing.expect_value(t, c.offset_seconds, 0) } @(test) @@ -35,6 +40,7 @@ test_parse_iso_date_date_only_zero_time :: proc(t: ^testing.T) { testing.expect_value(t, c.hour, 0) testing.expect_value(t, c.minute, 0) testing.expect_value(t, c.second, 0) + testing.expect_value(t, c.has_offset, false) } @(test) @@ -49,6 +55,47 @@ test_parse_iso_date_too_short_errors :: proc(t: ^testing.T) { testing.expect(t, !ok, "input shorter than 10 chars should fail") } +@(test) +test_parse_offset_positive_colon :: proc(t: ^testing.T) { + c, ok := parse_iso_date("2026-03-15T08:49:54+07:00") + testing.expect(t, ok, "should parse") + testing.expect_value(t, c.has_offset, true) + testing.expect_value(t, c.offset_seconds, 25200) +} + +@(test) +test_parse_offset_positive_no_colon :: proc(t: ^testing.T) { + c, ok := parse_iso_date("2026-03-15T08:49:54+0530") + testing.expect(t, ok, "should parse") + testing.expect_value(t, c.has_offset, true) + testing.expect_value(t, c.offset_seconds, 19800) +} + +@(test) +test_parse_offset_hours_only :: proc(t: ^testing.T) { + c, ok := parse_iso_date("2026-03-15T08:49:54+05") + testing.expect(t, ok, "should parse") + testing.expect_value(t, c.has_offset, true) + testing.expect_value(t, c.offset_seconds, 18000) +} + +@(test) +test_parse_offset_none_with_time :: proc(t: ^testing.T) { + c, ok := parse_iso_date("2026-03-15T08:49:54") + testing.expect(t, ok, "should parse") + testing.expect_value(t, c.has_offset, false) + testing.expect_value(t, c.offset_seconds, 0) +} + +@(test) +test_parse_offset_skips_fractional_seconds :: proc(t: ^testing.T) { + c, ok := parse_iso_date("2026-03-15T08:49:54.123Z") + testing.expect(t, ok, "should parse") + testing.expect_value(t, c.has_offset, true) + testing.expect_value(t, c.offset_seconds, 0) + testing.expect_value(t, c.second, 54) +} + // --------------------------------------------------------------------------- // format_date / match_token // --------------------------------------------------------------------------- @@ -140,9 +187,9 @@ test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) { } @(test) -test_format_date_mst_always_utc :: proc(t: ^testing.T) { - // Date_Components carries no offset yet, so MST is a hardcoded - // placeholder until real timezone support lands. +test_format_date_mst_defaults_utc :: proc(t: ^testing.T) { + // Date_Components constructed directly (not via parse_iso_date) + // defaults to UTC for the MST token. dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12} result := format_date(dt, "MST") testing.expect_value(t, result, "UTC")