Tree-Walking Evaluation: Expressions & Statements

Writing a Compiler/Interpreter: Fundamentals

Chapter 4 · Tree-Walking Evaluation: Expressions & Statements

Chapter 3's Evaluator could compute the value of a single expression — but a real Wisp program isn't one expression, it's a sequence of statements, some of which produce a value nobody reads and some of which exist purely to cause an effect, like printing something. This chapter turns that one-expression evaluator into a real interpreter: statement node classes with their own accept/visit dispatch, string and boolean literals the parser never handled before, comparison operators actually wired up to the evaluator, and two pieces of quiet, easy-to-get-wrong semantics — what counts as "true," and how a number should look when printed.

Statements Are Not Expressions

Every Expr node produces a value when evaluated. A statement doesn't — it's executed for its effect. Wisp gets two for now: an expression statement (evaluate something and throw the result away — this is what a bare 2 + 2; is) and a print statement (evaluate something, then actually output it).

class Stmt: def accept(self, visitor): raise NotImplementedError @dataclass class ExpressionStmt(Stmt): expression: Expr def accept(self, visitor): return visitor.visit_expression_stmt(self) @dataclass class PrintStmt(Stmt): expression: Expr def accept(self, visitor): return visitor.visit_print_stmt(self)

Same shape as every Expr node from Chapter 3 — a small dataclass, an accept() method, double dispatch. The parser grows a second grammar layer above expressions: a statement() function that checks for the print keyword first, and a top-level parse_program() that keeps calling declaration() until it runs out of tokens, building a flat list of statements — an actual program, not just one expression.

Literals the Parser Never Handled Before

Chapter 1's lexer already produced STRING tokens and recognized true/false/nil as keywords — but Chapter 2 and 3's primary() only ever consumed NUMBER tokens. Printing anything interesting needs strings and booleans too, so primary() grows four new branches for them, each producing an ordinary Literal node wrapping a real Python str, bool, or None.

Verified directly — a real 8-statement Wisp program parses and runs end to end
Running print 2 + 3 * 4;, print "hello, " + "world";, print 5 < 3;, print !0;, print nil;, print 3.0;, print 3.5;, and a bare 2 + 2; through parse_program() then Interpreter.interpret() produces exactly 7 lines of output (the bare expression statement contributes none): 14, hello, world, false, false, nil, 3, 3.5.

Truthiness: Not Python's

The unary ! operator needs an answer to "is this value true-ish?" for any Wisp value — a number, a string, nil. The tempting shortcut is Python's own bool()/not — except Python treats 0, 0.0, and "" as falsy, and Wisp deliberately doesn't.

def is_truthy(value): if value is None: return False if isinstance(value, bool): return value return True # everything else -- including 0.0 and "" -- is truthy in Wisp
Verified directly — trusting Python's own truthiness gives the wrong answer for "!0"
is_truthy(0.0) correctly returns True in Wisp's own rules, even though Python's bool(0.0) is False. Evaluating !0 with the correct is_truthy() check gives false — the right answer, since 0 is a truthy value in Wisp and negating a truthy value is false. A naive implementation using Python's own not 0.0 directly would have silently returned True instead — confidently wrong, with no error anywhere to catch it.
Only nil and false are falsy — that's the entire rule
It's a short list on purpose. Some languages treat empty strings, empty collections, or zero as falsy too (Python and JavaScript both do, in overlapping but not identical ways) — Wisp doesn't, matching the same two-value falsy set used by Lua and Ruby. The value of picking a short, memorizable rule is exactly what this section just demonstrated: it's easy to accidentally reach for the host language's own truthiness instead, and the bug that produces is silent, not a crash.

Comparisons and a Real Equality Rule

Chapter 2's grammar already had a full precedence ladder for ==, !=, <, <=, >, >= — but Chapter 3's Evaluator only ever implemented the four arithmetic operators inside visit_binary. This chapter fills in the rest, and equality specifically needs its own helper rather than a bare Python ==.

def _is_equal(self, left, right): if type(left) is not type(right): return False return left == right
Verified directly — the type check is doing real work, not just defensive boilerplate
print true == 1; evaluates to false in Wisp. Without the explicit type(left) is not type(right) guard, a bare Python left == right would have said True — Python's own bool is a subclass of int, so True == 1.0 is genuinely True at the Python level. Wisp treats booleans and numbers as different types with nothing in common, so the type check isn't defensive padding — it's the one line standing between "correct" and a real, silent bug inherited from the host language's own type system.
The same type check correctly lets equal-looking numbers through
print 3.0 == 3; evaluates to true — both literals parse through the same float(tok[1]) call in primary(), so type(3.0) is type(3.0) is trivially true, and the values themselves are equal. The type check isn't about literal spelling; it's about which Wisp value Wisp actually produced.

Runtime Errors Instead of Leaking Python's Own

Feeding the wrong types into an arithmetic or comparison operator shouldn't crash the interpreter with a Python traceback a Wisp programmer has no way to make sense of. Each operator branch in visit_binary checks its operand types first and raises a dedicated WispRuntimeError with a message about the actual Wisp operation, before ever letting Python's own operators run on mismatched types.

Verified directly — a type mismatch is caught cleanly, with the raw Python error visible for contrast
Evaluating "five" < 3; raises WispRuntimeError: operands of '<' must be numbers, got str and float. The same comparison performed with Python's own unguarded < operator instead raises TypeError: '<' not supported between instances of 'str' and 'float' — technically accurate, but describing Python's own type system to someone who has never heard of Python and is just trying to run a Wisp script.

Printing a Number Without Its Python Accent

Every Wisp number is a Python float under the hood, even literals that look like whole numbers — 3.0, not a separate integer type. Printed with Python's own str(), that's "3.0". A language with no separate int type shouldn't visibly leak that implementation detail every time a whole number gets printed.

def stringify(value): if value is None: return "nil" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, float): if value == int(value): return str(int(value)) # 3.0 -> "3", not "3.0" return str(value) return str(value)
Verified directly — whole and fractional numbers print differently, and correctly
stringify(3.0) returns "3"; stringify(3.5) returns "3.5". Both are confirmed in the 8-statement program run earlier in this chapter: print 3.0; and print 3.5; printed 3 and 3.5 respectively, not 3.0 and 3.5.

Where This Connects

This chapter's findingWhat it connects to
Stmt classes, parallel to Chapter 3's own Expr classes, with the same accept()/double-dispatch shapeChapter 5's own var declarations and Chapter 6's own if/while statements will be new Stmt subclasses following this exact pattern
The Interpreter class built here, extending Chapter 3's Evaluator concept with statement handlingChapter 5 extends this same class again with an Environment for variable storage — the object doesn't get replaced, it keeps growing
WispRuntimeError, raised for type mismatchesChapter 9's own error-handling chapter, which distinguishes this from a parse-time SyntaxError and gives it a real line number to report
Wisp's own truthiness rule (only nil/false are falsy)Chapter 6's own if statements and and/or short-circuit logic will call is_truthy() directly, unchanged from this chapter

Hands-On Exercises

Exercise 1

Add a modulo operator (%) at the same precedence level as * and / in factor(), including lexer support and a type-checked visit_binary case that raises WispRuntimeError for non-number operands, matching the existing arithmetic operators. Verify "10 % 3;" evaluates to 1.

📄 View solution
Exercise 2

Evaluate -"hello"; using this chapter's own interpreter. Determine whether unary minus's existing type check catches it as a clean WispRuntimeError, or whether it's actually a raw Python exception leaking through undetected — and explain exactly which line of visit_unary is responsible either way.

📄 View solution
Exercise 3

This chapter showed print 3.0 == 3; evaluating to true while print true == 1; evaluates to false. Trace both through _is_equal's own type check step by step, and explain precisely why one passes the type(left) is not type(right) guard and the other doesn't, given that both pairs of literals "look like" they could be considered equal.

📄 View solution

Chapter 4 Quick Reference

  • Statements vs. expressions: Stmt nodes execute for effect; Expr nodes evaluate to a value — ExpressionStmt discards the value, PrintStmt uses it
  • Verified: an 8-statement program parsed and ran end to end, producing exactly 7 lines of output — the bare expression statement contributed none
  • Truthiness: only nil and false are falsy in Wisp — verified that trusting Python's own truthiness would have silently miscomputed !0
  • Equality: _is_equal checks type(left) is not type(right) first — verified this is the one line preventing Python's own True == 1.0 quirk from leaking into Wisp
  • Runtime errors: type-mismatched operators raise a clean WispRuntimeError instead of a raw Python TypeErrorverified side by side
  • stringify(): whole-number floats print without a trailing .0verified 3.0 prints as 3, 3.5 prints as 3.5
  • Next chapter: Variables, Scope & Environments — giving the interpreter somewhere to actually store values between statements