Skip to content

Control Flow Analysis

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

Control-Flow Analysis

Who this is for: parser engineers and lint-rule authors who consume the CFG. The full Result/Segment field layout is in Internals.

code_path.zig builds a full multi-segment control-flow graph during semantic analysis. Its module doc states it is a Zig port of ESLint's CodePathAnalysis, and the structures bear that out: the same fork/choice/ loop/try context machinery, segment graph, and event protocol. The CFG drives per-node reachability (node_reachable), loop-exit reachability (loop_exit_reachable), and per-reference segment IDs (references.seg_id).

In the default path the CFG is built single-threaded as part of the combined resolveFull walk. There is also a resolveFullCfg "CFG half" that consumes the same event stream and can run on a worker thread (opt-in via ScopeCfgParallel, stitched by combineParts) — but analyze does not use it (see Semantic Analysis).

The model (mirrors ESLint)

es-parser ESLint equivalent Role
CodePathBuilder CodePathAnalyzer + CodePathState drives the analysis
Segment CodePathSegment a straight-line basic block
CodePath CodePath one function / program / field-initializer / static-block
ForkContext ForkContext manages parallel segment arrays at a branch
ChoiceContext (choice state) if/else, &&, ||, ??, ?: (ChoiceKind: test_kind, logical_and, logical_or, nullish, loop, switch_kind)
LoopContext (loop state) while / do-while / for / for-in / for-of
TryContext (try state) try / catch / finally
SwitchContext (switch state) switch / case / default (pushSwitchContext/popSwitchContext/makeSwitchCaseBody)

SegmentId and CodePathId are u32 with NONE_SEG / NONE_CP = maxInt(u32) sentinels. A CodePath has an Origin (program, function, class_field_initializer, class_static_block), an upper parent, an initial_segment, and ranges for its final/returned/ thrown segment sets.

Segments

A Segment is a straight-line basic block. Its predecessor adjacency comes in four flavors — all_prev (every predecessor), prev (reachable only), looped_prev (back-edges), and collapsed_prev (for unreachable segments: the nearest reachable ancestors) — each stored as a [start,end) range into flat target pools. all_prev/prev/collapsed_prev are set at creation and then immutable; looped_prev is the exception — it is written after creation by markLooped when a back-edge is added. The hot "next" adjacency is split into a separate 16-byte SegNextInfo sidecar (4 per cache line) because it too is written on every markUsed/markLooped. (Field-level layout: Internals.)

To walk the graph from a segment s: successors are seg_next[s].next_start..next_end indexed into next_targets (reachable only; all_next_targets includes dead edges); predecessors are seg_prev_start[s]..seg_prev_end[s] into prev_targets; back-edges live in looped_targets. A reference's seg_id (on ReferenceTable) is a direct u32 index into these segment slices (NONE_SEG = maxInt(u32) means no active code path), so references[i].seg_idseg_*[seg_id] connects a reference to its segment.

A new segment is reachable iff any of its predecessors is reachable (newNextSegment); newUnreachableSegment creates an explicitly-dead segment. For unreachable segments, buildCollapsedPrev runs a BFS back through unreachable predecessors to collect the nearest reachable ancestors, using a per-segment generation counter to dedup in O(work) rather than O(N²) — this lets consumer rules (e.g. no-useless-return) avoid recursive walks at runtime.

Forks, branches, loops, try, switch

ForkContext holds the parallel segment slices at a branch point. To avoid an allocation per branch, it inlines the first FC_INLINE_CAP (= 2) slices and spills the rest to a heap ArrayListUnmanaged — most fork contexts hold ≤ 2 entries before being discarded. reachable() reports whether any segment in the head slice is reachable (consulted by try/catch to decide whether the catch entry is live).

  • Choice (if/else, &&/||/??, ?:): a ChoiceContext carries a true_fork and false_fork; the post-branch segment merges them. The short-circuit operators are driven by the logical_* events and ternaries by the cond_* events.
  • Loop (LoopContext): records the continue destination and creates a back-edge from the body end (or continue path) back to the loop entry via markLooped. The loop "entry node" each iteration follows ESLint's isLoopingTarget: while → condition, do-while → body, for → update ‖ test ‖ body, for-in/for-of → binding (loopingTargetNode in event_resolver.zig).
  • Try (TryContext): tracks returned_fork, thrown_fork, and try_end_fork; the finally block, when a thrown_fork is non-empty, is built for both the normal and exception lanes.
  • Switch (SwitchContext): pushSwitchContext/popSwitchContext model the case/default fan-out and fall-through (makeSwitchCaseBody); a switch fork uses ChoiceKind.switch_kind. (Relevant to no-fallthrough-style rules.)

The event protocol

Like ESLint, the builder emits a CFG event stream (EventType: codepath_start/codepath_end, seg_start/seg_end, unreachable_seg_start/unreachable_seg_end, seg_loop; with an EventPhase of enter/exit/post/after_enter). A consumer can replay these to receive the equivalent of ESLint's onCodePathStart / onCodePathSegmentStart / … rule callbacks.

The result

CodePathBuilder.Result is fully SoA: the segment fields are parallel slices, plus seg_reachable, seg_next, the codepaths, the CFG events, and the flat adjacency target pools that the per-segment ranges index into. Two performance properties matter to a consumer: adjacency lives in flat target pools (no per-segment lists), and finish() transfers the builder's arena into the Result rather than copying the arrays out. With Options.cfg_pool_alloc, the pools are bump-allocated from a caller buffer so a serializer can publish their offsets without a copy. (Full field list: Internals.)

How reachability reaches the AST

During the CFG walk the resolver records, per .reference event, the current segment ID and whether the path is alive (ref_event_seg_ids / ref_event_alive). combineParts then writes each reference's seg_id and, for dead references, sets node_reachable[node] = 0. The semantic post-pass computeLoopBodyExitability consumes the result plus the AST to fill loop_exit_reachable and to propagate statement-level deadness after terminators and infinite empty loops (see Semantic Analysis).


Next: Performance and Concurrency · Semantic Analysis

Clone this wiki locally