nx_itoa_lib.nx source
↩ module page · 95 lines · 4781 B
1// nx_itoa_lib.nx -- THE shared integer->decimal emitter. ONE copy, so the corpus stops retyping it.
2//
3// LIFTED, NEVER COPIED (2026-07-31, debt 1785557603). ccz_cat_num was already correct, already
4// MSB-first, already zero-allocation, and already had 10+ callers -- it was simply IMPRISONED inside
5// nx_crashresume_census_core.nx, a crash-resume census organ. Seven files imported an entire census
6// just to print an integer. That import cost, NOT ignorance of the primitive, is why ~87 sites
7// hand-rolled their own. LAW: WHEN A CORRECT PRIMITIVE IS RETYPED, MEASURE ITS IMPORT COST BEFORE
8// BLAMING DISCOVERABILITY -- people do not retype what is CHEAP to reach.
9//
10// THE LEAK WAS NEVER IN THE PRIMITIVE, IT WAS IN THE MISSING WRAPPER. ccz_cat_num allocates nothing.
11// What every clone hand-rolled was the fd shim around it, e.g. nx_lock_reap_gate.g_putn:
12// let b: *u8 = sys_mmap(32); let e: i64 = ccz_cat_num(b, 0, v); sys_write(1, b, e); return 0
13// -- one mmap per call, never freed. nxi_fd below is that shim, written ONCE and always freeing.
14//
15// The census now imports THIS file; NishiLang import is transitive (verified: nx_lock_reap_gate
16// imports only nx_syscalls + nx_lock_reap_core, and resolves ccz_cat_num through the core), so all
17// existing callers keep resolving with no edit.
18//
19// LAYERING: lives in runtime/ so BOTH runtime/ and _hdl_build/ can import it.
20// license_tier: ORIGINAL No hw writes (Rule 26).
21import "nx_syscalls.nx"
22// MSB-FIRST (2026-07-31, debt 1785516350): the previous body built digits LEAST-significant first,
23// which comes out BACKWARDS and therefore needed a sys_mmap(32) scratch buffer to reverse through --
24// and never freed it, leaking a page per call across 12+ importers. Emitting MOST-significant first
25// needs no buffer at all, so this now ALLOCATES NOTHING. Output bytes and the NUL-terminate contract
26// are unchanged; this is a rewrite of the algorithm, not a sprinkled munmap (rule 3).
27const CCZ_ASCII_0: i64 = 48
28const CCZ_MINUS: i64 = 45
29const CCZ_DEC: i64 = 10
30func ccz_cat_num(buf: *u8, off: i64, v: i64) -> i64 {
31 var o: i64 = off
32 var m: i64 = v
33 if m == 0 { buf[o] = CCZ_ASCII_0 as u8; o = o + 1; buf[o] = 0 as u8; return o }
34 if m < 0 { buf[o] = CCZ_MINUS as u8; o = o + 1; m = 0 - m }
35 // i64 MIN negates to itself and stays negative; clamp rather than loop forever on the digit walk.
36 if m < 0 { m = 0 }
37 var pw: i64 = 1
38 while m / pw >= CCZ_DEC { pw = pw * CCZ_DEC }
39 while pw > 0 {
40 buf[o] = (CCZ_ASCII_0 + ((m / pw) % CCZ_DEC)) as u8
41 o = o + 1
42 pw = pw / CCZ_DEC
43 }
44 buf[o] = 0 as u8
45 return o
46}
47
48// max i64 is 19 digits + sign + the NUL ccz_cat_num writes; 24 leaves slack, well under one page.
49const NXI_BUF: i64 = 24
50const NXI_STDOUT: i64 = 1
51const NXI_STDERR: i64 = 2
52
53// Write v as decimal to fd. ONE buffer, ALWAYS freed -- the balanced shape nx_mmapbal certifies.
54// This is the drop-in for every hand-rolled putn/gn/wn/pn clone. Returns bytes written.
55func nxi_fd(fd: i64, v: i64) -> i64 {
56 let b: *u8 = sys_mmap(NXI_BUF)
57 let n: i64 = ccz_cat_num(b, 0, v)
58 sys_write(fd, b, n)
59 sys_munmap(b, NXI_BUF)
60 return n
61}
62
63func nxi_out(v: i64) -> i64 { return nxi_fd(NXI_STDOUT, v) }
64func nxi_err(v: i64) -> i64 { return nxi_fd(NXI_STDERR, v) }
65
66// NUL-FREE buffer form (2026-07-31). ccz_cat_num NUL-terminates -- it writes dst[ret]=0 -- which is
67// right for its own callers but WRONG as a drop-in for the large clone family whose contract is
68// "append digits, touch nothing else, return the new offset". Pointing those at ccz_cat_num would
69// write one byte past the returned offset, and a clone that patches a number into the MIDDLE of an
70// already-built buffer would have the next byte clobbered. nx_office_serve.of_catn alone has 60
71// call sites, none of them audited for that.
72// So the lib carries BOTH contracts explicitly rather than making every migrator guess:
73// ccz_cat_num -> digits + NUL, returns the offset BEFORE the NUL
74// nxi_buf -> digits only, returns the offset AFTER them, ZERO bytes touched beyond
75// Both are MSB-first and allocate NOTHING. Constants are the CCZ_ ones lifted with ccz_cat_num.
76func nxi_buf(dst: *u8, off: i64, v: i64) -> i64 {
77 var p: i64 = off
78 var m: i64 = v
79 if m < 0 {
80 dst[p] = CCZ_MINUS as u8
81 p = p + 1
82 m = 0 - m
83 }
84 // i64 MIN negates to ITSELF and stays negative. Clamp to 0 rather than looping forever or
85 // emitting garbage -- a documented bound, never a silent wrong number.
86 if m < 0 { m = 0 }
87 var pw: i64 = 1
88 while m / pw >= CCZ_DEC { pw = pw * CCZ_DEC }
89 while pw > 0 {
90 dst[p] = (CCZ_ASCII_0 + ((m / pw) % CCZ_DEC)) as u8
91 p = p + 1
92 pw = pw / CCZ_DEC
93 }
94 return p
95}