R0KernelPath
module 178 / 262

Abstract Syntax Trees & Semantic Analysis

ASTs are the moment source code becomes a data structure your compiler can reason about — semantic analysis is where that data structure learns what the program means.

~18h focused study8 concepts

Overview

Abstract Syntax Trees and Semantic Analysis

After a parser verifies that your program is grammatically correct, the compiler faces a deeper and more interesting question: does this program mean anything? A syntactically valid sentence like "Colorless green ideas sleep furiously" (Chomsky's famous example) is grammatical but semantically nonsensical. The same problem arises in programming: int x = "hello" parses fine but violates type rules. This module is about building the data structure that makes deeper reasoning possible — the Abstract Syntax Tree — and then using it to check and understand what a program actually says.

The Abstract Syntax Tree (AST) is the bridge between the linear text of source code and the recursive, hierarchical structure of computation. Unlike the raw parse tree (which slavishly records every grammar production), the AST retains only what matters for meaning: the operator in 3 + 4 * 5, the binding in let x = ..., the control flow in if-else. It is the compiler's internal model of the program — the same way a sentence diagram is a mathematician's model of grammatical structure.

Semantic analysis is the phase where the compiler uses the AST to enforce rules the grammar cannot express. Is every variable declared before use? Do the types of arguments match the function's parameters? Is break used only inside a loop? Does a function always return a value on every path? These questions require context — specifically, a symbol table that tracks what names mean in what scope. This module builds that entire machinery, from constructing the AST to walking it with the Visitor pattern, to performing the rich semantic checks that make a language safe and meaningful.

This material corresponds to roughly weeks 3–6 of a compiler construction course (MIT 6.035, Stanford CS143). By the end, you will understand how production compilers like GCC, Clang, and javac represent and analyze programs internally — and you will be able to build this infrastructure from scratch.

why this matters for the kernel

ASTs are not confined to compiler courses. Every major language tool — linters (ESLint, Pylint), formatters (Prettier, Black), refactoring engines (IntelliJ, clangd), static analyzers (Coverity, CodeQL), transpilers (Babel, TypeScript), and even AI code models — operates on ASTs. When you understand how an AST is built and traversed, you can write tools that transform code programmatically: automated refactors, custom lint rules, domain-specific languages, code generators. This knowledge separates engineers who use tools from engineers who build them.

Semantic analysis specifically is where language design and type theory intersect with practical engineering. The symbol table and scope rules you implement here are the same structures that underpin Hindley-Milner type inference in Haskell and ML, the borrow checker in Rust, and the gradual typing systems in TypeScript and Python. Understanding how a compiler resolves names and checks types gives you the vocabulary to reason clearly about language features — closures, generics, overloading, shadowing — and to debug the subtle errors that arise when those features interact. It is one of the deepest payoffs of studying compilers at the algorithm level rather than just the API level.

Compiler Phases: From Source to Decorated AST

rendering diagram…

Each phase transforms the program representation: the parser builds a raw AST, the declaration visitor populates the symbol table, the resolve visitor decorates AST nodes with symbol pointers, and the type checker annotates every node with a type. Semantic checks run as additional visitors before code generation begins.

The concepts

01From Parse Tree to Abstract Syntax Tree

The Cost of Concreteness

A parse tree (also called a concrete syntax tree or CST) is a faithful record of the grammar derivation. Every grammar rule that fired, every token matched, appears as a node. For the expression 3 + 4 * 5 with a grammar that eliminates left-recursion through Expr → Term (('+' | '-') Term)*, the parse tree has nodes for Expr, Term, Factor, and so on — even for the implicit precedence machinery that has no semantic content.

The abstract syntax tree strips all of that scaffolding. It keeps only semantically meaningful structure:

  • Parentheses are gone (precedence is encoded in tree shape).
  • Intermediate nonterminals like Term, Factor, Expr collapse into operator nodes.
  • Keywords like if, while, return become node types, not children.
  • Optional punctuation (semicolons, commas) disappears.

For 3 + 4 * 5, the AST is just:

     +
    / \
   3   *
      / \
     4   5

The shape itself encodes that * binds tighter than + — no extra nodes needed.

How Parsers Build ASTs

In a recursive-descent parser, each parsing function returns an AST node:

def parse_add_expr():
    left = parse_mul_expr()          # left subtree
    while current_token() == '+':
        consume('+')                 # discard the operator token
        right = parse_mul_expr()     # right subtree
        left = BinOpNode('+', left, right)  # fold left-associatively
    return left

The key insight: the grammar drives which AST node to create; the token ('+') is captured as a label on the node, not as a separate child.

AST Node Design

AST nodes are typically a class hierarchy (in OO languages) or a tagged union/algebraic data type (in functional languages). A minimal Python example:

from dataclasses import dataclass
from typing import Optional

@dataclass
class IntLiteral:
    value: int

@dataclass
class BinOp:
    op: str           # '+', '-', '*', '/'
    left: 'Expr'
    right: 'Expr'

@dataclass
class IfStmt:
    condition: 'Expr'
    then_branch: 'Stmt'
    else_branch: Optional['Stmt']

@dataclass
class VarDecl:
    name: str
    type_ann: 'TypeExpr'
    init: Optional['Expr']

Each class is an AST node kind. The tree is built bottom-up during parsing and then handed to semantic analysis.

# Building and printing a tiny expression AST
from __future__ import annotations
from dataclasses import dataclass
from typing import Union

@dataclass
class Num:
    value: int
    def __repr__(self): return str(self.value)

@dataclass
class BinOp:
    op: str
    left: Expr
    right: Expr
    def __repr__(self):
        return f'({self.left} {self.op} {self.right})'

Expr = Union[Num, BinOp]

# AST for  3 + 4 * 5
tree = BinOp('+', Num(3), BinOp('*', Num(4), Num(5)))
print(tree)   # -> (3 + (4 * 5))

# Evaluate by recursion
def eval_expr(node: Expr) -> int:
    if isinstance(node, Num):
        return node.value
    if isinstance(node, BinOp):
        l, r = eval_expr(node.left), eval_expr(node.right)
        if node.op == '+': return l + r
        if node.op == '*': return l * r
    raise ValueError(f'Unknown node: {node}')

print(eval_expr(tree))  # -> 23

02The Visitor Pattern

The Problem: Adding Operations Without Modifying Nodes

Once you have an AST, you need to perform many passes over it: type-checking, constant folding, code generation, pretty-printing. If you put each operation as a method on each AST node class, you get an explosion of responsibilities — every node class grows a typecheck(), codegen(), prettyprint(), constantFold() method. Adding a new pass means modifying every node class. This violates the Open/Closed Principle.

The Visitor pattern inverts the relationship. Each pass is encapsulated in a Visitor class. Each AST node implements a single accept(visitor) method that dispatches to the appropriate visitor method. Adding a new pass means writing a new Visitor class — the AST node classes are untouched.

The Double-Dispatch Trick

The heart of the pattern is double dispatch: the call node.accept(visitor) dispatches first on the type of node, and then visitor.visit_BinOp(node) dispatches on the visitor type. This gives you the behavior of visit(BinOp, TypeCheckVisitor) without needing multiple dispatch in the language.

class Visitor:
    def visit_Num(self, node):   raise NotImplementedError
    def visit_BinOp(self, node): raise NotImplementedError

# Each AST node gets a one-liner accept:
class Num:
    def accept(self, v): return v.visit_Num(self)

class BinOp:
    def accept(self, v): return v.visit_BinOp(self)

# A concrete pass: constant folding
class ConstFold(Visitor):
    def visit_Num(self, node):
        return node  # already constant
    def visit_BinOp(self, node):
        l = node.left.accept(self)
        r = node.right.accept(self)
        if isinstance(l, Num) and isinstance(r, Num):
            ops = {'+': l.value + r.value, '*': l.value * r.value}
            return Num(ops[node.op])
        return BinOp(node.op, l, r)

Visitors in Real Compilers

In Java (ANTLR4 generates these), Go (ast.Walk), Rust (syn crate), and Clang (the RecursiveASTVisitor), the Visitor pattern or a close relative is the standard traversal mechanism. LLVM's InstVisitor uses the same idea for IR passes. Understanding this pattern is essential for reading compiler source code.

# Visitor that pretty-prints an expression AST
class PrettyPrint:
    def visit_Num(self, node):
        return str(node.value)
    def visit_BinOp(self, node):
        l = node.left.accept(self)
        r = node.right.accept(self)
        return f'({l} {node.op} {r})'

class TypeCheck:
    """Returns 'int' or raises TypeError."""
    def visit_Num(self, node):
        return 'int'
    def visit_BinOp(self, node):
        lt = node.left.accept(self)
        rt = node.right.accept(self)
        if lt != rt:
            raise TypeError(f'Type mismatch: {lt} {node.op} {rt}')
        return lt

# Add accept to each node class (or use Python singledispatch)
Num.accept   = lambda self, v: v.visit_Num(self)
BinOp.accept = lambda self, v: v.visit_BinOp(self)

tree = BinOp('+', Num(3), BinOp('*', Num(4), Num(5)))
print(tree.accept(PrettyPrint()))   # ((3 + (4 * 5)))
print(tree.accept(TypeCheck()))     # int

03Symbol Tables

The Compiler's Address Book

A symbol table is the central data structure of semantic analysis. It maps names (identifiers) to their attributes: type, kind (variable, function, parameter, type alias), memory location (in later passes), and any other information needed to compile a use of that name.

When the compiler encounters int x = 5;, it enters x into the symbol table with attributes {kind: variable, type: int, value: 5}. When it later encounters x + 3, it looks up x to find its type so it can typecheck the addition.

What Goes in a Symbol Entry?

A typical entry contains:

  • Name (the string key)
  • Kind: variable, function, type, constant, label
  • Type: int, float, (int, int) → bool, etc.
  • Scope level: how deeply nested the declaration is
  • Source location: file, line, column (for error messages)
  • Other attributes: is it initialized? is it mutable? what is its storage class?

Implementation

The simplest implementation is a hash table from strings to symbol entries. A production symbol table also supports scope nesting (see next concept). Many compilers (GCC, LLVM) use a chained hash table with a per-scope slab allocator for fast reset when a scope exits.

typedef struct Symbol {
    const char *name;
    Kind        kind;    // VAR, FUNC, TYPE
    Type       *type;
    int         scope_level;
    SourceLoc   loc;
    struct Symbol *next; // for chaining in hash bucket
} Symbol;

typedef struct SymbolTable {
    Symbol *buckets[HASH_SIZE];
    int     current_scope;
} SymbolTable;

The Two Core Operations

  • define(name, attrs): Insert a new symbol. If the name already exists in the same scope, it is a redeclaration error.
  • resolve(name): Look up a name, searching from the innermost scope outward. If not found at any scope level, it is an undeclared identifier error.
# Python dict-based symbol table (single scope for now)
class SymbolTable:
    def __init__(self):
        self._table: dict[str, dict] = {}

    def define(self, name: str, kind: str, typ: str, loc=None):
        if name in self._table:
            raise SemanticError(f"Redeclaration of '{name}' at {loc}")
        self._table[name] = {'kind': kind, 'type': typ, 'loc': loc}

    def resolve(self, name: str) -> dict:
        if name not in self._table:
            raise SemanticError(f"Undeclared identifier '{name}'")
        return self._table[name]

class SemanticError(Exception): pass

st = SymbolTable()
st.define('x', 'var', 'int')
st.define('foo', 'func', 'int->bool')
print(st.resolve('x'))     # {'kind': 'var', 'type': 'int', 'loc': None}
st.define('x', 'var', 'int')  # raises SemanticError: Redeclaration of 'x'

04Scope and Name Resolution

Scoping: Rules for What a Name Means Here

Most languages have lexical (static) scoping: a name refers to the textually enclosing declaration. The alternative — dynamic scoping, used in some Lisps and Bash — looks up names at runtime along the call stack. Almost all modern languages (C, Java, Python, Rust, Go, Haskell) use lexical scope, and that is what we implement here.

The Scope Stack

Lexical scope is naturally modeled as a stack of symbol tables, one table per active scope:

[global scope]  →  [function scope]  →  [inner block scope]

When you enter a new scope (function body, block, for-loop), push a fresh table. When you exit, pop it. When resolving a name, walk from the innermost (top) table outward until found — or error if exhausted.

This gives you shadowing for free: if x is declared in both the inner and outer scope, the inner declaration is found first and shadows the outer one.

class ScopedSymbolTable:
    def __init__(self):
        self.scopes: list[dict] = [{}]   # stack; [0] = global

    def enter_scope(self):
        self.scopes.append({})

    def exit_scope(self):
        self.scopes.pop()

    def define(self, name, attrs):
        current = self.scopes[-1]
        if name in current:
            raise SemanticError(f"Redeclaration of '{name}' in same scope")
        current[name] = attrs

    def resolve(self, name):
        # Walk inward-to-outward
        for scope in reversed(self.scopes):
            if name in scope:
                return scope[name]
        raise SemanticError(f"Undeclared identifier '{name}'")

Scope Rules in Real Languages

RuleLanguageDescription
Block scopeC99+, Java, RustVariables declared inside {} are not visible outside
Function scopeJavaScript varvar declarations hoist to the top of the function
Class scopeJava, C++Members visible anywhere in the class body
Module/file scopePython, GoTop-level names visible throughout the file
Forward declarationsC, C++Must declare before use (structs can be incomplete types)

Two-Pass Name Resolution

Some languages (Java, C#) allow forward references within a class — a method can call another method declared below it. This requires two passes:

  1. First pass: scan the class and enter all member declarations into the symbol table.
  2. Second pass: resolve the bodies.

Within a single function in C, no forward references are allowed — declarations must precede use, so one pass suffices.

# Scope stack in action: resolving names in nested blocks
st = ScopedSymbolTable()

# Global
st.define('PI', {'kind': 'const', 'type': 'float', 'value': 3.14})

# Enter function 'area'
st.enter_scope()
st.define('r', {'kind': 'param', 'type': 'float'})

  # Inner block
  st.enter_scope()
  st.define('r', {'kind': 'var', 'type': 'int'})  # shadows outer r
  print(st.resolve('r')['type'])   # int  (inner shadows outer)
  print(st.resolve('PI')['value']) # 3.14 (walks up to global)
  st.exit_scope()

print(st.resolve('r')['type'])     # float (outer r is back)
st.exit_scope()

try:
    st.resolve('r')                # error: r is gone
except Exception as e:
    print(e)   # Undeclared identifier 'r'

05Building the Symbol Table with an AST Visitor

Wiring It Together: The First Semantic Pass

The standard way to populate the symbol table is a two-part AST walk:

  1. Declaration visitor: Walk the AST; whenever you hit a declaration node (VarDecl, FuncDecl, ClassDecl), enter the symbol into the current scope. Push/pop scopes as you enter/exit block nodes.
  2. Resolution visitor: Walk the AST; whenever you hit a name-use node (IdentExpr, CallExpr), call resolve() to look it up and attach the resolved symbol to the node (this is called decorating the AST — adding semantic annotations to nodes).

Some compilers merge these into a single pass (safe for simple languages with no forward references). Others perform them strictly separately.

Decorating the AST

After resolution, each IdentExpr node carries a pointer to its symbol entry:

BinOp('+',
  IdentExpr('x')  ← .symbol = {kind: var, type: int, scope: 1}
  Num(3)
)

This decoration allows later passes (type inference, code generation) to work directly with symbol information without calling back into the symbol table by name. It is an important design decision: the AST becomes a richer, annotated structure as it passes through semantic analysis.

The Full Flow

Source text
  → [Lexer] → tokens
  → [Parser] → raw AST
  → [DeclVisitor] → AST + populated symbol table
  → [ResolveVisitor] → decorated AST (each name node points to its symbol)
  → [TypeCheckVisitor] → decorated AST + type annotations on every node
  → [CodeGen] → IR / assembly

Each arrow is a pass over the AST. Real compilers often merge adjacent passes for performance, but the conceptual separation is clean.

# A minimal declaration + resolution visitor for a tiny language
# AST: Program([VarDecl | AssignStmt | IdentExpr])

from dataclasses import dataclass, field
from typing import Optional, Any

@dataclass
class VarDecl:
    name: str
    typ: str
    init: Optional[Any] = None
    symbol: dict = field(default_factory=dict, repr=False)

@dataclass
class IdentExpr:
    name: str
    symbol: dict = field(default_factory=dict, repr=False)

class DeclVisitor:
    def __init__(self):
        self.st = ScopedSymbolTable()

    def visit_VarDecl(self, node: VarDecl):
        entry = {'kind': 'var', 'type': node.typ}
        self.st.define(node.name, entry)
        node.symbol = entry            # decorate the declaration node too

    def visit_IdentExpr(self, node: IdentExpr):
        node.symbol = self.st.resolve(node.name)  # decorate with resolved symbol

decl_x = VarDecl('x', 'int')
use_x  = IdentExpr('x')

dv = DeclVisitor()
dv.visit_VarDecl(decl_x)
dv.visit_IdentExpr(use_x)

print(use_x.symbol)  # {'kind': 'var', 'type': 'int'}
# use_x.symbol IS THE SAME OBJECT as decl_x.symbol

06Type Systems and Type Checking

Types as Contracts

A type is a set of values together with the operations permitted on them. The compiler's job in type checking is to verify, at compile time, that every operation is applied to a value of an appropriate type — without running the program.

There are two broad families:

  • Static typing (C, Java, Rust, Haskell): Types are checked at compile time. Errors are caught before the program runs.
  • Dynamic typing (Python, JavaScript, Ruby): Type errors are caught at runtime.

Within static typing:

  • Nominal typing (Java, C#): Two types are compatible if they have the same name or one is declared to extend the other.
  • Structural typing (TypeScript, Go interfaces, OCaml modules): Two types are compatible if they have the same structure (same fields, same method signatures).

The Type Checker Pass

A type checker is a visitor that:

  1. For each expression node, computes and records its type (the synthesis rule).
  2. For each statement node, checks that sub-expressions have expected types (the checking rule).

A type environment Γ maps variable names to their types (this is effectively your symbol table's type column). The type checking rules are often written as typing judgments:

Γ ⊢ e : τ

read as "in environment Γ, expression e has type τ."

For example:

[T-Int]   Γ ⊢ n : int     (n is an integer literal)
[T-Var]   Γ ⊢ x : τ      if (x : τ) ∈ Γ
[T-Add]   Γ ⊢ e1 : int   Γ ⊢ e2 : int
          ─────────────────────────────────
          Γ ⊢ e1 + e2 : int

Type Coercion and Promotion

Most languages define implicit widening conversions: int → float, char → int. The type checker must apply these rules and insert implicit cast nodes in the AST. An explicit narrowing conversion (float to int) requires the programmer to write a cast, because it may lose information.

Type Errors

Good type error messages are an art. The best compilers (Elm, Rust) explain why the types conflict, show the expected vs. actual type, and point to the relevant source location. Poor compilers cascade a single root error into dozens of misleading messages. The technique to avoid cascading: when a type error occurs, substitute a special ErrorType for the problematic subexpression and continue analysis — ErrorType is compatible with everything, preventing spurious further errors.

# Type checker for arithmetic expressions with float promotion
def typecheck(node, env: dict) -> str:
    if isinstance(node, Num):
        return 'int'
    if isinstance(node, FloatLit):
        return 'float'
    if isinstance(node, IdentExpr):
        if node.name not in env:
            raise TypeError(f"Undeclared: {node.name}")
        return env[node.name]
    if isinstance(node, BinOp):
        lt = typecheck(node.left,  env)
        rt = typecheck(node.right, env)
        if lt == rt:
            return lt   # same type: result is that type
        # Widening: int + float -> float
        numeric = {'int', 'float'}
        if lt in numeric and rt in numeric:
            # Insert implicit cast node in the AST
            if lt == 'int':
                node.left = CastNode('float', node.left)
            else:
                node.right = CastNode('float', node.right)
            return 'float'
        raise TypeError(f"Cannot apply '{node.op}' to {lt} and {rt}")

# Example:
# env = {'pi': 'float'}
# typecheck(BinOp('+', Num(3), IdentExpr('pi')), env)
# -> 'float'  (and wraps Num(3) in CastNode('float', ...))

07Semantic Checks Beyond Syntax and Types

What the Grammar and Types Cannot Enforce

Even after you have a well-typed program, there are contextual or semantic constraints that require deeper analysis. These are sometimes called context-sensitive rules, and they are the reason semantic analysis is more than just type checking.

Common Semantic Checks

1. Use before initialization In Java and Rust, the compiler verifies that every variable is definitely initialized before use. This requires a form of data-flow analysis (covered in a later module), but a simple approximation is checking whether a VarDecl has an initializer.

2. Break/Continue placement break and continue are only legal inside a loop (or switch). The checker tracks a "loop depth" counter; entering a loop increments it, exiting decrements it. Seeing break when the counter is 0 is an error.

3. Return on all paths A function that declares a non-void return type must have a return statement on every possible execution path. This is a control-flow problem: build a control-flow graph (CFG) of the function body and check that every path from entry to exit passes through a return.

4. Unreachable code Code after a return, break, or continue in the same block is unreachable. Good compilers warn about this.

5. Dead code / unused variables Many compilers and linters track whether a declared variable is ever read. This catches bugs like int result = compute(); return 0; where result is never used.

6. Integer overflow / array bounds (optional) Some compilers (Rust, AdaCore SPARK) perform range analysis to prove at compile time that an array index is always in bounds or that an integer cannot overflow.

7. Duplicate case labels in switch Two case 42: branches in the same switch statement — detectable by scanning the case labels.

Implementation Pattern

For loop-depth checks:

class LoopChecker(Visitor):
    def __init__(self):
        self.loop_depth = 0
    def visit_WhileStmt(self, node):
        self.loop_depth += 1
        node.body.accept(self)
        self.loop_depth -= 1
    def visit_BreakStmt(self, node):
        if self.loop_depth == 0:
            raise SemanticError("'break' outside of loop")

This pattern — a counter or flag that tracks context as you traverse — is the workhorse of context-sensitive checking.

# Return-on-all-paths checker for a simple if-else language
# Returns True if the subtree DEFINITELY returns.
def definitely_returns(node) -> bool:
    if isinstance(node, ReturnStmt):
        return True
    if isinstance(node, IfStmt):
        # Only certain if BOTH branches return AND else exists
        if node.else_branch is None:
            return False
        return (definitely_returns(node.then_branch) and
                definitely_returns(node.else_branch))
    if isinstance(node, Block):
        # Certain if ANY statement definitely returns
        # (subsequent stmts are unreachable but that's a warning, not error)
        return any(definitely_returns(s) for s in node.stmts)
    return False  # assignment, expr-stmt, etc. don't return

# Example use:
# func body:  if (x > 0) { return 1; } else { return -1; }
# definitely_returns -> True
#
# func body:  if (x > 0) { return 1; }
# definitely_returns -> False  => error: missing return

08AST Transformations: Desugaring

Syntax Sugar and Lowering

Desugaring is the process of transforming complex language constructs into simpler ones. It is typically performed on the AST before or during semantic analysis, making later passes (especially code generation) simpler by reducing the number of node types they need to handle.

The term comes from syntactic sugar — syntactically convenient notation that the compiler secretly transforms into a more primitive form. Classic examples:

SugarDesugars to
for (x in xs)while loop with an iterator
x += 1x = x + 1
a[i]*(a + i) (in C)
[x*2 for x in xs] (Python)map(lambda x: x*2, xs)
do { ... } while (cond)while (true) { ... if (!cond) break; }
String interpolation f"Hello {name}"string concatenation / format call

Why Desugar?

  • Simplifies code generation: codegen only handles the small core language.
  • Simplifies semantic analysis: you write type-checking rules for the desugared form once.
  • Enables optimization: the optimizer works on the simpler form and the benefit applies to all sugar that maps to it.

Lowering vs. Desugaring

Desugaring happens at the AST level — transforming one AST node into another. Lowering happens later, transforming the AST into an IR. In practice the terms are used loosely. The key principle is: reduce the surface area of the AST that subsequent passes must handle, as early as possible.

In Practice

Haskell's GHC desugars almost everything into a tiny Core language (a typed lambda calculus with case expressions and let bindings). All the syntactic richness of Haskell — where clauses, list comprehensions, do-notation, guards — is eliminated in a single desugaring pass. This is a major reason GHC can perform aggressive optimizations: it only needs to optimize Core.

# Desugar ForIn into While in the AST
@dataclass
class ForIn:
    var: str
    iterable: Expr
    body: Stmt

@dataclass
class WhileStmt:
    cond: Expr
    body: Stmt

@dataclass
class Block:
    stmts: list

def desugar_for_in(node: ForIn) -> Block:
    """
    for x in xs:
        body
    ->
    _iter = iter(xs)
    while True:
        try: x = next(_iter)
        except StopIteration: break
        body
    (Simplified version — just illustrates the idea)
    """
    iter_var = '_iter_' + node.var
    return Block([
        VarDecl(iter_var, 'iter', CallExpr('iter', [node.iterable])),
        WhileStmt(
            cond=BoolLit(True),
            body=Block([
                AssignStmt(node.var, CallExpr('next', [IdentExpr(iter_var)])),
                node.body,
            ])
        )
    ])

Key insights

  • ✦The AST abstracts away parsing mechanics (grammar rules, precedence, punctuation) and keeps only what is semantically meaningful — the shape of the tree encodes operator precedence and associativity directly.
  • ✦The Visitor pattern separates what-to-do (the pass) from the data-structure (the AST), making it easy to add new analysis passes without modifying node classes — an essential design principle for large compilers.
  • ✦A scoped symbol table is fundamentally a stack of hash maps; entering a scope pushes a new map, exiting pops it, and resolution walks from top to bottom. This single data structure implements block scope, shadowing, and nested functions.
  • ✦Decorating the AST — annotating each name-use node with a pointer to its resolved symbol entry — eliminates repeated name lookups in later passes and is how type information propagates through the tree.
  • ✦Type errors should produce an ErrorType sentinel that is compatible with everything, allowing the checker to continue and report all errors in a single compilation rather than halting at the first.
  • ✦Many semantic constraints (break outside loop, missing return, use before initialization) are not type rules but control-flow or context-sensitive rules checked by maintaining counters or flags during AST traversal.
  • ✦Desugaring transforms rich surface syntax into a smaller core language, compressing the complexity that type checking and code generation must handle — GHC Core is the canonical example of taking this to the extreme.

Common pitfalls

  • ⚠Confusing the parse tree (faithful grammar derivation) with the AST (semantic structure): the CST has nodes for every grammar production; the AST collapses them. Intermediate nonterminals like 'Term' and 'Factor' should not appear in the AST.
  • ⚠Implementing a single flat symbol table instead of a scope stack, then failing to handle shadowing, block scope, or the re-use of names across different scopes in the same function.
  • ⚠Stopping type checking at the first error instead of using ErrorType to continue — this causes one root-cause error to masquerade as dozens, making diagnostics useless.
  • ⚠Forgetting the two-pass requirement for mutually recursive functions or forward references: declaring all names in a first pass and resolving all uses in a second pass, rather than resolving eagerly in one pass.
  • ⚠Building the Visitor pattern but forgetting to recursively call accept() on child nodes — the visitor only visits the root node and misses the rest of the tree. Every non-leaf visitor method must visit its children explicitly.
  • ⚠Treating the visitor as a pure function (no state) when semantic checks inherently require stateful context — the loop-depth counter, the current function's return type, the current class scope all require mutable visitor state.

Practice

  1. 01
    medium

    Implement a complete expression evaluator using the Visitor pattern for a language that supports integer literals, float literals, binary operators (+, -, *, /), variables, and let-bindings: `let x = 3 + 4 in x * 2`. Write at minimum: the AST node classes, a type-checking visitor (int/float), a constant-folding visitor, and an evaluation visitor.

  2. 02
    medium

    Add a return-type checker to a simple function language. Given an AST for functions with if/else, return statements, and blocks, implement a visitor that (a) verifies every declared return type matches the actual expression type, and (b) verifies that every non-void function definitely returns on all paths. Generate clear error messages with source locations.

  3. 03
    hard

    Extend the scoped symbol table to support closures correctly. In a language with nested function definitions, a closure can capture variables from its enclosing scope. The inner function's symbol table must be able to resolve names from outer function scopes. Implement 'free variable' detection: for a given function AST node, return the set of names that are used inside but not declared inside (i.e., they must be captured from the environment).

  4. 04
    hard

    Prove (informally but rigorously) that the scope stack correctly models lexical scoping for a block-structured language: if a name 'x' is declared at scope depth d and used at scope depth d' >= d, the resolution finds the nearest enclosing declaration. Then prove that if 'x' is declared at two different scopes d1 < d2, a use at d2 resolves to the d2 declaration (shadowing). Write the argument as a structural induction over the nesting depth.

  5. 05
    hard

    Implement a basic symbol table for a tiny C-like language that supports structs with named fields. When `s.field` is encountered, look up `s` to find its struct type, then look up `field` in that struct's field table. Handle the case where struct types are declared before the variables that use them (requires two-pass or forward declarations).

Resources

📕Compilers: Principles, Techniques, and Tools (Dragon Book, 2nd ed.)
bookpaidAho, Lam, Sethi, Ullman — Pearson/Addison-Wesley

The canonical reference. Chapters 2, 6, and 7 cover ASTs, symbol tables, and semantic analysis in depth. Section 6.3 covers three-address code; section 2.8 covers AST construction during parsing.

📕Engineering a Compiler (3rd ed.)
bookpaidCooper, Torczon — Morgan Kaufmann / Elsevier

Cooper & Torczon. Chapter 5 covers the front end including AST construction and symbol tables. Clearer than the Dragon Book on many practical points.

🎓MIT 6.035 Computer Language Engineering
coursefree

Full lecture notes and project specs for a full compiler. Lectures 4-6 cover semantic analysis, ASTs, and symbol tables directly.

📕Crafting Interpreters
bookfree

Robert Nystrom's free online book. Chapters 8-12 build an AST, a resolver (scope/name resolution), and a type checker step by step in Java and C. Exceptional clarity.

📕Design Patterns: Elements of Reusable Object-Oriented Software
bookpaidGamma, Helm, Johnson, Vlissides — Addison-Wesley

Gang of Four. Chapter on the Visitor pattern (pp. 331-344) is the canonical treatment. Read alongside the compiler context to understand the double-dispatch motivation.

✎Abstract Syntax Tree — Wikipedia
articlefree

Good overview with concrete examples and discussion of CST vs. AST distinction.

📄LLVM Language Reference Manual
docsfree

Seeing the IR that AST-based code generation targets helps motivate what semantic analysis must verify before codegen. The type system in LLVM IR is a good reference for what 'lowered types' look like.

✎Hindley-Milner Type System — Wikipedia
articlefree

After learning basic type checking, this article on the classic type inference algorithm (used in Haskell and ML) is the natural next step for understanding type systems at depth.

Checkpoints — you can now…

  • ✓You can explain the difference between a concrete syntax tree (parse tree) and an abstract syntax tree, and manually draw the AST for a given expression or statement.
  • ✓You can implement the Visitor pattern for a small AST class hierarchy — writing both the accept() methods on nodes and a concrete visitor — and explain why double dispatch is needed.
  • ✓You can implement a scoped symbol table (stack of hash maps) with enter_scope, exit_scope, define, and resolve, and trace through a nested-scope example by hand to predict which declaration a name resolves to.
  • ✓You can implement a type-checking visitor for arithmetic expressions that handles implicit widening (int to float), produces typed error messages with source locations, and inserts implicit cast nodes in the AST.
  • ✓You can implement context-sensitive semantic checks — specifically break/continue placement and return-on-all-paths — by maintaining state in the visitor and reasoning about control flow structure.
  • ✓You can describe the full pipeline from source text to a decorated, type-annotated AST, name each phase, and explain what invariant each phase establishes that the next phase depends on.