jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

commit 3636dc06b2e4e30205982ce3ac6a8d1765c69982
parent 9e2ca18ae08c67d987b537c7a8ac9abef559cb2f
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 23 Sep 2026 21:22:32 -0300

timefmt: format and parse times with strftime directives

Log names, report headers and the timestamps in tool output all follow
strftime conventions, and core:time speaks a different dialect. format
renders a time in UTC from a directive string, local does the same in
the machine's zone, and parse reads one back. iso, stamp and date are
the three shapes scripts write most, and duration prints a time.Duration
the way a person reads it. Formatting is UTC unless the local variants
are used; parsing produces UTC.

Diffstat:
Atimefmt/timefmt.odin | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atimefmt/timefmt_test.odin | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 445 insertions(+), 0 deletions(-)

diff --git a/timefmt/timefmt.odin b/timefmt/timefmt.odin @@ -0,0 +1,394 @@ +/* +Package timefmt formats and parses times with strftime directives, which is +what a script wants for log names, report headers and parsing tool output. + + timefmt.iso(now) 2026-09-23T10:41:02Z + timefmt.stamp(now) 20260923-104102 + timefmt.format(now, "%Y-%m-%d %H:%M") 2026-09-23 10:41 + timefmt.local(now, "%H:%M %Z") 12:41 CEST + t := must(timefmt.parse("2026-09-23 10:41", "%Y-%m-%d %H:%M")) + timefmt.duration(3*time.Hour + 2*time.Second) 3h0m2s + +Directives: %Y %y %m %d %e %H %M %S %f (milliseconds) %j %a %A %b %B %p +%I %u %w %s (unix seconds) %z %Z %% . Formatting is UTC unless the local +variants are used; parsing produces UTC and supports %Y %y %m %d %e %H %M %S +%f %s %b %B %p %I and literal text. +*/ +package timefmt + +import "core:strconv" +import "core:strings" +import "core:time" +import "core:time/datetime" +import "core:time/timezone" + +Parts :: struct { + year: int, + month: int, // 1..12 + day: int, // 1..31 + hour: int, + minute: int, + second: int, + nanos: int, + weekday: int, // 0 = Sunday + yday: int, // 1..366 + offset: int, // seconds east of UTC + zone: string, +} + +// iso renders t as RFC 3339 in UTC without fractional seconds. +iso :: proc(t: time.Time, allocator := context.allocator) -> string { + return format(t, "%Y-%m-%dT%H:%M:%SZ", allocator) +} + +// stamp renders t compactly for file names: 20260923-104102. +stamp :: proc(t: time.Time, allocator := context.allocator) -> string { + return format(t, "%Y%m%d-%H%M%S", allocator) +} + +// date renders t as YYYY-MM-DD. +date :: proc(t: time.Time, allocator := context.allocator) -> string { + return format(t, "%Y-%m-%d", allocator) +} + +// format renders t in UTC according to layout. +format :: proc(t: time.Time, layout: string, allocator := context.allocator) -> string { + return format_parts(utc_parts(t), layout, allocator) +} + +// local renders t in the machine's time zone. When the zone database cannot +// be read it falls back to UTC, and %Z prints "UTC". +local :: proc(t: time.Time, layout: string, allocator := context.allocator) -> string { + return format_parts(local_parts(t), layout, allocator) +} + +// duration renders d the way humans read it: 250ms, 4.2s, 3m12s, 1h0m2s, +// 2d3h. +duration :: proc(d: time.Duration, allocator := context.allocator) -> string { + b := strings.builder_make(allocator) + d := d + if d < 0 { + strings.write_byte(&b, '-') + d = -d + } + switch { + case d < time.Millisecond: + strings.write_int(&b, int(d / time.Microsecond)) + strings.write_string(&b, "µs") + case d < time.Second: + strings.write_int(&b, int(d / time.Millisecond)) + strings.write_string(&b, "ms") + case d < time.Minute: + tenths := int(d / (time.Second / 10)) + strings.write_int(&b, tenths / 10) + if tenths % 10 != 0 { + strings.write_byte(&b, '.') + strings.write_int(&b, tenths % 10) + } + strings.write_byte(&b, 's') + case d < time.Hour: + strings.write_int(&b, int(d / time.Minute)) + strings.write_byte(&b, 'm') + strings.write_int(&b, int(d % time.Minute / time.Second)) + strings.write_byte(&b, 's') + case d < 24 * time.Hour: + strings.write_int(&b, int(d / time.Hour)) + strings.write_byte(&b, 'h') + strings.write_int(&b, int(d % time.Hour / time.Minute)) + strings.write_byte(&b, 'm') + strings.write_int(&b, int(d % time.Minute / time.Second)) + strings.write_byte(&b, 's') + case: + strings.write_int(&b, int(d / (24 * time.Hour))) + strings.write_byte(&b, 'd') + strings.write_int(&b, int(d % (24 * time.Hour) / time.Hour)) + strings.write_byte(&b, 'h') + } + return strings.to_string(b) +} + +// parse reads s according to layout and returns a UTC time. Fields the +// layout does not mention default to 1970-01-01 00:00:00. +parse :: proc(s, layout: string) -> (t: time.Time, ok: bool) { + p := Parts{year = 1970, month = 1, day = 1} + pm := -1 // -1 unset, 0 am, 1 pm + unix_set := false + unix: i64 + rest := s + li := 0 + for li < len(layout) { + c := layout[li] + if c != '%' { + if len(rest) == 0 || rest[0] != c { + return {}, false + } + rest = rest[1:] + li += 1 + continue + } + li += 1 + if li >= len(layout) { + return {}, false + } + d := layout[li] + li += 1 + switch d { + case 'Y': + p.year = take_int(&rest, 4, 4) or_return + case 'y': + yy := take_int(&rest, 2, 2) or_return + p.year = 2000 + yy if yy < 69 else 1900 + yy + case 'm': + p.month = take_int(&rest, 1, 2) or_return + case 'd', 'e': + rest = strings.trim_left_space(rest) + p.day = take_int(&rest, 1, 2) or_return + case 'H': + p.hour = take_int(&rest, 1, 2) or_return + case 'I': + p.hour = take_int(&rest, 1, 2) or_return + case 'M': + p.minute = take_int(&rest, 1, 2) or_return + case 'S': + p.second = take_int(&rest, 1, 2) or_return + case 'f': + ms := take_int(&rest, 1, 3) or_return + p.nanos = ms * 1_000_000 + case 's': + v := take_int(&rest, 1, 19) or_return + unix = i64(v) + unix_set = true + case 'b', 'B': + p.month = take_name(&rest, MONTHS[:]) or_return + case 'a', 'A': + _ = take_name(&rest, DAYS[:]) or_return + case 'p': + if len(rest) < 2 { + return {}, false + } + switch strings.to_lower(rest[:2], context.temp_allocator) { + case "am": + pm = 0 + case "pm": + pm = 1 + case: + return {}, false + } + rest = rest[2:] + case '%': + if len(rest) == 0 || rest[0] != '%' { + return {}, false + } + rest = rest[1:] + case: + return {}, false + } + } + if len(rest) != 0 { + return {}, false + } + if unix_set { + return time.unix(unix, i64(p.nanos)), true + } + if pm == 1 && p.hour < 12 { + p.hour += 12 + } else if pm == 0 && p.hour == 12 { + p.hour = 0 + } + dt, err := datetime.components_to_datetime(p.year, p.month, p.day, p.hour, p.minute, p.second, p.nanos) + if err != nil { + return {}, false + } + return time.datetime_to_time(dt) +} + +// utc_parts breaks t into calendar fields in UTC. +utc_parts :: proc(t: time.Time) -> Parts { + p := parts_from_datetime(t, 0) + p.zone = "UTC" + return p +} + +// local_parts breaks t into calendar fields in the machine's zone. +local_parts :: proc(t: time.Time) -> Parts { + region, ok := timezone.region_load("local", context.temp_allocator) + if !ok || region == nil { + return utc_parts(t) + } + utc_dt, dt_ok := time.time_to_datetime(t) + if !dt_ok { + return utc_parts(t) + } + local_dt, tz_ok := timezone.datetime_to_tz(utc_dt, region) + if !tz_ok { + return utc_parts(t) + } + zone, _ := timezone.shortname(local_dt) + naive := local_dt + naive.tz = nil + shifted, time_ok := time.datetime_to_time(naive) + if !time_ok { + return utc_parts(t) + } + offset := int(time.time_to_unix(shifted) - time.time_to_unix(t)) + p := parts_from_datetime(t, offset) + p.zone = zone + return p +} + +// ---- internals ---------------------------------------------------------- + +@(rodata) +MONTHS := [12]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"} +@(rodata) +DAYS := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + +parts_from_datetime :: proc(t: time.Time, offset: int) -> Parts { + shifted := time.time_add(t, time.Duration(offset) * time.Second) + dt, _ := time.time_to_datetime(shifted) + p := Parts { + year = int(dt.year), + month = int(dt.month), + day = int(dt.day), + hour = int(dt.hour), + minute = int(dt.minute), + second = int(dt.second), + nanos = int(dt.nano), + offset = offset, + } + p.weekday = int(time.weekday(shifted)) + if ord, err := datetime.date_to_ordinal(dt.date); err == nil { + if ny, nerr := datetime.new_year(dt.year); nerr == nil { + if nyo, oerr := datetime.date_to_ordinal(ny); oerr == nil { + p.yday = int(ord - nyo) + 1 + } + } + } + return p +} + +format_parts :: proc(p: Parts, layout: string, allocator := context.allocator) -> string { + b := strings.builder_make(allocator) + i := 0 + for i < len(layout) { + c := layout[i] + if c != '%' || i + 1 >= len(layout) { + strings.write_byte(&b, c) + i += 1 + continue + } + d := layout[i + 1] + i += 2 + switch d { + case 'Y': + pad(&b, p.year, 4) + case 'y': + pad(&b, p.year % 100, 2) + case 'm': + pad(&b, p.month, 2) + case 'd': + pad(&b, p.day, 2) + case 'e': + if p.day < 10 { + strings.write_byte(&b, ' ') + } + strings.write_int(&b, p.day) + case 'H': + pad(&b, p.hour, 2) + case 'I': + h := p.hour % 12 + pad(&b, 12 if h == 0 else h, 2) + case 'M': + pad(&b, p.minute, 2) + case 'S': + pad(&b, p.second, 2) + case 'f': + pad(&b, p.nanos / 1_000_000, 3) + case 'j': + pad(&b, p.yday, 3) + case 'a': + strings.write_string(&b, DAYS[p.weekday][:3]) + case 'A': + strings.write_string(&b, DAYS[p.weekday]) + case 'b': + strings.write_string(&b, MONTHS[p.month - 1][:3]) + case 'B': + strings.write_string(&b, MONTHS[p.month - 1]) + case 'p': + strings.write_string(&b, "AM" if p.hour < 12 else "PM") + case 'u': + strings.write_int(&b, 7 if p.weekday == 0 else p.weekday) + case 'w': + strings.write_int(&b, p.weekday) + case 's': + unix := datetime_unix(p) + strings.write_i64(&b, unix) + case 'z': + off := p.offset + strings.write_byte(&b, '-' if off < 0 else '+') + off = abs(off) + pad(&b, off / 3600, 2) + pad(&b, off % 3600 / 60, 2) + case 'Z': + strings.write_string(&b, p.zone) + case '%': + strings.write_byte(&b, '%') + case: + strings.write_byte(&b, '%') + strings.write_byte(&b, d) + } + } + return strings.to_string(b) +} + +datetime_unix :: proc(p: Parts) -> i64 { + dt, err := datetime.components_to_datetime(p.year, p.month, p.day, p.hour, p.minute, p.second, p.nanos) + if err != nil { + return 0 + } + t, ok := time.datetime_to_time(dt) + if !ok { + return 0 + } + return time.time_to_unix(t) - i64(p.offset) +} + +pad :: proc(b: ^strings.Builder, v, width: int) { + buf: [24]byte + s := strconv.write_int(buf[:], i64(v), 10) + for _ in len(s) ..< width { + strings.write_byte(b, '0') + } + strings.write_string(b, s) +} + +take_int :: proc(rest: ^string, min_digits, max_digits: int) -> (v: int, ok: bool) { + n := 0 + for n < len(rest) && n < max_digits && rest[n] >= '0' && rest[n] <= '9' { + n += 1 + } + if n < min_digits { + return 0, false + } + v, ok = strconv.parse_int(rest[:n], 10) + rest^ = rest[n:] + return +} + +// take_name matches a full or three-letter name from names, case-insensitive, +// and returns its 1-based index. +take_name :: proc(rest: ^string, names: []string) -> (index: int, ok: bool) { + for name, i in names { + if len(rest) >= len(name) && strings.equal_fold(rest[:len(name)], name) { + rest^ = rest[len(name):] + return i + 1, true + } + } + for name, i in names { + if len(rest) >= 3 && strings.equal_fold(rest[:3], name[:3]) { + rest^ = rest[3:] + return i + 1, true + } + } + return 0, false +} diff --git a/timefmt/timefmt_test.odin b/timefmt/timefmt_test.odin @@ -0,0 +1,51 @@ +package timefmt + +import "core:testing" +import "core:time" + +@(test) +format_directives :: proc(t: ^testing.T) { + // 2026-09-23T10:41:02.250Z is a Wednesday, day 266 of the year. + tm := time.unix(1790160062, 250_000_000) + testing.expect_value(t, iso(tm, context.temp_allocator), "2026-09-23T10:41:02Z") + testing.expect_value(t, stamp(tm, context.temp_allocator), "20260923-104102") + testing.expect_value(t, date(tm, context.temp_allocator), "2026-09-23") + testing.expect_value(t, format(tm, "%a %A %b %B %e %j %y %f %I%p %u %w %s %z %Z %%", context.temp_allocator), + "Wed Wednesday Sep September 23 266 26 250 10AM 3 3 1790160062 +0000 UTC %") +} + +@(test) +parse_roundtrip :: proc(t: ^testing.T) { + tm, ok := parse("2026-09-23 10:41:02", "%Y-%m-%d %H:%M:%S") + testing.expect(t, ok) + testing.expect_value(t, time.time_to_unix(tm), i64(1790160062)) + + tm, ok = parse("23 Sep 2026 3:05 pm", "%d %b %Y %I:%M %p") + testing.expect(t, ok) + testing.expect_value(t, format(tm, "%H:%M", context.temp_allocator), "15:05") + + tm, ok = parse("1790160062", "%s") + testing.expect(t, ok) + testing.expect_value(t, time.time_to_unix(tm), i64(1790160062)) + + _, ok = parse("2026-13-01", "%Y-%m-%d") + testing.expect(t, !ok, "month 13 must fail") + _, ok = parse("2026-09-23 extra", "%Y-%m-%d") + testing.expect(t, !ok, "trailing text must fail") +} + +@(test) +durations :: proc(t: ^testing.T) { + testing.expect_value(t, duration(250 * time.Millisecond, context.temp_allocator), "250ms") + testing.expect_value(t, duration(4200 * time.Millisecond, context.temp_allocator), "4.2s") + testing.expect_value(t, duration(3 * time.Minute + 12 * time.Second, context.temp_allocator), "3m12s") + testing.expect_value(t, duration(time.Hour + 2 * time.Second, context.temp_allocator), "1h0m2s") + testing.expect_value(t, duration(51 * time.Hour, context.temp_allocator), "2d3h") + testing.expect_value(t, duration(-500 * time.Microsecond, context.temp_allocator), "-500µs") +} + +@(test) +local_has_zone :: proc(t: ^testing.T) { + s := local(time.now(), "%Z", context.temp_allocator) + testing.expect(t, len(s) > 0, "zone name must not be empty") +}