iso8601.nx source
↩ module page · 190 lines · 6599 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
28import "syscalls.nx"
29import "civil_date.nx"
30
31const ISO_ERR_FORMAT: i64 = -1
32const ISO_ERR_SHORT: i64 = -2
33
34// Write a 2-digit zero-padded integer at out[off].
35func iso_put_2d(out: *u8, off: i64, v: i64) -> i64 {
36 out[off] = 0x30 + ((v / 10) % 10)
37 out[off + 1] = 0x30 + (v % 10)
38 return off + 2
39}
40
41// Write a 4-digit zero-padded year at out[off].
42func iso_put_4d(out: *u8, off: i64, v: i64) -> i64 {
43 out[off] = 0x30 + ((v / 1000) % 10)
44 out[off + 1] = 0x30 + ((v / 100) % 10)
45 out[off + 2] = 0x30 + ((v / 10) % 10)
46 out[off + 3] = 0x30 + (v % 10)
47 return off + 4
48}
49
50// Format a CivilDate (assumed UTC) as "YYYY-MM-DDTHH:MM:SSZ".
51// Returns 20 on success or ISO_ERR_SHORT.
52func iso_format(out: *u8, cap: i64, cd: *CivilDate) -> i64 {
53 if cap < 20 { return ISO_ERR_SHORT }
54 var off: i64 = 0
55 off = iso_put_4d(out, off, cd.year)
56 out[off] = 0x2D; off = off + 1
57 off = iso_put_2d(out, off, cd.month)
58 out[off] = 0x2D; off = off + 1
59 off = iso_put_2d(out, off, cd.day)
60 out[off] = 0x54; off = off + 1 // 'T'
61 off = iso_put_2d(out, off, cd.hour)
62 out[off] = 0x3A; off = off + 1
63 off = iso_put_2d(out, off, cd.minute)
64 out[off] = 0x3A; off = off + 1
65 off = iso_put_2d(out, off, cd.second)
66 out[off] = 0x5A; off = off + 1 // 'Z'
67 return off
68}
69
70// Convenience: format unix seconds directly.
71func iso_format_unix(out: *u8, cap: i64, unix_sec: i64) -> i64 {
72 let cd_raw: *u8 = sys_mmap(128)
73 let cd: *CivilDate = cd_raw as *CivilDate
74 civil_from_unix(unix_sec, cd)
75 return iso_format(out, cap, cd)
76}
77
78// Parse 2 decimal digits at buf[off]. Returns value or -1.
79func iso_parse_2d(buf: *u8, off: i64) -> i64 {
80 let a: i64 = buf[off]
81 let b: i64 = buf[off + 1]
82 if a < 0x30 { return -1 }
83 if a > 0x39 { return -1 }
84 if b < 0x30 { return -1 }
85 if b > 0x39 { return -1 }
86 return (a - 0x30) * 10 + (b - 0x30)
87}
88
89// Parse 4 decimal digits.
90func iso_parse_4d(buf: *u8, off: i64) -> i64 {
91 let hi: i64 = iso_parse_2d(buf, off)
92 if hi < 0 { return -1 }
93 let lo: i64 = iso_parse_2d(buf, off + 2)
94 if lo < 0 { return -1 }
95 return hi * 100 + lo
96}
97
98// Parse an ISO 8601 string into *unix_sec_out. Returns 0 on
99// success. Accepts 20-char minimum + optional ".fff" fractional
100// seconds + optional timezone offset.
101func iso_parse(buf: *u8, n: i64, unix_sec_out: *i64) -> i64 {
102 if n < 20 { return ISO_ERR_SHORT }
103 let y: i64 = iso_parse_4d(buf, 0)
104 if y < 0 { return ISO_ERR_FORMAT }
105 if buf[4] != 0x2D { return ISO_ERR_FORMAT }
106 let mo: i64 = iso_parse_2d(buf, 5)
107 if mo < 0 { return ISO_ERR_FORMAT }
108 if buf[7] != 0x2D { return ISO_ERR_FORMAT }
109 let d: i64 = iso_parse_2d(buf, 8)
110 if d < 0 { return ISO_ERR_FORMAT }
111 // Accept 'T' (RFC 3339) or ' ' (loose).
112 if buf[10] != 0x54 {
113 if buf[10] != 0x20 { return ISO_ERR_FORMAT }
114 }
115 let h: i64 = iso_parse_2d(buf, 11)
116 if h < 0 { return ISO_ERR_FORMAT }
117 if buf[13] != 0x3A { return ISO_ERR_FORMAT }
118 let mi: i64 = iso_parse_2d(buf, 14)
119 if mi < 0 { return ISO_ERR_FORMAT }
120 if buf[16] != 0x3A { return ISO_ERR_FORMAT }
121 let s: i64 = iso_parse_2d(buf, 17)
122 if s < 0 { return ISO_ERR_FORMAT }
123
124 var tail: i64 = 19
125 // Skip optional .fff fractional.
126 if tail < n {
127 if buf[tail] == 0x2E {
128 tail = tail + 1
129 while tail < n {
130 if buf[tail] < 0x30 { break }
131 if buf[tail] > 0x39 { break }
132 tail = tail + 1
133 }
134 }
135 }
136
137 // Timezone offset. 'Z' = UTC; +HH:MM / -HH:MM accepted.
138 var tz_off: i64 = 0
139 if tail < n {
140 if buf[tail] == 0x5A {
141 tz_off = 0
142 tail = tail + 1
143 } else {
144 var sign: i64 = 0
145 if buf[tail] == 0x2B { sign = 1 }
146 if buf[tail] == 0x2D { sign = -1 }
147 if sign == 0 { return ISO_ERR_FORMAT }
148 tail = tail + 1
149 if n < tail + 5 { return ISO_ERR_FORMAT }
150 let oh: i64 = iso_parse_2d(buf, tail)
151 if oh < 0 { return ISO_ERR_FORMAT }
152 if buf[tail + 2] != 0x3A { return ISO_ERR_FORMAT }
153 let om: i64 = iso_parse_2d(buf, tail + 3)
154 if om < 0 { return ISO_ERR_FORMAT }
155 tz_off = sign * (oh * 3600 + om * 60)
156 }
157 }
158
159 let local_unix: i64 = unix_from_civil(y, mo, d, h, mi, s)
160 *unix_sec_out = local_unix - tz_off
161 return 0
162}
163
164// Compile-only smoke: round-trip.
165func main() -> i64 {
166 let out: *u8 = sys_mmap(64)
167 let n: i64 = iso_format_unix(out, 64, 0)
168 if n != 20 { return 1 }
169 // "1970-01-01T00:00:00Z"
170 if out[0] != 0x31 { return 2 } // '1'
171 if out[10] != 0x54 { return 3 } // 'T'
172 if out[19] != 0x5A { return 4 } // 'Z'
173
174 // Parse back.
175 let parsed: *i64 = (sys_mmap(16)) as *i64
176 if iso_parse(out, 20, parsed) != 0 { return 5 }
177 if *parsed != 0 { return 6 }
178
179 // 2026-04-22T12:34:56Z round-trip.
180 let u: i64 = unix_from_civil(2026, 4, 22, 12, 34, 56)
181 iso_format_unix(out, 64, u)
182 iso_parse(out, 20, parsed)
183 if *parsed != u { return 7 }
184
185 // Parse with +02:00 offset: "1970-01-01T02:00:00+02:00" = unix 0.
186 if iso_parse("1970-01-01T02:00:00+02:00", 25, parsed) != 0 { return 8 }
187 if *parsed != 0 { return 9 }
188
189 return 0
190}