Skip to content

Control Flow Analysis

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

Control-Flow Analysis

The control-flow graph is a Zig port of ESLint's CodePathAnalysis (code_path.zig:3), and it runs off the same event stream as the rest of the semantic phase. The parser's branch_*, loop_*, try_*, switch_*, logical_*, cond_*, and terminator events drive the builder directly — there is no separate AST walk. The result lets a lint rule answer "can control reach this node?" and "does this loop body always exit on the first iteration?"

Segments

The graph is built from segments — maximal straight-line runs — stored as a MultiArrayList and addressed by seg_id used as a direct index (code_path.zig:360). A segment is nine u32 columns:

Segment (36 bytes = 9 × u32)                           (code_path.zig:28)
  codepath
  all_prev_start / all_prev_end              all predecessors
  prev_start / prev_end                      reachable predecessors only
  looped_prev_start / looped_prev_end        loop back-edges
  collapsed_prev_start / collapsed_prev_end  reachable-ancestor closure

Three of the four prev-edge ranges are written once at creation and never change. looped_prev is the exception: markLooped writes the back-edge from a loop's end to its head after the head segment already exists (code_path.zig:747). Forward adjacency (next) lives in a separate 16-byte SegNextInfo sidecar so a forward walk does not drag the whole segment through cache (code_path.zig:49).

The context model

The builder mirrors ESLint's state objects (code_path.zig:8); each maps to a syntactic construct:

CodePath       a function / program / static-block / field-initializer body  (code_path.zig:68)
ForkContext    parallel segment lists during a fork (inline cap 2)           (code_path.zig:109)
ChoiceContext  if/else, &&, ||, ??, reused for loop & switch tests           (code_path.zig:299)
LoopContext    loop continue/break targets                                   (code_path.zig:337)
TryContext     try / catch / finally lanes                                   (code_path.zig:316)
SwitchContext  switch case bodies                                            (code_path.zig:310)

ChoiceKind is test_kind, logical_and, logical_or, nullish, loop, switch_kind (code_path.zig:297). The builder keeps the live choice / loop / try / switch contexts plus the current fork context and code path (code_path.zig:404).

How events drive it

The resolver's per-event switch (under do_cfg) maps each event to a builder call (event_resolver.zig):

terminator   → makeReturn / makeThrow / makeBreak[Labeled] / makeContinue[Labeled]
               and sets cfg_alive = false for the code after it              (:690)
loop_open / loop_test_end / loop_body_end / loop_close
             → pushLoopContext / setLoopContinueDest / makeLoopBackEdge /
               popLoopContext                                               (:994)
try_open / try_catch_start / try_finally_start / try_close
             → pushTryContext / makeCatchBlock / makeFinallyBlock /
               popTryContext                                               (:1007)
switch_open / switch_case_start / switch_close
             → pushSwitchContext / makeSwitchCaseBody / popSwitchContext    (:1028)
logical_* / cond_*
             → pushChoiceContext / makeLogicalRight |
               makeConsequent · makeConditionalAlternate / popChoiceContext (:1040)

The branch_* events themselves maintain the resolver's cfg_alive liveness; the actual if/else fork is driven by the choice-context arms.

Reachability

Each segment carries one reachability byte in a hot sidecar. The entry segment is seeded reachable (code_path.zig:574); every other segment is reachable iff any predecessor is — newNextSegment ORs over the prevs and createSegment records the result (code_path.zig:586, :650). This is one forward computation, not a fixpoint: it is exact because segments are created in forward order, so a segment's predecessors are finalized before it (a loop head's reachability is fixed by its entry edge before markLooped adds the back-edge). A terminator creates an explicit unreachable successor for the code after it, and for an unreachable segment buildCollapsedPrev inherits each predecessor's already-computed reachable-ancestor set (code_path.zig:633). Separately, the resolver marks dead AST nodes through node_reachable (event_resolver.zig:695).

The Result

CodePathBuilder.Result (code_path.zig:1772) is a flat SoA snapshot — the per-segment columns, the adjacency target pools, the code-path pools, and the emitted event list — plus the builder's ArenaAllocator moved in wholesale. Moving the arena instead of copying each array avoids a large per-array memcpy; Result.deinit just frees the arena. finish first calls flattenCpPools to interleave each code path's return/throw lists into contiguous ranges; its only failure mode is arena OOM, surfaced as a panic because the caller already had to handle OOM while building (code_path.zig:1890).

Clone this wiki locally