-
Notifications
You must be signed in to change notification settings - Fork 0
Semantic Analysis
Semantic analysis turns the parser's event stream into four things: the lexical
scope tree, the symbol table, the reference table (each reference resolved to its
symbol), and — optionally — the control-flow graph with per-node reachability.
The core of it does not walk the AST. It consumes the linear event stream the
parser emitted (scope_events.zig) in one forward pass (event_resolver.zig); two later post-passes do scan the AST
(loop-exit reachability, and parent-building on request). The public face is
semantic.SemanticAnalyzer.
// src/semantic.zig — analyze:180, analyzeWithOptions:203
pub fn analyze(allocator, ast: *const Ast) !SemanticResult // module mode
pub fn analyzeModule(allocator, ast, is_module: bool) !SemanticResult
pub fn analyzeWithGlobals(allocator, ast, globals: []const u8) !SemanticResult
pub fn analyzeWithOptions(allocator, ast, opts: Options) !SemanticResult// src/semantic.zig:141
pub const SemanticAnalyzer.Options = struct {
is_module: bool = true,
globals: []const u8 = &.{}, // null-separated global names
build_parents: bool = false, // compute parent_indices
build_ref_ranges: bool = true, // per-symbol reference ranges (getRefRange)
need_cfg: bool = true, // build the CFG + reachability
diagnose_redeclare: bool = false, // emit duplicate-binding early errors
annex_b: bool = true,
};analyze* requires an AST parsed with event emission on; if scope_events is
empty it returns error.MissingScopeEvents rather than a silently empty result.
Parser.parse enables emission by default.
// src/semantic.zig:24
pub const SemanticResult = struct {
scopes: ScopeTree,
symbols: SymbolTable,
references: ReferenceTable,
diagnostics: []const Diagnostic = &.{},
node_reachable: []u8 = &.{}, // 1 = live, 0 = dead
loop_exit_reachable: []u8 = &.{}, // per-loop: is the exit reachable
code_path_result: ?CodePathBuilder.Result = null,
parent_indices: []const u32 = &.{}, // set iff build_parents
ref_by_sym: []const ReferenceId = &.{}, // set iff build_ref_ranges
pub fn deinit(self, allocator) void { … }
};need_cfg = false skips the control-flow build, a substantial fraction of total
analysis work; node_reachable / loop_exit_reachable come back empty (consumers
bounds-check and treat every node as live) and code_path_result is null.
The binder and CFG builder read sentinel-initialized buffers in which 0 means
"none / unset", so they assume fresh allocations are zeroed. Pass an allocator
that zero-fills new memory:
OK ArenaAllocator over std.heap.page_allocator (zeroed OS pages)
OK any allocator wrapped in semantic.ZeroingAllocator (semantic.zig:94)
UB a raw GeneralPurposeAllocator, or an arena over c_allocator
(dirty reused blocks) — garbage reads, crashes in ReleaseFast
This can't be checked at runtime — Debug and ReleaseSafe poison fresh memory with
0xAA regardless of allocator, so a probe always sees non-zero, and ReleaseFast
has no hook — so it is a caller contract. The bundled runners use an arena over
page_allocator and are fine.
The resolver keeps a small set of hot structures and dispatches on EventKind in
one forward pass.
scope_map name-hash → currently-visible symbol
ref_cache 512-entry direct-mapped L1, 8 KB (event_resolver.zig:409)
hoist_map scope-keyed map for the forward-reference retry pass
undo_stacks per-depth LIFO, restores visibility on scope exit
unresolved_refs queue of references that missed on first sight
-
scope_opencreates aScopeTreeentry of the event'sScopeKindunder the current scope; for var-scopes and functions it also opens a code path. -
scope_closepops the scope and LIFO-restoresscope_mapandref_cachefromundo_stacks— reinstating each shadowed name's previous binding. -
declarecomputes the hoist target (varand Annex-B functions climb to the nearest var-scope; everything else stays put), creates the symbol, inserts it intoscope_mapandref_cache, records the prior binding for undo, and registers it inhoist_map. -
referenceappends to theReferenceTableand tries to resolve at once: checkref_cache, fall back toscope_map. A hit resolves to the symbol and marks read/write/typeof; a miss is queued.
Resolution is therefore two-phase. The immediate O(1) resolve handles every
already-visible binding during the walk. A retry pass then drains
unresolved_refs: it climbs the var-scope chain through hoist_map (O(1) per
level) to catch forward references to hoisted var / function bindings, then
the lexical chain for forward let / const / class references captured in
closures (const cb = () => { x = 1 }; let x;). One pass suffices because forward bindings survive scope close: each declaration
is also recorded in a persistent hoist_map keyed by scope, even though
scope_map is LIFO-unwound at scope_close. By the walk's end that map is
complete, so the retry resolves any forward reference with one climb up the scope
chain — no fixpoint iteration. Resolution is name-binding only, though: that
example resolves x to the right symbol, but the temporal-dead-zone violation is
not detected here (hasTDZ exists but the resolver never consults it). Anything still unresolved is left
with symbol_id = .none — a potential global, or genuinely undeclared.
opts.globals is a null-separated name list, matching ESLint's
languageOptions.globals. After the walk, a post-pass scans the still-unresolved
references; for any whose name matches a configured global it creates an
implicit_global symbol in the global scope and resolves the reference to it. A
real local binding still wins — globals only fill the gaps.
scope.ScopeTree (scope.zig:100) is a flat SoA tree (MultiArrayList) addressed by
ScopeId = enum(u32). It uses left-child / right-sibling links (parent,
first_child, last_child, next_sibling), so no scope heap-allocates.
ScopeKind (scope.zig:26) covers global, module, function, arrow_function, block,
class, catch_clause, switch_stmt, static_block, with_stmt,
class_field_initializer, and elided.
Flags are derived once, at addScope, and propagated:
-
moduleandclassscopes are always strict; strictness is inherited downward at creation, soisStrictModeis a flag read, not an ancestor walk. -
functionprovidesargumentsandthis;arrow_functionis a var-scope but provides neither (it inherits them from the enclosing real function). - each entry caches
var_scope, the nearest enclosing var-scope, sonearestVarScope— thevar-hoisting target — is O(1). -
elidedis a block scope the parser opened speculatively that turned out to hold no block-scoped declarations; the resolver skips it and attributes its references to the enclosing scope.
Bindings are not stored on the scope; bindings_start / bindings_count index
into the SymbolTable.
symbol.SymbolTable (symbol.zig:178) is SoA, SymbolId = enum(u32). Each entry carries a name
(a zero-copy slice into source), a packed-u16 SymbolFlags (symbol.zig:26), a BindingKind, a
scope_id, a decl_node, and a ref_range. The flags record the binding's
nature (is_var / is_let / is_const / is_function / is_class /
is_parameter / …) and its usage (is_read / is_written / is_type_of /
is_exported / is_implicit_global / …); see symbol.zig for the full set.
BindingKind (symbol.zig:79) distinguishes the JS bindings (var, let, const,
function_decl, function_decl_annex_b, class_decl, parameter,
catch_param, import_binding, the function/class expression self-names) from
the TS declaration kinds (type_decl, interface_decl, enum_decl,
namespace_decl, type_param, type_import_binding) and implicit_global. It
exposes the predicates the resolver needs — hasTDZ() (let / const /
class_decl), isHoisted() (var, both function kinds), isImmutable()
(const, imports).
reference.ReferenceTable (reference.zig:88) is SoA, ReferenceId = enum(u32). Each reference has a
symbol_id (.none until resolved), a kind, a node_id, a scope_id, a
write_expr_id (the RHS for write / read-write refs), and a seg_id (its CFG
segment). ReferenceKind has six values (reference.zig:35):
read a plain use
write an assignment target
read_write x++, x += …
write_init the initializing write of a declarator — counts as a write for
liveness but does NOT set the symbol's is_written, so a
prefer-const rule still sees "never reassigned"
type_of a typeof operand
type_read a TS type-position use
With build_ref_ranges on, the resolver's buildRefRanges pass (event_resolver.zig:1300) groups
references by symbol with a counting sort (O(n + k), unresolved refs sorting
to the end) and sets each symbol's ref_range into the resulting ref_by_sym
array, so symbols.getRefRange(sym) returns a symbol's references in O(1).
Walk every reference to a symbol and classify it:
const range = sem.symbols.getRefRange(sym_id); // needs build_ref_ranges
for (sem.ref_by_sym[range.start..range.end]) |ref_id| {
const kind = sem.references.getKind(ref_id); // .read / .write / …
const node = sem.references.getNode(ref_id); // the identifier NodeIndex
const live = sem.node_reachable.len == 0 or
sem.node_reachable[@intFromEnum(node)] != 0;
_ = .{ kind, live };
}A symbol's declaration site is getDeclNode(sym_id) and its scope is
getScope(sym_id). To list the symbols of a scope, scan the table by scope_id
— the table is in declaration order, so filter rather than slice:
for (sem.symbols.list.items(.scope_id), 0..) |sid, i|
if (sid == scope_id) { const sym = symbol.SymbolId.fromInt(@intCast(i)); _ = sym; }With diagnose_redeclare on, the resolver runs checkRedeclarations (event_resolver.zig:1398) — a
multi-pass duplicate-binding check covering same-scope lexical duplicates,
lexical-vs-var across the block/var-scope boundary, duplicate block functions
with the Annex B B.3.3.4 exemption, parameter-vs-body, and catch-param-vs-var
per B.3.5. It is opt-in because a linter with its own no-redeclare rule handles
this itself; spec-conformance callers opt in. The check runs on JavaScript input
only — on TypeScript it is skipped (event_resolver.zig:1411), since declaration merging makes duplicate
interface / namespace / enum / overload bindings legal, so diagnose_redeclare
produces no diagnostics on .ts / .tsx / .d.ts.
When need_cfg is on, computeLoopBodyExitability (semantic.zig:257) walks each loop body to decide
whether every path exits on the first iteration (so a no-unreachable-loop-style
rule can fire), handling break vs continue vs return / throw, switch
fall-through, labels, and infinite-empty-loop trapping. It also propagates
unreachability to statements after a terminator, so reachability is marked on
statement nodes, not only on the reference sub-nodes the resolver touched.
Parent links are not stored and not built by the parser; build_parents triggers
parent_builder.buildParentsOnly. See
AST and Memory Layout.
es-parser — MIT licensed. This wiki documents the implementation under src/; when a detail matters, the source is authoritative.