Back to blog

Validating Real-World JavaScript with a PEG Grammar

feeding real code to the parser

so my idea is pretty simple, I've been writing a PEG grammar for ES2025 JavaScript in Odin using my parser generator Pegasus. the grammar is sitting at 778 lines with about 190 rules and it parses most of the spec examples fine. but spec examples are clean. real code is not. so lets try to validate it against actual production JavaScript and see what falls apart.

I grabbed 69 files from Three.js, Express, and some Node.js internals. the thinking is these cover a wide range of JS patterns — Three.js has heavy math and class hierarchies, Express is callback-heavy with lots of string manipulation, and Node internals use every weird corner of the language you can imagine.

the test harness

the validation loop is straightforward. walk a directory, try to parse each file, collect the results:

run_validation :: proc(dir: string) -> Validation_Result {
    result: Validation_Result
    paths := collect_js_files(dir)
    defer delete(paths)

    for path in paths {
        source, ok := os.read_entire_file(path)
        if !ok {
            result.skipped += 1
            continue
        }
        defer delete(source)

        parse_result := pegasus.parse(es2025_grammar, source)
        if parse_result.ok {
            result.passed += 1
        } else {
            result.failed += 1
            append(&result.failures, Failure{path, parse_result.error_pos, parse_result.expected})
        }
    }
    return result
}

first run: 31 out of 69 passed. not great. but the failures were clustered around a few specific patterns which was actually encouraging — it meant the core grammar was solid and I just had gaps in the edges.

template literals were the first wall

tagged template literals broke everything. my grammar handled basic backtick strings fine but something like html`<div class="${cls}">` was failing because the rule for template_literal didn't account for the tag expression preceding it. the fix was small but I had to restructure how the parser entered template mode:

template_literal :: proc(p: ^Parser) -> ^Node {
    // tag expression is optional — can be any member_expression
    tag := try_parse(p, member_expression)
    expect(p, .BACKTICK)
    parts := parse_template_parts(p)
    expect(p, .BACKTICK)
    return make_template_node(tag, parts)
}

the nested ${} expressions inside templates were also tricky because they can contain basically any expression, including other template literals. so the parser has to handle recursive template depth. Three.js's ShaderChunk.js was the file that exposed this — it has templates nested three levels deep for generating GLSL code.

regex literals vs division

this is the classic ambiguity. when the parser sees / it needs to decide: is this the start of a regex or a division operator? the answer depends on what came before. after an identifier or number it's division. after an operator or keyword it's regex. I had most of this right but missed a few cases — specifically after ) it can be either depending on whether the parens belong to an if/while/for or a function call.

Node's url.js had a line that looked roughly like if (something) /pattern/.test(x) and my parser was reading the /pattern/ as two division operators. I ended up tracking a can_start_regex flag based on the previous token type which felt hacky but it's basically what V8 does too.

destructuring depth

Express's route handling code uses deeply nested destructuring with defaults, computed property names, and rest elements all mixed together. something like const { params: { id = defaultId, ...rest }, query: { [dynamicKey]: value } } = req. my binding_pattern rule handled each of these individually but the combination exposed an ordering issue where computed properties inside nested destructuring weren't being parsed because the rule was consuming the [ as an array pattern start.

after second run with fixes: 54 out of 69. getting closer.

what I learned

the remaining 15 failures were mostly around edge cases I haven't prioritized yet — things like with statements in non-strict mode, some exotic unicode escapes in identifiers, and a couple files that used stage 3 proposals that aren't technically in ES2025 yet.

the big takeaway is that spec grammars and real-world grammars are different things. the spec describes what's valid but it doesn't tell you how to disambiguate. every ambiguity in the spec — regex vs division, template tags vs member expressions, arrow functions vs parenthesized expressions — becomes a concrete decision point in a PEG grammar where rule ordering matters. the spec can say "it's one of these" but PEG forces you to pick which one to try first, and getting that order wrong means you silently parse the wrong thing instead of failing loudly.

69 files isn't a huge corpus but it was enough to find the patterns that matter. next step is running it against the test262 suite which has something like 50,000 test cases. that should be fun.