nx_tsv.nx source
↩ module page · 235 lines · 7583 B
1// tsv.nx -- Tab-separated values reader + writer.
2//
3// The "database format" for sovereign apps that don't yet have a
4// SQLite binding (per ROADMAP P8). Append-only files, one per
5// entity (people.tsv, obligations.tsv, interactions.tsv). Reads
6// are linear scans; writes are fwrite-append at end.
7//
8// Format (RFC 4180 TSV variant, simpler than CSV):
9// - Fields separated by TAB (0x09)
10// - Rows separated by LF (0x0A)
11// - No field-level quoting -- TAB and LF inside fields escaped
12// via \t and \n (our convention; not RFC 4180)
13// - Leading #-comment rows skipped by the reader
14//
15// Not designed for megabyte-scale; linear scans are O(n). Fine
16// at the ~1k-record scale that CLAUDE.md rule #1 (Schema First)
17// implies for a CRM/ops app's first iteration.
18//
19// Invariants:
20// TSV1 Round-trip exact for any row whose fields don't contain
21// raw TAB or LF. Fields with TAB/LF are lossy unless
22// caller pre-escapes via tsv_escape_field.
23// TSV2 Comment rows (# at start-of-line) never contribute
24// fields to row callbacks.
25// TSV3 Trailing newline on the file is tolerated; absence is
26// also fine.
27// TSV4 Reader is streaming: processes byte-by-byte without
28// buffering whole rows. Safe on arbitrarily-long files.
29
30// nx_safety_envelope:
31// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
32// sil_target: SIL1
33// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
34// verdict: NOT_YET_EVALUATED
35
36import "nx_syscalls.nx"
37import "nx_fs.nx"
38
39// ---- callback-driven reader -----------------------------------
40//
41// Instead of building a heap-allocated list of rows (which would
42// require dynamic allocation primitives we don't cheaply have),
43// we call a caller-supplied function for each complete field +
44// for end-of-row. Caller accumulates as needed.
45//
46// Since NishiLang lacks function pointers today, we invert control:
47// caller walks the byte stream themselves using tsv_scan_field.
48
49// Scan one field starting at buf[*pos]. Updates *pos to the byte
50// after the field's terminator. Writes field-start offset to
51// *field_off, field length (not counting terminator) to *field_len.
52//
53// Returns:
54// 0 field-complete, row continues (separator was TAB)
55// 1 field-complete, row ended (separator was LF or EOF)
56// -1 malformed input (shouldn't happen on well-formed TSV)
57func tsv_scan_field(buf: *u8, len: i64, pos: *i64,
58 field_off: *i64, field_len: *i64) -> i64 {
59 let start: i64 = *pos
60 var p: i64 = start
61 while p < len {
62 let b: i64 = buf[p]
63 if b == 0x09 { // TAB
64 *field_off = start
65 *field_len = p - start
66 *pos = p + 1
67 return 0
68 }
69 if b == 0x0A { // LF
70 *field_off = start
71 *field_len = p - start
72 *pos = p + 1
73 return 1
74 }
75 p = p + 1
76 }
77 // EOF mid-field.
78 *field_off = start
79 *field_len = p - start
80 *pos = p
81 return 1
82}
83
84// Skip a comment row (starts with '#'). Returns new position,
85// or `pos` unchanged if this row isn't a comment.
86func tsv_skip_comment(buf: *u8, len: i64, pos: i64) -> i64 {
87 if pos >= len { return pos }
88 if buf[pos] != 0x23 { return pos } // '#'
89 var p: i64 = pos
90 while p < len {
91 if buf[p] == 0x0A { return p + 1 }
92 p = p + 1
93 }
94 return len
95}
96
97// ---- writer ---------------------------------------------------
98
99// Write a null-terminated field to out buffer, TAB-separated from
100// previous field; if first_in_row, no leading TAB. Field bytes
101// are copied raw -- caller pre-escapes TAB/LF if needed.
102func tsv_write_field(out: *u8, pos: *i64, cap: i64,
103 first_in_row: i64,
104 field: *u8, field_len: i64) -> i64 {
105 let p0: i64 = *pos
106 var p: i64 = p0
107 if first_in_row == 0 {
108 if p >= cap { return -1 }
109 out[p] = 0x09
110 p = p + 1
111 }
112 if p + field_len > cap { return -1 }
113 var i: i64 = 0
114 while i < field_len {
115 out[p + i] = field[i]
116 i = i + 1
117 }
118 p = p + field_len
119 *pos = p
120 return 0
121}
122
123// End the current row: write LF.
124func tsv_end_row(out: *u8, pos: *i64, cap: i64) -> i64 {
125 let p0: i64 = *pos
126 if p0 >= cap { return -1 }
127 out[p0] = 0x0A
128 *pos = p0 + 1
129 return 0
130}
131
132// ---- escaping helpers ----------------------------------------
133//
134// TAB -> \t, LF -> \n, \ -> \\. Converts in-place-like into an
135// output buffer with growth. Returns written length.
136
137func tsv_escape_field(in_bytes: *u8, n: i64,
138 out: *u8, cap: i64) -> i64 {
139 var i: i64 = 0
140 var o: i64 = 0
141 while i < n {
142 let b: i64 = in_bytes[i]
143 if b == 0x09 {
144 if o + 2 > cap { return -1 }
145 out[o] = 0x5C; out[o + 1] = 0x74
146 o = o + 2
147 } else {
148 if b == 0x0A {
149 if o + 2 > cap { return -1 }
150 out[o] = 0x5C; out[o + 1] = 0x6E
151 o = o + 2
152 } else {
153 if b == 0x5C {
154 if o + 2 > cap { return -1 }
155 out[o] = 0x5C; out[o + 1] = 0x5C
156 o = o + 2
157 } else {
158 if o >= cap { return -1 }
159 out[o] = b
160 o = o + 1
161 }
162 }
163 }
164 i = i + 1
165 }
166 return o
167}
168
169// Inverse of tsv_escape_field: \t -> TAB, \n -> LF, \\ -> \.
170func tsv_unescape_field(in_bytes: *u8, n: i64,
171 out: *u8, cap: i64) -> i64 {
172 var i: i64 = 0
173 var o: i64 = 0
174 while i < n {
175 let b: i64 = in_bytes[i]
176 if b == 0x5C {
177 if i + 1 < n {
178 let nxt: i64 = in_bytes[i + 1]
179 var put: i64 = 0
180 if nxt == 0x74 { put = 0x09 }
181 else {
182 if nxt == 0x6E { put = 0x0A }
183 else {
184 if nxt == 0x5C { put = 0x5C }
185 else { put = nxt }
186 }
187 }
188 if o >= cap { return -1 }
189 out[o] = put
190 o = o + 1
191 i = i + 2
192 } else {
193 if o >= cap { return -1 }
194 out[o] = b
195 o = o + 1
196 i = i + 1
197 }
198 } else {
199 if o >= cap { return -1 }
200 out[o] = b
201 o = o + 1
202 i = i + 1
203 }
204 }
205 return o
206}
207
208// Compile-only smoke: round-trip a 2-field row through
209// tsv_write_field / tsv_scan_field.
210func main() -> i64 {
211 let out: *u8 = sys_mmap(64)
212 let pos_raw: *u8 = sys_mmap(16)
213 let pos: *i64 = pos_raw as *i64
214 *pos = 0
215
216 tsv_write_field(out, pos, 64, 1, "alice", 5)
217 tsv_write_field(out, pos, 64, 0, "42", 2)
218 tsv_end_row(out, pos, 64)
219
220 let written: i64 = *pos
221 if written != 9 { return 1 } // "alice\t42\n" = 9 bytes
222
223 // Scan back: first field "alice" (5 bytes, TAB-terminated).
224 let scan_pos: *i64 = sys_mmap(16) as *i64
225 *scan_pos = 0
226 let foff: *i64 = sys_mmap(16) as *i64
227 let flen: *i64 = sys_mmap(16) as *i64
228 let r1: i64 = tsv_scan_field(out, written, scan_pos, foff, flen)
229 if r1 != 0 { return 2 } // TAB -> row continues
230 if *flen != 5 { return 3 }
231 let r2: i64 = tsv_scan_field(out, written, scan_pos, foff, flen)
232 if r2 != 1 { return 4 } // LF -> row ends
233 if *flen != 2 { return 5 }
234 return 0
235}