docs

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.

execution strategies compared
  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.

kemlang/interpreter.py - the two dispatch methods
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.

kemlang/interpreter.py - the Environment class
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.

environment chain for a nested program
  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 5
kemlang/interpreter.py - execute_block creates and restores scope
def 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 exception

Control 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.

kemlang/interpreter.py - while loop with break/continue
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
execution trace for: farvu { bhai bol i i che i + 1 } jya sudhi i <= 3
  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.

kemlang/interpreter.py - print and input
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.

kemlang/interpreter.py - top-level error handling
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