Skip to main content

nova_runtime/
lib.rs

1//! The Nova runtime: support functions linked into every Nova program.
2//!
3//! Phase 1 scope (spec `13-RUNTIME.md`, "minimal: panic, allocator, basic
4//! types"): string values, console output, and sum-type allocation. All
5//! functions use the C ABI so compiled Nova code (Cranelift/LLVM) can call
6//! them directly; for `nova run` they are registered as JIT symbols via
7//! [`symbols`].
8//!
9//! **Memory:** heap values (records, sums, arrays, closures, strings, and
10//! `Bytes`) are managed by a conservative mark-and-sweep garbage
11//! collector — see [`gc`] and `docs/adr/0002-phase1-leaking-allocator.md`.
12//! All heap allocation routes through [`gc::alloc`], which reclaims
13//! unreachable objects.
14
15use std::sync::OnceLock;
16
17/// Byte-buffer intrinsics for `std/bytes`. `pub`, not private, for the same
18/// reason as [`fs`]: its own doc comment explains the boundary it implements
19/// (one representation, shared with `NovaStr`, distinguished only by
20/// `hir::Ty`). Branch `byte-type`'s own Task 3
21/// (`docs/superpowers/plans/2026-08-12-byte-type.md`) added the rest of the
22/// byte surface beside this module's first four intrinsics, the same way
23/// this crate's other modules build on `task`.
24pub mod bytes;
25/// SHA-256, SHA-512, HMAC-SHA-256 and its constant-time check, plus random
26/// bytes and a bounded random integer, for `std/crypto`. Private, like
27/// [`file`], [`http`], [`net`], [`poll`] and [`log`]: nothing outside this
28/// crate names `crypto::` directly -- the three `nova_rt_crypto_*` symbols
29/// reach the JIT and linked binaries through their `#[no_mangle]` C names and
30/// through [`symbols`], which needs only this module's *items* public, not
31/// the module path itself.
32mod crypto;
33/// The open-file table and its five intrinsics for `std/fs`'s `File`.
34/// Private, not `pub` like [`fs`]/[`bytes`]/[`io`]: nothing outside this
35/// crate names `file::` directly — the five `nova_rt_file_*` symbols reach
36/// the JIT and linked binaries through their `#[no_mangle]` C names and
37/// through [`symbols`], both of which need only this module's *items*
38/// public, not the module path itself. Branch `file-open-openoptions`'s
39/// Task 1 added this module beside `fs`, `io` and `bytes`, reusing `fs`'s
40/// per-task slot table rather than owning a second one.
41mod file;
42/// Filesystem intrinsics for `std/fs`. `pub`, not private: its own doc
43/// comment explains the boundary it implements; nothing about that requires
44/// hiding the module itself. Branch `std-fs-strings`'s Task 3 and Task 4
45/// (`docs/superpowers/plans/2026-08-11-std-fs-strings.md`) built most of this
46/// module, and branch `byte-type`'s own Task 4 later added `read`/`write`
47/// beside them, the same way this crate's other modules build on `task`.
48pub mod fs;
49mod gc;
50/// The request-head parser for `std/http`. Private, like [`file`], [`net`],
51/// [`poll`] and [`time`]: nothing outside this crate names `http::` directly
52/// -- the `nova_rt_http_parse_request` symbol reaches the JIT and linked
53/// binaries through its `#[no_mangle]` C name and through [`symbols`], which
54/// needs only this module's *items* public, not the module path itself.
55mod http;
56/// The three standard streams' intrinsics for `std/io`. `pub`, not private,
57/// for the same reason as [`fs`] and [`bytes`]: its own doc comment explains
58/// the boundary it implements. Branch `read-write-stdio`'s Task 1 added this
59/// module beside `fs` and `bytes`, reusing `fs`'s per-task slot table rather
60/// than owning a second one.
61pub mod io;
62/// The logger's configuration cell for `std/log`. Private, like [`file`],
63/// [`net`], [`poll`] and [`time`]: nothing outside this crate names `log::`
64/// directly -- the three `nova_rt_log_*` symbols reach the JIT and linked
65/// binaries through their `#[no_mangle]` C names and through [`symbols`].
66mod log;
67/// The open-socket table and the two-phase, non-blocking `connect` for
68/// `std/net`'s `TcpStream`. Private, like [`file`]: nothing outside this
69/// crate names `net::` directly -- the `nova_rt_net_*` symbols reach the JIT
70/// and linked binaries through their `#[no_mangle]` C names and through
71/// [`symbols`], both of which need only this module's *items* public, not
72/// the module path itself. Branch `io-poller-std-net`'s Task 3 added this
73/// module beside `file`, reusing `file`'s handle-table model rather than
74/// inventing a second one, and `poll`'s `RawSocket`/`Interest`/
75/// `set_nonblocking` rather than a second socket representation.
76mod net;
77/// The executor's third wake source (socket readiness), and the two types
78/// (`RawSocket`, `Interest`) that let `task.rs` name a socket wait without
79/// depending on this module's platform types. Private, like [`gc`] and
80/// `file`: nothing outside this crate needs `poll::` directly -- its two
81/// callers are both in this crate, `task.rs` (for `wait`) and `net.rs` (for
82/// `set_nonblocking` and `wait`).
83mod poll;
84/// The program's arguments and `exit`, behind `std/process` (see its module
85/// doc and `docs/adr/0023-program-arguments.md`). `pub` so the driver's JIT
86/// path can set the list, and so `tests/process_args_*.rs` can drive it.
87pub mod process;
88/// The process-wide shutdown flag `std/http`'s `Server::listen` polls (see
89/// its module doc and `docs/adr/0022-process-shutdown-signals.md`). `pub`,
90/// unlike [`time`], so `tests/signal_*.rs` can drive it.
91pub mod signal;
92/// `pub`, not private like [`gc`]: `task`'s ABI constants (`PollFn`,
93/// `POLL_READY`, `STATE_SLOT_TAG`, `STATE_SLOT_TEMPS`) are not all read by
94/// this crate's own runtime logic -- some exist purely as the documented
95/// contract codegen (Task 5's `async_lower.rs`) must reproduce. A private
96/// module would make those `pub` items unreachable from outside the crate,
97/// which both defeats their purpose and makes rustc's `dead_code` lint treat
98/// them as genuinely dead, since nothing inside this crate alone uses them.
99pub mod task;
100/// The monotonic clock behind `std/time`.
101mod time;
102
103/// A Nova string value: immutable UTF-8, `{ len, ptr }`.
104///
105/// A runtime-made `String` or `Bytes` value is **one leaf GC object**: this
106/// header at offset 0, and its bytes inline at offset 16, where `ptr` points
107/// ([`alloc_str_object`]). A string literal's header from
108/// [`nova_rt_str_new`] is a 16-byte leaf whose `ptr` targets static data.
109///
110/// **Invariant: `ptr` never points into another GC object.** Every header is
111/// a leaf, so the collector never traces `ptr`. A header that pointed into a
112/// different GC object would not keep it alive. A future zero-copy slice or
113/// view must not reuse this type with a leaf header. Only
114/// [`alloc_str_object`] and [`nova_rt_str_new`] write a `ptr`, and nothing
115/// in this crate's production code calls `nova_rt_str_new`. The test
116/// `only_the_two_builders_write_a_novastr_ptr` checks both by scanning the
117/// source text; its doc names some of what such a scan cannot see. What the
118/// code generators pass to `nova_rt_str_new` rests on its `# Safety`
119/// contract.
120///
121/// **A convention, not an invariant: [`gc_str_filled`]'s `fill` does not
122/// allocate GC memory.** Memory safety does not depend on it, since the new
123/// object stays reachable from `gc_str_filled`'s own frame across `fill`.
124/// It keeps `fill` cheap, and its liveness argument to the one allocation
125/// before it. Every current writer only calls [`Fill::put`].
126#[repr(C)]
127pub struct NovaStr {
128    pub len: u64,
129    pub ptr: *const u8,
130}
131
132/// The size of a [`NovaStr`] header, and the offset of a runtime-made
133/// string's bytes inside its one object.
134pub(crate) const STR_HEADER: usize = std::mem::size_of::<NovaStr>();
135
136/// Allocate one leaf GC object for a `String` or `Bytes` value of `len` bytes:
137/// the [`NovaStr`] header at offset 0, written here, then room for the bytes
138/// at offset [`STR_HEADER`], which the caller fills. Returns the header and
139/// the address of the bytes.
140///
141/// `max(len, 1)` keeps `ptr` inside the object even when `len` is 0; without
142/// it an empty value's `ptr` would sit one past a 16-byte slot, in the next
143/// object. The size saturates, so an overflowing `len` reaches `gc::alloc`'s
144/// size-limit abort instead of wrapping to a too-small object.
145///
146/// One allocation where there were two: in
147/// `examples/05-json-api/BENCHMARK.md`'s "(alloc-mix)", `String` and `Bytes`
148/// buffers were 67.0 of 300.9 allocations per ten-user request, each with a
149/// separate header.
150pub(crate) fn alloc_str_object(len: usize) -> (*mut NovaStr, *mut u8) {
151    let size = STR_HEADER.saturating_add(len.max(1));
152    let base = gc::alloc(size, false);
153    let node = base as *mut NovaStr;
154    // SAFETY: `base` has `size` writable bytes: the header and at least one
155    // byte after it.
156    let bytes = unsafe { base.add(STR_HEADER) };
157    // SAFETY: as above; nothing else refers to this object yet.
158    unsafe {
159        (*node).len = len as u64;
160        (*node).ptr = bytes;
161    }
162    (node, bytes)
163}
164
165/// Store a Rust string as a GC-managed `NovaStr` value: one leaf object
166/// holding the header and, inline after it, a copy of the bytes.
167///
168/// `pub(crate)`, not private: `fs`'s `gc_message` reuses this rather than
169/// reproducing `NovaStr { len, ptr }`'s layout a second time, which is
170/// precisely the drift class this shared helper exists to avoid.
171pub(crate) fn gc_str(s: &str) -> *mut NovaStr {
172    // SAFETY: the writer puts exactly `s.len()` bytes of valid UTF-8.
173    unsafe { gc_str_filled(s.len(), |out| out.put(s.as_bytes())) }
174}
175
176/// Allocate a GC string of exactly `len` bytes as one leaf object -- the
177/// header, then the bytes inline ([`alloc_str_object`]) -- and let `fill`
178/// write the bytes straight into it.
179///
180/// The builtins that build a result from their arguments use this rather
181/// than collecting into a Rust `String` and calling [`gc_str`]. That buffer
182/// cost a system-heap allocation, a free and a second copy per call: in
183/// `examples/05-json-api/BENCHMARK.md`'s "(reprofile-3)", the three largest
184/// such builtins spent 3.5–3.6% of the server thread in the system heap.
185///
186/// GC safety: there is one allocation, and `fill` runs after it. That
187/// allocation can run a collection, so whatever `fill` reads from the GC heap
188/// must still be reachable then. A caller's arguments are: a pointer held in
189/// the caller's frame or in a callee-saved register is a root, and marking is
190/// range-based, so a pointer into a string's bytes keeps that string's whole
191/// object alive (`gc.rs`'s module doc comment). [`nova_rt_str_chars`] relies
192/// on the same. By convention `fill` does not allocate GC memory (see
193/// [`NovaStr`]); every writer only calls [`Fill::put`].
194///
195/// # Safety
196/// `fill` must write exactly `len` bytes of valid UTF-8 through
197/// [`Fill::put`]. Writing more panics. Writing fewer leaves zero bytes in the
198/// string, which a debug build catches.
199pub(crate) unsafe fn gc_str_filled(len: usize, fill: impl FnOnce(&mut Fill<'_>)) -> *mut NovaStr {
200    let (node, bytes) = alloc_str_object(len);
201    // SAFETY: `alloc_str_object` reserved `len` writable bytes at `bytes`
202    // that nothing else refers to yet.
203    let mut out = Fill {
204        out: unsafe { std::slice::from_raw_parts_mut(bytes, len) },
205        at: 0,
206    };
207    fill(&mut out);
208    debug_assert_eq!(
209        out.at, len,
210        "a GC string's writer filled a different length than it asked for"
211    );
212    node
213}
214
215/// The write position in a byte buffer [`gc_str_filled`] sized exactly.
216pub(crate) struct Fill<'a> {
217    out: &'a mut [u8],
218    at: usize,
219}
220
221impl Fill<'_> {
222    /// Append `bytes`. Past the buffer's end this panics rather than write
223    /// out of bounds, and from an `extern "C"` builtin that panic aborts the
224    /// process.
225    pub(crate) fn put(&mut self, bytes: &[u8]) {
226        self.out[self.at..self.at + bytes.len()].copy_from_slice(bytes);
227        self.at += bytes.len();
228    }
229}
230
231/// Test-only: allocate a `Bytes` payload, for a test that hands a runtime
232/// intrinsic a `Bytes` argument directly rather than going through `std/
233/// bytes`'s own Nova-level constructors.
234///
235/// Delegates to [`bytes::gc_bytes`], which already does exactly this and is
236/// `pub(crate)` -- named at the crate root only so `io`'s own tests can reach
237/// it as `crate::gc_bytes_for_test` without spelling out the `bytes` module
238/// path at each call site.
239///
240/// `#[cfg(test)]` only, no platform gate: every caller is an ordinary,
241/// cross-platform unit test.
242#[cfg(test)]
243pub(crate) fn gc_bytes_for_test(data: &[u8]) -> *mut NovaStr {
244    bytes::gc_bytes(data)
245}
246
247/// Read a `NovaStr` back as a `&str`.
248///
249/// `pub(crate)`, not private: `fs`'s intrinsics read their `NovaStr`
250/// arguments through this rather than a second copy of the same unsafe cast.
251///
252/// # Safety
253/// `s` must point to a valid `NovaStr` whose `ptr`/`len` reference valid
254/// UTF-8 (guaranteed for strings produced by the compiler and runtime).
255pub(crate) unsafe fn as_str<'a>(s: *const NovaStr) -> &'a str {
256    let s = &*s;
257    std::str::from_utf8_unchecked(std::slice::from_raw_parts(s.ptr, s.len as usize))
258}
259
260/// Create a string value from raw bytes (used for string literals).
261///
262/// The header is a 16-byte **leaf**: its `ptr` targets static literal data,
263/// which the collector never frees, so tracing it could never reach a GC
264/// object. That depends on this function's safety contract.
265///
266/// # Safety
267/// `ptr` must point to `len` bytes of valid UTF-8 that outlive the program
268/// (string literal data emitted by codegen), never into the GC heap.
269#[no_mangle]
270pub unsafe extern "C" fn nova_rt_str_new(ptr: *const u8, len: u64) -> *mut NovaStr {
271    let node = gc::alloc(STR_HEADER, false) as *mut NovaStr;
272    (*node).len = len;
273    (*node).ptr = ptr;
274    node
275}
276
277/// Print a string followed by a newline to stdout.
278///
279/// # Safety
280/// `s` must be a valid `NovaStr` pointer.
281#[no_mangle]
282pub unsafe extern "C" fn nova_rt_println(s: *const NovaStr) {
283    println!("{}", as_str(s));
284}
285
286/// Print a string to stdout without a trailing newline.
287///
288/// # Safety
289/// `s` must be a valid `NovaStr` pointer.
290#[no_mangle]
291pub unsafe extern "C" fn nova_rt_print(s: *const NovaStr) {
292    use std::io::Write;
293    let out = std::io::stdout();
294    let mut lock = out.lock();
295    let _ = lock.write_all(as_str(s).as_bytes());
296    let _ = lock.flush();
297}
298
299/// Write `s` to stderr with no trailing newline.
300///
301/// Mirrors [`nova_rt_print`]'s explicit lock-and-flush shape rather than
302/// [`nova_rt_println`]'s `println!`, but not its reason. Rust 1.95's
303/// `Stdout` is a `LineWriter` unconditionally, so a partial line with no
304/// trailing newline can sit in that buffer until something flushes it --
305/// there, the explicit flush is load-bearing. `Stderr` carries no such
306/// buffer at all (Rust's own source marks its raw handle "not buffered"),
307/// so a write reaches the OS on contact regardless of a trailing newline;
308/// the flush call below is a harmless no-op kept for symmetry with
309/// [`nova_rt_print`], not a mechanism this stream needs.
310///
311/// # Safety
312/// `s` must point to a live `NovaStr`.
313#[no_mangle]
314pub unsafe extern "C" fn nova_rt_eprint(s: *const NovaStr) {
315    use std::io::Write;
316    let err = std::io::stderr();
317    let mut lock = err.lock();
318    // SAFETY: forwarding this function's own contract.
319    let _ = lock.write_all(unsafe { as_str(s) }.as_bytes());
320    let _ = lock.flush();
321}
322
323/// Write `s` to stderr followed by a newline.
324///
325/// # Safety
326/// `s` must point to a live `NovaStr`.
327#[no_mangle]
328pub unsafe extern "C" fn nova_rt_eprintln(s: *const NovaStr) {
329    // SAFETY: forwarding this function's own contract.
330    eprintln!("{}", unsafe { as_str(s) });
331}
332
333/// Concatenate two strings into a new string value.
334///
335/// GC safety: `a`'s and `b`'s bytes are read after the result's buffer is
336/// allocated, kept alive across it as [`gc_str_filled`] describes.
337///
338/// # Safety
339/// `a` and `b` must be valid `NovaStr` pointers.
340#[no_mangle]
341pub unsafe extern "C" fn nova_rt_str_concat(a: *const NovaStr, b: *const NovaStr) -> *mut NovaStr {
342    let (a, b) = (as_str(a), as_str(b));
343    gc_str_filled(a.len() + b.len(), |out| {
344        out.put(a.as_bytes());
345        out.put(b.as_bytes());
346    })
347}
348
349/// Concatenate every string in `parts` into one new string: the n-ary form
350/// string interpolation of three or more parts lowers to.
351///
352/// One copy of each part into one result, where pairwise concatenation made
353/// a new string per part, each copying everything built so far. Each part is
354/// copied straight into the result's bytes.
355///
356/// GC safety: the parts are read after the result's buffer is allocated.
357/// `parts`, held in this frame, keeps the array and so every part alive
358/// across that allocation, as [`gc_str_filled`] describes.
359///
360/// # Safety
361/// `parts` must point to a Nova array `{ len: i64, elems… }` whose element
362/// `i`, at byte offset `8 + 8*i`, is a valid `NovaStr` pointer. A negative
363/// length is treated as zero, as `nova_rt_str_from_chars` does.
364#[no_mangle]
365pub unsafe extern "C" fn nova_rt_str_concat_n(parts: *const u8) -> *mut NovaStr {
366    let words = parts as *const i64;
367    let n = (*words).max(0) as usize;
368    let part = |i: usize| as_str(*words.add(1 + i) as *const NovaStr);
369    let total = (0..n).map(|i| part(i).len()).sum();
370    gc_str_filled(total, |out| {
371        for i in 0..n {
372            out.put(part(i).as_bytes());
373        }
374    })
375}
376
377/// Join every string in `parts` with `sep` between each pair: the body of
378/// `std/strings`' `String.join`.
379///
380/// One copy of each part and separator, straight into the result's buffer,
381/// sized up front. The Nova-level `join` it replaces walked every part
382/// character by character into a character array.
383///
384/// GC safety: the parts and `sep` are read after the result's buffer is
385/// allocated, kept alive across it as in [`nova_rt_str_concat_n`].
386///
387/// # Safety
388/// `sep` must be a valid `NovaStr` pointer. `parts` must point to a Nova
389/// array laid out as [`nova_rt_str_concat_n`] requires. A negative length is
390/// treated as zero, as there.
391#[no_mangle]
392pub unsafe extern "C" fn nova_rt_str_join(sep: *const NovaStr, parts: *const u8) -> *mut NovaStr {
393    let words = parts as *const i64;
394    let n = (*words).max(0) as usize;
395    let sep = as_str(sep);
396    let part = |i: usize| as_str(*words.add(1 + i) as *const NovaStr);
397    let total = sep.len() * n.saturating_sub(1) + (0..n).map(|i| part(i).len()).sum::<usize>();
398    gc_str_filled(total, |out| {
399        for i in 0..n {
400            if i > 0 {
401                out.put(sep.as_bytes());
402            }
403            out.put(part(i).as_bytes());
404        }
405    })
406}
407
408/// The character index of the first occurrence of `needle` in `haystack`,
409/// or `-1`: the body of `std/strings`' `String.index_of` and
410/// `String.contains`. An empty needle occurs at `0`.
411///
412/// A byte search finds the same first match a character-by-character one
413/// would. Both strings are valid UTF-8, and no character starts with a
414/// continuation byte, so a byte match can only begin on a character
415/// boundary. The characters before the match are then counted to turn its
416/// byte offset into a character index. Nothing here allocates, and a miss
417/// counts nothing. The Nova-level search this replaces built a `[Char]` for
418/// each string first.
419///
420/// # Safety
421/// `haystack` and `needle` must be valid `NovaStr` pointers.
422#[no_mangle]
423pub unsafe extern "C" fn nova_rt_str_index_of(
424    haystack: *const NovaStr,
425    needle: *const NovaStr,
426) -> i64 {
427    let h = as_str(haystack);
428    match h.find(as_str(needle)) {
429        Some(at) => h[..at].chars().count() as i64,
430        None => -1,
431    }
432}
433
434/// `s` as a JSON string literal, quotation marks included: the body of
435/// `std/json`'s `quote`.
436///
437/// `"` and `\` take a backslash. The five control characters with a short
438/// escape become `\n`, `\r`, `\t`, `\b` and `\f`, and every other byte below
439/// `0x20` becomes `\u00XX` in lowercase hex (RFC 8259). Everything else is
440/// copied as it is. Working on bytes is exact: every byte of a multi-byte
441/// UTF-8 character is `0x80` or above, so none can be mistaken for one of
442/// these, and every slice copied ends next to an ASCII byte, on a character
443/// boundary. The Nova-level `quote` this replaces built a `[Char]` and
444/// scanned it character by character.
445///
446/// The escaped length is counted first, so the result is written once,
447/// straight into its GC buffer.
448///
449/// GC safety: `s`'s bytes are read after the result's buffer is allocated,
450/// kept alive across it as [`gc_str_filled`] describes.
451///
452/// # Safety
453/// `s` must be a valid `NovaStr` pointer.
454#[no_mangle]
455pub unsafe extern "C" fn nova_rt_json_quote(s: *const NovaStr) -> *mut NovaStr {
456    const HEX: &[u8; 16] = b"0123456789abcdef";
457    let src = as_str(s).as_bytes();
458    // What each escape adds to its one source byte: one more byte for a
459    // two-byte escape, five more for `\u00XX`.
460    let extra: usize = src
461        .iter()
462        .map(|&b| match b {
463            b'"' | b'\\' | b'\n' | b'\r' | b'\t' | 0x08 | 0x0c => 1,
464            0x00..=0x1f => 5,
465            _ => 0,
466        })
467        .sum();
468    gc_str_filled(src.len() + 2 + extra, |out| {
469        out.put(b"\"");
470        let mut start = 0;
471        for (i, &b) in src.iter().enumerate() {
472            let short: &[u8] = match b {
473                b'"' => b"\\\"",
474                b'\\' => b"\\\\",
475                b'\n' => b"\\n",
476                b'\r' => b"\\r",
477                b'\t' => b"\\t",
478                0x08 => b"\\b",
479                0x0c => b"\\f",
480                0x00..=0x1f => b"",
481                _ => continue,
482            };
483            out.put(&src[start..i]);
484            if short.is_empty() {
485                out.put(&[
486                    b'\\',
487                    b'u',
488                    b'0',
489                    b'0',
490                    HEX[(b >> 4) as usize],
491                    HEX[(b & 15) as usize],
492                ]);
493            } else {
494                out.put(short);
495            }
496            start = i + 1;
497        }
498        out.put(&src[start..]);
499        out.put(b"\"");
500    })
501}
502
503/// Compare two strings for byte equality.
504///
505/// # Safety
506/// `a` and `b` must be valid `NovaStr` pointers.
507#[no_mangle]
508pub unsafe extern "C" fn nova_rt_str_eq(a: *const NovaStr, b: *const NovaStr) -> i8 {
509    (as_str(a) == as_str(b)) as i8
510}
511
512/// Byte-lexicographic comparison of two strings: `-1`, `0`, or `1`.
513///
514/// # Safety
515/// `a` and `b` must be valid `NovaStr` pointers.
516#[no_mangle]
517pub unsafe extern "C" fn nova_rt_str_cmp(a: *const NovaStr, b: *const NovaStr) -> i64 {
518    match as_str(a).as_bytes().cmp(as_str(b).as_bytes()) {
519        std::cmp::Ordering::Less => -1,
520        std::cmp::Ordering::Equal => 0,
521        std::cmp::Ordering::Greater => 1,
522    }
523}
524
525/// The per-process seed for [`nova_rt_str_hash`].
526///
527/// ONE value for the whole process. Not per thread and not per call: a `Map`
528/// built under one seed and probed under another finds nothing, so a
529/// thread-local would corrupt a map shared across threads and reseeding per
530/// call would corrupt every map. `OnceLock` gives exactly that, and `gc.rs`
531/// already uses the same pattern.
532///
533/// `RandomState` is `std`'s own `HashMap` seed source, so this draws OS
534/// entropy with no new dependency and nothing above MSRV 1.78.
535///
536/// **Nova code CAN recover this value, exactly, in one call.** An earlier
537/// version of this comment said it could not, on the grounds that no builtin
538/// exposes it and that `collect_externs` refuses every `nova_`-prefixed
539/// `extern` name. The `collect_externs` half is true; the closed world over
540/// routes is not. `str_hash` is the builtin that exposes it: `("").hash()`
541/// runs FNV's loop body zero times, so it returns [`splitmix64_finalize`]
542/// applied to this raw seed, and that finalizer is a bijection with a
543/// published inverse. Measured on this tree -- `("").hash()` from an ordinary
544/// Nova program gave `-4152075758151904758`, inverting the finalizer gave the
545/// seed `0xa3881b6692503399`, and that seed predicted `("abc").hash()` and
546/// `("hello world").hash()` from the same process exactly; four processes gave
547/// four different values, so the seed is live. What that costs and what it
548/// does not is at [`nova_rt_str_hash`].
549fn str_hash_seed() -> u64 {
550    static SEED: OnceLock<u64> = OnceLock::new();
551    *SEED.get_or_init(|| {
552        use std::hash::{BuildHasher, Hasher};
553        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
554        h.write_u64(0);
555        h.finish()
556    })
557}
558
559/// The per-process seed for `Int`, `Bool` and `Char` hashing.
560///
561/// A SEPARATE call from [`str_hash_seed`], deliberately. Were the two one
562/// value, `(0).hash()` would equal `("").hash()` exactly, since both reduce to
563/// [`splitmix64_finalize`] applied to the seed.
564///
565/// What the separate call buys is that the two seeds DIFFER, effectively
566/// certainly. It does not buy independence, and an earlier version of this
567/// comment claimed "two independent keys", which is false.
568/// `RandomState::new()` caches one 128-bit OS draw per THREAD and hands out
569/// `(k0, k1)` from it, storing `(k0 + 1, k1)` on each call — so two calls on a
570/// thread share `k1` exactly and differ in `k0` by the number of calls between
571/// them. `std`'s own comment says that increment exists to vary `HashMap`
572/// iteration order, not to make keys independent. Each seed here is then the
573/// same keyed hash over the same one-word message under those related keys, and
574/// the two differ because those outputs do. The variant is deliberately not
575/// named: `std` documents `DefaultHasher`'s algorithm as unspecified and free
576/// to change, so naming it would go stale on a `std` upgrade, and the argument
577/// holds for any keyed hash. (Whichever thread first reaches each `OnceLock`
578/// supplies its draw, and neither initialiser pins a thread, so the two can
579/// also come from two separate OS draws.)
580///
581/// Whether recovering one seed yields the other therefore rests on that keyed
582/// hash's behaviour under a RELATED KEY, not on independence. This increment
583/// does not verify that, and `int_and_str_hash_seeds_are_drawn_separately` does
584/// not test it: that test pins only that the two differ.
585///
586/// One value for the whole process, for the same reason `str_hash_seed` gives:
587/// a `Map` built under one seed and probed under another finds nothing.
588fn int_hash_seed() -> u64 {
589    static SEED: OnceLock<u64> = OnceLock::new();
590    *SEED.get_or_init(|| {
591        use std::hash::{BuildHasher, Hasher};
592        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
593        h.write_u64(0);
594        h.finish()
595    })
596}
597
598/// The per-process seed for `std/core`'s `Int`, `Bool` and `Char` hashing,
599/// XORed into the input before `mix64` runs.
600///
601/// **Nova code can recover this value in one call**, since those `Hash` impls
602/// XOR it in: `(0).hash()` is `mix64(0 ^ seed)`, which is `mix64(seed)`, and
603/// `mix64` is an invertible bijection. That is the same shape `("").hash()`
604/// already has for the string seed, it follows from ADR 0005's one-shot `Hash`
605/// returning a plain `Int`, and the gate claim already declines the adaptive
606/// attacker for whom it matters. What the seed buys is precomputation
607/// resistance. `tests/runtime/hash.nova` performs that recovery, to cancel the
608/// seed when it asserts `mix64`'s canonical vectors.
609///
610/// Nullary and takes no pointer, so it needs no `unsafe`. Plain `"C"`, not
611/// `"C-unwind"`: every caller is a compiled Nova frame — `std/core`'s three
612/// `Hash` impls, reached from user code — and [`crate::task::PollFn`]'s doc
613/// closes its constraint block by saying the permission `"C-unwind"` grants
614/// exists for that module's Rust-side entry points, not for compiled Nova
615/// frames. `task`'s `abort_with` states the general form in its own doc: a
616/// generated frame carries no landing pads, no drop glue and no unwind table,
617/// so an unwind that passed through one would have to be resolved against a
618/// frame the unwinder has no description of.
619///
620/// The panic that would do it is real rather than hypothetical. `get_or_init`
621/// runs `RandomState::new`, which draws OS entropy on the first call, and `std`
622/// panics when that draw fails — unconditionally on Linux and on the
623/// `getrandom`/`getentropy` platforms, and under a `debug_assert` on current
624/// Windows. `(0).hash()` inside an `async fn` body puts that first call inside
625/// a generated poll function. Plain `"C"` escalates such an unwind to `panic in
626/// a function that cannot unwind` and aborts, making the prohibited case
627/// structurally impossible instead of resting on the panic not happening.
628///
629/// [`nova_rt_str_hash`] is the precedent this follows: it makes the same
630/// `RandomState::new` draw through the same `OnceLock` and is plain `"C"` too.
631/// The nullary readers in `log` and `time` use `"C-unwind"` at this same
632/// signature; that divergence is pre-existing, is not resolved here, and their
633/// panic profiles differ from this one's. Earlier versions of this comment
634/// specified `"C-unwind"` by reasoning from those readers' shape, which is the
635/// inference [`nova_rt_float_fixed`]'s doc warns against — a neighbouring
636/// family's ABI is not an argument — and one of them went further and asserted
637/// that `task`'s rule was narrower than the module states. It is not.
638#[no_mangle]
639pub extern "C" fn nova_rt_int_hash_seed() -> i64 {
640    int_hash_seed() as i64
641}
642
643/// splitmix64's finalizer — the same function `std/core`'s `mix64` computes.
644///
645/// `std/core` writes it in signed Nova with masked shifts, because Nova's `>>`
646/// on a negative `Int` sign-extends; on `u64` here the plain form is the same
647/// function. Its constants correspond: Nova's `-4658895280553007687` is
648/// `0xbf58476d1ce4e5b9` and `-7723592293110705685` is `0x94d049bb133111eb`.
649///
650/// This is LOAD-BEARING, not decoration. `Map` selects buckets with
651/// `hash & (cap - 1)` -- the low bits -- and FNV-1a's low bits barely depend on
652/// its starting value: bit 0 of an FNV-1a result is bit 0 of the basis XOR the
653/// parity of the input bytes' low bits, so changing the basis flips bit 0
654/// identically for both keys of a colliding pair and leaves the collision
655/// standing. Measured, seeding alone left 30 of a constructed 48-key attack in
656/// one bucket and moved only 66.7% of keys at capacity 8; with this finalizer
657/// the same attack falls to 3 and 87.3% of keys move, against an ideal of
658/// 87.5%. The spec's section 2 carries the method and the full figures.
659///
660/// `pub` for one reason: `nova-driver`'s `async_end_to_end` tests tie this
661/// function to `std/core`'s `mix64` by running Nova's `.hash()` through the
662/// real pipeline and comparing against this one, over a seed both sides read
663/// from the same process-global `OnceLock`. The records have long said the two
664/// compute the same function and nothing asserted it.
665///
666/// **This is Rust-internal visibility and creates no Nova surface**, which is
667/// what distinguishes it from ADR 0005's prohibition on publishing `mix64`.
668/// That prohibition is about Nova's namespace: publishing `mix64` would make
669/// its exact definition a compatibility surface and forecloses the freedom ADR
670/// 0005 reserves to replace its constants or rounds. Nothing here reaches a
671/// Nova program, and `mix64` stays module-private.
672pub fn splitmix64_finalize(mut z: u64) -> u64 {
673    z ^= z >> 30;
674    z = z.wrapping_mul(0xbf58_476d_1ce4_e5b9);
675    z ^= z >> 27;
676    z = z.wrapping_mul(0x94d0_49bb_1331_11eb);
677    z ^ (z >> 31)
678}
679
680/// Seeded, finalized hash of a Nova string's bytes, as an `i64`.
681///
682/// `std/core`'s `impl Hash for String` reaches this through the `str_hash`
683/// builtin. An earlier version of this comment justified that by saying Nova
684/// cannot walk a string's bytes itself, on the grounds that `String` has no
685/// length, indexing or iteration and is not FFI-safe. The FFI-safety half is
686/// true. The byte-walking half was true when written and is now **stale**,
687/// by two separate routes rather than the one an earlier draft of this
688/// paragraph named. The byte and char BUILTINS -- `str_chars`,
689/// `bytes_from_string_intrinsic`, `bytes_at` -- are `Builtin::STD_ONLY`
690/// members, and the resolver seeds every member of that array into every std
691/// module's scope, which is how `std/core` comes to walk a string at its own
692/// `impl Debug for String`. Separately, `std/bytes` and `std/strings` are
693/// `STD_MODULES` entries whose public items reach other modules through
694/// `import_std_module`. `STD_ONLY` holds builtins, not modules; conflating
695/// the two is what that earlier draft did.
696/// The reason that survives does not rest on impossibility: the hash lives in
697/// one place, and reimplementing FNV-1a in Nova would pin a second copy of it
698/// free to diverge from this one. ADR 0005's 2026-08-27 amendment records the
699/// same correction against the same sentence.
700///
701/// Seeded FNV-1a over the bytes, then splitmix64's finalizer. The seed is
702/// per-process (see `str_hash_seed`) and the finalizer is what makes the seed
703/// reach the low bits `Map` selects buckets from; the reasoning and the
704/// measurements are at `splitmix64_finalize`. This used to be unseeded FNV-1a
705/// alone, described here as "adequate for a hash map's bucket selection" --
706/// true of an honest workload and false of an adversarial one, which is why it
707/// changed.
708///
709/// STILL NOT COLLISION-RESISTANT, and still not for anything
710/// security-sensitive. What the seed and finalizer buy is resistance to a
711/// PRECOMPUTED collision set: the seed is drawn per process, so a colliding key
712/// set cannot be built offline against a process that has not started yet, and
713/// recovering the seed means first obtaining a hash from the running process.
714/// This paragraph used to close "`mix64` -- the `Int`, `Bool` and `Char` impls
715/// -- is not seeded at all." That is no longer true: those impls XOR
716/// [`nova_rt_int_hash_seed`]'s separate per-process draw into `mix64`'s input,
717/// so they carry the same precomputation resistance and the same recoverable
718/// seed as this one does. `mix64` itself is still unseeded, which is a
719/// different statement -- the seeding is in the impls that call it.
720///
721/// An adversary who can observe the process and adapt is OUT OF SCOPE, and the
722/// route is concrete rather than a category: one string hash read out of this
723/// process recovers the seed, because `("").hash()` is the finalizer applied to
724/// the seed alone (see `str_hash_seed`), so an attacker who can obtain any
725/// `String` hash from a running process can construct collisions for it. The
726/// channel is ADR 0005's one-shot `fn hash(self) -> Int` returning a whole
727/// 64-bit result in one call, which predates the seeding: seeding did not open
728/// the channel, it made the value behind it worth recovering. Precomputation
729/// resistance is the half that survives.
730///
731/// The `u64 -> i64` reinterpretation at the end means the result may be
732/// negative, so a caller selecting buckets must mask (`hash & (cap - 1)`,
733/// which is non-negative for any `i64`) rather than take a remainder.
734///
735/// # Safety
736/// `s` must point to a valid `NovaStr`.
737#[no_mangle]
738pub unsafe extern "C" fn nova_rt_str_hash(s: *const NovaStr) -> i64 {
739    let mut h: u64 = str_hash_seed();
740    for b in as_str(s).as_bytes() {
741        h ^= *b as u64;
742        h = h.wrapping_mul(0x0000_0100_0000_01b3);
743    }
744    splitmix64_finalize(h) as i64
745}
746
747/// Number of Unicode scalar values in `s`.
748///
749/// Separate from [`nova_rt_str_chars`] so that asking a string's length does
750/// not allocate an array of its characters.
751///
752/// # Safety
753/// `s` must point to a valid `NovaStr`.
754#[no_mangle]
755pub unsafe extern "C" fn nova_rt_str_len_chars(s: *const NovaStr) -> i64 {
756    as_str(s).chars().count() as i64
757}
758
759/// Decompose `s` into a Nova `[Char]`.
760///
761/// The result must match **exactly** what codegen emits for an array: one
762/// block holding `{ len: i64, elem0, elem1, … }`, element `i` at byte offset
763/// `8 + 8*i`, allocated *scanned* the way [`nova_rt_alloc`] allocates (it
764/// takes no scan parameter and always scans). A `Char` element is its `i64`
765/// Unicode scalar value, because `Ty::Char` and `Ty::Int` are both
766/// `MirTy::I64`.
767///
768/// Scanning an array of scalars can retain garbage that happens to look like
769/// a pointer. That is the conservative collector's existing behaviour for any
770/// `[Int]`, not something new here.
771///
772/// # Safety
773/// `s` must point to a valid `NovaStr`.
774#[no_mangle]
775pub unsafe extern "C" fn nova_rt_str_chars(s: *const NovaStr) -> *mut u8 {
776    // Counted first and written straight into the GC array, rather than
777    // collected into a `Vec<char>` and copied. The intermediate vector was a
778    // system-heap allocation per call, regrown whenever the string outgrew
779    // its first guess, and a free: 6.1–6.8% of the server thread in
780    // `examples/05-json-api/BENCHMARK.md`'s "(reprofile)".
781    let text = as_str(s);
782    let n = text.chars().count();
783    // `8` for the length header plus `8` per element — the same size
784    // arithmetic `nova_rt_alloc` is asked for when codegen builds an array.
785    // A char count cannot overflow this on a 64-bit target, and `gc::alloc`
786    // rejects an undescribable size regardless.
787    //
788    // `text` is read again after this allocation, which can run a
789    // collection. Its bytes stay alive because `text` itself, a pointer to
790    // them held in this frame, is a root: the collector scans every frame on
791    // the stack and the callee-saved registers (`gc.rs`'s module doc
792    // comment). The `NovaStr` header is not read again.
793    let block = gc::alloc(8 + 8 * n, true);
794    let words = block as *mut i64;
795    *words = n as i64;
796    for (i, c) in text.chars().enumerate() {
797        *words.add(1 + i) = c as i64;
798    }
799    block
800}
801
802/// Encode a Nova `[Char]` back into a string.
803///
804/// A word that is not a valid Unicode scalar value becomes
805/// [`char::REPLACEMENT_CHARACTER`] rather than aborting, matching what
806/// [`nova_rt_char_to_str`] already does. Nova source cannot produce one —
807/// there is no `Int` → `Char` conversion in the language (`let c: Char = 65`
808/// is `E0010`, `'a' + 1` is `E0010`, and no such builtin exists) — so this is
809/// defensive only.
810///
811/// # Safety
812/// `cs` must point to a Nova array of `Char`: `{ len: i64, elems… }` with
813/// element `i` at byte offset `8 + 8*i`.
814#[no_mangle]
815pub unsafe extern "C" fn nova_rt_str_from_chars(cs: *const u8) -> *mut NovaStr {
816    let words = cs as *const i64;
817    let n = (*words).max(0) as usize;
818    let mut out = String::new();
819    for i in 0..n {
820        let v = *words.add(1 + i);
821        out.push(char::from_u32(v as u32).unwrap_or(char::REPLACEMENT_CHARACTER));
822    }
823    gc_str(&out)
824}
825
826/// Uppercase `s` with full Unicode case mapping.
827///
828/// Whole-string rather than `Char` → `Char` because the mapping is not 1:1 —
829/// `ß` uppercases to `SS` — so a per-character signature could not express it
830/// and would silently corrupt such input.
831///
832/// # Safety
833/// `s` must point to a valid `NovaStr`.
834#[no_mangle]
835pub unsafe extern "C" fn nova_rt_str_to_upper(s: *const NovaStr) -> *mut NovaStr {
836    gc_str(&as_str(s).to_uppercase())
837}
838
839/// Lowercase `s` with full Unicode case mapping. Whole-string for the same
840/// reason as [`nova_rt_str_to_upper`].
841///
842/// # Safety
843/// `s` must point to a valid `NovaStr`.
844#[no_mangle]
845pub unsafe extern "C" fn nova_rt_str_to_lower(s: *const NovaStr) -> *mut NovaStr {
846    gc_str(&as_str(s).to_lowercase())
847}
848
849/// Parse decimal text as a `Float`.
850///
851/// Returns `0.0` for anything Rust's parser rejects. That fallback is
852/// unreachable by construction: `std/json`'s `scan_number` validates the
853/// number's lexical shape against JSON's grammar — `-?(0|[1-9][0-9]*)`
854/// `(\.[0-9]+)?([eE][+-]?[0-9]+)?` — and only then slices the matched span and
855/// calls this, so every input is a text Rust's `f64` parser also accepts (its
856/// accepted grammar is a strict superset of JSON's).
857///
858/// The fallback exists because **this function must not panic.** It is
859/// `extern "C"` with no `-unwind`, so a panic cannot unwind out of it: it
860/// escalates to `panic in a function that cannot unwind` and aborts the
861/// process. See [`nova_rt_float_fixed`]'s doc comment for the measured
862/// behaviour of that escalation. Hence `unwrap_or`, never `unwrap`.
863///
864/// # Safety
865/// `s` must point to a valid `NovaStr`.
866#[no_mangle]
867pub unsafe extern "C" fn nova_rt_str_to_float(s: *const NovaStr) -> f64 {
868    as_str(s).parse::<f64>().unwrap_or(0.0)
869}
870
871/// Format an `Int` as a string.
872///
873/// The digits are written on the stack and copied once into GC memory,
874/// rather than through `to_string`'s heap `String`.
875#[no_mangle]
876pub extern "C" fn nova_rt_int_to_str(v: i64) -> *mut NovaStr {
877    // Twenty bytes hold `i64::MIN`: a sign and nineteen digits.
878    let mut text = [0u8; 20];
879    let mut at = text.len();
880    let mut rest = v.unsigned_abs();
881    loop {
882        at -= 1;
883        text[at] = b'0' + (rest % 10) as u8;
884        rest /= 10;
885        if rest == 0 {
886            break;
887        }
888    }
889    if v < 0 {
890        at -= 1;
891        text[at] = b'-';
892    }
893    let text = &text[at..];
894    // SAFETY: the writer puts exactly `text.len()` ASCII bytes.
895    unsafe { gc_str_filled(text.len(), |out| out.put(text)) }
896}
897
898/// Format a `Float` as a string.
899#[no_mangle]
900pub extern "C" fn nova_rt_float_to_str(v: f64) -> *mut NovaStr {
901    gc_str(&v.to_string())
902}
903
904/// Format a `Float` to a fixed number of decimal places.
905///
906/// `extern "C"` with no `unsafe` and no `-unwind`, matching
907/// [`nova_rt_float_to_str`] directly above: this takes no pointer, and
908/// `format!` on an `f64` cannot panic once `places` is clamped. Do not reach
909/// for `extern "C-unwind"` here because the async intrinsics use it -- the two
910/// families differ, and an earlier draft of this increment's spec got it wrong
911/// by pattern-matching the wrong neighbour.
912///
913/// `places` is clamped to `0..=17`. The ceiling is where an `f64` stops
914/// carrying information. **The floor is load-bearing in a stronger way than
915/// "it would panic":** a negative precision reaches `format!` and panics
916/// `Formatting argument out of range`, and because this function is
917/// `extern "C"` with no `-unwind` that panic cannot unwind — it escalates to
918/// `panic in a function that cannot unwind` and **aborts the process**
919/// (measured: `STATUS_STACK_BUFFER_OVERRUN`, on both the direct Rust path and
920/// the JIT-compiled path, where the backtrace truncates at this frame because
921/// generated code carries no unwind tables).
922///
923/// That escalation is the argument *for* `extern "C"` here rather than against
924/// it. [`crate::task`]'s module docs already set the rule this follows:
925/// anything reachable from a generated call site must abort rather than
926/// attempt to unwind. The clamp is what keeps this function inside that rule.
927#[no_mangle]
928pub extern "C" fn nova_rt_float_fixed(v: f64, places: i64) -> *mut NovaStr {
929    let places = places.clamp(0, 17) as usize;
930    gc_str(&format!("{:.*}", places, v))
931}
932
933/// Format a `Bool` as `true` / `false`.
934#[no_mangle]
935pub extern "C" fn nova_rt_bool_to_str(v: i8) -> *mut NovaStr {
936    gc_str(if v != 0 { "true" } else { "false" })
937}
938
939/// Format a `Char` (Unicode scalar value) as a string.
940#[no_mangle]
941pub extern "C" fn nova_rt_char_to_str(v: i64) -> *mut NovaStr {
942    let c = char::from_u32(v as u32).unwrap_or(char::REPLACEMENT_CHARACTER);
943    gc_str(&c.to_string())
944}
945
946/// Allocate `size` zeroed bytes for a heap value — a sum `{ tag, fields... }`,
947/// record `{ fields... }`, array `{ len, elems... }`, or closure environment.
948/// The result is GC-managed and its slots are traced for further pointers.
949///
950/// Never returns null: a size too large to describe as a heap layout, and one
951/// the system allocator cannot satisfy, both abort (see [`gc::alloc`]).
952#[no_mangle]
953pub extern "C" fn nova_rt_alloc(size: i64) -> *mut u8 {
954    gc::alloc(size.max(8) as usize, true)
955}
956
957/// Abort if `index` is outside `0..len` (an array bounds violation).
958#[no_mangle]
959pub extern "C" fn nova_rt_check_bounds(index: i64, len: i64) {
960    if index < 0 || index >= len {
961        eprintln!("nova: panic: array index {index} out of bounds for length {len}");
962        std::process::abort();
963    }
964}
965
966/// Abort the program with a panic message given as a Nova string.
967///
968/// # Safety
969/// `s` must point to a valid `NovaStr`.
970#[no_mangle]
971pub unsafe extern "C" fn nova_rt_panic_str(s: *const NovaStr) -> ! {
972    let msg = if s.is_null() { "" } else { as_str(s) };
973    eprintln!("nova: panic: {msg}");
974    std::process::abort();
975}
976
977/// `test_selector() -> i64`. See `Builtin::TestSelector`.
978#[no_mangle]
979pub extern "C" fn nova_rt_test_selector() -> i64 {
980    std::env::var("NOVA_TEST_INDEX")
981        .ok()
982        .and_then(|s| s.parse::<i64>().ok())
983        .unwrap_or(-1)
984}
985
986/// All runtime symbols, for registration with the JIT (or a linker map).
987///
988/// **Every `nova_mir::RtFunc` must appear here.** A `RtFunc` variant is
989/// declared to both codegen backends by the `rt_funcs!` macro, which guarantees
990/// the two backends cannot disagree about the enum — but it says nothing about
991/// this table, which is hand-maintained. A variant missing from here compiles
992/// clean and only fails when the JIT tries to resolve the symbol, inside
993/// `cranelift-jit`'s `finalize_definitions`, which `panic!`s rather than
994/// returning an error. `nova-codegen-cranelift`'s
995/// `every_rt_func_symbol_is_registered_with_the_jit` pins the containment,
996/// since that crate is the one that depends on both this one and `nova-mir`.
997pub fn symbols() -> Vec<(&'static str, *const u8)> {
998    vec![
999        ("nova_rt_str_new", nova_rt_str_new as *const u8),
1000        ("nova_rt_println", nova_rt_println as *const u8),
1001        ("nova_rt_print", nova_rt_print as *const u8),
1002        ("nova_rt_eprint", nova_rt_eprint as *const u8),
1003        ("nova_rt_eprintln", nova_rt_eprintln as *const u8),
1004        ("nova_rt_str_concat", nova_rt_str_concat as *const u8),
1005        ("nova_rt_str_concat_n", nova_rt_str_concat_n as *const u8),
1006        ("nova_rt_str_join", nova_rt_str_join as *const u8),
1007        ("nova_rt_str_index_of", nova_rt_str_index_of as *const u8),
1008        ("nova_rt_json_quote", nova_rt_json_quote as *const u8),
1009        ("nova_rt_str_eq", nova_rt_str_eq as *const u8),
1010        ("nova_rt_str_cmp", nova_rt_str_cmp as *const u8),
1011        ("nova_rt_str_hash", nova_rt_str_hash as *const u8),
1012        ("nova_rt_str_len_chars", nova_rt_str_len_chars as *const u8),
1013        ("nova_rt_str_chars", nova_rt_str_chars as *const u8),
1014        (
1015            "nova_rt_str_from_chars",
1016            nova_rt_str_from_chars as *const u8,
1017        ),
1018        ("nova_rt_str_to_upper", nova_rt_str_to_upper as *const u8),
1019        ("nova_rt_str_to_lower", nova_rt_str_to_lower as *const u8),
1020        ("nova_rt_int_to_str", nova_rt_int_to_str as *const u8),
1021        ("nova_rt_float_to_str", nova_rt_float_to_str as *const u8),
1022        ("nova_rt_float_fixed", nova_rt_float_fixed as *const u8),
1023        ("nova_rt_str_to_float", nova_rt_str_to_float as *const u8),
1024        ("nova_rt_bool_to_str", nova_rt_bool_to_str as *const u8),
1025        ("nova_rt_char_to_str", nova_rt_char_to_str as *const u8),
1026        ("nova_rt_alloc", nova_rt_alloc as *const u8),
1027        ("nova_rt_check_bounds", nova_rt_check_bounds as *const u8),
1028        ("nova_rt_panic_str", nova_rt_panic_str as *const u8),
1029        ("nova_rt_test_selector", nova_rt_test_selector as *const u8),
1030        ("nova_rt_task_spawn", task::nova_rt_task_spawn as *const u8),
1031        (
1032            "nova_rt_task_block_on",
1033            task::nova_rt_task_block_on as *const u8,
1034        ),
1035        (
1036            "nova_rt_task_is_done",
1037            task::nova_rt_task_is_done as *const u8,
1038        ),
1039        (
1040            "nova_rt_task_take_output",
1041            task::nova_rt_task_take_output as *const u8,
1042        ),
1043        (
1044            "nova_rt_task_release",
1045            task::nova_rt_task_release as *const u8,
1046        ),
1047        (
1048            "nova_rt_task_yield_future",
1049            task::nova_rt_task_yield_future as *const u8,
1050        ),
1051        (
1052            "nova_rt_task_sleep_future_nanos",
1053            task::nova_rt_task_sleep_future_nanos as *const u8,
1054        ),
1055        (
1056            "nova_rt_task_join_future",
1057            task::nova_rt_task_join_future as *const u8,
1058        ),
1059        (
1060            "nova_rt_task_timeout_future",
1061            task::nova_rt_task_timeout_future as *const u8,
1062        ),
1063        (
1064            "nova_rt_fs_read_to_string",
1065            fs::nova_rt_fs_read_to_string as *const u8,
1066        ),
1067        (
1068            "nova_rt_fs_write_string",
1069            fs::nova_rt_fs_write_string as *const u8,
1070        ),
1071        (
1072            "nova_rt_fs_take_string",
1073            fs::nova_rt_fs_take_string as *const u8,
1074        ),
1075        (
1076            "nova_rt_fs_last_error_message",
1077            fs::nova_rt_fs_last_error_message as *const u8,
1078        ),
1079        ("nova_rt_fs_temp_dir", fs::nova_rt_fs_temp_dir as *const u8),
1080        ("nova_rt_fs_exists", fs::nova_rt_fs_exists as *const u8),
1081        (
1082            "nova_rt_fs_create_dir",
1083            fs::nova_rt_fs_create_dir as *const u8,
1084        ),
1085        (
1086            "nova_rt_fs_create_dir_all",
1087            fs::nova_rt_fs_create_dir_all as *const u8,
1088        ),
1089        (
1090            "nova_rt_fs_remove_file",
1091            fs::nova_rt_fs_remove_file as *const u8,
1092        ),
1093        (
1094            "nova_rt_fs_remove_dir_all",
1095            fs::nova_rt_fs_remove_dir_all as *const u8,
1096        ),
1097        ("nova_rt_fs_read_dir", fs::nova_rt_fs_read_dir as *const u8),
1098        (
1099            "nova_rt_fs_take_string_array",
1100            fs::nova_rt_fs_take_string_array as *const u8,
1101        ),
1102        ("nova_rt_fs_kind", fs::nova_rt_fs_kind as *const u8),
1103        ("nova_rt_fs_read", fs::nova_rt_fs_read as *const u8),
1104        (
1105            "nova_rt_fs_take_bytes",
1106            fs::nova_rt_fs_take_bytes as *const u8,
1107        ),
1108        ("nova_rt_fs_write", fs::nova_rt_fs_write as *const u8),
1109        (
1110            "nova_rt_io_stdin_read",
1111            io::nova_rt_io_stdin_read as *const u8,
1112        ),
1113        (
1114            "nova_rt_io_stdout_write",
1115            io::nova_rt_io_stdout_write as *const u8,
1116        ),
1117        (
1118            "nova_rt_io_stderr_write",
1119            io::nova_rt_io_stderr_write as *const u8,
1120        ),
1121        (
1122            "nova_rt_io_stdout_flush",
1123            io::nova_rt_io_stdout_flush as *const u8,
1124        ),
1125        (
1126            "nova_rt_io_stderr_flush",
1127            io::nova_rt_io_stderr_flush as *const u8,
1128        ),
1129        ("nova_rt_file_open", file::nova_rt_file_open as *const u8),
1130        ("nova_rt_file_close", file::nova_rt_file_close as *const u8),
1131        ("nova_rt_file_read", file::nova_rt_file_read as *const u8),
1132        ("nova_rt_file_write", file::nova_rt_file_write as *const u8),
1133        ("nova_rt_file_flush", file::nova_rt_file_flush as *const u8),
1134        (
1135            "nova_rt_net_connect_future",
1136            net::nova_rt_net_connect_future as *const u8,
1137        ),
1138        ("nova_rt_net_close", net::nova_rt_net_close as *const u8),
1139        (
1140            "nova_rt_net_read_future",
1141            net::nova_rt_net_read_future as *const u8,
1142        ),
1143        (
1144            "nova_rt_net_write_future",
1145            net::nova_rt_net_write_future as *const u8,
1146        ),
1147        (
1148            "nova_rt_net_read_timeout_future",
1149            net::nova_rt_net_read_timeout_future as *const u8,
1150        ),
1151        ("nova_rt_net_listen", net::nova_rt_net_listen as *const u8),
1152        (
1153            "nova_rt_net_local_port",
1154            net::nova_rt_net_local_port as *const u8,
1155        ),
1156        (
1157            "nova_rt_net_accept_future",
1158            net::nova_rt_net_accept_future as *const u8,
1159        ),
1160        ("nova_rt_bytes_len", bytes::nova_rt_bytes_len as *const u8),
1161        (
1162            "nova_rt_bytes_from_string",
1163            bytes::nova_rt_bytes_from_string as *const u8,
1164        ),
1165        (
1166            "nova_rt_bytes_is_utf8",
1167            bytes::nova_rt_bytes_is_utf8 as *const u8,
1168        ),
1169        (
1170            "nova_rt_bytes_to_string_unchecked",
1171            bytes::nova_rt_bytes_to_string_unchecked as *const u8,
1172        ),
1173        ("nova_rt_bytes_at", bytes::nova_rt_bytes_at as *const u8),
1174        (
1175            "nova_rt_bytes_slice",
1176            bytes::nova_rt_bytes_slice as *const u8,
1177        ),
1178        (
1179            "nova_rt_bytes_concat",
1180            bytes::nova_rt_bytes_concat as *const u8,
1181        ),
1182        (
1183            "nova_rt_bytes_to_ints",
1184            bytes::nova_rt_bytes_to_ints as *const u8,
1185        ),
1186        (
1187            "nova_rt_bytes_from_ints",
1188            bytes::nova_rt_bytes_from_ints as *const u8,
1189        ),
1190        ("nova_rt_bytes_eq", bytes::nova_rt_bytes_eq as *const u8),
1191        ("nova_rt_int_hash_seed", nova_rt_int_hash_seed as *const u8),
1192        (
1193            "nova_rt_http_parse_request",
1194            http::nova_rt_http_parse_request as *const u8,
1195        ),
1196        (
1197            "nova_rt_crypto_hash",
1198            crypto::nova_rt_crypto_hash as *const u8,
1199        ),
1200        (
1201            "nova_rt_crypto_random_bytes",
1202            crypto::nova_rt_crypto_random_bytes as *const u8,
1203        ),
1204        (
1205            "nova_rt_crypto_random_int",
1206            crypto::nova_rt_crypto_random_int as *const u8,
1207        ),
1208        (
1209            "nova_rt_time_now_nanos",
1210            time::nova_rt_time_now_nanos as *const u8,
1211        ),
1212        (
1213            "nova_rt_time_now_epoch_nanos",
1214            time::nova_rt_time_now_epoch_nanos as *const u8,
1215        ),
1216        (
1217            "nova_rt_log_config_level",
1218            log::nova_rt_log_config_level as *const u8,
1219        ),
1220        (
1221            "nova_rt_log_config_to_stderr",
1222            log::nova_rt_log_config_to_stderr as *const u8,
1223        ),
1224        (
1225            "nova_rt_log_set_config",
1226            log::nova_rt_log_set_config as *const u8,
1227        ),
1228        (
1229            "nova_rt_shutdown_requested",
1230            signal::nova_rt_shutdown_requested as *const u8,
1231        ),
1232        (
1233            "nova_rt_process_arg_count",
1234            process::nova_rt_process_arg_count as *const u8,
1235        ),
1236        (
1237            "nova_rt_process_arg",
1238            process::nova_rt_process_arg as *const u8,
1239        ),
1240        (
1241            "nova_rt_process_exit",
1242            process::nova_rt_process_exit as *const u8,
1243        ),
1244    ]
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    unsafe fn to_string(s: *mut NovaStr) -> String {
1252        as_str(s).to_string()
1253    }
1254
1255    unsafe fn make_str(s: &'static str) -> *mut NovaStr {
1256        nova_rt_str_new(s.as_ptr(), s.len() as u64)
1257    }
1258
1259    #[test]
1260    fn int_to_str_formats() {
1261        unsafe {
1262            assert_eq!(to_string(nova_rt_int_to_str(6765)), "6765");
1263            assert_eq!(to_string(nova_rt_int_to_str(-1)), "-1");
1264        }
1265    }
1266
1267    /// Every digit count from one to nineteen, both signs, and both ends of
1268    /// `i64`, against Rust's own formatting. `i64::MIN` has no positive
1269    /// counterpart, so a sign handled by negating the value overflows there.
1270    #[test]
1271    fn int_to_str_matches_rust_at_every_digit_count_and_both_ends() {
1272        let mut cases = vec![0, i64::MAX, i64::MIN, i64::MIN + 1];
1273        let mut p: i64 = 1;
1274        for _ in 0..19 {
1275            cases.extend([p, p - 1, -p, 1 - p]);
1276            p = p.saturating_mul(10);
1277        }
1278        for v in cases {
1279            unsafe { assert_eq!(to_string(nova_rt_int_to_str(v)), v.to_string(), "{v}") };
1280        }
1281    }
1282
1283    #[test]
1284    fn bool_to_str_formats() {
1285        unsafe {
1286            assert_eq!(to_string(nova_rt_bool_to_str(1)), "true");
1287            assert_eq!(to_string(nova_rt_bool_to_str(0)), "false");
1288        }
1289    }
1290
1291    /// Build a Nova `[String]` array `{ len, elems… }` from `parts`, for
1292    /// `nova_rt_str_concat_n`. The parts are allocated first so the array is
1293    /// the last allocation before the call.
1294    unsafe fn str_array(parts: &[&'static str]) -> *const u8 {
1295        let ptrs: Vec<*mut NovaStr> = parts.iter().map(|p| make_str(p)).collect();
1296        let block = gc::alloc(8 + 8 * ptrs.len(), true) as *mut i64;
1297        *block = ptrs.len() as i64;
1298        for (i, p) in ptrs.iter().enumerate() {
1299            *block.add(1 + i) = *p as i64;
1300        }
1301        block as *const u8
1302    }
1303
1304    /// The lengths every layout test covers: empty, short, one whose
1305    /// `16 + len` crosses a class boundary, the largest that stays a small
1306    /// object (`16 + 2032 = 2048`), the first that goes to the large path, and
1307    /// a large one at a class size.
1308    const STR_LENGTHS: [usize; 6] = [0, 2, 113, 2032, 2033, 2048];
1309
1310    /// `len` ASCII bytes with a static lifetime, so `make_str` can take them.
1311    fn text_of(len: usize) -> &'static str {
1312        let s: String = "abcdefghij".repeat(len / 10 + 1)[..len].to_string();
1313        Box::leak(s.into_boxed_str())
1314    }
1315
1316    /// One leaf object per runtime string: the header at its start, the bytes
1317    /// inline at offset 16
1318    /// (`docs/superpowers/specs/2026-10-04-one-allocation-strings-design.md`).
1319    unsafe fn assert_one_leaf_string(s: *mut NovaStr, expected: &str) {
1320        let len = expected.len();
1321        assert_eq!(
1322            gc::object_info(s as usize),
1323            Some((16 + len.max(1), false)),
1324            "len {len}: one leaf object of 16 + max(len, 1) bytes"
1325        );
1326        assert_eq!(
1327            (*s).ptr as usize,
1328            s as usize + 16,
1329            "len {len}: bytes inline at offset 16"
1330        );
1331        assert_eq!((*s).len, len as u64, "len {len}");
1332        assert_eq!(as_str(s), expected, "len {len}");
1333    }
1334
1335    #[test]
1336    fn a_runtime_string_is_one_leaf_object_with_its_bytes_inline() {
1337        for len in STR_LENGTHS {
1338            let expected = text_of(len);
1339            unsafe {
1340                assert_one_leaf_string(gc_str(expected), expected);
1341                // `gc_str_filled` through a builtin, the text split across two
1342                // literals.
1343                let (a, b) = expected.split_at(len / 2);
1344                assert_one_leaf_string(nova_rt_str_concat(make_str(a), make_str(b)), expected);
1345            }
1346        }
1347    }
1348
1349    #[test]
1350    fn a_literal_header_is_a_sixteen_byte_leaf() {
1351        unsafe {
1352            let s = make_str("literal");
1353            assert_eq!(gc::object_info(s as usize), Some((16, false)));
1354            assert_eq!(as_str(s), "literal");
1355        }
1356    }
1357
1358    #[test]
1359    fn an_empty_strings_pointer_lies_inside_its_own_object() {
1360        unsafe {
1361            let s = gc_str("");
1362            let base = s as usize;
1363            let ptr = (*s).ptr as usize;
1364            let size = gc::object_info(base).expect("a live object").0;
1365            assert!(
1366                base + 16 <= ptr && ptr < base + size,
1367                "ptr {ptr:#x} must lie in [{:#x}, {:#x})",
1368                base + 16,
1369                base + size
1370            );
1371        }
1372    }
1373
1374    /// A string reached only through a pointer into its bytes -- at offset 16
1375    /// and at its last byte -- survives a collection, with an unrooted control
1376    /// swept. Explicit roots, so it is deterministic on every platform.
1377    #[test]
1378    fn a_runtime_string_reached_only_through_its_bytes_survives_a_collection() {
1379        for len in [2usize, 113, 2033] {
1380            let expected = text_of(len);
1381            unsafe {
1382                let s = gc_str(expected);
1383                let control = gc_str(expected);
1384                assert!(gc::object_info(s as usize).is_some(), "len {len}");
1385                assert!(gc::object_info(control as usize).is_some(), "len {len}");
1386                for k in [0, len - 1] {
1387                    gc::sweep_with_roots_for_test(&[(*s).ptr as usize + k]);
1388                    assert_eq!(
1389                        gc::object_info(s as usize),
1390                        Some((16 + len, false)),
1391                        "len {len} k {k}: the whole object survives"
1392                    );
1393                }
1394                assert_eq!(
1395                    gc::object_info(control as usize),
1396                    None,
1397                    "len {len}: the unrooted control is swept"
1398                );
1399                assert_eq!(as_str(s), expected, "len {len}");
1400            }
1401        }
1402    }
1403
1404    /// Every `.rs` file under this crate's `src`, found by walking the
1405    /// directory at test time, so a module added later is scanned without
1406    /// anyone listing it.
1407    fn runtime_sources() -> Vec<(String, String)> {
1408        fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) {
1409            let mut entries: Vec<_> = std::fs::read_dir(dir)
1410                .expect("the crate's src directory is readable")
1411                .map(|e| e.expect("a readable directory entry").path())
1412                .collect();
1413            entries.sort();
1414            for path in entries {
1415                if path.is_dir() {
1416                    walk(&path, out);
1417                } else if path.extension().is_some_and(|x| x == "rs") {
1418                    let text = std::fs::read_to_string(&path).expect("a readable source file");
1419                    out.push((path.display().to_string(), text.replace("\r\n", "\n")));
1420                }
1421            }
1422        }
1423        let mut out = Vec::new();
1424        walk(
1425            &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
1426            &mut out,
1427        );
1428        out
1429    }
1430
1431    /// A file's production code: the lines before the first line that is
1432    /// exactly `mod tests {`, with comment lines dropped. Matching the whole
1433    /// line, not a substring, keeps a mention of `mod tests {` in a comment
1434    /// from cutting the scan short.
1435    fn production_code(source: &str) -> String {
1436        source
1437            .lines()
1438            .take_while(|l| l.trim() != "mod tests {")
1439            .filter(|l| !l.trim_start().starts_with("//"))
1440            .collect::<Vec<_>>()
1441            .join("\n")
1442    }
1443
1444    /// `.ptr =` and `.ptr=` assignments in `code`, not counting a `==`
1445    /// comparison that starts the same way.
1446    fn ptr_assignments(code: &str) -> usize {
1447        [".ptr =", ".ptr="]
1448            .iter()
1449            .map(|needle| {
1450                code.match_indices(needle)
1451                    .filter(|(i, m)| !code[i + m.len()..].starts_with('='))
1452                    .count()
1453            })
1454            .sum()
1455    }
1456
1457    /// **Where a `NovaStr`'s `ptr` may be written, pinned.** Every header is a
1458    /// leaf, so the collector never traces a `ptr`: a header pointing into
1459    /// another GC object would not keep it alive. Only [`alloc_str_object`]
1460    /// (`ptr` = its own bytes) and [`nova_rt_str_new`] (`ptr` = its caller's
1461    /// pointer, static literal data by its `# Safety` contract) write one.
1462    ///
1463    /// This scans the production code of every `.rs` file under the crate's
1464    /// `src` and fails on any of:
1465    /// - a `.ptr =` assignment outside those two builders;
1466    /// - a `.ptr` on a line that also takes an address (`&mut `,
1467    ///   `addr_of_mut!`, `addr_of!`, `&raw `), the start of a write through
1468    ///   `ptr::write`, `mem::replace`, `mem::swap` or a `cast_mut`;
1469    /// - a `NovaStr` struct literal, or an `impl` block on it;
1470    /// - a call to `nova_rt_str_new` from this crate's production code, so a
1471    ///   zero-copy slice cannot hand it a pointer into a GC string. Its only
1472    ///   callers are the code generators, for string literals.
1473    ///
1474    /// It is a text scan, not a proof, and what it cannot see is open-ended.
1475    /// Among it: a raw write at an offset (`*(p as *mut *const u8).add(1) =
1476    /// ...`), a header copied whole or built from raw words (`ptr::read`,
1477    /// `copy_nonoverlapping`, `transmute`), a call to `nova_rt_str_new`
1478    /// through a function pointer or alias, anything reached through a
1479    /// renamed path (`use ... as`, a type alias), a write split across lines,
1480    /// and code outside this crate.
1481    #[test]
1482    fn only_the_two_builders_write_a_novastr_ptr() {
1483        let sources = runtime_sources();
1484        // Coverage floor: a scan that found nothing would pass vacuously.
1485        assert!(
1486            sources.len() >= 14,
1487            "scanned only {} runtime source files",
1488            sources.len()
1489        );
1490        let lib_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1491            .join("src")
1492            .join("lib.rs")
1493            .display()
1494            .to_string();
1495        let lib = sources
1496            .iter()
1497            .find(|(p, _)| *p == lib_path)
1498            .expect("lib.rs is among the scanned files");
1499
1500        for (path, source) in &sources {
1501            let code = production_code(source);
1502            let writes = ptr_assignments(&code);
1503            if *path == lib_path {
1504                assert_eq!(
1505                    writes, 2,
1506                    "{path}: exactly two `.ptr =` writes, one per builder"
1507                );
1508            } else {
1509                assert_eq!(
1510                    writes, 0,
1511                    "{path}: writes a `NovaStr`'s `ptr` outside the two builders"
1512                );
1513            }
1514            let literals = code.matches("NovaStr {").count()
1515                - code.matches("struct NovaStr {").count()
1516                - code.matches("*mut NovaStr {").count()
1517                - code.matches("*const NovaStr {").count();
1518            assert_eq!(
1519                literals, 0,
1520                "{path}: builds or implements `NovaStr` outside its definition"
1521            );
1522            let borrows = code
1523                .lines()
1524                .filter(|l| {
1525                    l.contains(".ptr")
1526                        && ["&mut ", "addr_of_mut!", "addr_of!", "&raw "]
1527                            .iter()
1528                            .any(|b| l.contains(b))
1529                })
1530                .count();
1531            assert_eq!(
1532                borrows, 0,
1533                "{path}: takes the address of a `NovaStr`'s `ptr`"
1534            );
1535            // lib.rs holds the definition; nothing in production code calls it.
1536            let calls = code.matches("nova_rt_str_new(").count();
1537            let definitions = usize::from(*path == lib_path);
1538            assert_eq!(
1539                calls, definitions,
1540                "{path}: calls `nova_rt_str_new` from production code"
1541            );
1542        }
1543
1544        let code = production_code(&lib.1);
1545        for builder in ["fn alloc_str_object(", "fn nova_rt_str_new("] {
1546            let start = code.find(builder).expect("the builder exists");
1547            let end = start
1548                + code[start..]
1549                    .find("\n}\n")
1550                    .expect("the builder's closing brace");
1551            assert_eq!(
1552                ptr_assignments(&code[start..end]),
1553                1,
1554                "`{builder}` writes `ptr` exactly once"
1555            );
1556        }
1557    }
1558
1559    #[test]
1560    fn concat_n_joins_every_part_in_order() {
1561        unsafe {
1562            for parts in [
1563                &[][..],
1564                &["solo"][..],
1565                &["a", "b", "c"][..],
1566                &["", "x", ""][..],
1567                &["日本", "語", "🦀", "-", "é"][..],
1568            ] {
1569                let got = as_str(nova_rt_str_concat_n(str_array(parts))).to_string();
1570                assert_eq!(got, parts.concat(), "parts {parts:?}");
1571            }
1572        }
1573    }
1574
1575    /// Against a character-level reference: the first `i` at which the
1576    /// needle's characters match the haystack's, or `-1`. Multi-byte text
1577    /// before a match is what tells a character index from a byte index.
1578    #[test]
1579    fn index_of_reports_the_first_match_as_a_character_index() {
1580        fn reference(h: &str, n: &str) -> i64 {
1581            let hc: Vec<char> = h.chars().collect();
1582            let nc: Vec<char> = n.chars().collect();
1583            if nc.len() > hc.len() {
1584                return -1;
1585            }
1586            (0..=hc.len() - nc.len())
1587                .find(|&i| hc[i..i + nc.len()] == nc[..])
1588                .map_or(-1, |i| i as i64)
1589        }
1590        unsafe {
1591            for (h, n) in [
1592                ("hello wörld, hello wörld", "wörld"),
1593                ("日本語のテキスト", "テキ"),
1594                ("aé🦀aé🦀", "🦀a"),
1595                ("axbab", "ab"),
1596                ("aaa", "aa"),
1597                ("abc", "abc"),
1598                ("ab", "abc"),
1599                ("application/json", "\r"),
1600                ("", "a"),
1601                ("", ""),
1602                ("é", ""),
1603                ("é", "e"),
1604            ] {
1605                let got = nova_rt_str_index_of(make_str(h), make_str(n));
1606                assert_eq!(got, reference(h, n), "haystack {h:?} needle {n:?}");
1607            }
1608        }
1609    }
1610
1611    /// Against a character-level port of the Nova `quote` this replaced in
1612    /// `std/json`: every control character, both characters that need a
1613    /// backslash, the boundary at `0x7F`, and multi-byte text.
1614    #[test]
1615    fn json_quote_matches_the_character_level_escaper() {
1616        fn reference(s: &str) -> String {
1617            let mut out = String::from("\"");
1618            for c in s.chars() {
1619                let code = c as u32;
1620                match c {
1621                    '"' => out.push_str("\\\""),
1622                    '\\' => out.push_str("\\\\"),
1623                    _ if code < 32 => match code {
1624                        10 => out.push_str("\\n"),
1625                        13 => out.push_str("\\r"),
1626                        9 => out.push_str("\\t"),
1627                        8 => out.push_str("\\b"),
1628                        12 => out.push_str("\\f"),
1629                        _ => out.push_str(&format!("\\u00{code:02x}")),
1630                    },
1631                    _ => out.push(c),
1632                }
1633            }
1634            out.push('"');
1635            out
1636        }
1637        let controls: &'static str = Box::leak(
1638            (0u8..32)
1639                .map(|b| b as char)
1640                .collect::<String>()
1641                .into_boxed_str(),
1642        );
1643        unsafe {
1644            for s in [
1645                "",
1646                "User Number 7",
1647                "a\"b\\c\nd",
1648                controls,
1649                "\u{7f}",
1650                "é🦀日本",
1651                "x\u{1f}y\u{0}z",
1652                "\"\\\"",
1653                "tab\there",
1654                // Multi-byte text right before an escape: a copy measured in
1655                // characters instead of bytes would take the wrong run here.
1656                "é\"🦀\n",
1657                "日本\u{1}語",
1658            ] {
1659                let got = as_str(nova_rt_json_quote(make_str(s)));
1660                assert_eq!(got, reference(s), "input {s:?}");
1661            }
1662        }
1663    }
1664
1665    /// Against Rust's own `join`, over `concat_n`'s shapes and separators
1666    /// from empty to multi-byte: a dropped, doubled or trailing separator,
1667    /// or a skipped part, changes the result for at least one pair.
1668    #[test]
1669    fn join_puts_the_separator_between_every_pair_of_parts() {
1670        unsafe {
1671            for sep in ["", ",", "->", "—🦀"] {
1672                for parts in [
1673                    &[][..],
1674                    &["solo"][..],
1675                    &["a", "b", "c"][..],
1676                    &["", "x", ""][..],
1677                    &["日本", "語", "🦀", "-", "é"][..],
1678                ] {
1679                    let got = as_str(nova_rt_str_join(make_str(sep), str_array(parts)));
1680                    assert_eq!(got, parts.join(sep), "sep {sep:?} parts {parts:?}");
1681                }
1682            }
1683        }
1684    }
1685
1686    /// Against Rust's own concatenation, with either side empty and with
1687    /// multi-byte text on both.
1688    #[test]
1689    fn concat_keeps_both_sides_whole() {
1690        unsafe {
1691            for (a, b) in [
1692                ("", ""),
1693                ("", "x"),
1694                ("x", ""),
1695                ("foo", "bar"),
1696                ("日本", "🦀é"),
1697            ] {
1698                let got = as_str(nova_rt_str_concat(make_str(a), make_str(b)));
1699                assert_eq!(got, format!("{a}{b}"), "{a:?} + {b:?}");
1700            }
1701        }
1702    }
1703
1704    #[test]
1705    fn concat_and_eq_work() {
1706        unsafe {
1707            let a = nova_rt_str_new("foo".as_ptr(), 3);
1708            let b = nova_rt_str_new("bar".as_ptr(), 3);
1709            let ab = nova_rt_str_concat(a, b);
1710            assert_eq!(to_string(ab), "foobar");
1711            let c = nova_rt_str_new("foobar".as_ptr(), 6);
1712            assert_eq!(nova_rt_str_eq(ab, c), 1);
1713            assert_eq!(nova_rt_str_eq(a, b), 0);
1714        }
1715    }
1716
1717    #[test]
1718    fn alloc_is_writable() {
1719        let p = nova_rt_alloc(24);
1720        assert!(!p.is_null());
1721        unsafe {
1722            *(p as *mut i64) = 42;
1723            assert_eq!(*(p as *const i64), 42);
1724        }
1725    }
1726
1727    #[test]
1728    fn str_cmp_orders_lexicographically() {
1729        unsafe {
1730            let a = make_str("abc");
1731            let b = make_str("abd");
1732            let c = make_str("abc");
1733            assert_eq!(nova_rt_str_cmp(a, b), -1);
1734            assert_eq!(nova_rt_str_cmp(b, a), 1);
1735            assert_eq!(nova_rt_str_cmp(a, c), 0);
1736        }
1737    }
1738
1739    #[test]
1740    fn str_cmp_prefix_is_less() {
1741        unsafe {
1742            let a = make_str("ab");
1743            let b = make_str("abc");
1744            assert_eq!(nova_rt_str_cmp(a, b), -1);
1745        }
1746    }
1747
1748    #[test]
1749    fn str_hash_is_deterministic_and_distinguishes() {
1750        unsafe {
1751            let a = make_str("hello");
1752            let b = make_str("hello");
1753            let c = make_str("world");
1754            assert_eq!(nova_rt_str_hash(a), nova_rt_str_hash(b));
1755            assert_ne!(nova_rt_str_hash(a), nova_rt_str_hash(c));
1756        }
1757    }
1758
1759    #[test]
1760    fn str_hash_handles_empty() {
1761        unsafe {
1762            let e = make_str("");
1763            // Must not panic and must be stable.
1764            assert_eq!(nova_rt_str_hash(e), nova_rt_str_hash(make_str("")));
1765        }
1766    }
1767
1768    #[test]
1769    fn int_hash_seed_is_stable_within_a_process() {
1770        // Whatever the value is, every call in one process must agree: a `Map`
1771        // built under one seed and probed under another finds nothing.
1772        let first = nova_rt_int_hash_seed();
1773        for _ in 0..64 {
1774            assert_eq!(nova_rt_int_hash_seed(), first);
1775        }
1776    }
1777
1778    #[test]
1779    fn int_and_str_hash_seeds_are_drawn_separately() {
1780        // Were these one value, `(0).hash()` would equal `("").hash()` exactly
1781        // in Nova. This pins that they differ, which is what the separate
1782        // `RandomState::new()` call buys. It does NOT pin that recovering one
1783        // fails to yield the other: two calls on one thread share `k1` and differ
1784        // in `k0` by the calls between them, and these two may instead come from
1785        // separate draws, so that question is about a keyed hash
1786        // under a related key and is untested here. The variant is deliberately
1787        // not named, for the reason `int_hash_seed`'s doc comment gives: `std`
1788        // documents `DefaultHasher`'s algorithm as unspecified, and the
1789        // related-key argument holds for any keyed hash. See that comment.
1790        // Equality is possible by chance at about 2^-64, which is the same
1791        // order this project already accepts for the cross-process string test.
1792        assert_ne!(int_hash_seed(), str_hash_seed());
1793        // The line above compares the two PRIVATE helpers, so on its own it
1794        // would still pass if the export returned the string seed -- the other
1795        // test in this pair calls the export but compares it only against
1796        // itself. These two pin the export: the first rules out its returning
1797        // the string seed, and the second pins it to the int seed, which is
1798        // the wiring this intrinsic exists to get right. The second is the
1799        // stronger claim and implies the first given the assert above; both
1800        // are here because the `!=` names the specific confusion worth
1801        // excluding if the `==` is ever weakened.
1802        assert_ne!(nova_rt_int_hash_seed() as u64, str_hash_seed());
1803        assert_eq!(nova_rt_int_hash_seed() as u64, int_hash_seed());
1804    }
1805
1806    #[test]
1807    fn str_len_chars_counts_scalars_not_bytes() {
1808        unsafe {
1809            assert_eq!(nova_rt_str_len_chars(make_str("café")), 4);
1810            assert_eq!(nova_rt_str_len_chars(make_str("日本語")), 3);
1811            assert_eq!(nova_rt_str_len_chars(make_str("")), 0);
1812            // A 4-byte scalar outside the BMP is still one character.
1813            assert_eq!(nova_rt_str_len_chars(make_str("🦀")), 1);
1814        }
1815    }
1816
1817    #[test]
1818    fn str_chars_writes_the_array_layout_codegen_expects() {
1819        unsafe {
1820            let block = nova_rt_str_chars(make_str("a→🦀"));
1821            let words = block as *const i64;
1822            // Length header first, then one i64 scalar per element.
1823            assert_eq!(*words, 3);
1824            assert_eq!(*words.add(1), 'a' as i64);
1825            assert_eq!(*words.add(2), '→' as i64);
1826            assert_eq!(*words.add(3), '🦀' as i64);
1827            // Reading back the written words alone cannot tell a correctly
1828            // sized 32-byte block (8 header + 8*3 elements) from one that
1829            // merely has enough allocator slop past a *wrong* declared size
1830            // for these four words to still land in live memory, and cannot
1831            // observe the `scan` flag at all — it only affects GC behaviour,
1832            // not what's readable. Assert what `alloc` actually recorded.
1833            assert_eq!(gc::object_info(block as usize), Some((32, true)));
1834            // An empty string still yields a well-formed zero-length array.
1835            let empty = nova_rt_str_chars(make_str(""));
1836            assert_eq!(*(empty as *const i64), 0);
1837            assert_eq!(gc::object_info(empty as usize), Some((8, true)));
1838        }
1839    }
1840
1841    /// A long string whose byte length and character count differ, so an
1842    /// array sized or filled by bytes instead of characters cannot pass: 100
1843    /// repetitions of `aé🦀` are 300 characters in 700 bytes.
1844    #[test]
1845    fn str_chars_sizes_and_fills_by_character_on_a_long_mixed_string() {
1846        unsafe {
1847            let text: &'static str = Box::leak("aé🦀".repeat(100).into_boxed_str());
1848            assert_eq!(text.len(), 700);
1849            let block = nova_rt_str_chars(make_str(text));
1850            let words = block as *const i64;
1851            assert_eq!(*words, 300);
1852            for (i, c) in text.chars().enumerate() {
1853                assert_eq!(*words.add(1 + i), c as i64, "element {i}");
1854            }
1855            assert_eq!(gc::object_info(block as usize), Some((8 + 8 * 300, true)));
1856        }
1857    }
1858
1859    #[test]
1860    fn str_from_chars_round_trips_and_substitutes_invalid_scalars() {
1861        unsafe {
1862            for s in ["", "ascii", "café", "日本語", "🦀🇹🇭"] {
1863                let back = nova_rt_str_from_chars(nova_rt_str_chars(make_str(s)));
1864                assert_eq!(nova_rt_str_eq(back, make_str(s)), 1, "round-trip {s}");
1865            }
1866            // A surrogate is not a scalar value; substitute, do not abort.
1867            let block = gc::alloc(16, true) as *mut i64;
1868            *block = 1;
1869            *block.add(1) = 0xD800;
1870            let s = nova_rt_str_from_chars(block as *const u8);
1871            assert_eq!(as_str(s), "\u{FFFD}");
1872        }
1873    }
1874
1875    #[test]
1876    fn case_mapping_handles_the_non_one_to_one_cases() {
1877        unsafe {
1878            assert_eq!(as_str(nova_rt_str_to_upper(make_str("Straße"))), "STRASSE");
1879            assert_eq!(as_str(nova_rt_str_to_lower(make_str("HÉLLO"))), "héllo");
1880            assert_eq!(as_str(nova_rt_str_to_upper(make_str(""))), "");
1881        }
1882    }
1883
1884    /// `case_mapping_handles_the_non_one_to_one_cases` only proves the two
1885    /// directions are each individually *correct*; it does not by itself
1886    /// prove that `nova_rt_str_to_upper` and `nova_rt_str_to_lower` are
1887    /// distinguishable from EACH OTHER or from an identity function that
1888    /// just returns its input. Both are real mutations a one-character typo
1889    /// could introduce (swapping which `.to_*case()` call a body makes, or
1890    /// dropping the case-mapping call entirely), and neither would be caught
1891    /// by a test that only checks the "forward" direction of each function in
1892    /// isolation if a caller isn't careful to pick inputs that actually
1893    /// change under the wrong operation too. Every input below is chosen so
1894    /// upper/lower/identity all disagree, so any of the three mutations
1895    /// produces a visibly wrong string here.
1896    #[test]
1897    fn case_mapping_is_distinguishable_from_identity_and_from_each_other() {
1898        unsafe {
1899            let straße_upper = as_str(nova_rt_str_to_upper(make_str("Straße")));
1900            assert_eq!(straße_upper, "STRASSE");
1901            assert_ne!(straße_upper, "Straße"); // rules out identity
1902            assert_ne!(
1903                straße_upper,
1904                as_str(nova_rt_str_to_lower(make_str("Straße")))
1905            ); // rules out the swap
1906
1907            let hello_lower = as_str(nova_rt_str_to_lower(make_str("HÉLLO")));
1908            assert_eq!(hello_lower, "héllo");
1909            assert_ne!(hello_lower, "HÉLLO"); // rules out identity
1910            assert_ne!(hello_lower, as_str(nova_rt_str_to_upper(make_str("HÉLLO")))); // rules out the swap
1911
1912            // A string with no cased characters at all (distinct from a
1913            // mixed string like "abc123", which still has letters to
1914            // transform) is unchanged by either direction — the identity
1915            // outcome is *correct* here, so this specifically exercises that
1916            // neither wrapper corrupts input it has nothing to do to.
1917            assert_eq!(
1918                as_str(nova_rt_str_to_upper(make_str("123 456!"))),
1919                "123 456!"
1920            );
1921            assert_eq!(
1922                as_str(nova_rt_str_to_lower(make_str("123 456!"))),
1923                "123 456!"
1924            );
1925        }
1926    }
1927
1928    /// The requested number of places, including a trailing zero the value does
1929    /// not itself carry.
1930    #[test]
1931    fn float_fixed_renders_the_requested_places() {
1932        unsafe {
1933            assert_eq!(to_string(nova_rt_float_fixed(100.0 / 3.0, 2)), "33.33");
1934            assert_eq!(to_string(nova_rt_float_fixed(1.5, 3)), "1.500");
1935            assert_eq!(to_string(nova_rt_float_fixed(33.333, 0)), "33");
1936        }
1937    }
1938
1939    /// Every lexical shape `std/json`'s `scan_number` can hand this — an
1940    /// integer, a decimal, a negative, both exponent letters, and all three
1941    /// exponent signs — plus the rejection path.
1942    ///
1943    /// "Both exponent spellings" is what this used to say, and it covered only
1944    /// `e` versus `E`, leaving out the `[+-]?` the grammar quoted on
1945    /// [`nova_rt_str_to_float`] admits beside them. `scan_number` accepts an
1946    /// explicit `+` there, so `1e+3` is a shape it really can hand this, and
1947    /// before that case was added no explicit `+` exponent existed anywhere in
1948    /// the repository — grepped, not assumed.
1949    ///
1950    /// The rejection cases are the load-bearing ones. `unwrap_or(0.0)` rather
1951    /// than `unwrap` is not a style choice: `unwrap` here would abort the whole
1952    /// process rather than fail an assertion, because this function cannot
1953    /// unwind (see its doc comment). These two cases are what proves the
1954    /// fallback returns instead.
1955    #[test]
1956    fn str_to_float_parses_every_shape_the_json_scanner_produces() {
1957        unsafe {
1958            assert_eq!(nova_rt_str_to_float(make_str("12")), 12.0);
1959            assert_eq!(nova_rt_str_to_float(make_str("2.5")), 2.5);
1960            assert_eq!(nova_rt_str_to_float(make_str("-3.5")), -3.5);
1961            assert_eq!(nova_rt_str_to_float(make_str("1e3")), 1000.0);
1962            assert_eq!(nova_rt_str_to_float(make_str("1e+3")), 1000.0);
1963            assert_eq!(nova_rt_str_to_float(make_str("1.5E-2")), 0.015);
1964
1965            // `-0` must not collapse to `0`: JSON writes both, they are
1966            // distinct `f64` bit patterns, and Display shows the sign. Compared
1967            // via the sign bit because `-0.0 == 0.0` is true, so an `assert_eq!`
1968            // against `-0.0` would pass even if the sign were lost.
1969            let neg_zero = nova_rt_str_to_float(make_str("-0"));
1970            assert_eq!(neg_zero, 0.0);
1971            assert!(neg_zero.is_sign_negative(), "-0 must keep its sign");
1972
1973            // Rejected input: text that is not a number, and the empty string.
1974            assert_eq!(nova_rt_str_to_float(make_str("nope")), 0.0);
1975            assert_eq!(nova_rt_str_to_float(make_str("")), 0.0);
1976        }
1977    }
1978
1979    /// A negative `places` has no meaning and must never reach `format!`'s
1980    /// precision argument, which would panic.
1981    #[test]
1982    fn negative_places_clamps_to_zero() {
1983        unsafe {
1984            assert_eq!(to_string(nova_rt_float_fixed(1.75, -1)), "2");
1985            assert_eq!(to_string(nova_rt_float_fixed(1.75, -1000)), "2");
1986        }
1987    }
1988
1989    /// Beyond 17 digits an `f64` carries no further information.
1990    #[test]
1991    fn places_above_seventeen_clamps() {
1992        unsafe {
1993            let at_limit = to_string(nova_rt_float_fixed(1.0 / 3.0, 17));
1994            let beyond = to_string(nova_rt_float_fixed(1.0 / 3.0, 40));
1995            assert_eq!(at_limit, beyond, "places above 17 must clamp to 17");
1996            assert_eq!(at_limit.len(), 19, "0. plus 17 digits");
1997        }
1998    }
1999
2000    /// A formatter that fails on a value its type permits is worse than one that
2001    /// shows it, so the non-finite values render as Rust renders them.
2002    #[test]
2003    fn non_finite_values_render_like_rust() {
2004        unsafe {
2005            assert_eq!(to_string(nova_rt_float_fixed(f64::NAN, 2)), "NaN");
2006            assert_eq!(to_string(nova_rt_float_fixed(f64::INFINITY, 2)), "inf");
2007            assert_eq!(to_string(nova_rt_float_fixed(f64::NEG_INFINITY, 2)), "-inf");
2008        }
2009    }
2010}