code wiki / (root) / nx_iso8601.nx

nx_iso8601.nx source

↩ module page · 198 lines · 6752 B

1// iso8601.nx -- format + parse ISO 8601 date-time strings. 2// 3// The canonical machine-readable timestamp format: 4// 2026-04-22T12:34:56Z 5// 2026-04-22T12:34:56.123Z (milliseconds -- optional) 6// 7// Used by: JSON APIs (the convention), HTTP Last-Modified headers 8// (RFC 7231 also accepts IMF-fixdate but ISO is simpler), 9// file export headers, nishi-pages post dates, obligation 10// deadlines, log timestamps. 11// 12// We support the "basic profile" of ISO 8601: 13// YYYY-MM-DDTHH:MM:SS[.fff]Z (UTC only, 'Z' suffix) 14// 15// Longer time-offset variants (+01:00, etc.) are NOT produced; 16// we always emit UTC. Parser accepts both 'Z' and an optional 17// '+HH:MM' / '-HH:MM' offset that we convert to seconds. 18// 19// Composes civil_date.nx. 20// 21// Invariants: 22// I1 Format output is exactly 20 bytes for seconds precision, 23// 24 bytes for millisecond precision. 24// I2 Parser rejects strings with unknown fields (e.g. week 25// numbers) with ISO_ERR_FORMAT; we stay narrowly useful. 26// I3 Round-trip exact for format(parse(s)) on strings we emit. 27 28// nx_safety_envelope: 29// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 30// sil_target: SIL1 31// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 32// verdict: NOT_YET_EVALUATED 33 34import "nx_syscalls.nx" 35import "nx_civil_date.nx" 36const ISO_MAGIC_3600: i64 = 3600 37const ISO_MAGIC_2026: i64 = 2026 38 39const ISO_ERR_FORMAT: i64 = -1 40const ISO_ERR_SHORT: i64 = -2 41 42// Write a 2-digit zero-padded integer at out[off]. 43func iso_put_2d(out: *u8, off: i64, v: i64) -> i64 { 44 out[off] = 0x30 + ((v / 10) % 10) 45 out[off + 1] = 0x30 + (v % 10) 46 return off + 2 47} 48 49// Write a 4-digit zero-padded year at out[off]. 50func iso_put_4d(out: *u8, off: i64, v: i64) -> i64 { 51 out[off] = 0x30 + ((v / 1000) % 10) 52 out[off + 1] = 0x30 + ((v / 100) % 10) 53 out[off + 2] = 0x30 + ((v / 10) % 10) 54 out[off + 3] = 0x30 + (v % 10) 55 return off + 4 56} 57 58// Format a CivilDate (assumed UTC) as "YYYY-MM-DDTHH:MM:SSZ". 59// Returns 20 on success or ISO_ERR_SHORT. 60func iso_format(out: *u8, cap: i64, cd: *CivilDate) -> i64 { 61 if cap < 20 { return ISO_ERR_SHORT } 62 var off: i64 = 0 63 off = iso_put_4d(out, off, cd.year) 64 out[off] = 0x2D; off = off + 1 65 off = iso_put_2d(out, off, cd.month) 66 out[off] = 0x2D; off = off + 1 67 off = iso_put_2d(out, off, cd.day) 68 out[off] = 0x54; off = off + 1 // 'T' 69 off = iso_put_2d(out, off, cd.hour) 70 out[off] = 0x3A; off = off + 1 71 off = iso_put_2d(out, off, cd.minute) 72 out[off] = 0x3A; off = off + 1 73 off = iso_put_2d(out, off, cd.second) 74 out[off] = 0x5A; off = off + 1 // 'Z' 75 return off 76} 77 78// Convenience: format unix seconds directly. 79func iso_format_unix(out: *u8, cap: i64, unix_sec: i64) -> i64 { 80 let cd_raw: *u8 = sys_mmap(128) 81 let cd: *CivilDate = cd_raw as *CivilDate 82 civil_from_unix(unix_sec, cd) 83 return iso_format(out, cap, cd) 84} 85 86// Parse 2 decimal digits at buf[off]. Returns value or -1. 87func iso_parse_2d(buf: *u8, off: i64) -> i64 { 88 let a: i64 = buf[off] 89 let b: i64 = buf[off + 1] 90 if a < 0x30 { return -1 } 91 if a > 0x39 { return -1 } 92 if b < 0x30 { return -1 } 93 if b > 0x39 { return -1 } 94 return (a - 0x30) * 10 + (b - 0x30) 95} 96 97// Parse 4 decimal digits. 98func iso_parse_4d(buf: *u8, off: i64) -> i64 { 99 let hi: i64 = iso_parse_2d(buf, off) 100 if hi < 0 { return -1 } 101 let lo: i64 = iso_parse_2d(buf, off + 2) 102 if lo < 0 { return -1 } 103 return hi * 100 + lo 104} 105 106// Parse an ISO 8601 string into *unix_sec_out. Returns 0 on 107// success. Accepts 20-char minimum + optional ".fff" fractional 108// seconds + optional timezone offset. 109func iso_parse(buf: *u8, n: i64, unix_sec_out: *i64) -> i64 { 110 if n < 20 { return ISO_ERR_SHORT } 111 let y: i64 = iso_parse_4d(buf, 0) 112 if y < 0 { return ISO_ERR_FORMAT } 113 if buf[4] != 0x2D { return ISO_ERR_FORMAT } 114 let mo: i64 = iso_parse_2d(buf, 5) 115 if mo < 0 { return ISO_ERR_FORMAT } 116 if buf[7] != 0x2D { return ISO_ERR_FORMAT } 117 let d: i64 = iso_parse_2d(buf, 8) 118 if d < 0 { return ISO_ERR_FORMAT } 119 // Accept 'T' (RFC 3339) or ' ' (loose). 120 if buf[10] != 0x54 { 121 if buf[10] != 0x20 { return ISO_ERR_FORMAT } 122 } 123 let h: i64 = iso_parse_2d(buf, 11) 124 if h < 0 { return ISO_ERR_FORMAT } 125 if buf[13] != 0x3A { return ISO_ERR_FORMAT } 126 let mi: i64 = iso_parse_2d(buf, 14) 127 if mi < 0 { return ISO_ERR_FORMAT } 128 if buf[16] != 0x3A { return ISO_ERR_FORMAT } 129 let s: i64 = iso_parse_2d(buf, 17) 130 if s < 0 { return ISO_ERR_FORMAT } 131 132 var tail: i64 = 19 133 // Skip optional .fff fractional. 134 if tail < n { 135 if buf[tail] == 0x2E { 136 tail = tail + 1 137 while tail < n { 138 if buf[tail] < 0x30 { break } 139 if buf[tail] > 0x39 { break } 140 tail = tail + 1 141 } 142 } 143 } 144 145 // Timezone offset. 'Z' = UTC; +HH:MM / -HH:MM accepted. 146 var tz_off: i64 = 0 147 if tail < n { 148 if buf[tail] == 0x5A { 149 tz_off = 0 150 tail = tail + 1 151 } else { 152 var sign: i64 = 0 153 if buf[tail] == 0x2B { sign = 1 } 154 if buf[tail] == 0x2D { sign = -1 } 155 if sign == 0 { return ISO_ERR_FORMAT } 156 tail = tail + 1 157 if n < tail + 5 { return ISO_ERR_FORMAT } 158 let oh: i64 = iso_parse_2d(buf, tail) 159 if oh < 0 { return ISO_ERR_FORMAT } 160 if buf[tail + 2] != 0x3A { return ISO_ERR_FORMAT } 161 let om: i64 = iso_parse_2d(buf, tail + 3) 162 if om < 0 { return ISO_ERR_FORMAT } 163 tz_off = sign * (oh * ISO_MAGIC_3600 + om * 60) 164 } 165 } 166 167 let local_unix: i64 = unix_from_civil(y, mo, d, h, mi, s) 168 *unix_sec_out = local_unix - tz_off 169 return 0 170} 171 172// Compile-only smoke: round-trip. 173func main() -> i64 { 174 let out: *u8 = sys_mmap(64) 175 let n: i64 = iso_format_unix(out, 64, 0) 176 if n != 20 { return 1 } 177 // "1970-01-01T00:00:00Z" 178 if out[0] != 0x31 { return 2 } // '1' 179 if out[10] != 0x54 { return 3 } // 'T' 180 if out[19] != 0x5A { return 4 } // 'Z' 181 182 // Parse back. 183 let parsed: *i64 = (sys_mmap(16)) as *i64 184 if iso_parse(out, 20, parsed) != 0 { return 5 } 185 if *parsed != 0 { return 6 } 186 187 // 2026-04-22T12:34:56Z round-trip. 188 let u: i64 = unix_from_civil(ISO_MAGIC_2026, 4, 22, 12, 34, 56) 189 iso_format_unix(out, 64, u) 190 iso_parse(out, 20, parsed) 191 if *parsed != u { return 7 } 192 193 // Parse with +02:00 offset: "1970-01-01T02:00:00+02:00" = unix 0. 194 if iso_parse("1970-01-01T02:00:00+02:00", 25, parsed) != 0 { return 8 } 195 if *parsed != 0 { return 9 } 196 197 return 0 198}