warming up your workspace

Build your own programming language

"Write your own language" sounds like a career, not a blog post. But the core of an interpreter is small and the same in every language: turn text into tokens, tokens into a tree, then walk the tree and do what it says. We will build one that handles arithmetic and variables, and actually runs.

The one idea

Source code is a string, and a string is useless to a machine until it has structure. An interpreter builds that structure in stages: characters to tokens (scanning), tokens to a tree (parsing), tree to a result (evaluation). Each stage takes a messier input and hands the next stage something cleaner.

Stage 1: scanner

The scanner groups characters into tokens: numbers, names, operators.

import re

def scan(src):
    tokens = []
    for tok in re.findall(r"\d+\.?\d*|[A-Za-z_]\w*|[-+*/()=]", src):
        if tok[0].isdigit():
            tokens.append(("num", float(tok)))
        elif tok[0].isalpha() or tok[0] == "_":
            tokens.append(("name", tok))
        else:
            tokens.append((tok, tok))
    tokens.append(("eof", None))
    return tokens

scan("x = 3 + 4 * 2") gives a clean list of tagged tokens. No parsing decisions yet, just words.

Stage 2: a Pratt parser

Parsing math is where people get stuck, because 3 + 4 * 2 must become "3 plus (4 times 2)," not "(3 plus 4) times 2." A Pratt parser handles precedence with one idea: each operator has a binding power, and we keep consuming operators as long as they bind tighter than the level we are at.

class Parser:
    def __init__(self, tokens):
        self.toks = tokens
        self.i = 0
    def peek(self): return self.toks[self.i]
    def next(self):
        t = self.toks[self.i]; self.i += 1; return t

    BP = {"+": 10, "-": 10, "*": 20, "/": 20}

    def expr(self, min_bp=0):
        kind, val = self.next()
        if kind == "num":   left = ("num", val)
        elif kind == "name": left = ("var", val)
        elif kind == "(":
            left = self.expr(0); self.next()   # consume ")"
        else:
            raise SyntaxError(f"unexpected {kind}")
        while True:
            op = self.peek()[0]
            if op not in self.BP or self.BP[op] < min_bp:
                break
            self.next()
            right = self.expr(self.BP[op] + 1)  # +1 = left-associative
            left = ("bin", op, left, right)
        return left

Statements are either name = expr or a bare expr:

    def stmt(self):
        if self.peek()[0] == "name" and self.toks[self.i + 1][0] == "=":
            name = self.next()[1]; self.next()  # consume "="
            return ("assign", name, self.expr(0))
        return ("print", self.expr(0))

The result is an abstract syntax tree: nested tuples like ("bin", "+", ("num", 3.0), ("bin", "*", ("num", 4.0), ("num", 2.0))). The precedence is now baked into the shape.

Stage 3: walk the tree

Evaluation is a recursive function that returns a value for each node, carrying an environment of variables.

def ev(node, env):
    kind = node[0]
    if kind == "num":    return node[1]
    if kind == "var":    return env[node[1]]
    if kind == "bin":
        _, op, a, b = node
        a, b = ev(a, env), ev(b, env)
        return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[op]
    if kind == "assign":
        env[node[1]] = ev(node[2], env); return env[node[1]]
    if kind == "print":
        v = ev(node[1], env); print(v); return v

def run(src, env=None):
    env = env if env is not None else {}
    p = Parser(scan(src))
    return ev(p.stmt(), env)

Prove it runs

env = {}
run("x = 3 + 4 * 2", env)   # assignment: x = 3 + (4*2) = 11
run("x", env)               # a bare expression, so it prints
run("y = x * 10", env)      # uses a variable
run("y - 1", env)           # prints
11.0
109.0

x became 11, not 14, so precedence worked (the assignment itself prints nothing; the bare x on the next line is what shows 11). y read x back out of the environment. That is a real, if tiny, interpreter.

From here to a real language

Functions are the natural next step and reuse everything above: a function is a tree plus a captured environment (a closure), a call is "make a new environment binding the arguments, then ev the body." Add conditionals and loops (more node types), a bytecode compiler if you want speed instead of walking the tree each time, and error messages with line numbers. But the spine does not change: scan, parse, evaluate.

Understanding this is why syntax stops being scary, you can see the tree behind any code you read. That is the whole premise of the compilers track: the tools that run your code are programs you could have written.