Every program you've ever written was read by another program first. Something
turned your text into tokens, the tokens into a tree, and the tree into
something a machine could run. Most engineers go their whole career treating
that pipeline as a black box that occasionally prints SyntaxError.
Building a language opens it. You'll design a small dynamically typed language with variables, functions, closures and a few data types, write an interpreter that walks its syntax tree, and then replace that with a compiler to bytecode and a virtual machine that runs it with a real garbage collector. Your first version probably takes a few weekends. Version two is where you learn why Python, Lua and JavaScript engines are built the way they are.
01Why build this
Languages are the one tool every engineer uses all day and almost nobody has looked inside. Building one pays off in places you wouldn't expect:
- Parsing stops being scary. Config formats, query languages, template engines and DSLs are all the same problem at a smaller size. After this, you'll write a recursive descent parser in an afternoon instead of reaching for regex.
- Scoping bugs make sense. You'll know exactly what a closure captures, why a loop variable captured in a callback surprises people, and what "hoisting" is.
- Performance intuitions get grounded. You'll see why a dictionary lookup per variable access is slow, what a "hot loop" costs in dispatch, and why JITs exist.
- GC pauses stop being weather. You'll have written the mark phase that walks every live object, so heap size and allocation rate will mean something concrete.
It also teaches a style of programming most day jobs don't: small recursive functions over trees, with the data structure doing the heavy lifting.
02What you're building
Your finished implementation takes source text through this pipeline:
print, fib, (, the number 20, ), ;. Whitespace and comments vanish here.?Why build a tree-walker first if you're going to throw it away?
Because it separates two hard problems. A tree-walker lets you get the
language's meaning right (scoping, closures, what return inside a loop does)
with a very direct implementation. Once the semantics are pinned down by tests,
the bytecode VM becomes a question of speed, and you can check it against the
tree-walker on every program you have.
03Before you start
| You need | Why | Where to get it |
|---|---|---|
| Comfort with recursion and trees | Parsing, resolving and compiling are all tree walks | Any data structures course |
| One language with manual memory, eventually | The VM and GC need to control memory layout | C is traditional; Rust works with some unsafe |
| A small grammar written down | You'll change your mind less often | Start from Lox in Crafting Interpreters and modify it |
| A folder of test programs with expected output | The only way to know a refactor didn't break semantics | Write them as you go, one per feature |
| A sense of how the heap works | The GC decides when memory is freed | Chapter 05 |
04The roadmap
Eight milestones. Milestones 1 to 4 give you a complete, if slow, language. Milestones 5 to 8 rebuild the back end for speed and memory safety.
A lexer
1 eveningWalk the source one character at a time and emit tokens: keywords,
identifiers, numbers, strings and punctuation. Two-character operators like
== and <= need one character of lookahead. Identifiers are scanned first and
then checked against a keyword table.
Record the line (and ideally column) on every token now. You'll want it for every error message you ever print, and it's painful to thread through later.
lex 'var x = 1.5 + foo;' prints seven tokens plus EOF with correct types and line numbers, and an unterminated string reports its line.Parse expressions into an AST
1 weekendWrite one function per precedence level: equality calls comparison, comparison calls term, term calls factor, factor calls unary, unary calls primary. Each level loops while it sees its own operators. That's recursive descent, and it handles precedence and left-associativity without any tables.
Add a printer that shows the tree in Lisp-style parentheses. It's the fastest way
to see whether your parser agrees with you about what a - b - c means.
1 + 2 * 3 - -4 parses to a tree that prints as (- (+ 1 (* 2 3)) (- 4)), and 1 + gives a clear error.A tree-walking interpreter
1 weekendAdd statements to the parser, then write an evaluate function that switches on
the node type and returns a value. Variables live in an environment: a hash
map from name to value, with a pointer to the enclosing environment. A block
creates a new one; a lookup walks outward until it finds the name.
Decide now how values are represented. A tagged union of number, bool, nil and a pointer to a heap object carries you all the way to the VM.
if, while and string concatenation prints the right output, and 1 + nil fails with a line number.Functions and closures
1 weekendA function value holds its parameter list, its body and the environment it was defined in. Calling it creates a new environment whose parent is that captured one, not the caller's. That single rule is lexical scoping, and closures fall out of it for free.
Then add a resolver pass. Without it, a closure that refers to an outer variable can end up seeing a different variable with the same name, declared later in the enclosing block. A resolver computes, at compile time, how many scopes up each variable lives, which fixes the bug and makes lookups faster.
makeCounter() returns a function that increments its own private count, two counters stay independent, and recursive fib(20) returns 6765.Compile to bytecode
1–2 weekendsDesign a small instruction set for a stack machine: CONSTANT, ADD,
GET_LOCAL, SET_GLOBAL, JUMP_IF_FALSE, CALL, RETURN. Your compiler walks
the AST and appends bytes; the VM is a loop around a big switch.
Locals stop being hash map entries and become slots on the value stack, indexed
by a number the compiler worked out. Forward jumps for if and while need
backpatching: emit a placeholder offset, compile the body, then go back and
fill it in. Write the disassembler before anything else here. You'll use it on
every bug.
Closures in the VM
1–2 weekendsClosures are the hardest part of moving to a stack VM. A local lives on the stack, but a closure can outlive the function that declared it. What Lua uses, and Crafting Interpreters teaches, is the upvalue: a reference that points at the stack slot while the variable is alive, and is "closed" by copying the value to the heap when the enclosing function returns.
Keep a list of open upvalues sorted by stack slot, so two closures over the same variable share one upvalue instead of getting separate copies.
makeCounter works on the VM, and a closure created in a loop captures a fresh variable per iteration if your language says it should.A garbage collector
1–2 weekendsLink every heap object into one list as you allocate it. To collect, mark everything reachable from the roots (the value stack, call frames, globals, open upvalues, and anything the compiler is holding mid-compilation), then sweep the list and free what isn't marked. Trigger a collection when allocated bytes pass a threshold, then set the next threshold to a multiple of what survived.
Add a "stress GC" flag that collects on every single allocation. Missing-root bugs are rare under normal load and constant under stress. That's what you want while debugging.
Make it fast
1–2 weekendsProfile before touching anything. It'll probably be one of the usual suspects: global lookups hashing a string every time (intern all strings so equality is a pointer compare), a slow hash table, and branch mispredictions in the dispatch loop.
Then try the classic tricks one at a time and measure each: computed goto
instead of switch where your compiler supports it, caching the instruction
pointer in a local, and packing values into 64 bits with NaN boxing. Some
will help a lot on your machine and some will do roughly nothing. That's its
own lesson. Chapter 01 explains why.
fib(30) runs several times faster than on your tree-walker, measured with the same script and the same machine.05Traps that catch everyone
| Symptom | Cause | Fix |
|---|---|---|
a - b - c evaluates as a - (b - c) | Right recursion where the grammar needs a loop | Loop within each precedence level so operators associate left |
| A closure sees a variable defined after it | Dynamic lookup through the environment chain | Resolve scopes statically before running |
return inside a loop keeps looping | Return implemented as a normal value | Unwind with an exception or a status flag in the tree-walker |
| Jumps land in the middle of an instruction | A jump offset was computed before its target was emitted | Backpatch, and test if with no else and nested loops |
| Two closures over one variable disagree | Each captured its own copy | Share one upvalue per stack slot; close it once |
| Random crashes that vanish under a debugger | A heap object wasn't reachable from any root during a collection | Run with stress GC on; audit every place the compiler or VM holds a pointer in a C local |
| Deep recursion segfaults the interpreter | The host stack overflowed before your language's limit | Cap frame depth and report a stack overflow in your language |
06Stretch goals
- Classes and methods. Add objects with fields, bound methods,
thisand single inheritance. Method calls are a good place to try an inline cache. - A generational or incremental GC. Split the heap by age, or interleave marking with the program, and measure the difference in pause times.
- A register VM. Rewrite the instruction set with registers instead of a stack, the way Lua 5 did, and compare instruction counts on the same programs.
- Static types. Add a type checker pass between the resolver and the compiler, and see how many runtime checks you can delete.
- Compile to something real. Emit WebAssembly, or C, and let an existing toolchain do the last step.
07References worth your time
The book this roadmap follows most closely: a tree-walker in Java, then a bytecode VM with closures and a GC in C. Free online.
A Pratt-parser tree-walker built test-first. Its sequel, Writing a Compiler in Go, covers the move to bytecode.
Chapter 4 builds a metacircular evaluator and explains environments more clearly than almost anything written since.
A short paper on Lua's register VM, upvalues and table design. Read it after milestone 6.
The standard reference on collectors, from mark-sweep to concurrent and generational designs.
A working Scheme interpreter in about a page. Good for seeing how little the core needs.
Readable production VMs in C. Look at lvm.c in Lua and ceval.c in CPython
once your own dispatch loop works.