KnowSys
λBuild it yourself

Build a programming language

A small language of your own design, first as a tree-walking interpreter and then as a bytecode VM with a garbage collector, fast enough to run real programs.

Ambitious⏱ 6–10 weekendsC · Rust · Go · Java

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:

What happens to `print fib(20);`
●
✂
Lexer
text → tokens
⌥
Parser
tokens → AST
⌖
Resolver
scopes
⚙
Compiler
AST → bytecode
▶
VM
dispatch loop
♻
GC
mark + sweep
Step 1. The lexer scans characters and emits tokens: print, fib, (, the number 20, ), ;. Whitespace and comments vanish here.
1 / 6

?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 needWhyWhere to get it
Comfort with recursion and treesParsing, resolving and compiling are all tree walksAny data structures course
One language with manual memory, eventuallyThe VM and GC need to control memory layoutC is traditional; Rust works with some unsafe
A small grammar written downYou'll change your mind less oftenStart from Lox in Crafting Interpreters and modify it
A folder of test programs with expected outputThe only way to know a refactor didn't break semanticsWrite them as you go, one per feature
A sense of how the heap worksThe GC decides when memory is freedChapter 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.

1

A lexer

1 evening

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

You’ll learntokenslexemesmaximal munchline numbers
Done when: lex 'var x = 1.5 + foo;' prints seven tokens plus EOF with correct types and line numbers, and an unterminated string reports its line.
2

Parse expressions into an AST

1 weekend

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

You’ll learnrecursive descentprecedence climbingassociativityPratt parsing
Done when: 1 + 2 * 3 - -4 parses to a tree that prints as (- (+ 1 (* 2 3)) (- 4)), and 1 + gives a clear error.
3

A tree-walking interpreter

1 weekend

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

You’ll learnevaluationenvironmentsstatements vs expressionsruntime errors
Done when: A script with variables, if, while and string concatenation prints the right output, and 1 + nil fails with a line number.
4

Functions and closures

1 weekend

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

You’ll learncall framesclosureslexical scopestatic resolutionreturn as control flow
Done when: makeCounter() returns a function that increments its own private count, two counters stay independent, and recursive fib(20) returns 6765.
5

Compile to bytecode

1–2 weekends

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

You’ll learnstack machinesopcodesconstant poolsjump patchingdisassembly
Done when: A disassembler prints readable bytecode for any script, and every test from milestones 3 and 4 produces the same output on the VM as on the tree-walker.
6

Closures in the VM

1–2 weekends

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

You’ll learnupvaluesopen vs closed upvaluesescaping variablesstack vs heap
Done when: makeCounter works on the VM, and a closure created in a loop captures a fresh variable per iteration if your language says it should.
7

A garbage collector

1–2 weekends

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

You’ll learnrootsmark-sweeptri-color markingallocation thresholdsstress testing
Done when: A loop that allocates a million strings runs in bounded memory, and the full test suite passes with the GC forced to run on every allocation.
8

Make it fast

1–2 weekends

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

You’ll learndispatch overheadstring interninghash tablesNaN boxingprofiling
Done when: fib(30) runs several times faster than on your tree-walker, measured with the same script and the same machine.

05Traps that catch everyone

SymptomCauseFix
a - b - c evaluates as a - (b - c)Right recursion where the grammar needs a loopLoop within each precedence level so operators associate left
A closure sees a variable defined after itDynamic lookup through the environment chainResolve scopes statically before running
return inside a loop keeps loopingReturn implemented as a normal valueUnwind with an exception or a status flag in the tree-walker
Jumps land in the middle of an instructionA jump offset was computed before its target was emittedBackpatch, and test if with no else and nested loops
Two closures over one variable disagreeEach captured its own copyShare one upvalue per stack slot; close it once
Random crashes that vanish under a debuggerA heap object wasn't reachable from any root during a collectionRun with stress GC on; audit every place the compiler or VM holds a pointer in a C local
Deep recursion segfaults the interpreterThe host stack overflowed before your language's limitCap frame depth and report a stack overflow in your language

06Stretch goals

  • Classes and methods. Add objects with fields, bound methods, this and 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

Robert Nystrom, Crafting Interpreters

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.

Thorsten Ball, Writing an Interpreter in Go

A Pratt-parser tree-walker built test-first. Its sequel, Writing a Compiler in Go, covers the move to bytecode.

Abelson and Sussman, Structure and Interpretation of Computer Programs

Chapter 4 builds a metacircular evaluator and explains environments more clearly than almost anything written since.

Ierusalimschy, de Figueiredo and Celes, The Implementation of Lua 5.0

A short paper on Lua's register VM, upvalues and table design. Read it after milestone 6.

Jones, Hosking and Moss, The Garbage Collection Handbook

The standard reference on collectors, from mark-sweep to concurrent and generational designs.

Peter Norvig, (How to Write a (Lisp) Interpreter (in Python))

A working Scheme interpreter in about a page. Good for seeing how little the core needs.

The Lua and CPython source

Readable production VMs in C. Look at lvm.c in Lua and ceval.c in CPython once your own dispatch loop works.

Chapters that back this project

Next project$ a Unix shell→