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 }