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).
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.
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.
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.
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 ==.
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.
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.
"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.
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 finding | What it connects to |
|---|---|
Stmt classes, parallel to Chapter 3's own Expr classes, with the same accept()/double-dispatch shape | Chapter 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 handling | Chapter 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 mismatches | Chapter 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
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.
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.
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.
Chapter 4 Quick Reference
- Statements vs. expressions:
Stmtnodes execute for effect;Exprnodes evaluate to a value —ExpressionStmtdiscards the value,PrintStmtuses 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
nilandfalseare falsy in Wisp — verified that trusting Python's own truthiness would have silently miscomputed!0 - Equality:
_is_equalcheckstype(left) is not type(right)first — verified this is the one line preventing Python's ownTrue == 1.0quirk from leaking into Wisp - Runtime errors: type-mismatched operators raise a clean
WispRuntimeErrorinstead of a raw PythonTypeError— verified side by side stringify(): whole-number floats print without a trailing.0— verified3.0prints as3,3.5prints as3.5- Next chapter: Variables, Scope & Environments — giving the interpreter somewhere to actually store values between statements