6. Errors are values

The last page of Mahler's Ninth is marked "ersterbend" — dying away. Nothing about it is a surprise; the movement has been saying so for twenty minutes.

Chapter 1 admitted that the receipt tells two lies, and promised this chapter would take them apart. Here is the second one, doing its work:

fn main() -> !int {
    let rows = """
        espresso,340
        pastry,lots
        tip,100
        """
    var total = 0
    for row in rows.lines() {
        var i = 0
        while row[i..i + 1] != "," { i += 1 }
        total += row[i + 1..].to_int() else 0
    }
    print("{"total":<10}{total:>5}")
    0
}
$ lupin lying.lu
total       440

The receipt is wrong and it is confident. A row said lots, else 0 decided that meant nothing, and 440 went out the door as though somebody had checked it. Nobody did. The program has no bug in the sense of a crash; it has a bug in the sense that it lies to its user, quietly, for as long as the input stays malformed.

Wolf’s answer is that failure is a value with a type, the type is written in the signature, and the caller cannot fail to notice it.

6.1 !T and the row

to_int() has a type this book has been rounding off since chapter 1. Here is one written out in full:

fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn main() -> !int {
    print("{comma("tip,100") else -1} {comma("tip 100") else -1}")
    0
}
$ lupin comma.lu
3 -1

Read int ! {no_comma} as “an int, or the failure no_comma.” The ! separates the success type from the row: the set of tags this function may fail with, written between braces, and complete. A tag is returned like any other value, either with return or as the last expression, and no wrapping ceremony stands between you and it. The tag is lowercase because it carries nothing: the compiler reads a CapCase mark as a promise that a payload waits inside, and W0603 is what it says when the case and the payload disagree.

The row grows when the function can fail in more than one way, and a tag carries a payload when the caller needs the details:

struct Bad { at: int, found: str }
fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
    let at = comma(row)?
    let text = row[at + 1..]
    text.to_int() else { return NotANumber(Bad { at: at + 1, found: text }) }
}
fn main() -> !int {
    print("{cents("espresso,340") else -1}")
    print("{cents("tip 100") else -1}")
    print("{cents("pastry,lots") else -1}")
    0
}
$ lupin cents.lu
340
-1
-1

cents announces two failures. no_comma is a bare tag: the fact that there is no comma is the whole story. NotANumber(Bad) carries a struct with the byte offset and the text found there, because a caller that wants to point at the problem needs to know where it is. The signature is now a complete account of what a call can do (one int and two named ways of not producing one), and it fits on a line.

When several signatures share a row, the row can be named once. error ReadErrors = {no_comma, NotANumber(Bad)} declares no new type. It is a spelling: int ! ReadErrors and int ! {no_comma, NotANumber(Bad)} are the same type, in both directions, with nothing to convert. A name inside a row stands for its tags, so a wider row can be built out of a narrower one, and ? moves between them the way it moves between any two rows:

struct Bad { at: int, found: str }
error ReadErrors = {no_comma, NotANumber(Bad)}
fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn cents(row: str) -> int ! ReadErrors {
    let at = comma(row)?
    let text = row[at + 1..]
    text.to_int() else { return NotANumber(Bad { at: at + 1, found: text }) }
}
fn short(row: str) -> int ! {ReadErrors, too_long} {
    if row.len > 12 { return too_long }
    cents(row)?
}
fn main() -> !int {
    print("{cents("espresso,340") else -1} {cents("tip 100") else -1} {short("pastry,lots") else -1} {short("espresso,340,x") else -2}")
    0
}
$ wolf run readerrors.lu
340 -1 -1 -2

short’s row is {no_comma, NotANumber(Bad), too_long}, and a diagnostic about it names tags, never the alias: a match that forgets one is told which, too_long by name, because the tags are what a caller has to handle. The alias saves the writer a line and costs the reader nothing.

The whole row need not be closed. A row that ends in .. is open: it says these tags, and possibly more, which is what a function wraps around a subsystem still under construction has to say. The price is symmetric and immediate: no caller of an open row can ever handle it exhaustively by name, so every one of them needs a catch-all. Closed rows are the default in this book for that reason. A tag no row declares is CapCase whatever it carries, which is the pact’s one exception: with no signature to name it, its case is the only thing that says it is a tag and not a name.

Coming from Python: an exception is not in the signature, so the complete list of what a call can raise is a property of the whole call tree below it, discoverable by reading all of it or by running into the ones that matter in production. Wolf puts the list in the type. The cost is that a function’s failures are part of its interface: add a tag and every exhaustive caller stops compiling, which is disruptive in exactly the way that a new failure mode is disruptive. Python defers that conversation and wolf holds it at compile time. Both cost something, and the one that surprises you at 3am is not the one with the list.

Exercise 6-3 (extension · lupin). Grow the row: add a too_long variant for inputs over four bytes and handle it. What else did you have to change, and what told you?

Exercise 6-4 (comprehension · lupin). The error carries a payload. Predict both printed lines of a digit(s, i) whose NotDigit(Bad) records the byte offset and the character it found, when it is called on byte 1 of "4x".

Exercise 6-6 (comprehension · lupin). The row below ends in .., which makes it open: probe may return tags the signature never lists. Predict all three numbers, and say which arm of the match handles probe(-1) and why the program compiles at all when Weird appears nowhere in any signature:

fn probe(n: int) -> int ! {Io(int), ..} {
    if n < 0 { return Weird }
    if n == 0 { return Io(4) }
    n
}
fn code_for(n: int) -> int {
    probe(n) else |err| {
        match err {
            Io(code) => 0 - code,
            _ => 0 - 99,
        }
    }
}
fn main() -> !int {
    print("{code_for(7)} {code_for(0)} {code_for(-1)}")
    0
}

6.2 ?, else, else |err|

A caller of a fallible function has exactly three things it can want: hand the failure to its caller, substitute a value, or decide by case. Wolf spells them with one character, one keyword, and match, and those three are the whole of how a row is handled: no operator reads a !int, no cast narrows one, and nothing happens to the failure that the code does not spell. This section is the first two, and the third in the form it takes on a bound failure; §6.6 is the third on its own.

? hands it up, and it has already appeared: the first line of cents above is let at = comma(row)?. Read ? as “or hand it up.” If comma succeeds, at is the byte offset and the next line runs. If it fails, cents stops there and returns that failure itself: the same tag, with no wrapping to undo, which is why no_comma appears in cents’s row without cents ever mentioning it in code. Rows compose by union: everything the callee can fail with joins everything the caller can, and the signature stays a complete account without anybody maintaining it by hand.

else substitutes. All of its forms are one construct:

fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn main() -> !int {
    let a = comma("tip,100") else 0
    let b = comma("tip 100") else 0
    let c = comma("tip 100") else |err| {
        print("no comma; treating the whole row as a name")
        "tip 100".len
    }
    print("{a} {b} {c}")
    0
}
$ lupin else.lu
no comma; treating the whole row as a name
3 0 7

else 0 is a default. else |err| { … } is a handler: the failure is bound to a name and the block’s value becomes the expression’s, so a handler is the other branch of a value, not a statement bolted onto the side. Between them sits else { … } with no binding, for when the fact of failure is enough. And ? is what you write when the answer is “not my decision”, the case that used to be four lines of plumbing.

A handler whose answer is to stop has no value to give, and it does not need one. A function declared -> never never returns ([type.fn.never]), so a call to it fits wherever a value is wanted, an int here:

fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn die(why: str) -> never {
    print(why)
    assert(false)
}
fn main() -> !int {
    let at = comma("tip,100") else |_| die("no comma: the ledger is corrupt")
    print("comma at {at}")
    0
}
$ wolf run die.lu
comma at 3

never is written only as a return type, and the compiler holds the body to it: a -> never body that can reach its end, or that holds a return, is E0401. die ends in assert(false), which stops the program with trap(assert); a call to another -> never function, or a loop with no break, would end it as well. Feed it "tip 100" and the handler prints its line and nothing after the call runs.

When the answer is your decision and the tags differ, match on the bound failure and the exhaustiveness rules from §3.4 apply to the row:

struct Bad { at: int, found: str }
fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
    let at = comma(row)?
    let text = row[at + 1..]
    text.to_int() else { return NotANumber(Bad { at: at + 1, found: text }) }
}

Both functions are §6.1’s, unchanged. The new part is the caller:

fn report(row: str) -> int {
    cents(row) else |err| {
        match err {
            NotANumber(bad) => {
                print("row `{row}`: byte {bad.at} starts `{bad.found}`, which is not a number")
                0
            },
            _ => {
                print("row `{row}`: no comma anywhere")
                0
            },
        }
    }
}
fn main() -> !int {
    print("{report("espresso,340")}")
    print("{report("pastry,lots")}")
    print("{report("tip 100")}")
    0
}
$ lupin report.lu
340
row `pastry,lots`: byte 7 starts `lots`, which is not a number
0
row `tip 100`: no comma anywhere
0

The payload is in scope inside its arm; bad.at and bad.found are the struct’s fields. Detail belongs in a tag, not in a printed string: the fact travels to the code that owns the policy, which decides whether to print it, point at it, or count it.

Coming from Go: if err != nil { return err } and ? do the same job. Go’s version is a statement you can forget to write, and the compiler cannot tell the difference between a _ you meant and a _ you regretted; wolf’s version is part of the expression, so an unhandled failure is a type error, not a style violation. What Go buys with the verbosity is that every propagation is visible at a glance, which is why ? is a character at the end of the call rather than an invisible unwinding: the propagation is still written down, one glyph long.

Finally, the failure that nobody handles. main returns !int, so ? works there too, and a failure that reaches the top ends the program:

fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn main() -> !int {
    let at = comma("tip 100")?
    print("comma at {at}")
    0
}
$ lupin top.lu
error: no_comma
$ echo $?
1

The tag on stderr and exit 1 is the fourth verdict alongside chapter 1’s three, and the barest of them: a tag, a stream, and a number, with no clause tag and no span, because nothing here is a fault. The program did what it was told and the thing it was told to do failed. A failure that reaches main is a program that declined to decide; §6.4 is the deciding.

Exercise 6-14 (comprehension + extension · lupin). An RPN evaluator is a loop and a stack, and the stack is a List[int]; words() (§2.5) is the tokenizer, so "3 4 + 2 *".words() is the input. Each operator pops twice, and pop answers int ! {none}, so spell it (mut stack).pop()? and give evaluate the row ! {none, parse}, because to_int fails on a token that is not a number. Trace the stack after each token on paper, then run. Then feed it 3 + and 3 x + and say, from the row and not from the code, which call answers each one and where your else |err| prints.

Exercise 6-1 (fingers · lupin). Write parse so empty input is an error, and give two call sites: one defaulting with else 0, one with else 7. Predict both prints first.

Exercise 6-2 (comprehension · lupin). chain calls parse through ?. Predict a and b, and name which row variant b’s handler sees.

Exercise 6-7 (extension · lupin). head prints a file’s first n lines, and a file with fewer than n lines is not a crash, it is an answer. Write head(text, n) whose error carries how many lines actually existed, and a caller that asks for 2 lines (succeeds) and 5 lines (handled). Why does the payload belong in the error instead of being printed by head itself?

6.6 match over the value

report in §6.2 made its decision in two steps: else |err| told the value from the failure, and a match on err told the failures from each other. When every outcome is a case, including the value, the match can stand on the call itself. cents is §6.1’s, unchanged:

struct Bad { at: int, found: str }
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," {
            let text = row[i + 1..]
            return text.to_int() else { return NotANumber(Bad { at: i + 1, found: text }) }
        }
        i += 1
    }
    no_comma
}

The caller matches on the call:

fn describe(row: str) -> str {
    match cents(row) {
        no_comma => "no comma",
        NotANumber(bad) => "`{bad.found}` at byte {bad.at} is not a number",
        0 => "nothing owed",
        n => "{n} cents",
    }
}
fn main() -> !int {
    print(describe("espresso,340"))
    print(describe("water,0"))
    print(describe("pastry,lots"))
    print(describe("tip 100"))
    0
}
$ lupin rowmatch.lu
340 cents
nothing owed
`lots` at byte 7 is not a number
no comma

The scrutinee is an int ! {no_comma, NotANumber(Bad)}, and the arms are of two kinds. A row arm names a tag of the row and binds its payload when it has one: no_comma and NotANumber(bad) are the two here, and bad is a Bad inside its arm exactly as it was in §6.2. A value arm is any pattern over the int, written as a match over an int would write it: 0 is a literal and n binds the rest. Which kind an identifier is, the row decides. An arm that names a tag of the scrutinee’s row is a row arm; anything else is a value pattern, so n binds the number because no tag is called n. A tag whose name is also a constructor reachable from the value’s type is refused by name rather than guessed at, which is the one way the rule can be ambiguous and the compiler’s answer to it.

Coverage is §3.4’s rule applied twice. The match must cover every tag of the row and the whole of the int, and here two tags and a binding do it. _ covers what is left on both halves at once: it is what a caller of an open row writes, because no list of names is complete there (§6.1), and what you write when the value does not matter.

The expression’s type is the arms’ type, str, and the row is gone. describe returns a plain str with no ! in its signature, because every tag was answered inside the match. That is the same consumption else performs, done by case.

Forget a tag and the compiler names it. The n arm is no defense: it binds every int and no tag, because the two halves are covered separately.

struct Bad { at: int, found: str }
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," {
            let text = row[i + 1..]
            return text.to_int() else { return NotANumber(Bad { at: i + 1, found: text }) }
        }
        i += 1
    }
    no_comma
}
fn main() -> !int {
    let owed = match cents("tip 100") {
        NotANumber(bad) => 0 - bad.at,
        n => n,
    }
    print("{owed}")
    0
}
error[E0801]: this `match` does not cover `no_comma`
  --> ./s7.lu:17:16
   |
17 |     let owed = match cents("tip 100") {
   |                ^^^^^^^^^^^^^^^^^^^^^^^^
18 |         NotANumber(bad) => 0 - bad.at,
19 |         n => n,
20 |     }
   | ^^^^^ not every value is matched
   |
   = note: over a fallible value a row arm covers one tag of the row, a value pattern covers values
     of the ok type, and `_` covers whatever is left on both halves ([type.row.match]).
   = note: add arms for the missing cases, or end the `match` with a `_` arm to catch the rest
     deliberately.

The missing arm is named by its tag, never by an alias: a row spelled ReadErrors would still be reported as no_comma, for §6.1’s reason, that the tags are what a caller has to handle.

else |err| match err { … } keeps working beside this, and the choice between them is about shape. Reach for match over the call when the value is one of the cases: when 0 means something different from 340, or when the arms all produce the same kind of answer and the value is one answer among several. Reach for else |err| when the value is the whole function and the failure is a detour: report wants to return the number and spend its words on the failures, and binding the failure keeps the number out of the arms. A match whose value arm is v => v beside three row arms is else |err| match err wearing more braces, and the shorter spelling is the honest one.

Exercise 6-15 (comprehension · wolf + lupin). Exercise 6-6’s code_for handles probe’s open row through else |err| and a match. Rewrite it as one match over probe(n) itself, with the arms Io(code), v and _. Predict the three numbers, then say what _ covers when v already binds every int, and why the program does not compile without it.

6.3 errdefer

defer from §4.3 runs on every exit. Some cleanup should run only on the bad one: a half-written file wants deleting, a half-built index wants discarding, and neither should happen when the work succeeded:

fn comma(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return i }
        i += 1
    }
    no_comma
}
fn scan(text: str) -> int ! {no_comma} {
    print("scan opened")
    defer print("scan closed")
    errdefer print("partial results discarded")
    var total = 0
    for row in text.lines() {
        total += comma(row)?
    }
    total
}
fn main() -> !int {
    print("a={scan("tip,100") else -1}")
    print("b={scan("tip 100") else -1}")
    0
}
$ lupin errdefer.lu
scan opened
scan closed
a=3
scan opened
partial results discarded
scan closed
b=-1

Two runs of the same function, and the difference is one line of output. The success path fires the defer and skips the errdefer. The failure path fires both, errdefer first: they unwind as one stack in reverse registration order, so the error-only cleanup that was registered later runs before the unconditional cleanup registered earlier. Ordering follows the same rule defer already taught; the only new thing is the condition.

The shape to keep is the pairing. Acquire, then errdefer the release, in adjacent lines, so the failure path is correct by construction rather than by remembering to write a handler at every ? below it. There are five ?s in a real parser and one errdefer.

Credit where it is due: errdefer is Zig’s, name and all, and so is the discipline of an error path you can read without following a stack unwinder. Wolf’s departure is the row: Zig’s error set is inferred by default, which makes the set cheap to write and hard to read at a boundary; wolf spells it on items for the same reason it spells parameter types on items. The try keyword’s job is ?’s job, and the catch |err| shape is where else |err| comes from.

Exercise 6-5 (comprehension · lupin). errdefer runs only on the error path. work(true) succeeds; work(false) fails after the errdefer is registered. Predict all four output lines.

6.4 Hardening by refactor

The receipt at the head of this chapter has both of chapter 1’s lies in it. Here they are, retired one construct at a time. Nothing below is a rewrite: each step changes one thing and each step runs.

Step 0 — the lie, and the trap under it. Feed the original a row with no comma at all, and the while walks off the end of the string:

fn main() -> !int {
    let rows = """
        espresso,340
        pastry 275
        tip,100
        """
    var total = 0
    for row in rows.lines() {
        var i = 0
        while row[i..i + 1] != "," { i += 1 }
        total += row[i + 1..].to_int() else 0
    }
    print("{total}")
    0
}
$ lupin step0.lu
step0.lu: trap(bounds): byte range 10..11 is outside a 10-byte string [mem.ub.defined] at 10:15
$ echo $?
3

The trap is doing us a favor by being loud, and it is still the wrong answer: a malformed row is not a defect in the program, it is data the program should have an opinion about.

Step 1 — name the failure. Bound the scan, and give the function a row:

fn cents(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return row[i + 1..].to_int() else 0 }
        i += 1
    }
    no_comma
}
fn main() -> !int {
    let rows = """
        espresso,340
        pastry 275
        tip,100
        """
    var total = 0
    for row in rows.lines() {
        total += cents(row)?
    }
    print("{total}")
    0
}
$ lupin step1.lu
error: no_comma
$ echo $?
1

The trap is gone and the program now stops on purpose, which is progress even though the output got worse. ? was the right first move because it is the smallest one: it says “this is not my decision” and pushes the question up one level, where the next step can answer it.

Step 2 — decide, badly. The one-word answer is else:

fn cents(row: str) -> int ! {no_comma} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," { return row[i + 1..].to_int() else 0 }
        i += 1
    }
    no_comma
}
fn main() -> !int {
    let rows = """
        espresso,340
        pastry 275
        tip,100
        """
    var total = 0
    for row in rows.lines() {
        total += cents(row) else 0
    }
    print("{total}")
    0
}
$ lupin step2.lu
440

Which is where we started: 440, confidently wrong, and now wrong on purpose in a place you can find. The decision has moved from to_int’s default into the caller’s loop, where the person who knows what a receipt is can see it. But else 0 is still the posture that turns “your input is broken” into “your total is smaller”.

Step 3 — decide, out loud. Two tags, a handler that reports what it skipped, and a count so the total is never quoted without its caveat:

struct Bad { at: int, found: str }
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
    var i = 0
    while i < row.len {
        if row[i..i + 1] == "," {
            let text = row[i + 1..]
            return text.to_int() else { return NotANumber(Bad { at: i + 1, found: text }) }
        }
        i += 1
    }
    no_comma
}

cents grew one tag and one nested else, and its signature says so. The loop is where the decision now lives:

fn main() -> !int {
    let rows = """
        espresso,340
        pastry 275
        tip,100
        """
    var total = 0
    var skipped = 0
    for row in rows.lines() {
        total += cents(row) else |err| {
            skipped += 1
            match err {
                NotANumber(bad) => print("skipped `{row}`: `{bad.found}` is not a number"),
                _ => print("skipped `{row}`: no comma"),
            }
            0
        }
    }
    print("{"total":<10}{total:>5}  ({skipped} row(s) skipped)")
    0
}
$ lupin step3.lu
skipped `pastry 275`: no comma
total       440  (1 row(s) skipped)

The number is the same 440 and the program is a different program. It knows what it skipped, it says so on the way past, and it reports the total with the fact attached. Three constructs did that: a row in the signature, ? inside cents for the failure it will not decide, and one else |err| at the level that can.

Rank the three postures the next time you write one. ? says the caller decides. A handler says I decide, here, visibly. else 0 says nobody decides. And a default is a decision, made in the one place that cannot know whether zero is safe.

Exercise 6-9 (extension · lupin). Three postures toward the input "7x": trap on it, default it to zero, or refuse it out loud. The mark the third one hands up keeps nothing, so it is a lowercase bare word, by §6.1’s pact. Predict all three printed lines of a main that calls parse_or_zero("7x") and then parse("7x") with a handler. Then the pointed part: rank the three postures for a program that reads numbers from a config file, and defend last place.

6.5 Capstone: wordcount

Part 1 ends with the program that started the language. Before wolf had a compiler it had a syntax argument, and the argument was settled by writing one program three ways: count the words in some text and report the most frequent. Everything in it is something you have now written: let and var, expressions, functions, closures, a List and a Map, a generic-shaped helper, and errors that are values.

The usage text and the helper that decides whether a word is new:

let USAGE = """
    usage: wordcount TEXT
    Count the words in TEXT and report the most frequent.
    """

fn index_of(xs: List[str], s: str) -> int {
    var i = 0
    while i < xs.len {
        if xs[i] == s { return i }
        i += 1
    }
    -1
}

USAGE is a top-level let: a value the whole file can read, dedented by its closing """ exactly as in chapter 2.

The counter, and the two small helpers it strips punctuation with. count returns two containers, which is what §5.1’s tuples are for: the tally, and the order the words arrived in.

fn is_mark(c: str) -> bool {
    c == "." || c == "," || c == ";" || c == "!" || c == "?"
}

fn bare(word: str) -> str {
    var a = 0
    var b = word.len
    while a < b && is_mark(word[a..a + 1]) { a += 1 }
    while b > a && is_mark(word[b - 1..b]) { b -= 1 }
    word[a..b]
}

fn count(text: str) -> (List[str], Map[str, int]) {
    var order = List[str]()
    var tally = Map[str, int]()
    for word in text.words() {
        let w = bare(word).lower()
        if w.is_empty() { continue }
        if index_of(order, w) < 0 { (mut order).push(w) }
        tally[w] = (tally[w] else 0) + 1
    }
    (order, tally)
}

words() yields byte views into text and copies nothing (§2.5). bare walks two indices inward past any of .,;!? and returns the slice between them, so wolf. and wolf are one word, and lower() makes the count case-insensitive. It is a helper and not a call to trim, because trim and its family take no argument ([mem.str.ws]): they strip whitespace and nothing else, and a call that passes the characters to strip is refused. bare allocates nothing either: a slice is a view. The if w.is_empty() { continue } is there because a lone ! strips to nothing, and a tally of empty strings is not what anybody asked for. The counting line is §5.1’s, unchanged and for §5.1’s reason: tally[w] answers none on a word’s first appearance, else 0 reads that answer as zero, and the write inserts or replaces. There is no first-time branch, because there is nothing for one to do — the list records first-seen order and the map needs no help.

The ranking, by the scan §5.2 wrote:

fn top(order: List[str], tally: Map[str, int], n: int) -> List[str] {
    var picked = List[str]()
    while picked.len < n {
        var best = ""
        for w in order {
            if index_of(picked, w) < 0 {
                if best == "" || tally[w] > tally[best] { best = w }
            }
        }
        if best == "" { break }
        (mut picked).push(best)
    }
    picked
}

top takes both containers and returns a new List; a return moves, so the caller owns the result and order is untouched for the next call. The if best == "" { break } is the case where the text has fewer than n distinct words, and break leaves the loop with what it has.

And main:

fn main() -> !int {
    let text = """
        The wolf runs, and the moon watches over the wolf.
        The pack answers; the moon does not.
        """
    if text.is_empty() {
        print(USAGE)
        return 2
    }
    let (order, tally) = count(text)
    print("{text.words().count()} words, {order.len} distinct")
    for w in top(order, tally, 4) {
        let name = w[..min(w.len, 12)]
        print("{name:<14}{tally[w]:>7}  {"#".repeat(tally[w])}")
    }
    0
}
$ lupin wordcount.lu
17 words, 11 distinct
the                 5  #####
wolf                2  ##
moon                2  ##
runs                1  #

Sixty lines, six functions, and a histogram. min(w.len, 12) keeps the column honest for a long word by slicing it: checked, as always, and free, because a slice is a view. The return 2 on the usage path is chapter 1’s exit-code contract: the program declined to do the work, and it says so with the code the shell can read. The text is embedded, which is what keeps this program’s output the same on every run and on either tool. Until lupin 0.1.36 it was load-bearing for a second reason as well — the reference interpreter had no filesystem at all — and that reason has retired: the interpreter reads and writes real files now. Chapter 26 reads real files, chapter 30 takes its names from the command line, and main’s if is where a caller’s input arrives in both.

The promise this loop owes you. The for word in text.words() loop in count visits every word on one core. In Part 3 it becomes parallel by changing one call: text.words() becomes a par over the input’s pieces, the tallies merge, and nothing else in this program changes. Signature, types, top, and main all stand. Chapter 13 is where that diff gets checked, and it is the chapter’s own obligation to print it. Hold us to it. If parallelizing this program turns out to need a rewrite, the claim was false and the book will say so on that page.

Exercise 6-10 (extension · lupin). The wordcount loop, grown by one requirement: count words, and separately count words of four bytes or more. Predict both numbers for the line the wolf runs and the moon watches over, then run.

Exercise 6-8 (design). Suppose cents moves into a library, behind a public API used by fifty programs. Argue both sides: should the public signature keep the two-tag row, or coarsen to a single Invalid tag with the detail inside? Name one concrete caller each design serves better, and what each design costs when a third failure mode appears.

Exercise 6-11 (extension · lupin). to_int has no inverse on this shelf, so build one: itoa(n), peeling digits with % 10 and / 10 and prepending. Handle zero and negatives, then close the loop: feed each result back through to_int and make main exit nonzero on any mismatch. Which of your three test values forced a line of code the other two never touch?

Exercise 6-12 (extension · lupin). Exercise 5-11’s decoder answers garbage with garbage: "4w" decodes to nothing, silently. Harden it by refactor: decode(s) -> str ! {empty, bad_run}, where empty input, a count with no letter before it, and a zero-length run are refusals with names. Table four malformed inputs through else |err| and a match. Which refusal required adding a check, and which two fell out of checks the loop already had?

Exercise 6-13 (extension · lupin). A date validator: parse_date("2026-02-29") should refuse, and say why. Split on -, parse the three fields, and return (int, int, int) ! {bad_shape, bad_month, bad_day} — reusing 4-7’s days_in for the day ceiling, leap rule included. Why is bad_shape checked first, and what happens to your month test if it is not?

That is Part 1. You can write single-threaded wolf now: values that hand themselves over, expressions that yield, arithmetic that refuses to lie, checked signatures, containers, type parameters, and failures that travel as values. You have not heard the word “lifetime” once, and there is exactly one sentence you have been asked to take on trust: that assignment hands the value over, and the reason is chapter 7’s.