Viscosity
Review
Part VII · Building Viscosity · Day 23

92. Parsing real JavaScript

Drip’s parser handled a language you designed. Viscosity has to read the JavaScript people really write. It does it with a mature parser behind a small adapter, and with a second, hand-written parser that proves the first one can be swapped out.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Describe ESTree, the shared tree format for JavaScript, and read a small ESTree tree.
  • Explain what the acorn adapter in Viscosity's JavaScript plugin does, including comments and TypeScript.
  • Read the hand-written mini parser's recursive-descent statements and Pratt expressions.
  • Say why the parser is pluggable, and how the repository checks that two parsers agree.

A bigger language

Drip’s grammar fits on a page. JavaScript’s specification is hundreds of pages long[5], and it grows every year. Writing a complete JavaScript parser is a project in itself, and it isn’t the project Viscosity is about. What Viscosity needs is a syntax tree it can lower to VIR.

Intuition

A shipping container has a standard size. Ships, cranes, trains and lorries were all built around it, so any of them can carry any container. ESTree is the standard container for JavaScript syntax trees: many parsers fill it, many tools unload it, and none of them need to know who packed it.

ESTree

ESTree began as the documented output of the parser built into Firefox’s SpiderMonkey engine, and caught on as the common format for tools that work on JavaScript source[2]. Every node is a plain object whose type field says which kind of node it is, a discriminated union, as chapter 14 showed[1]. Here is the real tree acorn builds for p.x += p.vx;, without its positions and optional flags:

ExpressionStatement
  expression: AssignmentExpression
    operator: "+="
    left: MemberExpression
      object: Identifier "p"
      property: Identifier "x"
      computed: false
    right: MemberExpression
      object: Identifier "p"
      property: Identifier "vx"
      computed: false

computed: false is how ESTree tells p.x from p[x]. Each node also has start and end offsets into the source, and, when asked for, a loc with line numbers. Those offsets are what VIR’s source references point at, and what emit replaces.

Definition

ESTree is the standard format for JavaScript syntax trees, shared by parsers such as acorn and by tools that read their output. Each node is an object with a string type (Identifier, MemberExpression, …) plus fields specific to that type, and usually start/end source offsets and a loc with lines and columns.

The acorn adapter

By default the JavaScript plugin uses acorn, a small, fast parser written in JavaScript that returns ESTree[3]. Inside lower, trimmed:

const js = opts.typescript
  ? stripTypes(source) : source;
const program = acorn.parse(js, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
  onComment: (_b, value, _s, _e,
      start, end) =>
    comments.push({ value,
      line: end?.line ?? start?.line }),
});

Three jobs hide in those lines. TypeScript is first turned into JavaScript by sucrase, which removes types and leaves everything else alone[4]. locations: true asks for line numbers, for explain. And onComment collects comments, which acorn reports through that callback rather than in the tree, and // @viscosity soa lives in one. After parsing, the plugin matches each annotation to the declaration on the next line.

Definition

A parser adapter wraps a third-party parser behind your own interface. It calls the library with the options you need, collects what the library reports separately (here, comments), and hands the rest of the system the one format it expects (here, ESTree). Nothing outside the adapter depends on the library itself.

A parser you can read

The rest of the plugin only depends on ESTree: estree.ts describes “the small slice of ESTree types the front-end touches”. So any parser that produces that slice, with offsets and lines, can replace acorn. jsPlugin({ parser: 'mini' }), or --parser mini on the command line, selects the book’s own: mini-parser.ts, 448 lines. Statements are recursive descent, as in Drip, one case per keyword:

case 'if': {
  p++; expect('(');
  const test = expression();
  expect(')');
  const consequent = statement();
  const alternate =
    eat('else') ? statement() : null;
  return node('IfStatement', t,
    { test, consequent, alternate });
}

Expressions use Pratt parsing with a table of binding powers. Here is part of the table, and the loop at the heart of binary, trimmed:

const BIN = { '??': 1, '||': 2,
  '&&': 3, '+': 10, '-': 10,
  '*': 11, '/': 11, '**': 12, … };

let left = unary();
for (;;) {
  const op = peek().value;
  const bp = BIN[op];
  if (bp === undefined || bp <= minBp)
    break;
  p++;
  // ** is right-associative
  const right = binary(
    op === '**' ? bp - 1 : bp);
  left = node('BinaryExpression', t,
    { operator: op, left, right });
}

(The real loop also makes &&, || and ?? into LogicalExpressions, and handles in.) Step through it:

Step throughRecursive descent meets Pratt
Tokens, the call stack of the parser, and the tree being builtx=1-2*3CALLSstatement()TREE / DECISION
statement() looks at the first token. x isn’t a keyword like let, if or for, so this must be an expression statement. Recursive descent: one function per kind of statement.
1/7
Try itBinding powers decide the shape
const BIN = { '+': 10, '-': 10,
  '*': 11, '/': 11, '**': 12 };

function parse(src) {
  const ts = src.split(' ');
  let p = 0;
  function binary(minBp) {
    let left = ts[p++];
    for (;;) {
      const op = ts[p];
      const bp = BIN[op];
      if (bp === undefined ||
          bp <= minBp) break;
      p++;
      const right = binary(
        op === '**' ? bp - 1 : bp);
      left = '(' + left + ' ' + op +
        ' ' + right + ')';
    }
    return left;
  }
  return binary(0);
}

console.log(parse('1 - 2 * 3'));
console.log(parse('1 - 2 - 3'));
console.log(parse('2 ** 3 ** 2'));

Small, and honest about it

The mini parser covers what numeric, data-heavy code uses: declarations, functions and arrows, loops including for-of, objects, arrays, calls, members and every operator. Outside that, it stops instead of guessing:

let y = a?.b;
→ optional chaining is not supported
  by the mini parser at line 1 near '?.'
Pitfall

“Stops” isn’t always “explains”. class A {} isn’t in the subset either, but the mini parser reads class as a plain name and then reports expected ';' at line 1 near 'A'. The error is safe but confusing. A parser’s error messages are only as good as the cases its author thought of.

How do you know it is right on what it does accept? Chapter 88’s answer: compare. mini-parser.test.ts compiles every program in the differential corpus (36 files) and the five end-to-end benchmark programs with both parsers, and requires the compiled code and the explanation to be byte-identical. The trees themselves needn’t be: the mini parser writes operator before left, and gives every node a loc with column 0 and no end, details the plugin never reads. What matters is that the plugin can’t tell them apart.

Under the hood

Why keep two parsers? acorn handles all of modern JavaScript, so it is the default, and the Playground uses it too. The mini parser is for learning: it is this book’s parsing chapters applied to the real language, and you can read all of it. Keeping it working forces the plugin to depend on ESTree alone, never on acorn’s quirks, and that is what lets a different parser be dropped in later.

Your turn

ExerciseParse a declaration into ESTree

Write parseLet(src) by recursive descent for the statement let NAME = EXPR; (or const; the ; is optional). EXPR is terms joined by + or -, left to right; a term is a whole number or a name. Return ESTree: a VariableDeclaration with kind and one VariableDeclarator (id, init). Numbers are { type: 'Literal', value }, names { type: 'Identifier', name }, and a + b is { type: 'BinaryExpression', operator, left, right }. Throw a SyntaxError if the input doesn’t fit: no let/const, a target that isn’t a name, a missing =, or tokens left over. The tokenizer and peek/next are written for you.

function parseLet(src) {
  const ts = src.match(/\d+|\w+|\S/g);
  let p = 0;
  const peek = () => ts[p];
  const next = () => ts[p++];
  // your code here
}

console.log(parseLet('let x = 1;'));
Extra practicePratt with logical operators

Write parseExpr(src) for space-separated tokens using binding powers: || 2, && 3, < 8, + and - 10, * 11, ** 12 (right-associative). Numbers become Literals, names Identifiers. && and || build LogicalExpressions, every other operator a BinaryExpression, each { type, operator, left, right }.

function parseExpr(src) {
  // your code here
}

What you learned

  • ESTree is the shared tree format for JavaScript: objects tagged by type, with start/end offsets and loc lines.
  • The acorn adapter calls acorn with the options Viscosity needs, collects comments for @viscosity annotations, and strips TypeScript first with sucrase.
  • The hand-written mini parser uses recursive descent for statements and Pratt binding powers for expressions, and produces the same ESTree slice.
  • Everything after parsing depends only on ESTree, so the parser is pluggable. A differential test checks that both parsers give byte-identical compiler output.

Check your understanding

0/6
  1. 1.

    What makes ESTree useful to Viscosity?

  2. 2.

    In ESTree, ps[i].x is a MemberExpression whose object is…

  3. 3.

    Why does the acorn adapter collect comments itself?

  4. 4.

    How does the mini parser decide that 1 - 2 * 3 means 1 - (2 * 3)?

  5. 5.

    The mini parser meets a?.b. What happens?

  6. 6.

    How does the repository check that the mini parser is a drop-in replacement for acorn?

References

  1. [1] ESTree contributors. The ESTree Spec (es5.md: Node objects). github.com/estree/estree. github.com/estree/estree/blob/master/es5.md (checked 2026-10-31)
  2. [2] ESTree contributors. The ESTree Spec (README). github.com/estree/estree. github.com/estree/estree (checked 2026-10-31)
  3. [3] Acorn contributors. Acorn: a tiny, fast JavaScript parser written in JavaScript (README). github.com/acornjs/acorn. github.com/acornjs/acorn/tree/master/acorn (checked 2026-10-31)
  4. [4] Alan Pierce, Sucrase contributors. Sucrase (README). github.com/alangpierce/sucrase. github.com/alangpierce/sucrase (checked 2026-10-31)
  5. [5] Ecma International, Technical Committee 39 (TC39). ECMAScript® Language Specification (ECMA-262), current draft. Ecma International; living draft at tc39.es/ecma262, source at github.com/tc39/ecma262. tc39.es/ecma262/ (checked 2026-10-31)