1. Hello, Wolf

Bruckner's Fourth opens with one horn call over a tremolo — everything the symphony will do, said once, quietly, before anyone explains it.

1.1 A program worth keeping

Books like this one open with a program that prints a greeting. That program is in this chapter too, and you should type it. First, though, a program you might keep.

You have a file of rows: a name, a comma, an amount in cents. The question is what they add up to.

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 }
        let name = row[..i]
        let cents = row[i + 1..].to_int() else 0
        total += cents
        print("{name:<10}{cents:>6}")
    }
    print("{"total":<10}{total:>6}")
    0
}

It answers:

$ lupin receipt.lu
espresso     340
pastry       275
tip          100
total        715

Eighteen lines, and nothing in them is a preview of syntax you meet later — this is the language. let names a value; var names one that changes. """ opens a block literal whose common indentation is trimmed away, so the data sits where you would put it in a file. Braces inside a string interpolate the expression between them, and {cents:>6} means right-align in six columns. row[..i] slices bytes. for walks what lines() hands it.

Two lines lie for convenience. The while loop trusts every row to hold a comma, and else 0 decides that an amount which is not a number is worth nothing. §1.5 shows what the first lie does when the input disagrees, and chapter 6 takes both apart; this program is Part 1’s running example, and it gets more honest in every chapter.

Now the greeting. The smallest wolf program that does anything:

fn main() -> !int {
    print("hello, wolf")
    0
}

main is where a program starts. print writes one line to standard output. The 0 on its own line is the value main returns, and the process exit code — §1.3 makes that concrete. The ! in !int says main may fail instead; chapter 6 is where that becomes a tool, and until then it is the shape main has.

No imports, no enclosing class, no guard against being imported: there is nothing above fn main because there is nothing a wolf file needs above fn main.

Exercise 1-1 (fingers · lupin) — Type the hello program exactly as printed and run it. Then make it print a second line of your choosing.

Exercise 1-3 (fingers · lupin) — Braces interpolate expressions, not only names. Print a sentence that computes 6 * 7 twice inside one string literal.

1.2 Two implementations, one language

Wolf is one binary. wolf compiles your program, runs it, formats it, and answers your editor’s questions; there is no separate package manager, no build-system layer, no second executable to keep in step with the first. Save the greeting as hello.lu and point the compiler at it:

$ wolf build hello.lu && ./hello
hello, wolf

hello is a program. It has no runtime to install beside it, it starts in the time the kernel takes to map it, and you can hand it to somebody who has never heard of wolf. The compiler said nothing on its way there, which is the only thing a compiler should say when it agrees with you.

While you are still editing, the build and the run fold into one command:

$ wolf run hello.lu
hello, wolf

wolf run compiles into a cache beside your file and executes what comes out, so an unchanged program’s second run does not visit the compiler at all. It is the same machinery with the shell step folded in.

Now the same file through the other door:

$ lupin hello.lu
hello, wolf

Nothing was compiled that time. lupin read the program and ran it, and the bytes on your terminal are the same bytes.

Why there are two

lupin is not the compiler with the optimizer switched off. It is an independent implementation of the wolf specification, written from the spec rather than from the compiler’s source, and its day job is to disagree: a program whose behavior differs between the two is a bug in one of them or a hole in the spec, and it gets found before you meet it. A language with one implementation cannot tell the difference between what it specified and what it happens to do.

So the answer to “which one is wolf” is neither. The specification is wolf, and these are two readings of it that have to keep matching. Every page in this book says which of them is speaking, and the pages where they speak in turn are the ones worth slowing down for — §1.5 begins the habit.

Ask either who it is and it tells you:

$ wolf --version
wolf 0.1.0 (wolfgang)
paired with lupin 0.1.8 (reference interpreter), pin 7886559
$ lupin --version
lupin 0.1.10 (wolf-interp, reference interpreter at pin 613c3dc)

Each names the other, and that is not decoration. The compiler’s second line is the interpreter revision it was tested against; the interpreter’s is the compiler revision it was tested against. Two implementations agreeing is worth something only when you can find out which two agreed.

The colophon records the versions this printing is true for; those lines are how you check that they are yours. Output that differs from a book’s printed output is a version question before it is a bug.

Both are cargo projects, and both build the same way on every tier-1 platform (linux x86-64 and aarch64, macOS aarch64, windows x86-64). Given a Rust toolchain and the two repositories, the commands are the ones this book’s own CI runs:

$ (cd wolf-lang && cargo build --bin wolf)
$ (cd wolf-interp && cargo build --release --bin lupin)

Cargo prints its usual progress and leaves wolf under wolf-lang/target/debug/ and lupin under wolf-interp/target/release/. Put both on your PATH, run the two --version commands above, and the rest of the chapter works.

Exercise 1-7 (fingers · wolf + lupin) — Compile the greeting with wolf build, run the binary, then run the source under lupin. Compare the two outputs byte for byte — diff <(./hello) <(lupin hello.lu) will do it. Then say which of the two runs could have printed something different, and what it would mean about the language if it had.

1.3 Scripts before projects

A wolf program can be one file, run in place:

$ wolf run hello.lu
hello, wolf

There is no project to create first, no manifest to write, no directory layout to honor, no virtual environment to activate, no lockfile to drift out of date. The file is the program. Chapter 22 covers what happens when one file stops being enough, and that is a later problem than most languages make it.

Coming from Python: the equivalent Python is about as short —

print("hello, wolf")

— and the difference is not in the file, it is around it. Python’s one-liner needs an interpreter on the machine that runs it, of a version the script never states; wolf’s compiles to a program that states its own requirements by being one. Neither answer is free. Python’s costs you an environment to reproduce; wolf’s costs you a compile, and §1.5 is where you watch what that compile buys.

The value main returns is the process exit code:

$ wolf run hello.lu
hello, wolf
$ echo $?
0

That is the last expression of main, handed to the shell. Wolf has no return ceremony at the end of a function — the last expression of a block is its value, which is a rule you will meet again in every if and match you write.

Loops come in the two shapes you expect. for x in xs walks a sequence; while cond repeats until the condition fails. Both are in §1.1’s program, and the exercises below are where you write one.

Exercise 1-2 (comprehension · lupin) — Before running, write down what this program prints and what echo $? shows afterward:

fn main() -> !int {
    print("working")
    3
}

Exercise 1-5 (fingers + extension · lupin) — The first table in The C Programming Language converts Fahrenheit to Celsius. Write wolf’s: 0 to 120 degrees in steps of 20, one line per row. Then look hard at the 20-degree row. Is it right?

1.4 The REPL: a spec you can interrogate

Run lupin with no arguments and it opens a session. Expressions evaluate and print their value and their type; declarations persist for as long as the session lives.

wolf> 6 * 7
42 : i32
wolf> "wolf".len
4 : i64
wolf> :type "wolf".len
i64
wolf> let howl = "awoo"
wolf> "{howl} {howl}"
awoo awoo : str
wolf> fn double(n: int) -> int { n * 2 }
defined fn `double`
wolf> double(21)
42 : i64
wolf> :quit

Two things in that session are worth more than they look. The type is printed on every line, so a question about types is one keystroke and never a guess: 6 * 7 is i32 because that is what an integer literal infers to, while "wolf".len is i64 because a length is. And :type answers without running the program for effect, which makes the prompt a way to interrogate the language rather than a calculator that happens to speak it.

Because this is the reference interpreter, the answers are the specification’s answers. When a later chapter claims wolf does something, you can ask.

A fault does not end the session:

wolf> let s = "wolf"
wolf> s[2..9]
trap(bounds): byte range 2..9 is outside a 4-byte string [mem.ub.defined] at 0..7
the session survives the trap; the world is as the fault left it [repl.trap.alive]
wolf> s.len
4 : i64
wolf> :quit

The prompt comes back, the world is whatever the fault left behind, and s is still four bytes long. Chapter 2 is where that message stops being a surprise.

One directive to note and set aside: :mem prints the state of the memory model — what exists, what owns it, what is still open. Part 2 is where it becomes the most useful four characters in the tool. :help lists the rest.

Exercise 1-4 (fingers · lupin REPL) — Open the REPL. Compute the number of seconds in a day, ask :type what type that expression has, then define a function mid-session and call it twice. Before you ask :type, write down your guess.

1.5 What run was doing for you

wolf run hello.lu looks like one action. It is six: the file is tokenized, parsed, its names resolved, its types checked, its memory discipline checked, and then — only then — run. lupin hello.lu is the same six. The two implementations part company at the last one, and only there: the compiler writes machine code and hands it to the kernel; the interpreter walks the program itself.

The phases matter because they divide the ways a program can end. There are three verdicts, and the interpreter gives each of them a distinct exit code.

The program never starts (exit 2). Delete a closing brace and the file is not a wolf program at all:

fn main() -> !int {
    print("almost")
    0
$ lupin almost.lu
almost.lu: E0202: the file ends where `}` was required [gram.expr.block] at 45..45
$ echo $?
2

The compiler refuses the same file, and takes more room to do it:

$ wolf build almost.lu
error[E0202]: this `{` is never closed
 --> ./almost.lu:1:19
  |
1 | fn main() -> !int {
  |                   ^ opened here
...
4 |
  | - the parser expected the closing `}` by here
  |

wolf build: the package does not compile; fix the errors above (`wolf --explain E0201` explains any code by name)

Two implementations, one rule, one error code, two voices: the interpreter reports the byte offset where the file ran out, the compiler points at the opener because that is where your hands go. Neither is paraphrasing the other, and neither of them produced a program. wolf --explain E0202 prints the rule behind both.

The program runs and hits a rule (exit 3). §1.1’s scan trusted every row to hold a comma. Here is the row that does not, and the loop alone:

fn main() -> !int {
    let row = "tip 100"
    var i = 0
    while row[i..i + 1] != "," { i += 1 }
    print(row[..i])
    0
}
$ lupin scan.lu
scan.lu: trap(bounds): byte range 7..8 is outside a 7-byte string [mem.ub.defined] at 68..81
$ echo $?
3

This program was legal. It started, it ran, and it walked off the end of a seven-byte string, which wolf defines as a fault rather than leaving it to luck: the run stops, names the operation, the range, the length, and the clause it enforces. Chapter 3 gives faults like this one their own section, and chapter 6 is where the receipt learns to say “this row has no comma” instead.

The program hands back an error (exit 1). The ! in !int has been sitting in every main since §1.1. It says the function may finish with an error instead of a number, and ? is how one travels:

fn main() -> !int {
    let cents = "twelve".to_int()?
    print("{cents}")
    0
}
$ lupin amount.lu
error: NotAnInt
$ echo $?
1

Nothing went wrong with the machine. "twelve" is not a number, to_int said so, ? handed the answer up rather than inventing one, and main ended the process with it. The print never ran. This is the verdict chapter 6 spends itself on; here it is enough to know that an error is a value that travels, and that a program which ends this way ended on purpose.

The thing you can keep

Those three verdicts are what the interpreter reports, and the interpreter is where most of this book’s programs run. What the compiler adds is a file:

$ wolf build hello.lu
$ ./hello
hello, wolf
$ echo $?
0

main’s value is the process’s value in both implementations, so the shell sees the same 0 whichever door the program came through. The binary is built with debug information, so a debugger breaks on the line you name in your own source, in your own file, and prints the numbers in scope there.

Exercise 1-6 (spelunking · lupin) — Delete the closing brace of a working program’s main and run it. Read the whole diagnostic: the code, the message, the clause tag, the span. Then run echo $?. Which of this section’s exit codes is this, and why is it not the code a trap would produce?

Exercise 1-8 (comprehension · wolf + lupin) — Build the greeting, then run wolf build hello.lu a second time without editing the file. Predict what the second build does before you run it, and where it put what it kept.