Skip to content

Strings

String8 is a pointer and a length. It does not own its bytes, it does not allocate, and it does not copy. Operations that only look at text are free functions that take a String8 and give one back. Operations that need new bytes take an arena, and say so in their signature.

Every string operation in Basalt falls into one of two groups: those that return a view into bytes that already exist, and those that take an arena because they must write new bytes. The signature tells you which.

String8 has no capacity, no null terminator and no ownership. It is two fields:

struct String8
{
const u8* data;
usize size;
};

That is the whole type. There is no separate owning string type — text is owned by whatever holds the bytes, which is usually an arena.

Three properties follow, and they are the reason the type is shaped this way:

  • A substring costs nothing. Str8Substr, Str8Skip, Str8Trim and Str8Prefix return a new String8 pointing into the same bytes. No allocation, no copy, at any length.
  • It can alias anything. A string literal, a constexpr array, a field of a const struct, a slice of a file you read into an arena. Because data is const u8*, the type never claims a right to write.
  • It works at compile time. The whole non-allocating half is constexpr, so Str8Find and Str8Equal can run in a static_assert.
constexpr String8 path = Str("assets/mesh.bin");
static_assert(Str8EndsWith(path, Str(".bin"))); // evaluated by the compiler

The surface divides cleanly, and the division is visible in every signature. A string function that produces new bytes takes Arena& as its first parameter; the rest neither allocate nor copy.

// Non-allocating: returns a view into
// bytes that already exist.
String8 trimmed = Str8Trim(line);
usize at = Str8Find(line, Str("="));
String8 key = Str8Prefix(line, at);
bool ok = Str8Equal(key, Str("width"));
// Allocating: takes the arena that will
// hold the new bytes.
String8 owned = Str8Copy(arena, key);
String8 joined = Str8Cat(arena, a, b);
String8 msg = Str8Format(arena, "w=%d", w);
String8List parts = Str8Split(arena, line, Str(","));

Parsing usually needs no allocation at all. You read a file into an arena once, then slice views out of it — no per-token copies, and every token points into the one buffer.

Concatenating in a loop is the standard way to build a string, and it is quadratic: each step copies everything written so far.

String8List avoids that. It is a linked list of views: you push pieces onto it — each push writes only that piece — then join once into a single buffer.

String8List report = {};
Str8ListPush(arena, report, Str("frame "));
Str8ListPushf(arena, report, "%d", frame_index); // formatted push
Str8ListPush(arena, report, Str(" drew "));
Str8ListPushf(arena, report, "%d", draw_count);
String8 line = Str8ListJoin(arena, report, Str("")); // one allocation, one pass

The list tracks total_size as you push, so the join knows the exact size to allocate before it starts. There is no growth, no reallocation, and no over-allocation.

This is also how output is produced elsewhere in Basalt: build a list of pieces, then emit once. Str8Split returns a String8List for the same reason — the pieces are views into the original text, so splitting a large document allocates only the list nodes.

Basalt stores text as UTF-8 everywhere, in u8 bytes. There is no wchar_t and no encoding flag on the type: a String8 is always UTF-8, and nothing has to ask.

Conversion happens only at the operating-system boundary, and only where the OS demands it. On Windows a path is converted to UTF-16 immediately before a CreateFileW-style call; on Linux, UTF-8 goes straight through. The conversion is not part of the string type, so no ordinary code pays for it.

String16 is a boundary type, never a working type

Section titled “String16 is a boundary type, never a working type”

UTF-16 does exist in the core, as exactly one type and two functions — and their whole purpose is to be transient.

const ScratchScope scratch;
const String16 wide = Str16From8(scratch.GetArena(), path);
CreateFileW(reinterpret_cast<LPCWSTR>(wide.data), /* ... */); // dies with the scope

String16 is { const u16* data; usize size; }u16, not wchar_t, because the unit is what matters and wchar_t is 16 bits on Windows and 32 on Linux. size counts code units, never characters.

Call What it gives you
Str16From8(arena, utf8) UTF-8 → UTF-16 in arena.
Str8From16(arena, utf16) UTF-16 → UTF-8 in arena, for a wide string an OS call handed back.

Two contracts are worth knowing before you use either:

  • Both results are NUL-terminated, and size excludes the terminator. So data goes straight into an LPCWSTR parameter and size into a length-taking one, with no fix-up at the call site. An empty input still yields a valid one-unit L"". The guarantee is on the value these two functions return — a String8 in general carries none, and slicing one with Str8Substr, Str8Skip or Str8Chop does not preserve it.
  • Malformed input becomes U+FFFD, never a failure. An ill-formed byte on the way in, an unpaired surrogate on the way back (NTFS names are unvalidated WCHAR sequences), one replacement character per maximal ill-formed subpart, and the rest of the string never desynchronises. Neither function returns a Result, because the only sane recovery is the substitution: the bytes came from a path you did not encode. The one failure is allocation, reported as {nullptr, 0}.

Keep a String16 no longer than the call it was made for. Storing one, returning one, or converting back and forth in a loop is the thing this design exists to prevent — the boundary is a line to cross, not a place to live.

This is the single most important thing to hold in your head:

What you might mean What size gives you
Number of bytes size
Number of code points ❌ not size
Number of user-visible characters ❌ not size, and not code points either

For ASCII the three coincide, which is exactly why the mistake survives testing. Str("café").size is 5, not 4.

The byte-oriented operations are still correct on UTF-8 text, because UTF-8 has a property worth knowing: a multi-byte sequence never contains the encoding of another character. So Str8Find on bytes can never match in the middle of a character, and byte comparison gives the same answer as code-point comparison.

What byte operations cannot do is split at a character boundary you did not compute. Str8Prefix(s, 3) on café cuts the é in half.

Trimming and whitespace handling are ASCII-only, and the function that decides is named IsAsciiWhitespace so the limit is visible in the code.

Case folding, normalisation, case-insensitive comparison and grapheme segmentation are not provided. Each one needs Unicode tables, and pulling in that data is a decision with a real size cost that no current use case has justified. When one does, it arrives as a separate module rather than growing this one.

So Str8Compare orders by byte value. It is a deterministic, fast ordering suitable for sorting and lookup keys. It is not alphabetical order in any human language.

Str8Format uses a printf format string and allocates the result in an arena. It carries the compiler’s format-checking attribute, so a mismatched argument is a warning at the call site rather than a crash at run time.

String8 label = Str8Format(arena, "%s: %d ms", name, elapsed_ms);

The format engine is printf-shaped on purpose. A compile-checked, type-safe format engine is a larger design with its own costs, and it is deferred until something needs it. This one is honest about being the simple version.

Parameters, invariants and edge cases are documented in basalt/core/String.h.

Signature Purpose
Str(cstr) A view over a null-terminated C string. Scans for the terminator.
Str8(data, size) A view over explicit bytes.
Str8Range(first, one_past_last) A view from a pair of pointers.
Signature Purpose
Str8IsEmpty(s) True when size is zero.
Str8Equal(a, b) Byte equality.
Str8Compare(a, b) Ordering by byte value. Negative, zero or positive.
Str8Find(haystack, needle, start) Byte offset of the first match, or STR8_NPOS.
Str8Contains(haystack, needle) Whether a match exists.
Str8StartsWith(s, prefix) / Str8EndsWith(s, suffix) Boundary tests.
Signature Purpose
Str8Substr(s, offset, len) A view of len bytes from offset.
Str8Prefix(s, n) / Str8Postfix(s, n) The first or last n bytes.
Str8Skip(s, n) / Str8Chop(s, n) Drop n bytes from the front or the back.
Str8Trim(s) / Str8TrimLeft(s) / Str8TrimRight(s) Remove ASCII whitespace.
Signature Purpose
Str8Copy(arena, s) A copy that outlives the source bytes.
Str8Cat(arena, a, b) Concatenation of two views.
Str8Format(arena, fmt, ...) printf-style formatting. Format-checked at compile time.
Str8Split(arena, s, sep) Splits into a String8List of views.
Signature Purpose
Str8ListPush(arena, list, str) Appends a view as a node.
Str8ListPushf(arena, list, fmt, ...) Formats and appends in one step.
Str8ListConcat(list, to_append) Moves one list onto the end of another.
Str8ListJoin(arena, list, sep) One allocation, one pass, into a single String8.
Field Purpose
String8::data const u8* — the bytes. Never owned.
String8::size Length in bytes.
String8List::node_count Number of pieces pushed.
String8List::total_size Sum of the piece sizes, before separators.
String16::data const u16* — UTF-16 code units. Never owned. NUL-terminated when it came from Str16From8.
String16::size Length in code units, excluding the terminator.