tsv.nx source
↩ module page · 229 lines · 7555 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
30import "syscalls.nx"
31import "fs.nx"
32
33// ---- callback-driven reader -----------------------------------
34//
35// Instead of building a heap-allocated list of rows (which would
36// require dynamic allocation primitives we don't cheaply have),
37// we call a caller-supplied function for each complete field +
38// for end-of-row. Caller accumulates as needed.
39//
40// Since NishiLang lacks function pointers today, we invert control:
41// caller walks the byte stream themselves using tsv_scan_field.
42
43// Scan one field starting at buf[*pos]. Updates *pos to the byte
44// after the field's terminator. Writes field-start offset to
45// *field_off, field length (not counting terminator) to *field_len.
46//
47// Returns:
48// 0 field-complete, row continues (separator was TAB)
49// 1 field-complete, row ended (separator was LF or EOF)
50// -1 malformed input (shouldn't happen on well-formed TSV)
51func tsv_scan_field(buf: *u8, len: i64, pos: *i64,
52 field_off: *i64, field_len: *i64) -> i64 {
53 let start: i64 = *pos
54 var p: i64 = start
55 while p < len {
56 let b: i64 = buf[p]
57 if b == 0x09 { // TAB
58 *field_off = start
59 *field_len = p - start
60 *pos = p + 1
61 return 0
62 }
63 if b == 0x0A { // LF
64 *field_off = start
65 *field_len = p - start
66 *pos = p + 1
67 return 1
68 }
69 p = p + 1
70 }
71 // EOF mid-field.
72 *field_off = start
73 *field_len = p - start
74 *pos = p
75 return 1
76}
77
78// Skip a comment row (starts with '#'). Returns new position,
79// or `pos` unchanged if this row isn't a comment.
80func tsv_skip_comment(buf: *u8, len: i64, pos: i64) -> i64 {
81 if pos >= len { return pos }
82 if buf[pos] != 0x23 { return pos } // '#'
83 var p: i64 = pos
84 while p < len {
85 if buf[p] == 0x0A { return p + 1 }
86 p = p + 1
87 }
88 return len
89}
90
91// ---- writer ---------------------------------------------------
92
93// Write a null-terminated field to out buffer, TAB-separated from
94// previous field; if first_in_row, no leading TAB. Field bytes
95// are copied raw -- caller pre-escapes TAB/LF if needed.
96func tsv_write_field(out: *u8, pos: *i64, cap: i64,
97 first_in_row: i64,
98 field: *u8, field_len: i64) -> i64 {
99 let p0: i64 = *pos
100 var p: i64 = p0
101 if first_in_row == 0 {
102 if p >= cap { return -1 }
103 out[p] = 0x09
104 p = p + 1
105 }
106 if p + field_len > cap { return -1 }
107 var i: i64 = 0
108 while i < field_len {
109 out[p + i] = field[i]
110 i = i + 1
111 }
112 p = p + field_len
113 *pos = p
114 return 0
115}
116
117// End the current row: write LF.
118func tsv_end_row(out: *u8, pos: *i64, cap: i64) -> i64 {
119 let p0: i64 = *pos
120 if p0 >= cap { return -1 }
121 out[p0] = 0x0A
122 *pos = p0 + 1
123 return 0
124}
125
126// ---- escaping helpers ----------------------------------------
127//
128// TAB -> \t, LF -> \n, \ -> \\. Converts in-place-like into an
129// output buffer with growth. Returns written length.
130
131func tsv_escape_field(in_bytes: *u8, n: i64,
132 out: *u8, cap: i64) -> i64 {
133 var i: i64 = 0
134 var o: i64 = 0
135 while i < n {
136 let b: i64 = in_bytes[i]
137 if b == 0x09 {
138 if o + 2 > cap { return -1 }
139 out[o] = 0x5C; out[o + 1] = 0x74
140 o = o + 2
141 } else {
142 if b == 0x0A {
143 if o + 2 > cap { return -1 }
144 out[o] = 0x5C; out[o + 1] = 0x6E
145 o = o + 2
146 } else {
147 if b == 0x5C {
148 if o + 2 > cap { return -1 }
149 out[o] = 0x5C; out[o + 1] = 0x5C
150 o = o + 2
151 } else {
152 if o >= cap { return -1 }
153 out[o] = b
154 o = o + 1
155 }
156 }
157 }
158 i = i + 1
159 }
160 return o
161}
162
163// Inverse of tsv_escape_field: \t -> TAB, \n -> LF, \\ -> \.
164func tsv_unescape_field(in_bytes: *u8, n: i64,
165 out: *u8, cap: i64) -> i64 {
166 var i: i64 = 0
167 var o: i64 = 0
168 while i < n {
169 let b: i64 = in_bytes[i]
170 if b == 0x5C {
171 if i + 1 < n {
172 let nxt: i64 = in_bytes[i + 1]
173 var put: i64 = 0
174 if nxt == 0x74 { put = 0x09 }
175 else {
176 if nxt == 0x6E { put = 0x0A }
177 else {
178 if nxt == 0x5C { put = 0x5C }
179 else { put = nxt }
180 }
181 }
182 if o >= cap { return -1 }
183 out[o] = put
184 o = o + 1
185 i = i + 2
186 } else {
187 if o >= cap { return -1 }
188 out[o] = b
189 o = o + 1
190 i = i + 1
191 }
192 } else {
193 if o >= cap { return -1 }
194 out[o] = b
195 o = o + 1
196 i = i + 1
197 }
198 }
199 return o
200}
201
202// Compile-only smoke: round-trip a 2-field row through
203// tsv_write_field / tsv_scan_field.
204func main() -> i64 {
205 let out: *u8 = sys_mmap(64)
206 let pos_raw: *u8 = sys_mmap(16)
207 let pos: *i64 = pos_raw as *i64
208 *pos = 0
209
210 tsv_write_field(out, pos, 64, 1, "alice", 5)
211 tsv_write_field(out, pos, 64, 0, "42", 2)
212 tsv_end_row(out, pos, 64)
213
214 let written: i64 = *pos
215 if written != 9 { return 1 } // "alice\t42\n" = 9 bytes
216
217 // Scan back: first field "alice" (5 bytes, TAB-terminated).
218 let scan_pos: *i64 = sys_mmap(16) as *i64
219 *scan_pos = 0
220 let foff: *i64 = sys_mmap(16) as *i64
221 let flen: *i64 = sys_mmap(16) as *i64
222 let r1: i64 = tsv_scan_field(out, written, scan_pos, foff, flen)
223 if r1 != 0 { return 2 } // TAB -> row continues
224 if *flen != 5 { return 3 }
225 let r2: i64 = tsv_scan_field(out, written, scan_pos, foff, flen)
226 if r2 != 1 { return 4 } // LF -> row ends
227 if *flen != 2 { return 5 }
228 return 0
229}