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

es-parser

A JavaScript, TypeScript, and JSX parser written in Zig. It does in one library what the JavaScript ecosystem usually splits across two: it produces an ESTree-shaped syntax tree, and it produces a full semantic model — lexical scopes, a symbol table, resolved references, and an ESLint-style control-flow graph — without ever walking the tree a second time. It was extracted from the Ez linter and still backs it.

What makes it different

A conventional toolchain parses with one library (acorn) and resolves scopes with another (eslint-scope) that walks the resulting tree again. es-parser folds that second walk into the first. While it parses, it emits a flat stream of scope, binding, reference, and control-flow events; the semantic phase replays that stream in a single linear pass. Many nodes in real source — literals, operators, punctuation — never emit an event, so the semantic phase touches only what actually affects scoping. That decision is the center of the design; the Architecture page works through it.

At a glance

Language     Zig (minimum 0.17.0-dev.607+456b2ec07)
Parsing      hand-written recursive descent + Pratt expressions
AST          struct-of-arrays (std.MultiArrayList) + a flat extra_data table
Semantics    a linear event stream emitted at parse time, replayed in one pass
CFG          a port of ESLint's CodePathAnalysis
Inputs       .js .mjs .cjs .jsx .ts .mts .cts .tsx .d.ts .d.mts .d.cts
Threading    single-threaded
License      MIT

Maturity

Pre-1.0 (v0.2.13). The API shape is stable but versioned 0.x — pin a tag. It is a per-file syntactic parser: no type checking, no cross-file resolution. CI runs on Linux and macOS against a pinned Zig dev build.

Conformance, reproduced locally via the conformance-* runners:

test262-parser-tests   must-parse 3966/3966 · must-reject 1389/1389
TypeScript tests/cases  19120/19136
Babel fixtures          valid 1928/1928 · invalid 1548/1548

The residual TypeScript cases need cross-file type analysis, which a single-file parser cannot do. The Babel rows cover es-parser's supported-feature subset — Flow, the pipeline operator, record/tuple, and other proposals are out of scope. Only test262-parser-tests and a semantic robustness sweep run in CI; the other numbers are measured by hand. See Conformance and Testing.

Minimal usage

const es = @import("es_parser");

var lr = try es.Lexer.tokenize(allocator, source);   // .js, script mode
defer lr.deinit(allocator);

var tree = try es.Parser.parse(allocator, source, lr.tokens.slice());
defer tree.deinit(allocator);

for (tree.errors) |d|
    if (d.severity == .@"error") std.debug.print("error: {s}\n", .{d.message});

var sem = try es.semantic.SemanticAnalyzer.analyze(allocator, &tree);
defer sem.deinit(allocator);

Parser.parse turns on event emission, so the returned Ast is ready for the analyzer with no pass in between. One caller obligation worth flagging early: the analyzer requires a zero-filling allocator (an arena over page_allocator, or the bundled ZeroingAllocator). Full options, TS/JSX, and module mode are in the API Reference.

Where to go

Using it as a library: API Reference and Building and Integration, then skim AST and Memory Layout and Semantic Analysis for the shape of the output.

Studying or hacking on it: Architecture first, then LexerParserTypeScript and JSXSemantic AnalysisControl-Flow Analysis, with Performance and Internals for the hot-path and file-level detail.

Clone this wiki locally