code wiki / (root) / nx_tsv.nx

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}