How it works
The Interpreter
The interpreter is where the program actually runs. It walks the AST node by node, executing statements and evaluating expressions, managing variable scope through a chain of environments.
Input
Program
root AST node
Output
int
exit code (0 or 1)
Source
interpreter.py
271 lines
What is a tree-walking interpreter?
A tree-walking interpreter executes a program by recursively walking its AST. For each node it visits, it calls the appropriate handler method. No intermediate representation (bytecode, machine code) is produced - the AST is the only thing the interpreter ever operates on.
Compared to compiled or bytecode approaches, a tree-walker is the simplest possible execution strategy. It is also the slowest - every variable lookup, every arithmetic operation, every conditional branch requires a Python function call and a runtime type dispatch. But for a scripting language where startup time and clarity matter more than raw throughput, this is the right trade-off.
NATIVE COMPILED (C, Rust, Go) Source ──▶ Compiler ──▶ x86 binary ──▶ CPU executes directly Speed: fastest. Build step: required. Complexity: very high. BYTECODE VM (CPython, JVM, Lua) Source ──▶ Compiler ──▶ bytecode ──▶ virtual machine interprets Speed: fast. Build step: implicit on run. Complexity: high. TREE-WALKING (kemlang-py, early Ruby 1.x, MRI before YARV) Source ──▶ Lexer ──▶ Parser ──▶ AST ──▶ walk and execute directly Speed: slow. Build step: none. Complexity: low. Best for: scripting, education, rapid prototyping.
Execute vs. evaluate
The interpreter has two recursive entry points: execute()for statements and evaluate() for expressions. The split matches the language's grammar: statements produce side effects (printing, assigning), expressions produce values.
def execute(self, stmt: Stmt):
"""Execute a statement - produces side effects, returns nothing."""
if isinstance(stmt, Print): return self.execute_print(stmt)
if isinstance(stmt, Declaration): return self.execute_declaration(stmt)
if isinstance(stmt, Assignment): return self.execute_assignment(stmt)
if isinstance(stmt, If): return self.execute_if(stmt)
if isinstance(stmt, While): return self.execute_while(stmt)
if isinstance(stmt, Block): return self.execute_block(stmt)
if isinstance(stmt, Break): raise BreakError()
if isinstance(stmt, Continue): raise ContinueError()
def evaluate(self, expr: Expr) -> KemValue:
"""Evaluate an expression - returns a KemValue, no side effects."""
if isinstance(expr, Literal): return expr.value
if isinstance(expr, Variable): return self.environment.get(expr.name)
if isinstance(expr, Binary): return self.evaluate_binary(expr)
if isinstance(expr, Unary): return self.evaluate_unary(expr)
if isinstance(expr, Input): return self.input_fn().rstrip("\n")Notice that execute() dispatches on the node type with isinstance() checks. This is the visitor pattern without the boilerplate - Python's dynamic typing makes it readable without implementing abstract visitor classes.
Environments and variable scope
Variables are stored in an Environment - a dictionary (dict[str, KemValue]) with a reference to an optional parent environment. The interpreter always has a current environment; it starts as the global environment and is temporarily replaced when entering a block.
class Environment:
def __init__(self, enclosing: Optional["Environment"] = None):
self.values: dict[str, KemValue] = {}
self.enclosing = enclosing # parent environment
def define(self, name: str, value: KemValue):
if name in self.values:
raise RuntimeError(f"Variable '{name}' already declared in this scope")
self.values[name] = value # aa x che 10
def get(self, name: str) -> KemValue:
if name in self.values:
return self.values[name]
if self.enclosing:
return self.enclosing.get(name) # walk up the chain
raise RuntimeError(f"Undefined variable '{name}'")
def assign(self, name: str, value: KemValue):
if name in self.values:
self.values[name] = value
return
if self.enclosing:
self.enclosing.assign(name, value) # assign in parent if not local
return
raise RuntimeError(f"Undefined variable '{name}'")When execute_block() is called (for an if-body or while-body), it creates a new Environment with the current environment as its parent. All variable declarations inside the block go into this new environment. When the block exits (even via exception), the interpreter restores the previous environment.
kem bhai
aa x che 10 <- defined in global env
aa n che 5 <- defined in global env
farvu {
aa i che 1 <- defined in while-body env (local)
jo i > 0 {
aa temp che i <- defined in if-body env (local to if)
}
} jya sudhi i <= n
┌─────────────────────────────────────────────────────┐
│ If-body Environment (innermost) │
│ values: { temp: 1 } │
│ enclosing ──────────────────────────────────────┐ │
└──────────────────────────────────────────────────┼──┘
│ lookup walks up
┌──────────────────────────────────────────────────▼──┐
│ While-body Environment │
│ values: { i: 1 } │
│ enclosing ──────────────────────────────────────┐ │
└──────────────────────────────────────────────────┼──┘
│
┌──────────────────────────────────────────────────▼──┐
│ Global Environment │
│ values: { x: 10, n: 5 } │
│ enclosing: None │
└─────────────────────────────────────────────────────┘
Reading 'n' from inside the while body:
while-body.get("n") -> not found -> enclosing.get("n")
global.get("n") -> found! -> returns 5def execute_block(self, stmt: Block):
previous = self.environment
try:
self.environment = Environment(self.environment) # push new scope
for statement in stmt.statements:
self.execute(statement)
finally:
self.environment = previous # always restore, even on exceptionControl flow via Python exceptions
tame jao (break) and aagal vado (continue) need to unwind the call stack immediately - a break inside three nested function calls must exit the loop that's several levels up the Python call stack.
kemlang-py uses Python exceptions for this. When the interpreter visits a Break node, it raises BreakError. The execute_while method catches it and exits the loop. This avoids threading a should_break flag through every recursive call - the exception automatically propagates to exactly the right catch site.
def execute_while(self, stmt: While):
"""farvu { body } jya sudhi condition
Note: body executes FIRST, then condition is checked (do-while semantics)."""
try:
while True:
with contextlib.suppress(ContinueError):
self.execute(stmt.body) # execute the block
# check condition AFTER body
condition = self.evaluate(stmt.condition)
if not self.is_truthy(condition):
break # normal exit
except BreakError:
pass # tame jao -> exit immediately i starts at 1 (declared before the loop)
Iteration 1:
execute(Block)
-> execute(Print) -> evaluate Literal(i) -> get i=1 -> output "1"
-> execute(Assignment) -> evaluate i+1 = 2 -> assign i=2
evaluate condition: i <= 3 -> 2 <= 3 -> True -> continue
Iteration 2:
execute(Block)
-> execute(Print) -> output "2"
-> execute(Assignment) -> assign i=3
evaluate condition: 3 <= 3 -> True -> continue
Iteration 3:
execute(Block)
-> execute(Print) -> output "3"
-> execute(Assignment) -> assign i=4
evaluate condition: 4 <= 3 -> False -> break
Final output: 1 2 3 (each on its own line)Input and output
bhai bol (print) evaluates its expression and calls Python's print() via a configurable output_fn callback. Using a callback rather than calling print() directly allows the test suite to capture output without monkey-patching builtins.
bapu tame bolo (read input) is an Input expression node. When evaluated, it calls Python's input() via the configurable input_fn and strips the trailing newline. The result is a string; if it looks like a number, the binary operators will auto-coerce it.
def execute_print(self, stmt: Print):
value = self.evaluate(stmt.expression)
self.output_fn(self.stringify(value)) # calls print() by default
def evaluate(self, expr: Expr) -> KemValue:
...
if isinstance(expr, Input):
return self.input_fn().rstrip("\n") # calls input() by default
# The Interpreter constructor:
def __init__(
self,
input_fn: Callable[[], str] = input, # overridable for tests
output_fn: Callable[[str], None] = print, # overridable for tests
):Error handling
The interpreter catches RuntimeError at the top level of interpret(). Any unhandled runtime error - undefined variable, type mismatch, division by zero - propagates up to this catch site, prints the error message to stdout, and returns exit code 1.
def interpret(self, program: Program) -> int:
try:
for statement in program.statements:
self.execute(statement)
return 0 # success
except RuntimeError as e:
self.output_fn(f"Runtime Error: {e.message}")
return 1 # error