Viscosity
Review
Part XI · Viscosity in Rust · Day 43

172. Parsing with oxc

acorn says the collection starts at offset 266. oxc says 286. Both are right, because one counts UTF-16 code units and the other counts bytes. Meet the Rust parser behind the port, its arena-allocated tree, and the rule that keeps every splice cutting at the same characters.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Call oxc’s parser the way engine/rust does, and relate its options to acorn’s.
  • Explain oxc’s arena-allocated AST, its 'a lifetime, and why the port keeps no AST after lowering.
  • Explain why UTF-8 byte offsets in Rust and UTF-16 offsets in JavaScript cut the source at the same characters, and convert between them.
  • Say which syntax errors oxc’s parser leaves to a later pass, and what that means for the port.

Two right answers

One of the programs written for the port, tests/extra/unicode_offsets.js, starts like this (trimmed; in sep each … is a U+2028 or U+2029, invisible line-break characters):

// Ünïcödé before the collection: …
/* U+2028 and U+2029 are line breaks … */
const label = 'héllo wörld 😀 𝒳';
const sep = 'a…b…c'; // …
const ps = [];

I parsed it with both parsers. acorn, the TypeScript engine’s parser, says the statement const ps = []; runs from offset 266 to 280. oxc, the Rust engine’s, says 286 to 300. Twenty apart, and both correct. And the parity test checks that both engines rewrite this file identically. This chapter is about how both can be true.

oxc

oxc, the Oxidation Compiler, is “a collection of high-performance tools for JavaScript and TypeScript written in Rust”: a parser, a linter, a formatter, a transformer, a minifier[1]. The port uses its parser and AST crates, version 0.156. The parser has “three inputs (a memory arena, a source string, and a SourceType) and one return struct”[2]. Here is the call in crates/frontend-js/src/lib.rs, trimmed:

let allocator = Allocator::default();
let options = ParseOptions {
    preserve_parens: false,
    parse_regular_expression: true,
    ..ParseOptions::default()
};
let ret = Parser::new(&allocator, source,
        SourceType::mjs())
    .with_options(options)
    .parse();
if let Some(e) =
    ret.diagnostics.errors().next() {
    return Err(syntax_error(source, e));
}
let program = ret.program;
let semantic = SemanticBuilder::new()
    .with_check_syntax_error(true)
    .build(&program);
if let Some(e) =
    semantic.diagnostics.errors().next() {
    return Err(syntax_error(source, e));
}

Line it up with chapter 92’s acorn adapter. SourceType::mjs() is acorn’s sourceType: 'module'. preserve_parens: false matches acorn’s default of not keeping parentheses as nodes, and parse_regular_expression checks regex literals, as acorn does. Errors come back as data in ret, not as an exception, so the plugin turns the first one into a CompileError; syntax_error writes it as message (line:column), the shape of acorn’s messages. The second check is the pitfall below.

Definition

oxc (the Oxidation Compiler) is a JavaScript and TypeScript toolchain written in Rust. Its parser takes an Allocator, the source and a SourceType, and returns a ParserReturn holding the Program (with its comments) and a list of syntax errors. The tree is a set of Rust types, allocated in the arena, with spans in UTF-8 byte offsets[2].

A tree in an arena

ESTree (chapter 92) is plain objects with a type string. oxc’s tree is Rust types: a Statement enum, an Expression enum with dozens of variants, and a struct per node kind. One variant, from oxc_ast:

pub enum Expression<'a> {
    // ...
    BinaryExpression(
        Box<'a, BinaryExpression<'a>>),
    // ...
}

That Box is not std’s. It is oxc’s arena box: a pointer to a node in the Allocator. On this machine an Expression is 16 bytes, a tag and that pointer. The kinds are split finer than ESTree’s, too: ps[i].x is a StaticMemberExpression whose object is a ComputedMemberExpression, where ESTree has one MemberExpression with a computed flag. Chapter 173 maps them back.

Intuition

A bump arena is a notepad. Each new node is written on the next free line; nothing is ever erased on its own. When you’re done, you throw the whole pad away.

'a is chapter 133’s lifetime, and here it names the arena. Every node borrows the allocator, so no node can outlive it. Step through what that means for the plugin:

Step throughThe AST’s life inside lower
oxc allocates the AST in an arena that lives only while lower runsfn lower(&self, source)let allocator = Allocator::default();resolvelowerparse → resolve scopes → lower to VIRModuleVIR, ownedJsExtranode factsSpan { start, end }emit(source, &lowered, plan)splices byte ranges of source; no tree needed
lower starts by making an Allocator, a local variable. It is a bump arena: a chunk of memory and a cursor. Nothing is in it yet.
1/6

oxc’s documentation spells out the deal. Objects in the arena “are never Dropped”; the memory is “released in bulk when the allocator is dropped”. To keep that from leaking, the arena refuses, at compile time, any type that implements Drop, such as a std::vec::Vec. That is why oxc ships arena versions of Box, Vec, String and HashMap[3].

For the port this settles a design question. The TypeScript engine keeps ESTree nodes alive inside the VIR, in SourceRef.node. The Rust tree dies when lower returns, so the VIR keeps a NodeRef index into a side table of facts instead (chapter 173). Spans survive: an oxc Span is two u32s with no lifetime.

Bytes and code units

Now the two offsets. A JavaScript string is a sequence of UTF-16 code units, and acorn’s “character offsets” count those. A Rust String is UTF-8 bytes, and oxc’s spans count bytes. Before const ps, the file has accented letters (one code unit, two bytes each), 😀 and 𝒳 (two code units, four bytes each), and U+2028 and U+2029 (one code unit, three bytes each). Measured on the same file:

             acorn       oxc
             (UTF-16)    (UTF-8)
const ps     266         286
ps[i].x      417         442
ps[3].name   715         749

Each engine splices its own string with its own offsets. The TypeScript emitter does out.slice(0, e.start) + e.text + out.slice(e.end). The Rust one, from emit.rs:

out.replace_range(
    e.start as usize..e.end as usize,
    &e.text);

slice counts code units and replace_range counts bytes, so both cut before the same p of ps[i].x. The fixture shows the result: the same s += ps.x[i] + t.length; } // ✓ — done line from both engines.

The danger is mixing the two. Hand acorn’s 266..280 to the Rust string and you get "9, and a line ", from a comment two lines up. Land inside a character and Rust panics, because a string slice must start and end on a character boundary[5]. When the port does need a JavaScript-style offset, lines::utf16_offset converts.

Try itBytes, chars and UTF-16 code units
fn main() {
    let s = "é😀x = 1";
    println!("bytes:  {}", s.len());
    let n = s.chars().count();
    println!("chars:  {n}");
    let u = s.encode_utf16().count();
    println!("UTF-16: {u}");
    let x = s.find('x').unwrap();
    let ux =
        s[..x].encode_utf16().count();
    println!("x: byte {x}, unit {ux}");
    println!("{:?}", &s[x..]);
    // byte 3 is inside the 😀:
    let ok = s.is_char_boundary(3);
    println!("{ok}");
    // so &s[3..] would panic
}
Open in Rust Playground ↗

Line numbers need care too, because oxc reports none. The plugin counts them itself in lines.rs, at the same breaks acorn counts: \n, \r\n, a lone \r, U+2028 and U+2029. tests/extra/annotation_line_breaks.js puts a U+2028 between two declarations to check it.

Pitfall

oxc’s parser doesn’t report every syntax error. Its documentation says the list “is not comprehensive”: more expensive checks are left to its semantic analyzer[2]. When this chapter was first written the port ran only the parser. I gave twelve small programs with early errors to acorn and to the Rust engine’s compile. acorn rejected eleven (the twelfth, assigning to a const, is a run-time error); oxc’s parser rejected one, a top-level return. So let a = 1; let a = 2; compiled in Rust. No parity fixture caught it, because every parity program is valid JavaScript. The SemanticBuilder pass above is the fix, and early_errors.rs now runs 108 invalid programs through both CLIs. Both reject this one, each in its own words and not always at the same token: Rust says Identifier `a` has already been declared (1:4), acorn Identifier 'a' has already been declared (1:15).

Under the hood

The arena is one reason oxc is fast. Allocating a node is a pointer bump, and freeing a tree of thousands of nodes is returning a few chunks; oxc’s own docs list the arena first among its parser’s performance techniques, ahead of u32 spans[2]. The same docs advise reusing one Allocator and calling reset between jobs, rather than making a new one each time[3]. The port makes a fresh one per lower, which is fine for a compiler that parses a file or two per run.

Your turn

ExerciseFrom acorn offsets to Rust offsets

A JavaScript tool hands you offsets in UTF-16 code units, as acorn reports them. Write byte_offset, which turns one into the UTF-8 byte offset of the same character in a Rust &str, so that &src[byte_offset(src, off)..] starts where JavaScript’s src.slice(off) does. c.len_utf16() tells you how many code units a char takes (1 or 2), and src.char_indices() gives each character with its byte offset.

// The byte offset in src of the
// character at UTF-16 offset off (what
// acorn and JavaScript's slice count).
// off is never inside a character; off
// at the end gives src.len().
fn byte_offset(src: &str, off: usize)
    -> usize {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • oxc is a Rust toolchain for JavaScript. Its parser takes an arena, the source and a SourceType, and returns the tree with its errors as data.
  • The tree lives in a bump arena: 'a ties every node to the Allocator, nodes are never dropped one by one, and the whole arena is freed at once. So the port keeps spans and facts, not the tree.
  • oxc spans count UTF-8 bytes, acorn’s offsets UTF-16 code units. Each engine slices its own string with its own offsets, so both cut at the same characters.
  • Line numbers are counted by the plugin at every ECMAScript line break.
  • oxc’s parser leaves some early errors to a semantic pass, so the port runs that pass’s syntax checks too.

Check your understanding

0/6
  1. 1.

    For const ps = []; in unicode_offsets.js, acorn reports 266 and oxc 286. Which is right?

  2. 2.

    What happens if the Rust emitter slices its String with acorn’s offsets 266..280?

  3. 3.

    Why does the Rust port keep no oxc AST after lower returns?

  4. 4.

    What does oxc’s Allocator do when it is dropped?

  5. 5.

    oxc reports no line numbers. How does explain still say "loop at line 6"?

  6. 6.

    Which statement about early errors in the port is true?

References

  1. [1] Boshen, VoidZero Inc. and contributors. Oxc, the Oxidation Compiler (README). github.com/oxc-project/oxc (MIT License). github.com/oxc-project/oxc (checked 2026-10-31)
  2. [2] Boshen, VoidZero Inc. and contributors. oxc_parser 0.156.0: crate documentation and ParserReturn. Source of the oxc_parser crate, version 0.156.0, on crates.io (github.com/oxc-project/oxc). docs.rs/oxc_parser/0.156.0/oxc_parser/ (checked 2026-10-31)
  3. [3] Boshen, VoidZero Inc. and contributors. oxc_allocator 0.156.0: Allocator. Source of the oxc_allocator crate, version 0.156.0, on crates.io (github.com/oxc-project/oxc). docs.rs/oxc_allocator/0.156.0/oxc_allocator/struct.Allocator.html (checked 2026-10-31)
  4. [4] 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)
  5. [5] The Rust Project Developers. Primitive type str (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/str/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/primitive.str.html (checked 2026-10-31)