code wiki / (root) / tsv.nx

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}