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}