-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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 Ast — deinit never frees them; the TokenizeResult owns them.
keywordLookup(text, ts) is a hand-rolled perfect hash, not a hash-map probe:
- length gate: only lengths 2–10 can be keywords;
- first-char gate: a comptime bitset
KW_FC_MASK[len](biti= letter'a'+i) rejects in O(1) any first letter that begins no keyword of that length; -
switch (len)→switch (text[0]), each candidate compared as a single packedu64word built bypK/loadU64(the keyword bytes little-endian-packed) — at most ~2 comparisons per lookup; - lengths 9–10 compare the first 8 bytes as a
u64plus 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.
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:
- op is
??(question_question) and the parsed left node islogical_or/logical_and; - op is
||/&&(pipe_pipe/ampersand_ampersand) and the left node isnullish_coalesce; - op is
??and the freshly-parsed right node islogical_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.
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 (read → read_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.
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_cache → scope_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.
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:
- same-scope duplicates — a lexical binding conflicts with any other binding; two var-like bindings are fine;
- lexical-in-block vs
varhoisting to the enclosing var-scope (avoids false positives for nested functions); - duplicate plain
FunctionDeclarations in a block — with the Annex B B.3.3.4 exemption (sloppy + non-strict, both plain, neither generator/async); - parameter vs body-block lexical;
- pattern catch-param vs
varin the catch body (the B.3.5 simple-identifier exemption does not apply to patterns).
CodePathBuilder.Result is fully SoA and arena-backed — finish() 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.
resolveFullScope → ScopePart (scopes/symbols/references + ref_by_sym +
ref_event_to_id); resolveFullCfg → CfgPart (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.
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.
es-parser — MIT licensed. This wiki documents the implementation under src/; when a detail matters, the source is authoritative.