Skip to content

Internals and Contributing

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

Internals and Contributing

Who this is for: contributors modifying es-parser's internals. Everything here is file-level mechanics that the conceptual pages deliberately summarize rather than spell out. If you only use the library, you want API Reference; if you want the design, the per-subsystem pages (Parser, Semantic Analysis, Control-Flow Analysis) cover it at the right altitude.

All of the below is read from the current source; when patching, re-verify against the file — comments can lag.


AST buffer ownership (ast.zig)

Several Ast buffers are transferred from the parser without a shrinking realloc, so the live slice is a prefix of a larger backing allocation. Each such buffer has a companion *_cap field recording the true allocation size: extra_data_cap, scope_events_cap, node_end_toks_cap, parent_fixups_cap. Ast.deinit frees via freeCapped: when cap > 0 it frees ptr[0..cap]; otherwise the slice was toOwnedSlice'd and len is the real size. Tokens are not owned by Astdeinit never frees them; the TokenizeResult owns them.


Keyword perfect-hash (lexer.zig)

keywordLookup(text, ts) is a hand-rolled perfect hash, not a hash-map probe:

  1. length gate: only lengths 2–10 can be keywords;
  2. first-char gate: a comptime bitset KW_FC_MASK[len] (bit i = letter 'a'+i) rejects in O(1) any first letter that begins no keyword of that length;
  3. switch (len)switch (text[0]), each candidate compared as a single packed u64 word built by pK/loadU64 (the keyword bytes little-endian-packed) — at most ~2 comparisons per lookup;
  4. lengths 9–10 compare the first 8 bytes as a u64 plus the trailing byte(s) individually.

The ts flag gates whether TS contextual keywords participate. After ./?. (isPropertyAccess) the keyword test is skipped — the next identifier is a property name. pK/loadU64 equivalence is asserted by a unit test.


Parser early-error guards (expressions.zig, parseBinaryExpression)

Both of the following are under an if (!p.is_ts) guard — in TS mode no syntax error is emitted (the parse proceeds; this file makes no claim about a downstream type error).

?? mixing — three checks, all emitting "Cannot mix '??' with '||' or '&&' without parentheses" and returning error.ParseError:

  1. op is ?? (question_question) and the parsed left node is logical_or/logical_and;
  2. op is ||/&& (pipe_pipe/ampersand_ampersand) and the left node is nullish_coalesce;
  3. op is ?? and the freshly-parsed right node is logical_or/logical_and.

** unary base — when the operator is ** (asterisk_asterisk) and the left node's tag is one of delete_expr, typeof_expr, void_expr, logical_not, bitwise_not, unary_plus, unary_minus, await_expr, emit "Unary expression cannot be the left operand of exponentiation" and return error.ParseError. prefix_inc/prefix_dec are intentionally absent — the spec's base is an UpdateExpression, which includes ++x/--x.


Parser event emission (parser.zig)

When emit_scope_events is on, the emit* helpers append to the EventStream through a hoisted write cursor ev_ptr/ev_len (raw pointer + length) to avoid ArrayList struct indirection on the per-reference hot path; ev_len is declared usize so it sits in the same 8-byte alignment group as ev_ptr (same cache line). ev_ptr is initialized from the pre-allocated stream's pointer and ev_len starts at 0; ev_len is synced back to scope_events.events.items.len at end of parse.

ref_event_idx: []u32 is a direct-mapped, node-indexed cache of the most recent reference event per node. The sentinel is 0, not 0xFFFF_FFFF (the parser.zig doc-comment claiming 0xFFFF_FFFF is stale — read the code): the array is @memset to 0, writes store event_idx + 1, reads test if (idx == 0) return; then use idx - 1, and cancellation resets to 0. It makes cancelReferenceForNode and reference-kind upgrades (readread_write for x++) O(1) instead of backward scans. Sized to nodes.capacity and grown with it.

The token-rewrite log for TS angle brackets (tok_mut_log, recordTokMut, undoTokMuts, TokMut{idx, tag, start}) is covered in TypeScript and JSX; the speculative-state snapshots (SpeculativeState, saveSpeculative, restoreSpeculative, checkpoint/restore) are there too.


Resolver hot structures (event_resolver.zig)

The single-pass walk (resolveFullImpl) keeps these (exact types):

Structure Type Role
scope_map std.HashMapUnmanaged(u64, SymbolId, NameHashCtx, 80) name-hash → currently-visible symbol, all live names
ref_cache [512]RefCacheEntry ({hash: u64, sym: SymbolId}, 12 B) direct-mapped L1 (hash & 511) in front of scope_map
hoist_map std.HashMapUnmanaged(u64, SymbolId, NameHashCtx, 80) name-hash combined with scope; O(1) ancestor probe in the retry pass
sym_to_canonical ArrayListUnmanaged(SymbolId) routes var/function redeclarations to the first symbol
undo_stacks [256]ArrayListUnmanaged(UndoEntry) per-depth LIFO; restores scope_map/ref_cache on scope_close
unresolved_refs ArrayListUnmanaged(UnresolvedRef) refs not visible at use site, retried at end

NameHashCtx uses the custom max-load-percentage of 80. Resolution is two-phase: O(1) immediate (ref_cachescope_map) during the walk; then a retry over unresolved_refs that walks the var-scope chain via hoist_map (O(1)/level) for hoisted forward refs, then the lexical chain for closure-captured let/const/ class forward refs. Leftovers keep symbol_id = .none.

checkRedeclarations (only when diagnose_redeclare)

First line: if (ast.is_ts) return &.{}; — the entire pass is a no-op for TypeScript ASTs (declaration merging is handled by skipping the check wholesale, not by BindingKind.canRedeclare, which has zero callers). So diagnose_redeclare produces no diagnostics on .ts/.tsx/.d.ts input. For JS, it is a multi-pass scan over the built symbol table:

  1. same-scope duplicates — a lexical binding conflicts with any other binding; two var-like bindings are fine;
  2. lexical-in-block vs var hoisting to the enclosing var-scope (avoids false positives for nested functions);
  3. duplicate plain FunctionDeclarations in a block — with the Annex B B.3.3.4 exemption (sloppy + non-strict, both plain, neither generator/async);
  4. parameter vs body-block lexical;
  5. pattern catch-param vs var in the catch body (the B.3.5 simple-identifier exemption does not apply to patterns).

CFG result and segment layout (code_path.zig)

CodePathBuilder.Result is fully SoA and arena-backedfinish() transfers the builder's arena into the result instead of copying the arrays out:

bump_pools_active: bool
seg_count: u32
seg_codepath, seg_all_prev_start/end, seg_prev_start/end,
  seg_looped_prev_start/end, seg_collapsed_prev_start/end: []const u32
seg_reachable: []const u8
seg_next: []const SegNextInfo
codepaths: []const CodePath
events: []const Event
all_prev_targets, prev_targets, all_next_targets,
  next_targets, looped_targets, collapsed_prev_targets: []const SegmentId
cp_final_pool, cp_returned_pool, cp_thrown_pool: []const SegmentId
arena: std.heap.ArenaAllocator

Segment stores prev adjacency as [start,end) ranges into the flat target pools (set once, immutable); the hot "next" adjacency is split into a 16-byte SegNextInfo sidecar (4 per cache line) because it is written on every markUsed/markLooped. ForkContext inlines its first FC_INLINE_CAP (= 2) segment slices and spills the rest to a heap list. When Options.cfg_pool_alloc is set, the prev-adjacency target pools are bump-allocated from a caller buffer (bump_pools_active = true) so a serializer can publish their offsets without copying.

Two-half assembly

resolveFullScopeScopePart (scopes/symbols/references + ref_by_sym + ref_event_to_id); resolveFullCfgCfgPart (code_path_result, node_reachable, loop_exit_reachable, ref_event_seg_ids, ref_event_alive). combineParts aligns the two by the running .reference-event index (the two arrays are filled in identical event order, so index k aligns by construction), stamping each reference's seg_id and marking dead references' nodes unreachable. ScopeCfgParallel.start/.join runs the CFG half on a worker thread.


Tests and gates

See Conformance and Testing. The robustness gate (zig build conformance-semantic) and the fuzz suite (zig build test --fuzz) are the two that most often catch regressions in this code; the default zig build also runs the zbc static analyzer over src/.


Back to Home.

Clone this wiki locally