Skip to content

Semantic Analysis

Eric San edited this page Jun 15, 2026 · 2 revisions

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.

The facade and the result

// 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 allocator contract

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

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_open creates a ScopeTree entry of the event's ScopeKind under the current scope; for var-scopes and functions it also opens a code path.
  • scope_close pops the scope and LIFO-restores scope_map and ref_cache from undo_stacks — reinstating each shadowed name's previous binding.
  • declare computes the hoist target (var and Annex-B functions climb to the nearest var-scope; everything else stays put), creates the symbol, inserts it into scope_map and ref_cache, records the prior binding for undo, and registers it in hoist_map.
  • reference appends to the ReferenceTable and tries to resolve at once: check ref_cache, fall back to scope_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.

Globals

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.

The scope tree

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:

  • module and class scopes are always strict; strictness is inherited downward at creation, so isStrictMode is a flag read, not an ancestor walk.
  • function provides arguments and this; arrow_function is a var-scope but provides neither (it inherits them from the enclosing real function).
  • each entry caches var_scope, the nearest enclosing var-scope, so nearestVarScope — the var-hoisting target — is O(1).
  • elided is 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.

The symbol table

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).

The reference table

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).

Worked queries

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; }

Early errors and reachability

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.

Parents

Parent links are not stored and not built by the parser; build_parents triggers parent_builder.buildParentsOnly. See AST and Memory Layout.

Clone this wiki locally