2. Strings, honestly

"Skin and Bones."— Cage the Elephant

A wolf string is skin and bones: bytes, and a length. Everything in this chapter follows from refusing to pretend otherwise.

Chapter 1’s receipt guessed its column width. Here it measures:

fn comma(row: str) -> int {
    var i = 0
    while row[i..i + 1] != "," { i += 1 }
    i
}
fn main() -> !int {
    let rows = """
        espresso,340
        pastry,275
        tip,100
        """
    var width = 0
    for row in rows.lines() {
        let i = comma(row)
        if i > width { width = i }
    }
    for row in rows.lines() {
        let i = comma(row)
        print("{row[..i]}{" ".repeat(width - i + 2)}{row[i + 1..]:>5}")
    }
    0
}
$ lupin align.lu
espresso    340
pastry      275
tip         100

Two passes: one to learn the widest name, one to print. The columns come from the data instead of from a number you typed and will forget to change. By the end of this chapter every operation in it is one you can account for (§2.5 prices the slices).

2.1 Literals, methods, interpolation

A string literal is written between double quotes and holds text in UTF-8. Braces inside it interpolate: the expression between the braces is evaluated and formatted into place.

fn main() -> !int {
    let item = "tip"
    print("[{item}] [{item:<8}] [{item:>8}] [{item:^8}]")
    print("[{item:.<8}] [{100:0>5}] [{2 + 2}]")
    print("[{{braces}}] [{item.upper()}]")
    0
}
$ lupin specs.lu
[tip] [tip     ] [     tip] [  tip   ]
[tip.....] [00100] [4]
[{braces}] [TIP]

Interpolation keeps the expression where its value lands, so the pair cannot drift apart during a refactor; there is one place to look, and no printf-style format string with an argument list on the other side. Any expression works, not only a name: {2 + 2} and {item.upper()} are as ordinary as {item}. To print a literal brace, double it.

After a colon comes the format spec: an optional fill character, an alignment (< left, > right, ^ centered), and a width. {item:.<8} means left-aligned in eight columns, padded with dots; {100:0>5} means right-aligned in five, padded with zeros. Interpolation belongs to the literal and not to print, so the same braces work in a multiline literal, in a string you never print, and anywhere else a literal is legal, except the raw literal §2.2 introduces.

A dot after the width introduces a precision, which rounds a number and truncates a string:

$ lupin eval '"{3.14159:>8.2}"'
    3.14 : str

The full spec is [[fill]align][+][0][width][.precision][type], and the three remaining pieces are quickly named. + prints a sign on positive numbers ({340:+} is +340). 0 pads with zeros after the sign, so {7:08} is 00000007. And a trailing type letter changes the base or the notation: x and X for hexadecimal, e and E for exponent form ({12345.6789:e} is 1.234568e+04). A float with no precision prints the shortest text that reads back as the same value, which is why a whole-valued f64 prints 12 and not 12.0.

Strings answer questions about themselves by method call:

wolf> "  espresso  ".trim()
espresso : str
wolf> "tip".upper()
TIP : str
wolf> "340".to_int()
340 : i64
wolf> "tip,100".starts_with("tip")
true : bool
wolf> "awoo".repeat(2)
awooawoo : str
wolf> "tip" + "," + "100"
tip,100 : str

upper and lower know Unicode ("é".upper() is "É"); trim removes whitespace from both ends; contains and starts_with answer yes or no; is_empty is the readable spelling of len == 0; + and += join. split cuts a string on a separator and hands back the pieces, find and rfind report where a separator is, and replace swaps one run of bytes for another. §2.4 sets split beside the byte scan that does the same work by hand.

Exercise 2-2 (fingers · lupin). Using format specs, print a two-column table: names left-aligned in 10 columns, numbers right-aligned in 4.

Exercise 2-12 (comprehension + extension · lupin). Center "DEN LOG" in twenty columns of stars, twice: once with a fill-align spec, once by hand with repeat and len. The padding is thirteen, which does not halve. Predict which side gets the extra star under each spelling before you run them, then state the rule.

2.2 Multiline and raw

Text with newlines in it goes between triple quotes, and the common indentation of the block is removed, so a literal can sit where it belongs in the code rather than jammed against the left margin:

fn main() -> !int {
    let who = "reader"
    let note = """
        dear {who},
          this line keeps its two spaces
        and this one does not
        """
    print("[{note}]")
    print("{note.len} bytes, {note.lines().count()} lines")
    0
}
$ lupin note.lu
[dear reader,
  this line keeps its two spaces
and this one does not
]
68 bytes, 3 lines

Three rules are visible in that output. The newline after the opening """ is not part of the string. The indentation of the closing """ sets the margin, and every line is dedented by that much; relative indentation survives, which is why the middle line keeps its two spaces. And the last line ends with a newline, because there is one before the closing delimiter, which is why the ] lands on its own line. Braces interpolate here exactly as they do in a one-line literal.

The margin is not a convention. A layout mistake inside a """ block does not announce itself — it hands you a string whose contents are not what you typed — so the lexer refuses the three ways to make one, a code each. A """ delimiter stands alone on its line, opening and closing alike: text sharing a line with either one is E0103. The closing delimiter’s column is the margin, so a content line that sits left of it has bytes the margin would eat:

fn main() -> !int {
    let note = """
        dear reader
      lost
        """
    print(note)
    0
}
error[E0104]: this line starts left of the string's margin
 --> ./s4.lu:7:1
  |
7 |       lost
  | ^^^^^^ the line starts here
8 |         """
  | -------- the margin is set here
  |
  = note: the whitespace before the closing `"""` is the margin stripped from every content line.
    Indent this line at least that far, or move the closing `"""` left.

The refusal points at both ends of the comparison: the line that is short, and the delimiter whose column decided how short. E0105 is the same comparison read byte for byte rather than by eye — a line indented with tabs against a closing """ indented with spaces is a mismatch even when the two render identically in your editor, because wolf never guesses a tab’s width. And a plain "…" string owes its closure the same way: { opens an interpolation, and an interpolation still open when the line ends is E0102, the string that never closed. The note names both repairs — } to close the hole, {{ for a literal brace.

When the text is full of backslashes, escape archaeology gets old. r"…" turns the escape table off:

fn main() -> !int {
    let path = r"C:\logs\{today}\wolf.log"
    print(path)
    print("{path.len} bytes")
    0
}
$ lupin raw.lu
C:\logs\{today}\wolf.log
24 bytes

In a raw literal nothing is special: \n is a backslash and an n, and the braces are two more characters, not an interpolation. Count the bytes on the page and you have the length.

Exercise 2-5 (comprehension · lupin REPL). Predict all three lengths before evaluating: "\n".len, r"\n".len, r"C:\temp".len.

Exercise 2-13 (fingers · lupin). Make the invisible visible: print "howl\tat\nthe moon" as one line in which each tab shows as <tab> and each newline as <nl>. Two replace calls chain. Why must the program spell its markers in ordinary literals but would have needed raw ones to match \t if the text had carried a real backslash?

2.3 Bytes, honestly

One honesty up front, because it will bite the Python refugee within the hour:

wolf> "é".len
2 : i64
wolf> let row = "tip,100"
wolf> row[..3]
tip : str
wolf> row[4..]
100 : str
wolf> row[3..4]
, : str

len counts bytes. Indexing takes a byte range, half-open: row[..3] is the first three bytes, row[4..] is everything from byte four to the end, and row[3..4] is the one byte between them. An omitted end fills in the boundary.

There is no s[i] that hands you the ith character. The subscript wants a range, always, and a program that asks for a single index does not run.

That absence is a design decision, not a missing feature. After thirty years of Unicode there is no cheap answer to what a character is — only answers that lie at different speeds. A byte is not a code point; a code point is not a grapheme; the letter your reader sees may be several of each. Wolf gives you byte offsets, checked slices, and iterators that spell their unit (bytes(), chars(), words(), lines(), each one named for what it hands you; §2.4 walks them), and it declines to hand you an s[i] whose meaning changes with the alphabet.

A range that runs off the end is a fault, not undefined behavior (§1.5 showed one). A range that stops partway through a character is a fault too:

fn main() -> !int {
    let s = "héllo"
    print("{s.len}")
    print(s[..3])
    print(s[..2])
    0
}
$ lupin utf8.lu
6
hé
utf8.lu: trap(bounds): byte range 0..2 splits a UTF-8 code point [mem.ub.defined] at 5:11

Six bytes for five letters, [..3] is hé because é is two bytes wide, and [..2] stops in the middle of é, so the run stops instead of handing you half a letter. This is the trade wolf makes in the open: you count in bytes, and every slice you take is checked to be a string.

Coming from Python: len("é") is 1 in Python 3 and 2 here, and s[i] gives you a one-character string there and nothing at all here. Python bought that with a representation it chooses per string and a cost model you cannot see. Wolf keeps one representation, tells you the unit, and refuses the operation whose answer depends on which definition of “character” you had in mind; the price is that you count bytes, and §2.5 puts what each operation costs on the page.

bytes() is the iterator that spells this section’s unit, and what it hands you is not a number. A byte is one octet — eight bits, unsigned, 0 through 255, one byte of storage — and it is a type of its own, spelled byte:

fn main() -> !int {
    let a = 65 as byte
    let hi = 200 as byte
    print("{a} {a as int} {hi as int}")
    print("{255 as byte} {256 as byte} {300 as byte} {-1 as byte}")
    print("{hi + hi} {a + 1} {(a + 1) as byte}")
    0
}
$ wolf run byte.lu
65 65 200
255 0 44 255
400 66 66

The posture is the one §2.4 gives char, and for the same reason: a width is a claim, so the type carries it and nothing widens behind your back. No literal adopts it. let b: byte = 65 does not compile, and neither does b == 10 on a byte the string handed you; 65 as byte is the spelling, in every position, a match arm included. Widening is what you write when a byte meets a number, and it cannot fail: every octet is an int, so b as int is total.

Narrowing is the interesting direction. int as byte keeps the value’s low eight bits, and it is the one narrowing cast in this language that does not trap: 255 as byte is 255, 256 as byte is 0, 300 as byte is 44, and -1 as byte is 255. §3.3’s arithmetic traps on overflow and this does not, because a byte exists to hold the low octet of whatever produced it — a mask, a shift, a checksum step — and a trap there would put a ceremony on every one of those lines.

The third line of output is the rule that catches people. Arithmetic on a byte is int’s arithmetic: hi + hi is 400, not a wrapped 144; a + 1 is an int; and a byte at zero minus one is -1. Nothing overflows eight bits because nothing stays in eight bits, and narrowing back is spelled — (a + 1) as byte. That is also why there is no b += 1: the right-hand side of it is an int.

The mask in that list is two operators. & keeps the bits two integers share, and ! on an integer flips every bit of it at its own width ([type.int.not]): !n is -n - 1, total, never a trap. A byte widens to int first, as it does under every bitwise operator, so !b is an int, and the octet’s own complement is spelled with the cast:

fn main() -> !int {
    let n = 300
    let b = 200 as byte
    let low = n & 0xff
    let high = n & !0xff
    let flipped = (!b) as byte
    print("{!n} {low} {high} {!b} {flipped}")
    0
}
$ wolf run mask.lu
-301 44 256 -201 55

n & 0xff is the low octet, the same 44 that 300 as byte keeps; n & !0xff is everything above it. ! on a bool is still logical not, and on a float it is refused.

Then why have the type at all, when every octet fits an int with seven bytes to spare? Because the seven bytes are the price, and they are not the whole of it. The same 65,536 octets held as List[int] charge a region at least seven times what they charge as List[byte] — eight for the width, and again for the buffer a growing list leaves behind it — and §8.9 measures the whole of it on both machines, where the multiple lands at sixteen on one and thirty-two on the other. That is why every builtin handing you raw octets is declared over List[byte]: bytes() here, and fs_read_bytes, fs_read_chunk and str_from_utf8 when a program wants a file’s bytes rather than its text.

The file your program is written in is bytes too, and it has one tolerance worth knowing. A byte order mark at the very start of a .lu file is stripped and is never a diagnostic — an editor that insists on writing one cannot break your build, and the formatter leaves it where it is. That formatter is wolf fmt, and Appendix E is its page. Anywhere else in the file those same three bytes are a stray character, E0107, like any other.

Exercise 2-1 (comprehension · lupin REPL). Predict all three before evaluating: "wolf".len, "é".len, "🐺".len.

Exercise 2-3 (comprehension · lupin). "wolf" has four bytes. Predict the exact behavior of:

fn main() -> !int {
    let s = "wolf"
    let t = s[2..9]
    print(t)
    0
}

Exercise 2-6 (comprehension · lupin REPL). "wolf" has four bytes. Predict each of these, precisely, as a value or an event: "wolf"[..2], "wolf"[2..], "wolf"[4..4], "wolf"[3..2].

2.4 Iterating meaning

When you want units rather than bytes, ask for the unit by name. lines() walks lines; words() walks whitespace-separated words. The method’s name is the definition of what you get, which is the point:

fn main() -> !int {
    let log = """
          espresso,340
        pastry,275

        tip,100
        """
    var rows = 0
    for line in log.lines() {
        let row = line.trim()
        if row.is_empty() { continue }
        rows += 1
        print("{rows}: [{row}] {row.words().count()} word(s)")
    }
    0
}
$ lupin iterate.lu
1: [espresso,340] 1 word(s)
2: [pastry,275] 1 word(s)
3: [tip,100] 1 word(s)

espresso,340 is one word: words() splits on whitespace and has no opinion about commas. The blank line was skipped by trim and is_empty, in the two places you can see, and by nothing else. count() counts what an iterator yields, so lines().count() and words().count() are line and word counts with no argument about which unit was meant.

One unit is missing from that list, and §2.3 told you why it is hard: the character. Wolf’s answer is a type. A char is one Unicode scalar value, the unit UTF-8 encodes, which is exactly why it can hold what a str holds and nothing a str could not. chars() is the iterator that spells it:

fn main() -> !int {
    let word = "naïve"
    print("{word.len} bytes, {word.chars().len} chars")
    for c in word.chars() {
        print("{c} = {c as int}")
    }
    let composed = "é"
    let decomposed = "e\u{301}"
    print("{composed.chars().len} {decomposed.chars().len} {composed == decomposed}")
    0
}
$ wolf run chars.lu
6 bytes, 5 chars
n = 110
a = 97
ï = 239
v = 118
e = 101
1 2 false

The output shows three decisions. Six bytes, five chars: a char is wider than a byte when it needs to be (ï is one char, two bytes), and the byte tier stays where §2.3 put it, in len and the checked slices. Each c prints as the character it is, never as a number, because a char is its own type, not an integer: let c: char = 65 refuses to compile, there is no arithmetic on chars, and when the scalar’s number is what you want you say so; c as int is the spelled-out direction, and it is total.

The cast back is not. n as char is checked the way every arithmetic operation in this language is checked, and it traps when n names no scalar: negative, past 0x10FFFF, or inside the surrogate gap that UTF-16 reserved and UTF-8 refuses.

fn main() -> !int {
    let n = 55296
    let c = n as char
    print("{c}")
    0
}
$ lupin cast.lu
cast.lu: trap(overflow): `55296 as char`: the value is 0xD800, inside the surrogate gap 0xD800..=0xDFFF — it names no character, and admitting it would mint a `char` that cannot be UTF-8-encoded (D24), so the cast traps ([type.char.cast], D56's family) [type.char.cast] at 3:13

The trap line says why for itself: admitting 0xD800 would mint a char no str could encode. Comparisons follow scalar-value order: 'a' < 'b', and also 'z' < 'é', because 122 is less than 233. That is order, not alphabetization; sorting for a human reader is a collation library’s job.

The last line of the chars-walk output is the third decision, and it is the one the type cannot spare you: a char is not a grapheme, not “what the reader sees”. é typed precomposed is one scalar; the same glyph typed as e plus a combining accent is two, in a string three bytes long, and the two strings do not compare equal. A char holds exactly one scalar, so the literal form refuses the combining spelling at the lexer, with the note that explains the whole trade:

fn main() -> !int {
    let g = 'e\u{301}'
    print("{g}")
    0
}
error[E0110]: a `char` literal holds exactly one Unicode scalar value
 --> ./s12.lu:5:13
  |
5 |     let g = 'e\u{301}'
  |             ^^^^^^^^^^ the second one starts at byte 2
  |
  = note: a `char` is one scalar, not a grapheme: an accented letter typed as base + combining
    accent is TWO scalars even when it renders as one glyph (spell it precomposed, or keep
    it in a `str`). For text, use a string literal `"…"`.

The refusal names both repairs. Spell the glyph precomposed and 'é' is one char and legal; keep the text in a str and it can hold as many scalars as it likes. What you cannot do is put two scalars in a type whose definition is “one scalar”. The lexer holds the line so the runtime never has to.

Splitting on a separator you choose is row.split(","), and the comma function at the head of this chapter is the same job written by hand: walk forward a byte at a time, comparing single-byte slices, and stop where the separator is. Both look at every byte once, so the scan costs what the method costs; what the scan buys is that you can see the byte offsets, which is the arithmetic every checked slice in §2.5 is made of. Write it once for the understanding, then reach for split.

Exercise 2-4 (extension · lupin). Extend the word counter to also report lines and bytes for a """ multiline block. Predict which of the three numbers is a byte count before running.

Exercise 2-7 (extension · lupin). Write encode, a run-length encoder over bytes: "aaabcc" becomes "a3b1c2". Walk the string with byte slices and equality only. What does your encoder do with the empty string, and did you have to write a special case for it?

Exercise 2-9 (comprehension · wolf). Predict all six numbers before running: for each of "wolf", "é", and "e\u{301}", both .len and .chars().len. Then the pointed half: which of the six could §2.3 have told you, and which one needed this section?

Exercise 2-10 (fingers · lupin). Reverse each word of "the wolf runs", keeping the words in order. Build each reversal with chars() and string joins alone. Then say why reversing the bytes instead would wreck "éland" while this version does not.

Exercise 2-11 (fingers · lupin). Squeeze: delete from "howling at the moon" every character that appears in a second string, here "aeiou". One pass, contains, and a growing result. Which of the two strings does your loop walk, and what happens to the program’s cost if you walk the other one?

Exercise 2-14 (fingers · lupin). Detab: replace each tab in "a\tbb\tccc" with the spaces that carry the column to the next tab stop, stops every four columns. Print the result in brackets so the spacing is checkable. Why is “replace each tab with four spaces” wrong, and which input proves it?

2.5 What the machine does

A slice is a view: two machine words, a pointer and a length, aimed at bytes that already exist. Taking one copies nothing.

fn main() -> !int {
    let row = "espresso,340"
    let name = row[..8]
    let cents = row[9..]
    print("{name} + {cents}")
    print("{row.len} {name.len} {cents.len}")
    0
}
$ lupin view.lu
espresso + 340
12 8 3

You can check the no-copy claim from the values alone. row is still twelve bytes after two slices were taken out of it, because nothing was taken out of it: name and cents are two pointers into the same twelve bytes, with lengths of 8 and 3.

wolf> let row = "espresso,340"
wolf> let name = row[..8]
wolf> name
espresso : str
wolf> name.len
8 : i64
wolf> row.len
12 : i64

The consequence is a cost model you can hold: row[..8] costs the same whether row is twelve bytes or twelve megabytes, so a parser that walks a large input in slices allocates once, at the input, and never again. That is why §2.4’s scan is not the slow way to split a row, and why nothing here needed a second string type: slicing does not build. Building is what + and += do, and building copies; remember that before you put one in a loop over a large file.

The second half of the cost model is the interpolation itself. A format string in C is data the program parses at runtime, matched against an argument list by convention and by hope. Wolf resolves the braces when it compiles the literal, into the formatting calls they name: no format string is left to parse at runtime, so the call and its arguments cannot disagree about how many %s you wrote. The machinery is the compile-time evaluator chapter 18 takes apart, where f-strings turn out to be its most-used customer and the one nobody notices. Whether the result beats C’s printf on your machine is a measurement, and this book does not make measured claims without the measurement: chapter 21 runs the comparison, prints the numbers CI produced, and names the cases where C still wins.

Exercise 2-8 (comprehension · lupin REPL). s is "wolfpack". Predict all four values, then say what slicing s cost: did any of these lines copy eight bytes?