Skip to content
Select theme

Error reporting with line numbers

Step 7 finished the language: every arm of both enums does the thing it was named for, and fact(5) computes 120. What it does not do is fail well. A scan error has printed an address since the scanning step, a parse error the same since the parsing one — and then a runtime error just says what went wrong, with no hint of where. The positions exist: every token has carried a line and a column since the scanner was written. The tree throws them away, and this step stops doing that. By the end, the wrong arity that used to get a bare verdict gets an address:

double(1, 2);
runtime error: 2:7 `double` expects 1 argument, got 2

The previous step is published and tagged, so start from its end state:

Terminal window
git checkout interpreter-ch07
cargo run -q

Still one file, src/main.rs. The scanner is untouched — it has done its job since step 2. The parser changes only in what it stores: it was already holding every token this step needs. The evaluator’s error arms learn to say where. At the end, the same command at the interpreter-ch08 tag runs the same programs, and every runtime error points at the token that caused it.

Three error types, two of them pointing. ScanError and ParseError have carried line and col from the day they were written:

struct ParseError {
line: usize,
col: usize,
message: String,
}

RuntimeError is the odd one out:

struct RuntimeError {
message: String,
}

Not because positions were unavailable — because the tree deleted them. Binary stores op: TokenKind, a bare variant of an enum; Variable stores a bare String. The parser held the whole token, line and column included, and kept a distillation. evaluate therefore has nothing to point with, and the driver prints a message with no address.

The fix is not a parallel position field on each node — it is to stop discarding the token. The variants that can fail at runtime keep the whole thing:

Variable(Token),
Unary { op: Token, operand: Box<Expr> },
Binary { op: Token, left: Box<Expr>, right: Box<Expr> },
Logical { op: TokenKind, left: Box<Expr>, right: Box<Expr> },
Assign { name: Token, value: Box<Expr> },
Call { callee: Box<Expr>, args: Vec<Expr>, paren: Token },
Index { object: Box<Expr>, index: Box<Expr>, bracket: Token },
List(Vec<Expr>),

Each keeps the token that will be to blame. A binary operator that needs numbers knows where it sits; a name that goes missing knows its own position; a call that goes wrong knows which ( opened it; an index that lands out of bounds knows its [. Logical keeps the bare kind on purpose: and and or accept any value — truthiness never fails — so there is nothing to point at.

Token’s derive list grows from Clone to Debug, Clone, PartialEq. The tree’s derives reach through every field, so a token living inside an Expr has to be a full citizen.

On the parser side, the call arm already consumed the (; now it keeps it:

let paren = self.advance();
...
lhs = Expr::Call { callee: Box::new(lhs), args, paren };

The index arm does the same with its [. Function parameters become tokens too — expect_ident hands back the whole token instead of just the name, because the duplicate-parameter check is about to want an address for the offender. Assignment targets need no new code at all: the check that a =’s left side is a Variable already binds what Variable holds, and what it holds is now a token.

With the tokens in the tree, the third error type catches up with the other two:

struct RuntimeError {
line: usize,
col: usize,
message: String,
}

Every failing arm in evaluate fills the fields from the token it kept:

TokenKind::Minus => match value {
Value::Number(n) => Ok(Value::Number(-n)),
_ => Err(RuntimeError {
line: op.line,
col: op.col,
message: format!(
"`{}` needs a number, got {}",
op.lexeme,
type_name(&value)
),
}),
},

A simplification falls out for free: the message quotes op.lexeme, the operator’s own spelling, so the operator_text lookup table that used to translate a TokenKind back into + and <= is dead weight and goes.

The driver prints all three error types in one format now — the same line:col message shape the scan errors have used since step 2:

runtime error: 1:6 `+` needs two numbers or two strings, ...

One verdict stays addressless: return outside a function. The Flow::Return that carries it lands in main’s driver, past every token. Giving it a position would mean threading one through every statement in the tree for a single message, and that trade is not worth it. It stays the one error that says what happened and not where.

Terminal window
cargo run -q
1 + 2 * 3;
"na" + "na";
!(1 == 2);
[10, 20, 30][1];
if (2 > 1) "big"; else "small";
7
nana
true
20
big
var score = 83;
if (score >= 90) "A";
else if (score >= 80) "B";
else "C";
B
var total = 0;
var i = 1;
while (i <= 5) {
total = total + i;
i = i + 1;
}
total;
1
2
3
3
6
4
10
5
15
6
15
fun add(a, b) { return a + b; }
add(2, 3);
5
fun fact(n) {
if (n < 2) { return 1; }
return n * fact(n - 1);
}
fact(5);
120
fun nothing() { return; }
nothing();
fun silent() { 1 + 1; }
silent();
nil
2
nil
fun double(n) { return n * 2; }
double(1, 2);
runtime error: 2:7 `double` expects 1 argument, got 2
"nope"(1);
runtime error: 1:7 a string cannot be called
"na" + 1;
runtime error: 1:6 `+` needs two numbers or two strings, got a string and a number
missing + 1;
runtime error: 1:1 undefined variable `missing`
[10, 20][9];
runtime error: 1:9 index 9 is out of bounds, the list has 2 elements
fun f(a, a) { return 1; }
runtime error: 1:10 duplicate parameter `a` in `f`
return 1;
runtime error: return outside of a function

The programs from last step all still run — nothing above the error arms moved. The errors at the bottom are where the step shows. Each one points at the token that caused it, and the choice of token is a decision in each case. The arity error points at the ( that opened the call, the token that says this is a call, not at the callee or the arguments. The type error points at +, not at either operand — the operator is what made the demand. A missing name points at itself, at 1:1. The out-of-bounds index points at the [ that opened the indexing. The duplicate parameter points at the second copy, because the parameters are tokens now and the offender has an address. And return 1; at the bottom is the one bare message, issued past every token.

birch at interpreter-ch08 fails as well as it runs: scan errors, parse errors and runtime errors all print line:col, and each points at the token that caused it. What the language does not have is a way to sit down and talk to it — every run is a file of source executed top to bottom, driver and all. The next step, A REPL for the interpreter, turns the driver into a loop: read a line, evaluate it, print the value, do it again. Until then, the track overview has the full itinerary.