← Back to projects
Summary

The middle of the compiler, where a parsed program is proven legal before anything is emitted. Rather than one large semantic-analysis visitor, the work is split into nine passes that each run to completion over the whole tree. Every pass has a single responsibility and can assume the previous ones already succeeded, which is what makes the ordering meaningful instead of incidental.

Stack

What it's built with.

C++17Visitor PatternSymbol TablesDiagnosticsStatic Analysis
Details

How it works.

Why nine passes instead of one visitor

A single semantic pass is tempting because it traverses the tree once. What it costs is ordering: a check that needs information the traversal has not gathered yet has to be deferred, and the deferral mechanism becomes a second, undocumented pass structure living inside the first one as mutable state.

Splitting into ordered passes makes those dependencies explicit and checkable. Type checking may assume every node carries a resolved type because TypeResolver already ran. Qualifier enforcement may assume names resolve because ScopeAnalyzer already ran. When a new rule needs to be added, the question 'where does this go' has an answer derivable from what the rule needs to know.

One owner for the symbol table

Scope handling is the classic place where a compiler acquires bugs that only appear on shadowed names in nested blocks. The rule here is that only the scope analyzer pushes and pops scopes; every other pass reads. That single constraint removes an entire category of failure, because there is no second component whose idea of the current scope can drift.

The runtime scope stack exists alongside it for a related but distinct reason. Code generation needs to know when storage comes into and goes out of scope, which is not the same as when a name is resolvable, and giving that its own stack keeps codegen from having to reinterpret the compile-time one.

Highlights

The things I'm proudest of.

  • ▹Nine passes in a fixed order, each assuming the invariants of the one before: ASTBuilder, ScopeAnalyzer, RoutineRecorder, TypeResolver, QualifierEnforcer, TypeChecker, TypeCastValidator, and a FinalJudgmentVisitor that catches what only becomes decidable once everything else is known.
  • ▹Scope resolution owns the symbol table outright: it is the only component permitted to push or pop a scope, and every other pass queries it. Distributed scope mutation is how symbol tables end up disagreeing with themselves.
  • ▹Maintained two independent scope stacks, one for compile-time resolution and one modelling runtime scope, because the question 'is this name visible here' and the question 'is this storage live here' are not the same question and conflating them breaks on shadowing.
  • ▹Qualifier enforcement is its own pass rather than a check inside the type checker, since const and var correctness is a property of assignment and aliasing rather than of types, and interleaving the two makes both harder to reason about.
  • ▹Sixteen typed diagnostic classes generated from a single macro (AliasingError, AssignError, CallError, DefinitionError, GlobalError, IndexError, LiteralError, MainError, MathError, ReturnError, SizeError, StatementError, StrideError, SymbolError, SyntaxError, TypeError), each carrying a line number and a description in a uniform format.
  • ▹A dedicated final-judgment pass for the checks that genuinely cannot run earlier, keeping the earlier passes free of 'come back to this later' state.