Error Handling & Runtime Diagnostics

Writing a Compiler/Interpreter: Fundamentals

Chapter 9 · Error Handling & Runtime Diagnostics

Every chapter since Chapter 1 has raised an error of one kind or another — an unterminated string, a missing parenthesis, an undefined variable, a type mismatch — but each used whatever exception felt convenient at the time, with no shared shape and no line number attached to most of them. This chapter fixes that: one small error hierarchy, real line numbers threaded through the lexer and parser and onto every AST node that needs one, and a genuine Wisp-level stack trace for runtime errors — built by hand, since Chapter 7 already established that this interpreter has no call stack of its own to read one from.

Three Categories, One Shared Shape

class WispError(Exception): def __init__(self, message, line=None): super().__init__(message) self.message = message self.line = line def report(self): where = f"[line {self.line}] " if self.line is not None else "" return where + type(self).__name__ + ": " + self.message class LexError(WispError): pass # Chapter 1: unterminated strings, bad characters class ParseError(WispError): pass # Chapter 2: unexpected tokens, missing punctuation class WispRuntimeError(WispError): pass # Chapter 4 onward: type errors, undefined names

Nothing about when each of these three fires has changed since the chapters that introduced them — the lexer still raises during tokenizing, the parser still raises during parsing, the interpreter still raises during evaluation. What's new is that all three now share one report() method and one consistent [line N] ErrorType: message format, instead of three unrelated ad-hoc exception shapes accumulated one chapter at a time.

Verified directly — one consistent report format across all three categories
var x = "never closed; (no closing quote) reports [line 1] LexError: unterminated string. if (5 > 3 { print "no paren"; } (a missing )) reports [line 1] ParseError: expected ), got { ('{'). print 5 + "text"; reports [line 1] WispRuntimeError: operands of '+' must be two numbers or two strings, got float and str.

Threading Line Numbers Through the Whole Pipeline

A line number has to originate somewhere real — the lexer's own scan position — and then survive being carried through every stage after that. Tokens grow a third field: (kind, lexeme, line), computed once per token as 1 + source.count('\n', 0, match_start). The parser then stamps that line onto every AST node it builds — Binary, Unary, Call, Variable, Get, Set, and FunctionStmt each grow a .line field, taken from whichever token triggered that node's construction (the operator for Binary, the opening ( for Call, the identifier itself for Variable).

Every place in the interpreter that already raised a WispRuntimeError — Chapter 4's type checks, Chapter 5's undefined-variable checks, Chapter 7's arity checks, Chapter 8's undefined-property checks — now passes node.line through, since the node being evaluated when the error is detected is always sitting right there in the same method.

Reachability: The Real Difference Between the Three Categories

The categories aren't just cosmetic labels — they genuinely behave differently, and the difference is about when the whole program has to be examined versus only the parts that actually run. Lexing and parsing both process the entire source file before a single statement executes. Interpretation only ever reaches the statements the program's own control flow actually visits.

Verified directly — a parse error inside a branch that never runs still fails immediately
if (false) { print 5 } print "fine"; — a missing semicolon inside an if (false) branch that can never execute — still reports [line 1] ParseError: expected ;, got } ('}'), and "fine" is never printed. The whole program has to parse successfully before the interpreter runs any of it, so the branch's own unreachability is irrelevant — the broken syntax is found regardless.
Verified directly — the exact same unreachable branch, with a runtime error instead, causes no failure at all
if (false) { print 5 + "oops"; } print "fine"; — syntactically valid, but a genuine type error if that branch ever ran — completes successfully and prints only ['fine'], no error whatsoever. 5 + "oops" is never evaluated, because the if's own condition is false, so visit_binary never runs against it and never gets the chance to detect the mismatch.
This is also true of lexical errors, not just parse errors
if (false) { var x = "unterminated; } print "fine"; raises the exact same LexError it would raise if that line were the very first thing in the file — tokenizing happens once, for the entire source text, before parsing even begins, so an unreachable branch's own broken string literal is found just as reliably as broken syntax is.

A Real Wisp Stack Trace

Chapter 7 established that Wisp's own call "stack" is just Python's — there's no explicit list of active calls anywhere. That's fine for making recursion work, but it means there's nothing to read a Wisp-level stack trace from when something goes wrong three function calls deep. This chapter adds exactly that: an explicit call_stack list on the interpreter, pushed and popped around every function call.

def call(self, interpreter, arguments, call_line=None): env = Environment(self.closure) for param, arg in zip(self.declaration.params, arguments): env.define(param, arg) interpreter.call_stack.append((self.declaration.name, call_line)) try: interpreter.execute_block(self.declaration.body, env) except ReturnException as r: return r.value except WispRuntimeError as e: if not hasattr(e, "wisp_stack"): e.wisp_stack = list(interpreter.call_stack) # snapshot BEFORE any frame pops raise finally: interpreter.call_stack.pop() return None

The snapshot has to happen in the innermost call() frame that sees the error — the first one to run its own except WispRuntimeError block — because that's the only point where every frame from the outermost call down to the one that actually failed is still present in call_stack. The finally block pops this frame regardless, but only after the except block above it has already run and taken its snapshot; the if not hasattr(...) guard stops an outer frame from overwriting that snapshot with its own, now-truncated view as the exception continues propagating upward.

Verified directly — a real, multi-frame trace through a three-level call chain
fun c(x) { return x + "oops"; } fun b(x) { return c(x); } fun a(x) { return b(x); } print a(5); reports:
[line 3] WispRuntimeError: operands of '+' must be two numbers or two strings, got float and str
  at c() (called from line 6)
  at b() (called from line 9)
  at a() (called from line 11)
  at <script>
Three real, correctly-ordered frames — innermost first — each showing the line where that specific call happened, not where the function was declared.

A Real Bug, Found While Writing This Chapter's Own Verification

Testing the stack trace against a recursive function surfaced a genuine gap: print 1 / 0; doesn't raise a WispRuntimeError at all.

Verified directly — division by zero leaks a raw Python exception, uncaught since Chapter 4
Running print 1 / 0; through the top-level driver raises a bare ZeroDivisionError: division by zero — Python's own exception, with no line number, no [line N] prefix, and no chance for this chapter's own error-reporting machinery to touch it at all, because visit_binary's / case has never once checked its divisor. The fix follows the same pattern as every other operand check since Chapter 4: if right == 0.0: raise WispRuntimeError("division by zero", node.line), checked before the division itself runs.

Where This Connects

This chapter's findingWhat it connects to
One WispError hierarchy for lex/parse/runtime errorsChapters 1, 2, and 4-8's own scattered, ad-hoc exceptions — unified here, not replaced; every earlier chapter's own error condition still fires at exactly the same point
Reachability: lex/parse errors are whole-program, runtime errors are execution-dependentChapter 6's own short-circuit evaluation and Exercise 2 (a while loop whose body never runs) — both are instances of the same general fact, that Wisp only evaluates what control flow actually visits
An explicit call_stack, pushed/popped around every WispFunction.call()Chapter 7's own finding that Wisp's call depth is bounded by Python's real stack — this chapter adds a Wisp-level view of that stack without changing the underlying mechanism at all
The division-by-zero gap, found and fixedChapter 6's own eager-dict arithmetic bug — the second real, previously-unflagged bug this course has found by actually testing edge cases rather than assuming coverage

Hands-On Exercises

Exercise 1

Run if (false) { var x = "unterminated; } print "fine"; — a lexical error (not a parse error) sitting inside a branch that can never execute. Determine whether the program prints fine or fails, and explain why this chapter's own "whole-program, before-any-execution" reasoning applies identically to lexing as it does to parsing, even though they're two separate stages.

📄 View solution
Exercise 2

Write a recursive countDown(n) that calls itself until n <= 0, at which point it evaluates n + "boom" (a genuine type error). Call it with an initial argument of 3, and inspect the resulting stack trace. Explain why the function name countDown appears multiple times, why three of those entries share one "called from" line while a fourth doesn't, and what that difference reveals about where each specific call in the trace actually originated.

📄 View solution
Exercise 3

Run three nested function calls (a calls b calls c, no errors anywhere) to completion, then inspect interpreter.call_stack immediately afterward. Determine whether any frames are left behind, and explain specifically which line of WispFunction.call() is responsible for the answer — would the same guarantee hold if that line were inside the try block instead of where it actually is?

📄 View solution

Chapter 9 Quick Reference

  • One hierarchy: WispErrorLexError / ParseError / WispRuntimeError, each with .line and a shared report()
  • Line numbers threaded through: tokens carry a line; the parser stamps it onto Binary/Unary/Call/Variable/Get/Set/FunctionStmt
  • Verified — reachability: lex/parse errors fire regardless of whether the broken code would ever run; runtime errors only fire if the line actually executes
  • call_stack: an explicit list, pushed/popped around every WispFunction.call()verified producing a correct, multi-frame, correctly-ordered trace
  • Real bug found and fixed: division by zero was leaking a raw Python ZeroDivisionError since Chapter 4, uncaught by any of this chapter's own reporting
  • Next chapter: Capstone — assembling every chapter's own component into one complete, working Wisp interpreter