Lesson 5.6 — Where semantic results live: AST annotation, side tables, query-based incremental analysis, desugaring and HIR lowering¶
Techniques: annotating the AST in place (Clang,
pebblec), side tables keyed by node identity (Go'stypes.Info, rustc'sTypeckResults, CPython'ssymtable), query-based incremental semantic analysis with demand-driven memoization and red–green re-validation (rustc's query system; salsa, used by rust-analyzer; Swift's request evaluator; Roslyn's immutable compilations; Adapton, Hammer et al. 2014), and desugaring into a smaller core / HIR lowering (rustc AST → HIR; Clang's implicit variables for range-basedfor; Pebble'sfor-range →whilescheme) · Pebble implements: annotation (NameExpr::setDecl,TypeRepr::setResolvedType,BreakStmt::setTarget; exercises E1–E3); thefordesugaring of pebble-spec §7 is what Chapter 11's lowering does · Prerequisites: Lesson 4.7 (arenas, ids, CST → AST → HIR), Lesson 5.5 · Time: 3 hours
Name resolution produces facts — "this use denotes that declaration", "this type repr means [int; 3]", "this break leaves that loop" — and later phases consume them. Where those facts are stored is a design decision with long consequences: it decides whether the AST can be shared between threads or kept across edits, whether an IDE can answer a question without re-analyzing the whole file, and what a phase must do when the program changes. This lesson compares the three answers used in production — write into the tree, write into a map beside the tree, or compute on demand and memoize — and the companion technique of changing the tree itself into a simpler one (desugaring) so that later phases have less to understand.
1. Problem and motivation¶
The problem. A semantic phase computes a partial function from program entities (nodes, declarations, items) to facts. Choose a representation that later phases can read cheaply, that stays correct when the program changes, and that does not force later phases to understand every surface construct.
AST annotation¶
The simplest store is the tree: add a field to each node class and have the resolver fill it. Clang's DeclRefExpr holds a pointer to its ValueDecl, every Expr holds its QualType, and ImplicitCastExpr nodes are inserted where conversions happen; the resulting "semantic AST" is what CodeGen reads. pebblec does the same (NameExpr::getDecl(), Expr::getType()). The price: the AST is mutated after parsing, so it is not a pure function of the source text, it cannot be shared between two analyses that disagree, and every node pays for fields that only some analyses need.
Side tables¶
A side table maps node identities to facts and leaves the tree untouched: Go's type checker returns everything in a types.Info (Types, Defs, Uses, Scopes maps keyed by AST node) [GO-Info]; rustc's type check of a function body produces TypeckResults, maps keyed by HirId; CPython's compiler stores scope information in a symtable next to the AST. The tree stays immutable (and can be shared or cached), several analyses can coexist, and dropping a phase's results is dropping a map. The price is a hash lookup per access and the need for stable node identities (Lesson 4.7's arena indices, HirId).
Query-based incremental analysis¶
An IDE or an incremental compiler re-analyzes after every edit, and most facts do not change. Query-based compilers structure the whole front end as memoized functions ("queries") — type_of(def), resolve(module), typeck(fn) — that call each other on demand; the system records which queries each query read, and after an edit it re-validates cached results instead of recomputing them. rustc's query system with red–green marking [RUSTC-DevQueries, RUSTC-DevIncr], the salsa framework used by rust-analyzer [SALSA], Swift's request evaluator (with cycle detection between requests) [SWIFT-RequestEvaluator] and Roslyn's immutable, lazily bound compilations [ROSLYN-Overview] follow this design; its theory is demand-driven incremental computation (Adapton [HPHF14]) and, earlier, incremental attribute evaluation [RTD83].
Desugaring and HIR lowering¶
Instead of teaching every later phase every surface construct, a front end can desugar: rewrite constructs into combinations of simpler ones with the same meaning. rustc lowers its AST to HIR, turning for loops into loop + match on Iterator::next, while let into loop + match, and ? into a match (lower_expr_for in rustc_ast_lowering) [RUSTC-Lowering]; Clang keeps CXXForRangeStmt but builds the implicit __range, __begin and __end variables into it. Pebble's specification defines for i in a..b { B } by a while loop with fresh names (pebble-spec §7). The price is diagnostics: an error found after desugaring must still be reported in terms of what the user wrote, so desugared nodes keep the source spans (and a "desugaring kind") of the original.
2. Definitions and algorithms¶
Definition 5.6.1 (Semantic fact store)
Let \(\mathcal{E}\) be a set of program entities with stable identities (node pointers in an arena, indices such as HirId, or path-like ids such as rustc's DefPath). A fact store for a phase is a partial function \(F : \mathcal{E} \rightharpoonup V\). It is an annotation if \(F(e)\) is stored in the entity's own node, a side table if it is stored in a map from identities to \(V\) owned by the phase, and a query if \(F\) is computed on demand by a memoized function.
Definition 5.6.2 (Query system, revisions, dependency graph)
A query system consists of inputs \(\iota(k)\) (source files, configuration), set by the user, and derived queries \(q(k)\) defined by pure functions that may call other queries. A revision counter increases whenever an input changes. Executing a derived query records a dependency list: the queries it read, in order. Each memoized result carries the revision in which it was computed or last validated and a fingerprint (a hash of the value). A result is green (valid in the current revision) or red (recomputed and changed).
Algorithm 5.6.3 (Demand-driven memoized query with dependency recording)
- Input: a query key \(q(k)\) and the current revision \(R\).
- Output: its value.
- Precondition: every query function is deterministic and reads the program only through queries.
- Postcondition: the value equals a from-scratch computation in revision \(R\).
- Invariant:
activeis the stack of queries being computed; a query found on it is a cycle.
function Get(q(k)):
if q(k) ∈ active: report "cycle" (with the stack as the explanation)
if memo[q(k)] exists and memo[q(k)].verified = R: return memo[q(k)].value
if memo[q(k)] exists and TryMarkGreen(q(k)): return memo[q(k)].value
push q(k) on active; deps ← []; v ← run q's function (each Get it makes is appended to deps)
pop active
changed ← (memo[q(k)] missing or fingerprint(v) ≠ memo[q(k)].fingerprint)
memo[q(k)] ← {value: v, deps, verified: R, changedAt: R if changed else memo's old changedAt}
return v
Algorithm 5.6.4 (Red–green re-validation, try_mark_green)
- Input: a query \(q(k)\) with a memo from an earlier revision.
- Output: true if the memo is still valid in revision \(R\) (and marks it verified), false otherwise.
- Precondition: inputs record
changedAt, the revision of their last change. - Postcondition: returns true only if recomputing \(q(k)\) now would give a value with the same fingerprint (Theorem 5.6.8).
- Invariant: dependencies are examined in the order they were read, so every dependency examined was read under the same earlier values as last time.
function TryMarkGreen(q(k)):
m ← memo[q(k)]
for d in m.deps, in order:
if d is an input:
if d.changedAt > m.verified: return false
else:
if memo[d].verified ≠ R and not TryMarkGreen(d):
Get(d) # recompute d (may turn it red)
if memo[d].changedAt > m.verified: return false # d is red: q must rerun
m.verified ← R # all dependencies green
return true
Definition 5.6.5 (Desugaring)
Let \(L\) be a surface language and \(L_0 \subseteq L\) a core sublanguage with meaning \([\![\cdot]\!]\). A desugaring is a function \(\delta : L \to L_0\) defined construct by construct, such that \([\![\delta(t)]\!] = [\![t]\!]\) for every program \(t\). HIR lowering is a desugaring into a separate, smaller tree type (the HIR), usually combined with name resolution (the HIR refers to declarations by id rather than by name).
Algorithm 5.6.6 (Desugaring Pebble's for into while, pebble-spec §7)
- Input: a statement
for i in a..b { B }. - Output: an equivalent block in the
whilecore. - Precondition: \(s\), \(e\), \(k\) are fresh: not writable in Pebble source (so they can neither capture nor be captured, as in Lesson 5.4's hygiene); \(B\) has been resolved, so its
break/continuetargets are known. - Postcondition: the output runs \(B\) with \(i = a, a+1, \dots, b-1\) and has the same effect as the loop (Theorem 5.6.9).
- Invariant: at the start of each iteration, \(k\) holds the next value of \(i\) and \(s \le k\).
3. Worked example¶
AST annotation¶
For fn f(n: int) -> int { let m = n; return m; }, resolveNames writes three annotations: the NameExpr n points to the ParamDecl, the NameExpr m to the VarDecl, and the two NamedTypeReprs int to the uniqued int type. ch05-resolve prints them (with an E0311 for the missing main, which does not matter here):
| node | field written | value |
|---|---|---|
NamedTypeRepr int (parameter) |
ResolvedType |
int |
NamedTypeRepr int (result) |
ResolvedType |
int |
NameExpr n |
Decl |
ParamDecl n |
NameExpr m |
Decl |
VarDecl m |
Side tables¶
The same facts as a side table (what Go's types.Info.Uses would hold, and what the reference ch05-resolve dump is):
| key (node id = position) | value |
|---|---|
1:9 (int) |
builtin int |
1:17 (int) |
builtin int |
1:31 (n) |
param n@1:6 |
1:41 (m) |
let m@1:27 |
Query-based incremental analysis¶
A query graph for a two-function file, and what happens after an edit inside g's body that does not change its signature:
flowchart LR
SRC([source text]) --> PARSE[parse]
PARSE --> ITEMS["items: signatures of f, g"]
PARSE --> BF["body(f)"]
PARSE --> BG["body(g)"]
ITEMS --> RF["resolve(f)"]
BF --> RF
ITEMS --> RG["resolve(g)"]
BG --> RG
RF --> TF["check(f)"]
RG --> TG["check(g)"]
check(f) was computed in revision 1 with dependencies in the order [resolve(f)], and resolve(f) with [items, body(f)]. The user edits g's body (revision 2) and asks for check(f) and check(g):
| step | query | action (Algorithm 5.6.4) | color |
|---|---|---|---|
| 1 | check(f) | TryMarkGreen: first dependency resolve(f) is not verified in rev 2 → TryMarkGreen(resolve(f)) | |
| 2 | resolve(f) | first dependency items: not verified → TryMarkGreen(items) |
|
| 3 | items | dependency parse not verified → TryMarkGreen(parse): its input source changed in rev 2 → false; recompute parse (new tree) |
parse red |
| 4 | items | parse is red → recompute items: same signatures, same fingerprint → changedAt stays 1 |
items green (early cutoff) |
| 5 | resolve(f) | items green; next dependency body(f): recompute from the new parse, same fingerprint | body(f) green |
| 6 | resolve(f) | all dependencies green → mark verified, reuse | resolve(f) green |
| 7 | check(f) | dependency green → reuse | check(f) green |
| 8 | check(g) | resolve(g) → body(g) recomputed: fingerprint changed → resolve(g) recomputed → check(g) recomputed | red |
Only the parse, the two cheap projections and g's analyses ran again; f's resolution and checking were re-validated without running. Step 4 is the early cutoff: a recomputed query whose value did not change stops the invalidation from spreading.
Desugaring and HIR lowering¶
Algorithm 5.6.6 on for i in 0..3 { if i == 1 { continue; } print(i); }:
{
let s = 0;
let e = 3;
var k = s;
while k < e {
let i = k;
if i == 1 { k = k &+ 1; continue; }
print(i);
k = k &+ 1;
}
}
| iteration | \(k\) at the test | i |
effect | \(k\) after |
|---|---|---|---|---|
| 1 | 0 | 0 | prints 0 | 1 |
| 2 | 1 | 1 | continue: skips the print, but increments first |
2 |
| 3 | 2 | 2 | prints 2 | 3 |
| exit | 3 | \(3 < 3\) is false |
A naive rewrite that kept continue unchanged would jump to the test without incrementing and loop forever at \(k = 1\): the reason the algorithm rewrites continue (and why Pebble's spec says "a continue in B jumps to the increment").
Try it
Run build/<preset>/bin/ch05-resolve tests/ch05/Inputs/scopes.pbl and read the output as a side table keyed by position. There is no drill for this lesson; the quiz items red-green-trace and desugar-continue are traces of the kind above.
4. Invariants and correctness¶
AST annotation¶
Correctness of annotation is correctness of the phase that writes it (Lessons 5.1–5.3), plus one structural rule: every node is annotated at most once, by one phase, and later phases only read. The reference solution follows it: resolveNames writes Decl, ResolvedType and loop targets; the type checker (Chapter 6) writes only Type. A phase that re-runs on the same tree must reset what it wrote, or annotation becomes a source of stale facts — the main reason IDE front ends prefer side tables or queries.
Side tables¶
Proposition 5.6.7 (Side tables are equivalent to annotations and keep the tree immutable)
For any annotation-writing phase \(\Phi\) there is a side-table phase \(\Phi'\) that reads the same inputs and writes \(F'(\mathrm{id}(n)) = F(n)\) for every annotated node \(n\); every later phase that reads \(F(n)\) gives the same results when it reads \(F'(\mathrm{id}(n))\) instead, provided identities are injective.
Proof
Define \(\Phi'\) as \(\Phi\) with each write "\(n.\mathit{field} := v\)" replaced by "\(\mathit{table}[\mathrm{id}(n)] := v\)", and each read replaced likewise. Injectivity makes the table a function on nodes, so every read returns the value the corresponding field would hold at that point (by induction on the sequence of reads and writes, which is the same in both versions). The tree is never written by \(\Phi'\).
Query-based incremental analysis¶
Theorem 5.6.8 (Red–green re-validation is from-scratch consistent)
Suppose every derived query is a deterministic function of the values of the queries it reads, and fingerprints are injective on the values that occur (no collisions). If TryMarkGreen(q(k)) returns true in revision \(R\), then recomputing \(q(k)\) from scratch in revision \(R\) yields a value with the memoized fingerprint.
Proof
By induction on the depth of \(q(k)\) in the dependency graph (the graph is acyclic: a cycle is reported by Algorithm 5.6.3). Let \(d_1, \dots, d_m\) be the recorded dependencies, read in this order when \(q(k)\) was last computed or validated (revision \(R_0 = m.\mathit{verified}\)). The function is deterministic, so its execution, and hence the next query it reads, depends only on the values read so far. TryMarkGreen returns true only if, for each \(d_j\) in order, \(d_j\)'s value has not changed since \(R_0\): inputs by their changedAt; derived queries because they were validated (green, by the induction hypothesis their current value equals the one from revision \(R_0\)) or recomputed with an unchanged fingerprint (so an equal value, by injectivity). Then a from-scratch execution in revision \(R\) reads \(d_1\) first (same as before), gets the same value, therefore reads \(d_2\) next, and so on: it follows the same path, reads the same values, and returns the same result — with the memoized fingerprint.
Why dependencies are checked in order
If q is if a() { b() } else { c() } and a changed from true to false, the recorded dependencies are [a, b]. Checking them in order stops at a (changed) and reruns q, which now reads c. Checking b first could find it green and wrongly conclude that q is green: the order is what makes the induction work.
Desugaring and HIR lowering¶
Theorem 5.6.9 (The for desugaring preserves meaning)
Let for i in a..b { B } evaluate \(a\) to \(\alpha\) and \(b\) to \(\beta\). Its desugaring by Algorithm 5.6.6 evaluates \(a\) and \(b\) once, in this order, and then runs \(B\) with \(i = \alpha, \alpha + 1, \dots, \beta - 1\) in order (zero times if \(\alpha \ge \beta\)), leaving the loop exactly when the original leaves it (the bound is reached, a break, or a return), with the same effects.
Proof
Bounds: let s = a; let e = b; evaluate them once, left to right; \(s, e\) are fresh and immutable, so nothing in \(B\) can change them. Loop invariant: at the \(j\)-th evaluation of the test (\(j \ge 0\)), \(k = \alpha + j\) and the body has run exactly for \(\alpha, \dots, \alpha + j - 1\). It holds for \(j = 0\). If the test \(k < e\) succeeds, the body binds a fresh i = k \(= \alpha + j\) and runs \(B'\). \(B'\) differs from \(B\) only at continue statements targeting this loop, each preceded by the increment, and \(B'\) falls off its end into the increment otherwise; either way \(k\) becomes \(\alpha + j + 1\) before the next test (no overflow: \(k < e \le 2^{63} - 1\), so &+ equals +). break and return leave the while exactly as they leave the for. continues of inner loops are untouched because \(B\) was resolved first and only this loop's targets are rewritten. Termination of the iteration: the test fails when \(k = \beta\) (or immediately if \(\alpha \ge \beta\)). Hygiene: since \(s\), \(e\), \(k\) are not source names, no use in \(B\) refers to them and no declaration in \(B\) shadows them, so \(B'\) sees exactly the bindings \(B\) saw (with i the fresh per-iteration binding, as the original's scoping says).
5. Complexity¶
Variables: \(n\) nodes, \(f\) facts per node on average, \(q\) queries executed, \(e_d\) recorded dependency edges, \(c\) the size of the change after an edit.
| Technique | Time per access | Space | Cost after an edit | Pathological case |
|---|---|---|---|---|
| AST annotation | \(O(1)\), a field load | \(O(n f)\) inside the nodes; every node pays for every field | re-run the phase on the changed tree (whole function or file) | many optional facts: memory for unused fields |
| Side tables | \(O(1)\) expected hash lookup | \(O(\text{facts})\) only where present | drop and recompute the table | hashing overhead in hot loops (rustc's FxHash exists for this) |
| Queries (Alg. 5.6.3–5.6.4) | \(O(1)\) for a verified memo | \(O(q + e_d)\) memo + dependency graph | \(O(\text{dependencies re-validated} + \text{queries recomputed})\), proportional to what the change affects | an early, widely read query whose value changes (e.g., a crate-wide item list) re-runs everything below it |
| Desugaring | — (a transformation) | \(O(n)\) for the new tree (HIR is usually smaller) | redo for the changed item | constructs that expand to large code (C++ coroutines, Swift result builders) |
Justification. Algorithm 5.6.4 visits each recorded dependency of a query at most once per revision (a query is marked verified the first time it is validated), so re-validation is linear in the part of the dependency graph reachable from the requested queries; recomputation happens only for red queries and the queries that read them before an early cutoff. rustc reports that incremental rebuilds after small edits re-use most query results; the -Z self-profile and -Z incremental-info flags (nightly) show the counts [RUSTC-DevIncr].
6. Variants and refinements¶
AST annotation¶
- Two trees instead of annotating one: Clang's semantic AST differs from the syntactic one only by inserted implicit nodes (casts, temporaries), so "annotation" includes adding nodes.
- Arena + indices (Lesson 4.7) make annotations cheap to clear: facts live in parallel arrays indexed by node id — halfway to side tables.
Side tables¶
- Per-owner tables (rustc's
TypeckResultsper function body) bound the size of a table and make it a unit of caching. - Weak/identity-keyed maps (JavaScript
WeakMap, JavaIdentityHashMap) let tables be attached to nodes without changing the node class, and be collected with the tree.
Query-based incremental analysis¶
- Red–green with fingerprints (rustc) vs salsa's durability levels and revision checks: salsa records the revision at which each input changed and skips re-validating queries that only depend on "durable" inputs (the standard library) [SALSA].
- Request evaluator (Swift): requests are memoized per key and cycle detection reports a cycle between requests as a diagnostic instead of a stack overflow [SWIFT-RequestEvaluator].
- Immutable snapshots (Roslyn): each edit creates a new
Compilationsharing unchanged syntax trees and lazily computed symbols; semantic models are computed on demand per tree [ROSLYN-Overview].
Desugaring and HIR lowering¶
- Keep the sugar and lower later (Clang keeps
CXXForRangeStmtfor tools and diagnostics, but adds the implicit variables inside it) vs desugar early (rustc's HIR has nofor). - Desugaring kinds on spans: rustc marks spans produced by lowering with a
DesugaringKind(ForLoop,QuestionMark,Await) so that diagnostics can say "in thisforloop" and lints can ignore generated code.
7. In real compilers¶
AST annotation¶
Clang annotates in place: Sema builds DeclRefExprs that point to the resolved declaration and sets every expression's type (clang/lib/Sema/SemaExpr.cpp, Sema::BuildDeclRefExpr, LLVM 23.1.2 [CLANG-SemaExpr]). pebblec does the same through the setters in pebble/include/pebble/AST/Expr.h.
Clang 23's semantic AST: bindings, types and inserted conversions
Reproduce (clang 23.1.2; node addresses renumbered by the perl filter):
cat > annot.c <<'EOF'
double scale(int n) { return n * 1.5; }
EOF
clang-23 -fsyntax-only -Xclang -ast-dump -fno-color-diagnostics annot.c 2>&1 \
| sed -n '/FunctionDecl.*scale/,$p' | perl -pe 's/0x[0-9a-f]+/$h{$&}||=("#".++$n)/ge'
Output (complete):
`-FunctionDecl #1 <annot.c:1:1, col:39> col:8 scale 'double (int)' external-linkage
|-ParmVarDecl #2 <col:14, col:18> col:18 used n 'int'
`-CompoundStmt #3 <col:21, col:39>
`-ReturnStmt #4 <col:23, col:34>
`-BinaryOperator #5 <col:30, col:34> 'double' '*'
|-ImplicitCastExpr #6 <col:30> 'double' <IntegralToFloating>
| `-ImplicitCastExpr #7 <col:30> 'int' <LValueToRValue>
| `-DeclRefExpr #8 <col:30> 'int' lvalue ParmVar #2 'n' 'int'
`-FloatingLiteral #9 <col:34> 'double' 1.500000e+00
What to notice: three kinds of annotation in one tree: the binding (DeclRefExpr #8 → ParmVar #2), a type on every expression ('int', 'double'), and two inserted nodes (ImplicitCastExpr) that the source does not contain. The parser built n * 1.5; Sema rewrote and annotated it in place.
Side tables¶
Go: types.Info with its Types, Defs, Uses, Implicits, Selections and Scopes maps (src/go/types/api.go, go1.24.7 [GO-Info]). rustc: TypeckResults holds per-body maps keyed by HirId (compiler/rustc_middle/src/ty/typeck_results.rs, rustc 1.94.1 [RUSTC-TypeckResults]). CPython: the symtable built by Python/symtable.c is consulted by the code generator without annotating the AST [CPY-Symtable].
Go 1.24's types.Info: every result in a map beside an immutable AST
Reproduce (go 1.24.7):
mkdir -p goside && cd goside && go mod init goside >/dev/null 2>&1
cat > main.go <<'EOF'
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"go/types"
"sort"
)
const src = `package p
func scale(n int) float64 {
k := 1.5
return float64(n) * k
}
`
func main() {
fset := token.NewFileSet()
file, _ := parser.ParseFile(fset, "p.go", src, 0)
// The AST is never modified: every result goes into a side table keyed by AST node.
info := &types.Info{
Types: map[ast.Expr]types.TypeAndValue{},
Defs: map[*ast.Ident]types.Object{},
Uses: map[*ast.Ident]types.Object{},
}
if _, err := new(types.Config).Check("p", fset, []*ast.File{file}, info); err != nil {
panic(err)
}
var lines []string
for e, tv := range info.Types {
if _, isIdent := e.(*ast.Ident); isIdent || tv.IsType() {
continue
}
lines = append(lines, fmt.Sprintf("Types[%v %q] = %v", fset.Position(e.Pos()), types.ExprString(e), tv.Type))
}
for id, obj := range info.Defs {
if obj != nil {
lines = append(lines, fmt.Sprintf("Defs[%v %s] = %v", fset.Position(id.Pos()), id.Name, obj))
}
}
for id, obj := range info.Uses {
lines = append(lines, fmt.Sprintf("Uses[%v %s] = %v", fset.Position(id.Pos()), id.Name, obj))
}
sort.Strings(lines)
for _, l := range lines {
fmt.Println(l)
}
}
EOF
go run .
Output (complete):
Defs[p.go:3:12 n] = var n int
Defs[p.go:3:6 scale] = func p.scale(n int) float64
Defs[p.go:4:2 k] = var k float64
Types[p.go:4:7 "1.5"] = float64
Types[p.go:5:9 "float64(n) * k"] = float64
Types[p.go:5:9 "float64(n)"] = float64
Uses[p.go:3:14 int] = type int
Uses[p.go:3:19 float64] = type float64
Uses[p.go:5:17 n] = var n int
Uses[p.go:5:22 k] = var k float64
Uses[p.go:5:9 float64] = type float64
What to notice: the same kinds of facts as Clang's semantic AST — declarations, uses and expression types — but stored in three maps keyed by AST node (Definition 5.6.1); the ast.File is exactly what the parser produced, so another tool can type-check the same tree with different settings.
Query-based incremental analysis¶
rustc: every analysis is a query declared in compiler/rustc_middle/src/query/mod.rs; the dependency graph and try_mark_green (Algorithm 5.6.4) live in compiler/rustc_query_system/src/dep_graph/graph.rs (rustc 1.94.1 [RUSTC-DepGraph]); the incremental cache is saved in the -C incremental directory between runs. Swift: Evaluator::operator() evaluates and caches requests and reports cycles (lib/AST/Evaluator.cpp, swift-6.1-RELEASE [SWIFT-Evaluator]). salsa 0.23 (salsa-rs/salsa, tag salsa-v0.23.0) implements revisions, durability and early cutoff for rust-analyzer [SALSA].
rustc 1.94 persists its query results and dependency graph between runs
Reproduce (rustc 1.94.1; the sed hides the crate and session hashes, which vary):
printf 'pub fn a() -> u32 { 1 }\npub fn b() -> u32 { a() + 1 }\n' > q.rs
rustc --edition 2021 --crate-type=lib -C incremental=inc q.rs
find inc -type f -name '*.bin' | sed -E 's#q-[^/]+#q-<crate>#; s#s-[^/]+#s-<session>#' | sort
Output (complete):
inc/q-<crate>/s-<session>/dep-graph.bin
inc/q-<crate>/s-<session>/query-cache.bin
inc/q-<crate>/s-<session>/work-products.bin
What to notice: dep-graph.bin is the recorded dependency graph of Definition 5.6.2 (with fingerprints), query-cache.bin the memoized results that the next compilation can mark green instead of recomputing (Algorithm 5.6.4), and work-products.bin the codegen units that can be reused. A second rustc with the same -C incremental=inc after an edit re-validates against this session.
Desugaring and HIR lowering¶
rustc: LoweringContext::lower_expr_for turns for pat in expr { body } into match IntoIterator::into_iter(expr) { mut iter => loop { match Iterator::next(&mut iter) { None => break, Some(pat) => body } } } (compiler/rustc_ast_lowering/src/expr.rs, rustc 1.94.1 [RUSTC-Lowering]). Clang: Sema::BuildCXXForRangeStmt creates the implicit __range, __begin, __end variables (clang/lib/Sema/SemaStmt.cpp, LLVM 23.1.2 [CLANG-SemaStmt]).
The desugared for loop in rustc 1.94's MIR, and Clang 23's range-for variables
Reproduce (rustc 1.94.1, clang 23.1.2; -Z unpretty=hir needs a nightly compiler, so the box shows MIR, which is built from the HIR):
cat > forloop.rs <<'EOF'
pub fn sum(n: u64) -> u64 {
let mut s = 0;
for i in 0..n {
s += i;
}
s
}
EOF
rustc --edition 2021 --crate-type=lib -C opt-level=0 -C overflow-checks=off --emit=mir -o forloop.mir forloop.rs
sed -n '4,60p' forloop.mir
cat > rangefor.cpp <<'EOF'
int sum(const int (&xs)[4]) {
int s = 0;
for (int x : xs) s += x;
return s;
}
EOF
clang++-23 -std=c++20 -fsyntax-only -Xclang -ast-dump -fno-color-diagnostics rangefor.cpp 2>&1 \
| grep -E "CXXForRangeStmt|VarDecl.*(__range|__begin|__end| x )" | sed -E 's/0x[0-9a-f]+ //; s/<[^>]*> //'
Output (complete):
fn sum(_1: u64) -> u64 {
debug n => _1;
let mut _0: u64;
let mut _2: u64;
let mut _3: std::ops::Range<u64>;
let mut _4: std::ops::Range<u64>;
let mut _6: std::option::Option<u64>;
let mut _7: &mut std::ops::Range<u64>;
let mut _8: isize;
scope 1 {
debug s => _2;
let mut _5: std::ops::Range<u64>;
scope 2 {
debug iter => _5;
let _9: u64;
scope 3 {
debug i => _9;
}
}
}
bb0: {
_2 = const 0_u64;
_4 = std::ops::Range::<u64> { start: const 0_u64, end: copy _1 };
_3 = <std::ops::Range<u64> as IntoIterator>::into_iter(move _4) -> [return: bb1, unwind continue];
}
bb1: {
_5 = move _3;
goto -> bb2;
}
bb2: {
_7 = &mut _5;
_6 = <std::ops::Range<u64> as Iterator>::next(copy _7) -> [return: bb3, unwind continue];
}
bb3: {
_8 = discriminant(_6);
switchInt(move _8) -> [0: bb6, 1: bb5, otherwise: bb4];
}
bb4: {
unreachable;
}
bb5: {
_9 = copy ((_6 as Some).0: u64);
_2 = Add(copy _2, copy _9);
goto -> bb2;
}
bb6: {
_0 = copy _2;
return;
}
}
|-CXXForRangeStmt <line:3:3, col:25>
| | `-VarDecl col:16 implicit used __range1 'const int (&)[4]' cinit
| | `-VarDecl col:14 implicit used __begin1 'const int *' cinit
| | `-VarDecl col:14 implicit used __end1 'const int *' cinit
| | `-VarDecl col:12 used x 'int' cinit
What to notice: nothing called "for" survives in the MIR: there is a hidden iter variable (_5, the compiler-generated name of the desugaring, which user code cannot reference: Algorithm 5.6.6's freshness), a call to into_iter once, a loop header calling next (bb2), and a match on Some/None (bb3). The nested scopes are the HIR's scopes: i lives in scope 3, inside the scope of iter — one fresh i per iteration, as in Pebble. Clang instead keeps the CXXForRangeStmt node and adds three implicit variables to it: desugaring by annotation.
8. Comparison¶
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| AST annotation | Any fact, stored where it is used; one version of the tree at a time | \(O(1)\) field access · the fastest | Diagnostics read facts directly from nodes | Lowest | Clang, GCC, pebblec, most batch compilers |
| Side tables | Same facts (Proposition 5.6.7), several analyses side by side, immutable tree | \(O(1)\) expected hash lookup | Same | Low; needs stable node ids | Go types.Info, rustc TypeckResults, CPython symtable, analyzers |
| Query-based incremental analysis | Same facts, computed on demand, re-validated after edits (Theorem 5.6.8) | Proportional to what an edit affects; overhead of recording dependencies | Cycles become diagnostics (Swift); consistent results across edits | High: every phase must be a pure query | rustc, rust-analyzer (salsa), Swift, Roslyn |
| Desugaring and HIR lowering | Fewer constructs for later phases; meaning preserved (Theorem 5.6.9) | \(O(n)\) per lowering | Needs desugaring-aware spans, or errors mention generated code | Medium per construct | rustc HIR, Kotlin, Swift SIL generation, Pebble's for (Ch 11) |
Choose annotation for a batch compiler that analyzes each file once. Choose side tables when the tree must stay immutable or be shared (tools, parallel analyses, several configurations). Choose queries when the compiler also serves an IDE or must be incremental; design every phase as a pure function from the start, because retrofitting is expensive (rustc's multi-year migration). Desugar a construct when later phases would otherwise duplicate its logic and the core form expresses it exactly; keep its source spans.
9. Assessment¶
| Technique | Quiz ids | Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| AST annotation | annotation-fields, find-clang-declrefexpr |
— (E1–E3 write the annotations; the tests read them) | ast-annotation |
E1–E3 |
| Side tables | side-table-equivalence, go-info-maps |
— | side-tables |
— (the ch05-resolve dump is one) |
| Query-based incremental analysis | red-green-trace, early-cutoff |
— (quiz traces; a drill would need a query engine) | query-incremental |
— |
| Desugaring and HIR lowering | desugar-continue, rust-for-mir |
— | desugaring |
Ch 11 lowers for this way |
References¶
See the chapter references.