Capstone: Build a Programming Language
Start with `print(1 + 2)`. Finish with a language you can defend the design of.
Build a programming language. Start where every language starts: make print(1 + 2) print 3.
That single line is the whole domain in miniature. It has to be split into tokens, arranged into a tree that knows + groups its two operands and print is applied to the result, checked to confirm that print exists and that adding two integers is defined, turned into instructions of some kind, and finally executed by something. Six representations for one line, and every one of them exists because the previous one could not answer the next question.
Then keep going. Add variables, and you need scopes and a symbol table. Add functions, and you need frames. Add closures, and you discover that a captured local cannot live on the stack. Add types, and you have to decide what the type system is promising. Add a loop that runs ten million times, and you need something other than a tree-walking interpreter. Each feature is a forcing function for a piece of machinery, and the point of building the language is to meet the machinery in the order that makes it necessary rather than in the order a textbook lists it.
The requirements below are the pieces. The injections are what goes wrong: each one arrives as a symptom, not as a diagnosis, and each has a fix that looks right and makes things worse. Naming the stage before touching the code is the skill being trained — a language that prints the wrong answer for 1 + 2 * 3 has a parser bug, and no amount of work in the evaluator will fix it.
The stages
Each one unlocks something the language could not express before, and forces a design question.
- 01A lexer
print(1 + 2)becomes five tokens instead of twelve characters — an identifier, two parentheses, two numbers and an operator, each with the byte range it came from.When two rules both match at the same position —inthe keyword andindexthe identifier — which one wins, and who decides?Maximal munch: take the longest match, then check the matched text against a keyword table. The alternative, giving keywords priority by rule order, silently lexesindexasinfollowed bydex. AtlasLang also records a start and end offset on every token from the first line of the implementation, because retrofitting spans later means rewriting every error path. - 02A parser
Nested expressions with correct grouping:
1 + 2 * 3parses as an addition whose right operand is a multiplication, which is a claim no token list can make.Precedence and associativity have to come from somewhere. Do you encode them in the grammar as a ladder of nonterminals, or in the parser as binding powers?Pratt parsing: one table of binding powers, one loop, and adding an operator is adding a row. A precedence ladder — expression, term, factor — encodes the same information in the shape of the grammar, which is clearer on paper and much worse to extend. Both are recursive descent; the difference is where the precedence lives. - 03A tree-walking interpreter
print(1 + 2)prints 3. The language runs, end to end, in under three hundred lines.Where do values live, and what is a value? An integer, a string and a function are three different things that all have to fit through the same evaluation function.A tagged union with an explicit kind field, evaluated by a recursive walk that returns one. It is the slowest possible execution strategy and the right one here: it is small enough to be obviously correct, and it becomes the reference implementation that the bytecode VM is later checked against. - 04Variables and scopes
let x = 2; print(x + 1);— names that refer to earlier declarations, with shadowing in nested blocks.When a name is used, which declaration does it refer to — and is that decided by where the code is written or by who called it?Lexical scoping, resolved statically: a chain of scopes built during a resolution pass, with each use annotated with the declaration it found and the number of hops to reach it. Dynamic scoping is easier to implement in a tree-walker and makes local reasoning impossible, which is why almost no language kept it. - 05Control flow
if,elseandwhile, and the short-circuiting&&and||that come with them.Is&&an operator that takes two values, or something else entirely?Something else. Short-circuiting means the right operand is evaluated only when the left one demands it, which no two-operand instruction can express, so&&is desugared into a branch and a merge in the front end. Making that lowering explicit at this stage is what makes the CFG builder at stage 12 straightforward instead of special-cased. - 06Functions and calls
User-defined functions with parameters, recursion, and a return value.
Where do a call’s arguments, locals and return address live, and who is responsible for cleaning them up?An explicit frame per call, with the caller pushing arguments and the callee owning its locals. Making the frame a real data structure — rather than reusing the host language’s call stack — costs a little now and pays at stage 10, when the same layout becomes the VM’s local slots, and again at stage 15, when it becomes a real stack frame. - 07Closures
Functions that are values: returned from other functions, stored in variables, and still able to see the variables they were written next to.
A captured local outlives the frame that declared it. Where does it actually live?Closure conversion: the captured variables move into a heap-allocated environment, and the function becomes a pair of code pointer and environment pointer. Capturing by value would be cheaper and would break every program that mutates a captured counter. This is the first stage where the source-level story and the runtime layout genuinely diverge. - 08A type checker
let n: int = true;is rejected before the program runs, with a span pointing at the initialiser rather than at the whole statement.What is this type system actually promising — and what is it deliberately not promising?It promises that every operation is applied to operands of a kind it is defined for, and nothing else: not that indices are in range, not that division has a non-zero divisor, not that the program terminates. Writing that limit down at the start is what stops the type checker from being asked to prove things it cannot. - 09Type inference
let n = 0;is anintwithout anyone saying so, and a function’s return type is deduced from what it returns.When a type is unknown, do you guess and check, or do you collect constraints and solve them?Collect and solve: generate fresh type variables, accumulate equality constraints while walking the tree, then unify. The payoff over local guessing is that a use anywhere in the function can determine a type used everywhere. The bill arrives in diagnostics — unification reports the mismatch where the constraint failed, which is often nowhere near the mistake. - 10A bytecode compiler
The tree is compiled once into a linear instruction sequence, and the tree is no longer needed to run the program.
Do instructions take their operands from a stack, or from numbered slots in the frame?A stack machine. A post-order walk of the AST is already a correct code generator, the encoding is one byte for most instructions, and there are no allocation decisions to make. A register machine would execute fewer instructions per unit of work — a real and measurable win — but it needs slot allocation, which is register allocation, which AtlasLang has not built yet. - 11A virtual machine
The bytecode runs, and every step is inspectable: instruction pointer, operand stack, frames.
What does the dispatch loop cost, and where does the time go in an interpreter?A switch on the opcode, executed once per instruction. The measurable costs are the dispatch branch, which predicts poorly because the next opcode is data, and the stack traffic. Naming them here is what makes the case for stage 14 concrete: an optimizer that removes instructions removes dispatches, and that is the whole of the interpreter speedup. - 12A control-flow graph
The function becomes a graph of basic blocks, with dominators and natural loops identified.
What is a basic block, exactly — and why is the definition worth being pedantic about?A maximal run of instructions with one entry at the top and one exit at the bottom: no branch in, no branch out, in the middle. The pedantry pays immediately, because a block with that property can be analysed as a unit, and every later analysis is phrased as facts flowing between blocks rather than between instructions. - 13SSA form
Every value has exactly one definition, and phi nodes appear at merges. Use-def chains become pointers instead of an analysis.
Where do phi nodes go? Putting one at every merge for every variable is correct and produces an unusable amount of noise.At the dominance frontier of each definition — the blocks where a definition stops being the only one that can reach a point. This is the moment the dominator tree built at stage 12 stops being an interesting fact and becomes load-bearing, and it is why the two stages are adjacent. - 14An optimizer
Constant folding, dead-code elimination, common-subexpression elimination and copy propagation, run to a fixed point over the SSA form.
What is this pass allowed to change, and how do you know it is not changing the program?Observable behavior is the boundary: output, faults, and anything the language says a conforming implementation must preserve. Two guards in AtlasLang make the rule visible — a division by a literal zero is never folded, because folding it moves a fault to compile time, and a call toprintis never eliminated for having an unused result, because printing is the behavior. - 15A native backend
IR becomes target instructions with physical registers, a stack frame, and a calling convention that other code can call into.
There are eight registers and thirty live values. Which ones go to memory?Build the interference graph, colour it, and when colouring gets stuck, spill the value with the lowest cost per use — weighting uses by loop depth, so a value used once outside a loop loses to one used every iteration. AtlasLang ships graph colouring and linear scan side by side so the quality-versus-compile-time trade is something you can see rather than read about. - 16Tooling
A formatter, a linter and a language server: hover types, go-to-definition, and diagnostics as you type.
The compiler throws away comments and formatting, and it stops at the first unparseable file. A language server can do neither. What has to change?The frontend does. Tooling needs a lossless tree that keeps trivia and survives errors, so the parser has to produce a concrete syntax tree with error nodes rather than an AST or a failure. Every language that added an editor experience late discovered this, and the retrofit is the expensive part — which is why it is a stage here rather than an appendix.
What the language must handle
Each requirement has a part that is harder than it looks.
Syntax feels like the fun part and is the part you can least afford to change later, because it is the only part your users see. Write three real programs in the proposed syntax before implementing any of it — not toy examples, the programs you actually expect people to write — and notice which decisions you regret at that scale.
The grammar you write first is almost certainly ambiguous, and the ambiguity will not announce itself — it shows up as a parse that is merely surprising. Test it against the classics: dangling else, unary minus against binary minus, and an assignment inside a condition.
Spans are the part everyone defers and nobody can retrofit cheaply. Every diagnostic, every hover, every source map and every debugger line table derives from ranges recorded here, so a lexer that drops them commits the whole project to messages that say "syntax error" and nothing more.
Getting a correct parse of correct input is the easy half. The hard half is what happens on incorrect input: a parser that returns a failure gives you a compiler that reports one error at a time, and an editor experience that goes blank the moment someone types an open brace.
The node design is an interface that every later pass depends on, and passes are written against it before you know what they need. Model what the language means rather than what the grammar did — and keep the span on every node, because the passes that need it are the ones you have not written yet.
Shadowing and declaration order are where the specification you did not write becomes visible. Can a variable be used before its declaration in the same block? Can a function? Most languages answer these two differently, and the difference is a deliberate decision rather than an implementation detail.
Resolution has to run as its own pass over the whole tree before type checking, not lazily during evaluation — otherwise an undefined name in a branch that never executes is never reported, and the language quietly becomes dynamically scoped in the places nobody tested.
Write down what it does not prove, on the same page as what it does. A type system that has been claimed to prevent runtime errors will be trusted for null safety, bounds safety and protocol correctness it never modelled, and the resulting bugs are attributed to the language rather than to the claim.
This is where the error messages are won or lost. The checker knows the expected type, the found type and the exact span; a message that reports all three and names the operation is a different product from one that says "type error".
The temptation is to optimize the AST directly, which works for two passes and then does not, because a tree has no notion of "the instruction before this one" and every analysis needs one. Choosing the IR level is the real decision — too high and lowering never simplifies anything, too low and target detail leaks into passes that should be portable.
Constructing blocks is straightforward until the edge cases arrive: unreachable code after a return, a loop with several exits, a break out of a nested loop, and blocks with no predecessors that still contain instructions. Getting the graph right is a prerequisite for everything after it being right.
Inserting phi nodes everywhere is correct and unusable; inserting them at dominance frontiers is what makes the form worth having. Leaving SSA is the half people forget, and it is where correctness bugs hide — parallel copies on critical edges have to be sequenced, and a naive ordering silently swaps two values.
Every pass needs a written precondition and a test that proves it declines when the precondition fails. The tests that matter are the negative ones: division by a literal zero must survive folding, and a call with an unused result must survive elimination if the call is observable.
A bytecode VM gets you running in a day and gives you a portable artifact; a native backend forces you to confront instruction selection, register allocation and a calling convention. Whichever you choose, keep the tree-walking interpreter as a reference implementation — it is how you find out that the new backend disagrees with it.
The boundary between compiled code and the runtime is an ABI you are designing whether or not you write it down: how values are represented, who allocates, how a call into a builtin passes arguments and reports errors. Changing it later invalidates every artifact already compiled.
Error recovery is what makes reporting more than one error possible, and it is genuinely hard: synchronise too eagerly and you produce a cascade of nonsense, too lazily and you report one error per compile. Suppressing errors that are consequences of an earlier one is a separate piece of work again.
Both need something the compiler frontend deliberately threw away: comments, formatting and a tree that survives a syntax error. Retrofitting a lossless, error-tolerant parse is the single most expensive thing on this list, which is why every language that added an editor experience late wishes it had not.
Then things go wrong
Each of these has a fix that looks right and makes it worse. That fix is the trap.
print(1 + 2 * 3) prints 9. Every arithmetic expression with mixed operators gives the wrong answer, consistently, and the same expression with explicit parentheses is fine.
The parser treats + and * as equal in binding power and associates left to right, so it builds (1 + 2) * 3. Nothing downstream is wrong: the evaluator is faithfully computing what the tree says.
Special-casing multiplication in the evaluator so it runs first. This produces the right answer for this expression and the wrong tree for every expression, and it breaks immediately on 2 * 3 + 4, on parenthesised subexpressions, and on anything that inspects the AST rather than evaluating it.
Fix the binding powers in the parser, then verify by printing the tree rather than the result. Add the standard mixed-operator cases to the test suite — including unary minus, which is the next one to break.
A program crashes deep inside evaluation with a null reference. The reported line is inside the interpreter, not inside the user’s program, and the actual mistake is a typo in an identifier three files away.
Names are being looked up at evaluation time rather than resolved in a pass. A name that never gets evaluated is never checked, so the error surfaces on the path that happens to run rather than at the declaration that is missing.
Catching the null and reporting "undefined variable" from the evaluator. It converts a crash into a message and leaves the real problem intact: the error still only appears when that path executes, so it still reaches production through any branch the tests do not cover.
Add a resolution pass that walks the whole tree before execution, binds every use to a declaration, and reports the unresolved ones with the span of the use and a suggestion from the names in scope.
Adding a number to a string produces something plausible and wrong — a concatenation where arithmetic was intended — and it propagates silently until a comparison much later behaves strangely.
There is no type checker. The evaluator coerces whatever it is given so that every operation succeeds, which means the program never fails at the point where it stopped making sense.
Adding a run-time check inside the addition operation that throws on mismatched operand kinds. It moves the failure earlier, which is an improvement, but it still only fires on the executed path — and it has now committed the language to checking at run time, which is a design decision made by accident.
Write the typing rules down, implement a checker pass over the annotated tree, and report the expected type, the found type and the span. Decide deliberately what remains a run-time check, and say so in the language documentation.
A function that returns a lambda works when the lambda is called immediately and returns nonsense when it is called later. In a loop, every closure produced sees the same final value.
The captured variable lives in the enclosing frame, and the frame is gone by the time the closure runs. The closure holds a reference to storage that has been reused.
Copying the captured values into the closure at creation. It fixes the crash and breaks mutation: a counter incremented through the closure no longer changes the original, and two closures over the same variable stop agreeing — a subtler bug that will be found much later.
Do closure conversion properly: move captured variables into a heap-allocated environment shared by the closure and the enclosing scope, and make the closure a pair of code pointer and environment. Then let escape analysis, later, put the environment back on the stack when it provably cannot escape.
A numeric loop that takes milliseconds in an established language takes seconds here. The profile is flat: no single function dominates, and the time is spread across the evaluator.
A tree-walking interpreter re-dispatches on node kinds every iteration and re-resolves names on every access. The work per operation is dominated by interpretation overhead, not by the operation.
Adding a cache to the evaluator — memoising node lookups, or caching resolved variables on the AST node. It buys a small constant factor, adds invalidation bugs the moment scopes are reused, and leaves the structural problem exactly where it was.
Compile to bytecode so the dispatch happens once per instruction instead of once per node, resolve names to slot indices at compile time, and then add the optimizations that remove instructions — because removing an instruction removes a dispatch.
A function with many live values compiles, but the generated code is dense with loads and stores, and it runs slower than the same function compiled with fewer locals.
More values are live simultaneously than there are physical registers, so the allocator is spilling — and it is choosing badly, spilling values used inside the loop rather than values used once outside it.
Increasing the number of registers the allocator may use, past what the target actually has. The generated code then references registers that do not exist, or clobbers ones the calling convention reserved, and the failure appears as memory corruption in the caller rather than as an error in the callee.
Fix the spill heuristic first: weight each value’s spill cost by the loop depth of its uses, so a value used every iteration outranks one used once. Then reduce pressure at the source — split the live range, rematerialize cheap values at their uses, and check whether an over-eager CSE created the pressure in the first place.
Each file compiles cleanly. Linking fails with an undefined symbol whose name only half resembles the function that was written, and a second attempt produces a duplicate-symbol error instead.
The two sides disagree about the symbol. One was compiled with a mangled name encoding parameter types and the other without; or the same generic body was emitted in both objects with strong linkage, so the linker sees two definitions of the same thing.
Renaming the function until the link succeeds. That silences the message and can leave the caller and callee disagreeing about the calling convention or the argument layout, which becomes a crash at run time that has nothing visibly to do with the rename.
Read the mangled name and decode it — the symbol says exactly what the compiler expected. Make the mangling scheme deterministic and shared, mark definitions that may be emitted more than once as mergeable, and add a link step to the test suite so this fails at build time rather than at deployment.
A function written once over a type parameter has to be compiled. Nothing in the front end says whether the body should be emitted once or once per instantiation, and the answer changes the runtime representation of every value it touches.
Erasure, reification and monomorphization are three different implementation strategies that the same source syntax hides. The choice determines whether values are boxed, whether calls inside the generic can be inlined, whether the type argument exists at run time, and how large the binary gets.
Monomorphizing everything because it produces the fastest code in the benchmark. Instantiation is transitive — a generic calling a generic multiplies — and compile time and binary size grow with it until the build becomes the problem, long after the decision is embedded in the ABI.
Decide from the language’s actual constraints and write the decision down. If binary size and compile time matter, erase and pay for boxing. If per-type speed matters, monomorphize and budget the code growth. If the type must be inspectable at run time, reify it and pay for the metadata.
A function needs to pause mid-body, return to its caller, and resume later at the same point with its locals intact. Nothing in the compiler’s model of a function supports this: a native frame is destroyed when it returns.
Suspension is not a function call. Every local live across a suspension point has to survive the frame going away, and the body has to become re-enterable at each of those points.
Running the body on a separate thread and blocking it at each suspension. It works for a demo and gives you one operating-system thread per pending operation, which is the exact cost the async design existed to avoid — and it silently changes the concurrency model of the language.
Lower the body to a state machine: one state per resume point, with every live-across-suspend local promoted to a field of a generated object. Accept the consequence — stack traces and lifetimes no longer match the source shape — and emit the debug metadata that lets a debugger reconstruct them.
A guard written to catch an overflow works in the debug build and is absent from the optimized one. The generated code jumps straight past the check, and the program corrupts memory instead of reporting an error.
The check is written in terms of an operation the language says is undefined on overflow, so the optimizer concluded the overflow cannot happen and the condition is always false. The check was removed as dead code, which is a legal transformation given that premise.
Lowering the optimization level for that file. The symptom goes away, the undefined behavior stays, and it will reappear the next time anything about the surrounding code changes — including in a different file after link-time optimization.
Rewrite the check so it does not depend on the undefined operation: test before the operation rather than after, or use the language’s checked or wrapping arithmetic. Run a sanitizer over the test suite to find the other places where the same pattern is hiding.