sysl

Strings

An immutable, validated []u8 — the same three words, with a guarantee added and an operation taken away.

A string is the slice from the last chapter with one thing added and one taken away: its bytes are guaranteed to be well-formed UTF-8, and nothing may write through it. Everything else — the three words, the O(1) substring, the retain on slicing — is the slice machinery unchanged.

That guarantee is not decoration. It is what lets char mean “a Unicode scalar value” rather than “whatever these bytes turned out to be”, and it is why there are no replacement characters anywhere in the language: there is nothing to repair.

Bytes and characters

A string is measured and indexed in bytes, and decoded into characters:

var s = "naïve"

var chars = 0

for c in s.chars do chars += 1

print("bytes:", s.len, "chars:", chars, "last byte:", s[s.len - 1])
bytes: 6 chars: 5 last byte: 101

Six bytes and five characters, because ï takes two. s[i] is a u8 — a byte, not a character — and s.chars is a cursor that decodes them one scalar value at a time.

That is Go’s choice rather than Swift’s, and the reason is placement: grapheme clusters need Unicode break tables, tables must not be in a kernel, and making them the default would put s.len at O(n) for every program that only wanted a byte offset. Grapheme clusters are a library built over this, not the thing underneath.

A substring shares its parent’s bytes and costs no copy:

var path = "/usr/local/bin"

print("front:", path[..<4], "back:", path[11..], "whole:", path.len)
front: /usr back: bin whole: 14

Two checks happen there rather than one. The bounds are checked, as on any slice — and the ends are checked for landing between characters. Slicing through the middle of a multi-byte character traps, because the alternative is a string that is not valid UTF-8, and the whole type rests on that not being possible.

The sharing has the hazard Go’s has: a two-byte substring of a two-megabyte string keeps the whole buffer alive. The operation that copies out of it is named — s.copy() — and the hazard is documented rather than encoded in a second type.

Joining

+ joins two strings and += appends onto a slot. Both allocate a fresh buffer; UTF-8 is closed under concatenation, so nothing is re-validated.

var name = "sysl"
var greeting = "hello, " + name

greeting += "!"

print(greeting, greeting.len)
hello, sysl! 12

+ is strict. It joins a string to a string and to nothing else:

print("n=" + 5)
'+' needs matching types, got string and int

That is the same no-implicit-conversion stance the numeric operators take. The written conversion is str(x), which renders a value into its string form:

print("count: " + str(3 * 14))
print(str(true) + " " + str('é') + " " + str(2.5))
count: 42
true é 2.5

Every case but a string allocates a fresh buffer. A bool renders to one of two literals and allocates nothing at all, and a struct or an enum renders through its Display implementation.

Interpolation

Writing str at every splice gets old, so a literal may carry a prefix:

var n = 42
var word = "left"
var ratio = 2.0 / 3.0

print(s"n is $n, and twice that is ${n * 2}")
print(f"[${n}%6d] [${word}%-6s] ${ratio}%.3f")
print(raw"a\nb")
n is 42, and twice that is 84
[    42] [left  ] 0.667
a\nb

s"…" splices $name or ${ expression }, each rendered by str — so s"a${e}b" is exactly "a" + str(e) + "b", and interpolation is not a new kind of value. f"…" adds one thing: a hole may be followed by a printf specifier controlling width, precision and justification, checked against the value’s type while compiling. raw"…" leaves a backslash as an ordinary character.

Note where the specifier sits — after the hole, not in a separate format string at the front. The value and the way it is formatted stay next to each other, which is the whole reason for the spelling.

Literals that span lines

var doc = """
    to whom it may concern:
    the indentation you see here is not in the value
    """

print(doc.len)
print(doc)
73
to whom it may concern:
the indentation you see here is not in the value

The content starts on the line after the opening delimiter, and each line’s incidental indentation is dropped — the strip is the least-indented line with content, together with the closing delimiter’s own line when it sits alone. So the closing delimiter is the control: move it left and the value keeps more indentation, right and it keeps less.

This matters more in an indentation-sensitive language than elsewhere. A block written inside a deeply nested body would otherwise carry that body’s indentation into its value, and how deep a piece of code sits is not something its data should record.

Trailing blanks are dropped too, since whitespace at the end of a line is invisible in a source file. A trailing space that is meant is written \u{20}, which survives because escapes are decoded after the trimming. And a \ at the end of a line joins it to the next, which is what the form is really for: a blob of embedded data written over twenty lines is a single constant, where the same data assembled with + would allocate and copy once per piece.

Comparison, and matching

== and < compare the byte sequences, which for well-formed UTF-8 is also codepoint order. Normalization is not applied — a composed é does not equal a decomposed one:

describe(cmd: string) -> string
    cmd match
        "add" -> "combines"
        "del" -> "removes"
        _     -> "unknown"

print(describe("add"), describe("nope"), "add" < "del")
combines unknown true

Swift compares by canonical equivalence, which is right for user-facing text and surprising in systems code, where a string is usually a path, a device name, or a protocol token that has to compare as the bytes it is. Normalization is a library operation, applied where it is wanted and visible when it costs something.

Building text a piece at a time

+= in a loop copies everything gathered so far on every step. StrBuilder keeps one growable buffer instead:

import sysl.text.str_builder

var b = str_builder()

b.push("items:")

for i in 1..3
    b.push_char(' ')
    b.push(str(i))

print(b.finish())
items: 1 2 3

The two ways in are the two that keep the guarantee: a push takes a string and a push_char takes a character, and UTF-8 is closed under appending either — so finish hands back a plain string that nobody has to validate. That is exactly why a builder is not a Writer: a public write taking arbitrary bytes would be an unchecked constructor with a friendlier name.

Coming from bytes

Bytes a program computed are the one route into a string that can fail, so it is the one that returns a Result:

import sysl.text.from_utf8

var good: [3]u8 = [104, 105, 33]
var bad: [2]u8  = [104, 255]

from_utf8(good) match
    Ok(s)  -> print("text:", s)
    Err(e) -> print("bad at", e.offset)

from_utf8(bad) match
    Ok(s)  -> print("text:", s)
    Err(e) -> print("bad at", e.offset, "truncated:", e.truncated)
text: hi!
bad at 1 truncated: false

The error carries the offending offset and one distinction a caller can act on: whether the input merely ended mid-sequence, which more bytes would fix, or holds something no continuation could rescue.

The bytes are copied rather than viewed, and that is deliberate — a slice is writable, so sharing would let a later write change something that had already been checked. Copying is what makes the validation mean anything afterwards.

Talking to C

A sysl string carries a length and may hold a NUL as an ordinary byte, so there is no free conversion to the shape C reads. For a literal there is no conversion needed at all — the compiler emits a NUL after every string literal in read-only data, and c"…" is that constant’s address:

extern printf(fmt: *u8, ...) -> int

printf(c"%d items\n", 7)
7 items

No allocation, no copy, no runtime. For a string that is not a literal, cstring(s) allocates a NUL-terminated copy and hands back a CString that owns it — and the hazard the explicitness exists for is worth stating as an equation: for cstring("a\0b"), cs.len is 3 and C’s strlen(cs.ptr) is

  1. Both are right, and neither can be made into the other.

Literals cost nothing

A string literal is bytes in read-only data with no owner at all — the owner word is null, and retain and release both test for that and do nothing. So a literal needs no allocation, no refcount traffic, and not even an instruction to build.

That is what lets allocator-free code hold, pass, compare and slice strings: panic messages, device node names, format fragments. Anything derived from a literal by slicing is immortal too, because it shares the owner — which is to say it shares having none.

The rule for the whole type is that the type is not gated, the allocating operations are. Holding, passing, comparing, indexing, slicing and iterating any string are free; the ones that make new bytes — +, str, copy(), from_utf8, str_builder, cstring — are the ones a no alloc module may not reach.


Next: enums and patterns — where Option and Result come from, and what match can really do.

Search

Esc
to navigate to open Esc to close