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

Parser

The parser is hand-written recursive descent for statements and declarations, with a Pratt (precedence-climbing) engine for expressions. It builds the SoA AST and, by default, the semantic event stream in the same pass, and it never throws on malformed input — it records a diagnostic, recovers, and keeps going.

Entry points

Parser.parse(alloc, src, tokens)                              // emit_events on  (parser.zig:458)
Parser.parseWithOptions(alloc, src, tokens, opts)             //                 (parser.zig:526)
Parser.parseWithLanguage(alloc, src, tokens, lang, is_module) //                 (parser.zig:534)

parse is the common path and turns event emission on for you. parseWithOptions takes the full options:

// src/parser.zig:466
pub const ParseOptions = struct {
    language: Language = .js,         // .js .jsx .ts .tsx .dts
    is_module: bool = false,          // import/export + always-strict
    global_return: bool = false,
    is_strict: ?bool = null,          // defaults to is_module
    annex_b: bool = true,
    experimental_decorators: bool = false,
    events_out: ?*ScopeEventStream = null,
    emit_events: bool = false,        // parse() sets this true
};

Events are emitted when either emit_events is set or an events_out sink is supplied (parser.zig:613).

The driver

parseProgram (parser.zig:1987) reserves the root at index 0, then loops while (!isAtEnd()), parsing one statement at a time into a scratch list and catching error.ParseError per statement so a single bad statement doesn't sink the parse (parser.zig:2033). The collected statements become the root's SubRange.

The Pratt expression engine

Expressions go through parseExpressionPrec(p, min_prec) (expressions.zig:112): parse a prefix, then loop, consuming any infix operator whose precedence is at least min_prec. Precedence is one table lookup per token:

Precedence  enum(u8), 0..20                          (expressions.zig:53)
  none=0  comma=1  assignment=2  conditional=3  nullish_coalesce=4
  logical_or=5 …  exponentiation=15  unary=16  postfix=17  call=18
  new_expr=19  primary=20
prec_table: [256]Precedence                          (expressions.zig:5857)  tag → precedence

Associativity lives in Precedence.next() (expressions.zig:83): left-associative operators recurse with level + 1, forcing the right operand to bind tighter, while the two right-associative levels — assignment and exponentiation — recurse at their own level. That is why a = b = c and 2 ** 3 ** 2 group to the right and everything else to the left.

Members, calls, optional chaining

Member access, calls, and ?. are all .call-precedence and run in the same postfix loop. Optional chaining uses exactly three tags — optional_member_expr, optional_computed_member_expr, optional_call_expr (expressions.zig:6513, :6477, :6466) — and ?.#priv reuses optional_member_expr (:6494).

Disambiguating arrows

(a, b) => … versus a parenthesized expression is decided after the ) is consumed: the parser collects the parenthesized items into scratch, and if the next token is => on the same line (and arrows are allowed), reinterprets them as parameters; otherwise they stay a grouping or sequence expression (expressions.zig:3402, :3625). Empty () forces an arrow. TS-typed arrows ((x: T): R =>) are caught earlier with speculative backtracking.

Assignment targets

An assignment LHS or arrow parameter list is parsed as an expression first, then rewritten into a pattern by reinterpretAsPattern (expressions.zig:7156): array_literal → array_pattern, object_literal → object_pattern, spread_element → rest_element, assign → assignment_pattern, recursing through properties and sequences. This is the cover-grammar trick — parse the permissive form, retype on commit.

ASI

expectSemicolon (parser.zig:1814) eats a ; if present; otherwise it inserts one before } or EOF, or when the next token starts a new line (isOnNewLine, parser.zig:1831, reading the token's precomputed newline flag). The no-line-terminator restrictions for return / throw / break / continue and postfix ++ / -- are enforced at their own sites.

Error recovery

On error.ParseError the driver records a Diagnostic, calls synchronize to skip to the next statement boundary, and drops an error_node placeholder in place (parser.zig:1842, :1889). synchronize advances with a 16-wide SIMD scan for the structural stops (;, }, EOF, statement-start keywords). A runaway guard bails the whole parse after more than 100 consecutive errors — i.e. on the 101st (parser.zig:2039) — and a forward-progress check guarantees at least one token is consumed per recovery, so it cannot loop.

Limits and sizing

Nesting is capped at max_recursion_depth = 400 (parser.zig:1915); at the limit enterRecursion emits a diagnostic and returns error.ParseError rather than overflowing the native stack. Node and extra_data buffers are pre-sized to tokens.len * 3/4 (≈0.75 nodes per token, parser.zig:657), so addNode and event pushes hit appendAssumeCapacity.

JavaScript-only early errors

Two operator-mixing rules are enforced in parseBinaryExpression, both gated on if (!p.is_ts) — TypeScript defers them to its own checker:

  • ?? mixing. a || b ?? c, a && b ?? c, and a ?? b || c (on either side) are rejected: ?? may not mix with || / && without parentheses (expressions.zig:5967, :6024).
  • ** unary base. The left operand of ** may not be an unparenthesized unary expression; the rejected set is exactly delete typeof void ! ~ +x -x await (expressions.zig:5988). prefix_inc / prefix_dec are intentionally allowed.

Clone this wiki locally