ADR 0071 — Bytes and text: bulk appends, byte order, bit rotation, searching and number parsing
- Status: Accepted by the owner, 2026-11-05 ("Accept as proposed, implement and release 0.15.0": §6 as proposed: generic byte-order methods, the bit counts included, one release). Implemented with the changes listed in §7, and released as 0.15.0. Raised by the work on a Postgres wire client, later SMTP/IMAP/MIME (notes from that session, passed on by the owner).
- Scope: type-system.md §6 (integer methods), §16.5 (slice bulk operations), §22.2 (
StringView), §23.1 (Vector<T, A>), §35 (floats);lib/core/(newbytes.cee,text.cee,numbers.cee);src/check/vectors.ceeandsrc/check/library.cee(the method tables); the interpreter;runtime/hosted/ceesafe_rt_float.c(float parsing); the corpus.
1. Context
A wire-protocol client spends its time turning values into bytes and bytes back into values. Today, every one of those steps is hand-written:
- Appending bytes.
Vector<T>haspushand nothing bulk. An encoder pushes byte by byte, or pushes zeros and thencopyFroms over them. This is the hottest path in such a client. - Byte order. Every Postgres message has big-endian lengths and fields. Each read or write is four shifts, four index checks and a
truncate. - Bit rotation. SHA-256 and MD5 (for SCRAM and the md5 login) need
rotateLeft/rotateRight.ownership.md§0 says "wrappingShlmasks", but no such method exists: the integer methods are the nine of §6 plusmin,max,absandunsignedAbs. - Searching.
StringViewandSlice<u8>have noindexOf,startsWith,endsWith,splitortrim. SCRAM messages (r=…,s=…,i=…), command tags (INSERT 0 1), and all of SMTP, IMAP and MIME need them. - Numbers from text. Postgres sends results as text. Integers are easy to hand-roll, but parsing a float with correct rounding is not. The runtime already formats floats exactly (§35.7); nothing reads them back.
What stays out (§4): hashes, HMAC, PBKDF2, hex and base64 belong in packages, and TLS is a separate decision.
2. Proposal
Every addition is a method on a built-in type, like sortBy and splitAt (§36.4, lib/core/slices.cee). The compiler's method tables map each name to a lib/core function written in CeeSafe. Only float parsing is in the C runtime, linked only when used, next to the float formatter. The names become reserved on their types, as the integer methods are (N0228).
2.1 Bulk appends on Vector<T, A> (§23.1)
For a Copy T (N0259 otherwise):
| Method | Result | What it does |
|---|---|---|
v.extend(s) (s: Slice<T>) | Result<void, AllocError> | Appends all of s, with at most one growth. On failure v is unchanged |
v.resize(n, fill) | Result<void, AllocError> | Grows to n with copies of fill, or truncates to n |
v.reserve(extra) | Result<void, AllocError> | Makes room for extra more elements without changing the length |
- They take
borrow mut thisand are two-phase (B5), likepush. v.extend(v.view())is rejected (N0240), ass.append(s.view())is for strings.Stringkeepsappend. It gets noextend, because its bytes must stay UTF-8.
2.2 Byte order
There are generic forms over the integer types, so the names don't multiply:
| Method | On | Result |
|---|---|---|
s.readBE<T>(at), s.readLE<T>(at) | Slice<u8>, StringView bytes, a vector | T |
s.writeBE(at, x), s.writeLE(at, x) | SliceMut<u8>, a mutable vector | void |
v.pushBE(x), v.pushLE(x) | Vector<u8, A> | Result<void, AllocError> |
x.byteSwap() | every integer type | T |
Tis any integer type, andx's type gives the width for writes and pushes. A flexible literal is N0260 (no width), so the code writesv.pushBE(5u32)orv.pushBE(length).- Each call checks bounds once (
at + size(T) ≤ length), and panics "index out of bounds" at the call otherwise. - Signed types read and write two's complement.
- The generated C is a
memcpyand a byte swap where the host's order differs, with no undefined behaviour for unaligned access.
A Postgres message header is then:
out.pushBE(0x51u8)?; // 'Q'
let at = out.length;
out.pushBE(0u32)?; // length, patched below
out.extend(sql.bytes())?;
out.pushBE(0u8)?;
let length = truncate<u32>(out.length - at);
out.viewMut().writeBE(at, length);2.3 Bits (§6)
On every unsigned integer type (N0234 on signed, as with the bit operators):
| Method | Result |
|---|---|
x.rotateLeft(n), x.rotateRight(n) | T; n: u32, taken modulo the bit width. Never panics |
x.wrappingShl(n), x.wrappingShr(n) | T; n modulo the bit width. This makes ownership.md §0 true |
x.leadingZeros(), x.trailingZeros(), x.countOnes() | u32 (the width for 0) |
2.4 Searching and trimming (§22.2, §16.5)
On StringView, with the same methods on Slice<u8> (taking and giving Slice<u8>):
| Method | Result |
|---|---|
s.indexOf(needle), s.lastIndexOf(needle) | usize? (an empty needle is found at 0, and at length by lastIndexOf) |
s.indexOfByte(b) | usize? |
s.contains(needle), s.startsWith(p), s.endsWith(p) | bool |
s.equalsIgnoreAsciiCase(t) | bool; only A–Z/a–z fold. SMTP, IMAP and HTTP headers need it |
s.trim(), s.trimStart(), s.trimEnd() | the view without ASCII whitespace (space, \t, \n, \v, \f, \r) |
s.splitOnce(sep) | TextHalves?: the parts before and after the first sep |
s.split(sep) | TextSplit: next(borrow mut this) -> StringView?, the pieces in order |
needle,pandsepareStringView(orSlice<u8>). An emptysepis a panic ("empty separator").- UTF-8. A match of valid UTF-8 inside valid UTF-8 always starts and ends on character boundaries. So every view these methods return is a valid
StringView, without further checks. - Loans. A result borrows
s, likes[lo..hi].TextSplit<r>andTextHalves<r>are region structs, likeSliceHalvesinlib/core/slices.cee. - Speed.
indexOfByteis a word-at-a-time scan.indexOfusesindexOfByteon the needle's first byte, then compares, which is enough for protocol text. A two-way search can come later, with the same results.
var fields = message.split(",");
while (true) {
let field = match (fields.next()) { Some(f) => f, null => { break; } };
match (field.splitOnce("=")) {
Some(kv) => { if (kv.left == "i") { iterations = kv.right.parseInt<u32>()?; } }
null => {}
}
}2.5 Numbers from text (§6, §35)
| Method | Result |
|---|---|
s.parseInt<T>() | T?, for any integer type T |
s.parseIntRadix<T>(radix) | T?; radix: u32 from 2 to 36 (panics otherwise); digits 0–9, a–z, A–Z |
s.parseFloat<T>() | T?, for f32 or f64 |
These are on StringView and on Slice<u8>.
- Integers. An optional
-(signedTonly) or+, then one or more digits, with nothing else: no whitespace, no_, no0x. Overflow isnull. - Floats. The grammar is that of the formatter's output plus the common forms:
[+-]digits[.digits][(e|E)[+-]digits], with digits on at least one side of the.;inf,infinityandnanin any case, with an optional sign. Postgres writesInfinity,-InfinityandNaN.
- Rounding. The result is the nearest
Tto the decimal value, with ties to even. Overflow gives ±∞, as IEEE 754 and every correctstrtoddo. Underflow gives the nearest subnormal or ±0. - How floats parse.
- A fast path (the Clinger/Eisel-Lemire method) handles nearly every input.
- A big-decimal fallback handles the rest, so every input is exact.
- It is in
ceesafe_rt_float.c, independent of the locale (neverstrtod), and linked only if the program parses a float. - The interpreter does the same over rationals, as it does for formatting (
float_format.cee). - Under
--no-float,parseFloat<f64>is rejected with N0297, like any other use of a float type (§35.9).
- Round-trip. For every finite
x, parsing whatConsole.writeLine(x)prints givesxback. The tests check this.
3. Alternatives considered
- Named byte-order methods (
readU32BE,writeI16LE, …): 8 types × 2 orders × read, write and push is 48 names, against 6 generic ones. A program that wants the explicit names can wrap the generic ones. (Question 1.) - Free functions in a
Bytesstruct (Bytes.readBE<u32>(s, at)) instead of methods: this needs no compiler tables, but it reads worse, and it doesn't matchcopyFromandfill. - A general
Iteratorinterface forsplit: CeeSafe has none yet.TextSplit.next()has the shape a future iterator protocol would adopt. strtodfor floats: it depends on the locale, varies by libc, and isn't in the freestanding profiles.
4. Not in this ADR
- Hashes and HMAC, PBKDF2, MD5: a package (PostgresClient first), in pure CeeSafe. 2.3 gives them what they need.
- Hex and base64: a package. Encoding fits in a few lines on top of 2.1 and 2.2.
- TLS: later, as its own ADR. The one runtime feature it will need is upgrading an existing
AsyncTcpStreamto TLS in place, for STARTTLS. Without that, STARTTLS needs a memory-BIO shim.
5. Testing
- The corpus: run-pass programs for each method, in the compiled program and in the interpreter. Check-fail programs for:
- the signed-type and flexible-literal errors;
v.extend(v.view());- a
Slice<u8>receiver forwriteBE.
- Integer parsing: every boundary (
MIN,MAX,MAX + 1) for every width, and every malformed form. - Float parsing:
- the round-trip over a few million random
f64s and everyf32exponent; - the known-hard inputs, such as halfway cases and the longest exact decimals (the
parse-number-fxx-test-dataset); - the special values.
- the round-trip over a few million random
- A compiler check: reimplementing the compiler's own hand-rolled helpers on the new methods, where it already has them (byte search in the lexer, decimal parsing), is a good check, but not part of this ADR.
6. Questions for the owner
- Byte order: generic
readBE<T>/writeBE/pushBE(proposed), or namedreadU32BE-style methods? - Bits: include
leadingZeros/trailingZeros/countOnes(cheap, and wanted by bitsets and allocators), or only rotation and the masking shifts? - Float parsing in the runtime C: in
ceesafe_rt_float.cnext to the formatter (proposed, and fastest), or in lib/core CeeSafe (slower to write and run, but no C)? - Release: all of it as 0.15.0, or 2.1–2.3 first (the encoder path) and 2.4–2.5 next?
7. Implementation and evidence (2026-11-05)
7.1 Where it is
- lib/core:
bytes.cee:ByteOrderandVectorBytes;text.cee:TextSearch,ByteSearch, the halves and splits, and a privateByteScan;numbers.cee:NumberParsingand a privateFloatDecimal.
- The checker:
src/check/bytes_text.ceechooses the lib/core function by name and type;vectors.cee(extend,resize,reserve) andio.cee(the bit methods) handle the new methods;members.ceeroutes them.
- Built-ins: eleven new ones,
bfIntRotateLefttobfVectorReserve, in the interpreter, the C backend andrt.h. - NIR:
- two-phase borrowing for
VectorBytescalls; - the call-site range checks of byte order (
checkByteRange).
- two-phase borrowing for
7.2 Changes from the text above
- Float parsing is in lib/core, in CeeSafe, not in the C runtime (question 3's proposed answer).
- Why. The interpreter needed the same algorithm in CeeSafe anyway (§2.5). One implementation now serves both, with no runtime file to link and no extern call.
- The algorithm. Clinger's fast path, then the simple decimal conversion of Go's strconv and Wuffs, both in exact integer arithmetic.
- Accuracy. Correctly rounded on every test below.
- Speed. About 50 ns for a short input (
3.14159) and about 210 ns for a 17-digit one (0.30000000000000004), which takes the decimal path. Exponents far from zero cost a few µs. A faster path (Eisel-Lemire) can come later without changing a result.
- No panics in the parsers. lib/core has no way to panic with a message of its own, so these give a defined result instead:
- a radix outside 2–36 is
null; - an empty separator is found at the start by
splitOnce, and gives the text whole fromsplit.
- a radix outside 2–36 is
- Byte order is on fixed-width types only: not
usizeorisize(N0207), since a wire format has a fixed width.- Receivers. Byte slices and byte vectors; a
StringViewreads through.bytes(). - Where it panics. Each read and write checks its range at the call (two NIR
BoundsChecks), so the panic is "index out of bounds" on the program's line, as §2.2 says. A check inside the library would have named lib/core.
- Receivers. Byte slices and byte vectors; a
VectorByteshas no allocator parameter. A vector's allocator isSystemHeaporFailingAllocator(§23.1), and a genericVector<u8, A>is not a type (N0263). So it has a function for each, and the checker picks one.- Two-phase. Its calls take their argument before the vector's loan, as
pushdoes. The NIR builder recognises the struct by its name, which only lib/core may use.
- Two-phase. Its calls take their argument before the vector's loan, as
--no-floatdoes not apply to library files (type-system.md §35.9). Programs are unaffected:- a program reaches the float parser only through
parseFloat<f32>orparseFloat<f64>, which N0297 still rejects; - unused library functions are not emitted.
- a program reaches the float parser only through
- Additions:
- a
Stringhas the search and parse methods, on its text; Vector<u8>has every byte-slice method, on its elements.
- a
7.3 Evidence
- Float parsing against exact references:
- 3.1 million inputs, from six seeds, with no difference:
- random doubles in three formats;
- random mantissas of up to 30 digits with exponents from −340 to 320;
- exact halfway points and their neighbours;
- random
f32s; - the boundaries (largest subnormal, smallest normal,
f64andf32overflow midpoints,2^53 + 1, 400-digit inputs); - malformed text.
- The references: Python's
floatforf64, and exact rational rounding forf32. - The interpreter: gives the same results on a 3,000-input subset.
- 3.1 million inputs, from six seeds, with no difference:
- Round-trip: 2 million random
f64s and 2 millionf32s, each printed withappendNumber, parse back to the same bits. - The corpus:
- New tests: five run-pass (
int-bits,vector-bulk,byte-order,text-search,number-parsing), one run-fail (byte-order-past-end) and six check-fail. The run-pass tests run compiled and interpreted. - Changed tests:
string-views.ceeno longer usestrimas its missing method. Four snapshots moved:- the HIR of
async-timeoutandeffects-of, where only closure numbers changed; - the C of
vectorsandslot-maps, which now include the new vector helpers.
- the HIR of
- New tests: five run-pass (
- Speed (Apple M-series,
-O2):readBE<u32>: 52 million calls in a loop take about 40 ms in total, about 1 ns each;indexOfBytescans about 10 GB/s;extendis amemcpyafter one capacity check.