jm

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

timefmt.odin (10560B)


      1 /*
      2 Package timefmt formats and parses times with strftime directives, which is
      3 what a script wants for log names, report headers and parsing tool output.
      4 
      5 	timefmt.iso(now)                       2026-09-23T10:41:02Z
      6 	timefmt.stamp(now)                     20260923-104102
      7 	timefmt.format(now, "%Y-%m-%d %H:%M")  2026-09-23 10:41
      8 	timefmt.local(now, "%H:%M %Z")         12:41 CEST
      9 	t := must(timefmt.parse("2026-09-23 10:41", "%Y-%m-%d %H:%M"))
     10 	timefmt.duration(3*time.Hour + 2*time.Second)   3h0m2s
     11 
     12 Directives: %Y %y %m %d %e %H %M %S %f (milliseconds) %j %a %A %b %B %p
     13 %I %u %w %s (unix seconds) %z %Z %% . Formatting is UTC unless the local
     14 variants are used; parsing produces UTC and supports %Y %y %m %d %e %H %M %S
     15 %f %s %b %B %p %I and literal text.
     16 */
     17 package timefmt
     18 
     19 import "core:strconv"
     20 import "core:strings"
     21 import "core:time"
     22 import "core:time/datetime"
     23 import "core:time/timezone"
     24 
     25 Parts :: struct {
     26 	year:     int,
     27 	month:    int, // 1..12
     28 	day:      int, // 1..31
     29 	hour:     int,
     30 	minute:   int,
     31 	second:   int,
     32 	nanos:    int,
     33 	weekday:  int, // 0 = Sunday
     34 	yday:     int, // 1..366
     35 	offset:   int, // seconds east of UTC
     36 	zone:     string,
     37 }
     38 
     39 // iso renders t as RFC 3339 in UTC without fractional seconds.
     40 iso :: proc(t: time.Time, allocator := context.allocator) -> string {
     41 	return format(t, "%Y-%m-%dT%H:%M:%SZ", allocator)
     42 }
     43 
     44 // stamp renders t compactly for file names: 20260923-104102.
     45 stamp :: proc(t: time.Time, allocator := context.allocator) -> string {
     46 	return format(t, "%Y%m%d-%H%M%S", allocator)
     47 }
     48 
     49 // date renders t as YYYY-MM-DD.
     50 date :: proc(t: time.Time, allocator := context.allocator) -> string {
     51 	return format(t, "%Y-%m-%d", allocator)
     52 }
     53 
     54 // format renders t in UTC according to layout.
     55 format :: proc(t: time.Time, layout: string, allocator := context.allocator) -> string {
     56 	return format_parts(utc_parts(t), layout, allocator)
     57 }
     58 
     59 // local renders t in the machine's time zone. When the zone database cannot
     60 // be read it falls back to UTC, and %Z prints "UTC".
     61 local :: proc(t: time.Time, layout: string, allocator := context.allocator) -> string {
     62 	return format_parts(local_parts(t), layout, allocator)
     63 }
     64 
     65 // duration renders d the way humans read it: 250ms, 4.2s, 3m12s, 1h0m2s,
     66 // 2d3h.
     67 duration :: proc(d: time.Duration, allocator := context.allocator) -> string {
     68 	b := strings.builder_make(allocator)
     69 	d := d
     70 	if d < 0 {
     71 		strings.write_byte(&b, '-')
     72 		d = -d
     73 	}
     74 	switch {
     75 	case d < time.Millisecond:
     76 		strings.write_int(&b, int(d / time.Microsecond))
     77 		strings.write_string(&b, "µs")
     78 	case d < time.Second:
     79 		strings.write_int(&b, int(d / time.Millisecond))
     80 		strings.write_string(&b, "ms")
     81 	case d < time.Minute:
     82 		tenths := int(d / (time.Second / 10))
     83 		strings.write_int(&b, tenths / 10)
     84 		if tenths % 10 != 0 {
     85 			strings.write_byte(&b, '.')
     86 			strings.write_int(&b, tenths % 10)
     87 		}
     88 		strings.write_byte(&b, 's')
     89 	case d < time.Hour:
     90 		strings.write_int(&b, int(d / time.Minute))
     91 		strings.write_byte(&b, 'm')
     92 		strings.write_int(&b, int(d % time.Minute / time.Second))
     93 		strings.write_byte(&b, 's')
     94 	case d < 24 * time.Hour:
     95 		strings.write_int(&b, int(d / time.Hour))
     96 		strings.write_byte(&b, 'h')
     97 		strings.write_int(&b, int(d % time.Hour / time.Minute))
     98 		strings.write_byte(&b, 'm')
     99 		strings.write_int(&b, int(d % time.Minute / time.Second))
    100 		strings.write_byte(&b, 's')
    101 	case:
    102 		strings.write_int(&b, int(d / (24 * time.Hour)))
    103 		strings.write_byte(&b, 'd')
    104 		strings.write_int(&b, int(d % (24 * time.Hour) / time.Hour))
    105 		strings.write_byte(&b, 'h')
    106 	}
    107 	return strings.to_string(b)
    108 }
    109 
    110 // parse reads s according to layout and returns a UTC time. Fields the
    111 // layout does not mention default to 1970-01-01 00:00:00.
    112 parse :: proc(s, layout: string) -> (t: time.Time, ok: bool) {
    113 	p := Parts{year = 1970, month = 1, day = 1}
    114 	pm := -1 // -1 unset, 0 am, 1 pm
    115 	unix_set := false
    116 	unix: i64
    117 	rest := s
    118 	li := 0
    119 	for li < len(layout) {
    120 		c := layout[li]
    121 		if c != '%' {
    122 			if len(rest) == 0 || rest[0] != c {
    123 				return {}, false
    124 			}
    125 			rest = rest[1:]
    126 			li += 1
    127 			continue
    128 		}
    129 		li += 1
    130 		if li >= len(layout) {
    131 			return {}, false
    132 		}
    133 		d := layout[li]
    134 		li += 1
    135 		switch d {
    136 		case 'Y':
    137 			p.year = take_int(&rest, 4, 4) or_return
    138 		case 'y':
    139 			yy := take_int(&rest, 2, 2) or_return
    140 			p.year = 2000 + yy if yy < 69 else 1900 + yy
    141 		case 'm':
    142 			p.month = take_int(&rest, 1, 2) or_return
    143 		case 'd', 'e':
    144 			rest = strings.trim_left_space(rest)
    145 			p.day = take_int(&rest, 1, 2) or_return
    146 		case 'H':
    147 			p.hour = take_int(&rest, 1, 2) or_return
    148 		case 'I':
    149 			p.hour = take_int(&rest, 1, 2) or_return
    150 		case 'M':
    151 			p.minute = take_int(&rest, 1, 2) or_return
    152 		case 'S':
    153 			p.second = take_int(&rest, 1, 2) or_return
    154 		case 'f':
    155 			ms := take_int(&rest, 1, 3) or_return
    156 			p.nanos = ms * 1_000_000
    157 		case 's':
    158 			v := take_int(&rest, 1, 19) or_return
    159 			unix = i64(v)
    160 			unix_set = true
    161 		case 'b', 'B':
    162 			p.month = take_name(&rest, MONTHS[:]) or_return
    163 		case 'a', 'A':
    164 			_ = take_name(&rest, DAYS[:]) or_return
    165 		case 'p':
    166 			if len(rest) < 2 {
    167 				return {}, false
    168 			}
    169 			switch strings.to_lower(rest[:2], context.temp_allocator) {
    170 			case "am":
    171 				pm = 0
    172 			case "pm":
    173 				pm = 1
    174 			case:
    175 				return {}, false
    176 			}
    177 			rest = rest[2:]
    178 		case '%':
    179 			if len(rest) == 0 || rest[0] != '%' {
    180 				return {}, false
    181 			}
    182 			rest = rest[1:]
    183 		case:
    184 			return {}, false
    185 		}
    186 	}
    187 	if len(rest) != 0 {
    188 		return {}, false
    189 	}
    190 	if unix_set {
    191 		return time.unix(unix, i64(p.nanos)), true
    192 	}
    193 	if pm == 1 && p.hour < 12 {
    194 		p.hour += 12
    195 	} else if pm == 0 && p.hour == 12 {
    196 		p.hour = 0
    197 	}
    198 	dt, err := datetime.components_to_datetime(p.year, p.month, p.day, p.hour, p.minute, p.second, p.nanos)
    199 	if err != nil {
    200 		return {}, false
    201 	}
    202 	return time.datetime_to_time(dt)
    203 }
    204 
    205 // utc_parts breaks t into calendar fields in UTC.
    206 utc_parts :: proc(t: time.Time) -> Parts {
    207 	p := parts_from_datetime(t, 0)
    208 	p.zone = "UTC"
    209 	return p
    210 }
    211 
    212 // local_parts breaks t into calendar fields in the machine's zone.
    213 local_parts :: proc(t: time.Time) -> Parts {
    214 	region, ok := timezone.region_load("local", context.temp_allocator)
    215 	if !ok || region == nil {
    216 		return utc_parts(t)
    217 	}
    218 	utc_dt, dt_ok := time.time_to_datetime(t)
    219 	if !dt_ok {
    220 		return utc_parts(t)
    221 	}
    222 	local_dt, tz_ok := timezone.datetime_to_tz(utc_dt, region)
    223 	if !tz_ok {
    224 		return utc_parts(t)
    225 	}
    226 	zone, _ := timezone.shortname(local_dt)
    227 	naive := local_dt
    228 	naive.tz = nil
    229 	shifted, time_ok := time.datetime_to_time(naive)
    230 	if !time_ok {
    231 		return utc_parts(t)
    232 	}
    233 	offset := int(time.time_to_unix(shifted) - time.time_to_unix(t))
    234 	p := parts_from_datetime(t, offset)
    235 	p.zone = zone
    236 	return p
    237 }
    238 
    239 // ---- internals ----------------------------------------------------------
    240 
    241 @(rodata)
    242 MONTHS := [12]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
    243 @(rodata)
    244 DAYS := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
    245 
    246 parts_from_datetime :: proc(t: time.Time, offset: int) -> Parts {
    247 	shifted := time.time_add(t, time.Duration(offset) * time.Second)
    248 	dt, _ := time.time_to_datetime(shifted)
    249 	p := Parts {
    250 		year   = int(dt.year),
    251 		month  = int(dt.month),
    252 		day    = int(dt.day),
    253 		hour   = int(dt.hour),
    254 		minute = int(dt.minute),
    255 		second = int(dt.second),
    256 		nanos  = int(dt.nano),
    257 		offset = offset,
    258 	}
    259 	p.weekday = int(time.weekday(shifted))
    260 	if ord, err := datetime.date_to_ordinal(dt.date); err == nil {
    261 		if ny, nerr := datetime.new_year(dt.year); nerr == nil {
    262 			if nyo, oerr := datetime.date_to_ordinal(ny); oerr == nil {
    263 				p.yday = int(ord - nyo) + 1
    264 			}
    265 		}
    266 	}
    267 	return p
    268 }
    269 
    270 format_parts :: proc(p: Parts, layout: string, allocator := context.allocator) -> string {
    271 	b := strings.builder_make(allocator)
    272 	i := 0
    273 	for i < len(layout) {
    274 		c := layout[i]
    275 		if c != '%' || i + 1 >= len(layout) {
    276 			strings.write_byte(&b, c)
    277 			i += 1
    278 			continue
    279 		}
    280 		d := layout[i + 1]
    281 		i += 2
    282 		switch d {
    283 		case 'Y':
    284 			pad(&b, p.year, 4)
    285 		case 'y':
    286 			pad(&b, p.year % 100, 2)
    287 		case 'm':
    288 			pad(&b, p.month, 2)
    289 		case 'd':
    290 			pad(&b, p.day, 2)
    291 		case 'e':
    292 			if p.day < 10 {
    293 				strings.write_byte(&b, ' ')
    294 			}
    295 			strings.write_int(&b, p.day)
    296 		case 'H':
    297 			pad(&b, p.hour, 2)
    298 		case 'I':
    299 			h := p.hour % 12
    300 			pad(&b, 12 if h == 0 else h, 2)
    301 		case 'M':
    302 			pad(&b, p.minute, 2)
    303 		case 'S':
    304 			pad(&b, p.second, 2)
    305 		case 'f':
    306 			pad(&b, p.nanos / 1_000_000, 3)
    307 		case 'j':
    308 			pad(&b, p.yday, 3)
    309 		case 'a':
    310 			strings.write_string(&b, DAYS[p.weekday][:3])
    311 		case 'A':
    312 			strings.write_string(&b, DAYS[p.weekday])
    313 		case 'b':
    314 			strings.write_string(&b, MONTHS[p.month - 1][:3])
    315 		case 'B':
    316 			strings.write_string(&b, MONTHS[p.month - 1])
    317 		case 'p':
    318 			strings.write_string(&b, "AM" if p.hour < 12 else "PM")
    319 		case 'u':
    320 			strings.write_int(&b, 7 if p.weekday == 0 else p.weekday)
    321 		case 'w':
    322 			strings.write_int(&b, p.weekday)
    323 		case 's':
    324 			unix := datetime_unix(p)
    325 			strings.write_i64(&b, unix)
    326 		case 'z':
    327 			off := p.offset
    328 			strings.write_byte(&b, '-' if off < 0 else '+')
    329 			off = abs(off)
    330 			pad(&b, off / 3600, 2)
    331 			pad(&b, off % 3600 / 60, 2)
    332 		case 'Z':
    333 			strings.write_string(&b, p.zone)
    334 		case '%':
    335 			strings.write_byte(&b, '%')
    336 		case:
    337 			strings.write_byte(&b, '%')
    338 			strings.write_byte(&b, d)
    339 		}
    340 	}
    341 	return strings.to_string(b)
    342 }
    343 
    344 datetime_unix :: proc(p: Parts) -> i64 {
    345 	dt, err := datetime.components_to_datetime(p.year, p.month, p.day, p.hour, p.minute, p.second, p.nanos)
    346 	if err != nil {
    347 		return 0
    348 	}
    349 	t, ok := time.datetime_to_time(dt)
    350 	if !ok {
    351 		return 0
    352 	}
    353 	return time.time_to_unix(t) - i64(p.offset)
    354 }
    355 
    356 pad :: proc(b: ^strings.Builder, v, width: int) {
    357 	buf: [24]byte
    358 	s := strconv.write_int(buf[:], i64(v), 10)
    359 	for _ in len(s) ..< width {
    360 		strings.write_byte(b, '0')
    361 	}
    362 	strings.write_string(b, s)
    363 }
    364 
    365 take_int :: proc(rest: ^string, min_digits, max_digits: int) -> (v: int, ok: bool) {
    366 	n := 0
    367 	for n < len(rest) && n < max_digits && rest[n] >= '0' && rest[n] <= '9' {
    368 		n += 1
    369 	}
    370 	if n < min_digits {
    371 		return 0, false
    372 	}
    373 	v, ok = strconv.parse_int(rest[:n], 10)
    374 	rest^ = rest[n:]
    375 	return
    376 }
    377 
    378 // take_name matches a full or three-letter name from names, case-insensitive,
    379 // and returns its 1-based index.
    380 take_name :: proc(rest: ^string, names: []string) -> (index: int, ok: bool) {
    381 	for name, i in names {
    382 		if len(rest) >= len(name) && strings.equal_fold(rest[:len(name)], name) {
    383 			rest^ = rest[len(name):]
    384 			return i + 1, true
    385 		}
    386 	}
    387 	for name, i in names {
    388 		if len(rest) >= 3 && strings.equal_fold(rest[:3], name[:3]) {
    389 			rest^ = rest[3:]
    390 			return i + 1, true
    391 		}
    392 	}
    393 	return 0, false
    394 }