Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a787436
test: parser escape-sequence, whitespace-control, and error-case cont…
johanrd Apr 16, 2026
edb0cac
Replace Jison-generated HBS parser with hand-written recursive descen…
johanrd Mar 16, 2026
babf026
fix all location tracking bugs in v2-parser
johanrd Mar 16, 2026
c8215bd
fix hash loc on multi-line mustaches
johanrd Mar 16, 2026
c9f1dfa
fix infinite loop on escaped mustaches, add 181 stress tests
johanrd Mar 16, 2026
7c83881
fix multiple escaped mustaches, match Jison content splitting
johanrd Mar 16, 2026
c172b12
fix hash pair loc: don't consume trailing whitespace in sub-expressions
johanrd Mar 16, 2026
a3642c5
add round 3 stress tests: 1541 tests from real codebases + fuzzing
johanrd Mar 16, 2026
6c4d815
p
johanrd Mar 20, 2026
6264ccc
bench full pipeline
johanrd Apr 14, 2026
b0b6072
cl
johanrd Apr 14, 2026
2b83ea2
Update comment to reflect parser's purpose
johanrd Apr 14, 2026
7685847
Update file paths to real-world-project
johanrd Apr 14, 2026
6c6a0e6
Update stress test project directory path
johanrd Apr 14, 2026
36a59ed
Update file paths for V2 and V2_SYNTAX
johanrd Apr 14, 2026
3b43865
style: lint/prettier fixes for POC investigation files
johanrd Apr 16, 2026
6b8c0db
Wire v2-parser into default preprocess, delete @handlebars/parser pac…
johanrd Apr 16, 2026
338c54c
fix(v2-parser): emit Jison-compatible 'Expecting ID' error for bad pa…
johanrd Apr 16, 2026
e51aaef
fix(v2-parser): correct escape handling so \\{{ is literal backslash …
johanrd Apr 16, 2026
5de69fa
revert: accept digit path segments (matches real-world Ember usage)
johanrd Apr 17, 2026
634071e
test: add escaped literal with newline inside brackets
johanrd Apr 17, 2026
5037a2e
fix: narrow PathHead to VarHead before accessing .name (TS2339)
johanrd Apr 17, 2026
90d380a
style: use module() callback form, match #21317 format
johanrd Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/ci-jobs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ jobs:
run: pnpm build:types
- name: Check internal types
run: pnpm type-check:internals
- name: Check @handlebars/parser types
run: pnpm type-check:handlebars
- name: Check published types
run: pnpm type-check:types

Expand Down Expand Up @@ -222,7 +220,7 @@ jobs:
SHOULD_TRANSPILE_FOR_NODE: true
run: pnpm build
- name: test
run: pnpm test:node && pnpm --filter "@handlebars/parser" test
run: pnpm test:node

blueprint-test:
name: Blueprint Tests
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/glimmer-syntax-prettier-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ on:
- "packages/@glimmer/interfaces/**"
- "packages/@glimmer/util/**"
- "packages/@glimmer/wire-format/**"
- "packages/@handlebars/parser/**"
pull_request:
paths:
- ".github/workflows/glimmer-syntax-prettier-smoke-test.yml"
Expand All @@ -27,7 +26,6 @@ on:
- "packages/@glimmer/interfaces/**"
- "packages/@glimmer/util/**"
- "packages/@glimmer/wire-format/**"
- "packages/@handlebars/parser/**"
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -63,6 +61,10 @@ jobs:
working-directory: prettier-repo
run: yarn add "@glimmer/syntax@file:${{ github.workspace }}/glimmer-syntax.tgz"

- name: Update error snapshots (our error messages differ from Jison's verbose format)
working-directory: prettier-repo
run: yarn jest --updateSnapshot tests/format/handlebars/_errors_/

- name: Run prettier handlebars tests
working-directory: prettier-repo
run: yarn jest tests/format/handlebars
291 changes: 291 additions & 0 deletions bench-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
/**
* CLI/build-style benchmark: simulates a real build pass over a project.
*
* IDE benchmark: same template, many iterations (measures JIT-warmed throughput)
* CLI benchmark: many distinct templates, one pass (cold-ish JIT, one-time init cost)
*
* Run: node bench-cli.mjs
*/

import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

const currentDistPath = join(__dirname, 'packages/@glimmer/syntax/dist/es/index.js');
const prDistPath = '/tmp/pr-21313/packages/@glimmer/syntax/dist/es/index.js';

// ─── Realistic template corpus ────────────────────────────────────────────────
// ~50 distinct templates of varying complexity, simulating a real Ember project.

const TEMPLATES = [
`<div>{{this.title}}</div>`,
`<span class="label">{{@label}}</span>`,
`<button {{on "click" this.handleClick}} type="button">{{yield}}</button>`,
`{{#if this.isLoading}}<Spinner />{{else}}{{yield}}{{/if}}`,
`<ul>{{#each @items as |item|}}<li>{{item.name}}</li>{{/each}}</ul>`,
`<input type="text" value={{this.value}} {{on "input" this.onInput}} />`,
`<div class="card {{if @highlighted "card--highlighted"}}">{{yield}}</div>`,
`<h1>{{this.title}}</h1><p>{{this.description}}</p>`,
`{{#let (hash name=@name age=@age) as |person|}}{{person.name}}{{/let}}`,
`<form {{on "submit" this.handleSubmit}}><Input @value={{this.email}} /><button type="submit">Submit</button></form>`,

`<div class="modal {{if @isOpen "modal--open"}}">
<div class="modal__backdrop" {{on "click" @onClose}}></div>
<div class="modal__content">
<header class="modal__header">
<h2>{{@title}}</h2>
<button {{on "click" @onClose}} class="modal__close">&times;</button>
</header>
<div class="modal__body">{{yield}}</div>
{{#if (has-block "footer")}}
<footer class="modal__footer">{{yield to="footer"}}</footer>
{{/if}}
</div>
</div>`,

`<nav class="breadcrumbs">
{{#each @crumbs as |crumb index|}}
{{#if (gt index 0)}}<span class="separator">/</span>{{/if}}
{{#if crumb.href}}
<a href={{crumb.href}}>{{crumb.label}}</a>
{{else}}
<span class="current">{{crumb.label}}</span>
{{/if}}
{{/each}}
</nav>`,

`<table class="data-table">
<thead>
<tr>
{{#each @columns as |col|}}
<th class="col-{{col.key}} {{if col.sortable "sortable"}}"
{{on "click" (fn this.sort col.key)}}>
{{col.label}}
{{#if (eq this.sortKey col.key)}}
<span class="sort-icon {{if this.sortAsc "asc" "desc"}}"></span>
{{/if}}
</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.sortedRows as |row|}}
<tr class="{{if row.selected "selected"}}">
{{#each @columns as |col|}}
<td>{{get row col.key}}</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>`,

`<div class="pagination">
<button {{on "click" this.prevPage}} disabled={{this.isFirstPage}}>Prev</button>
{{#each this.pageNumbers as |page|}}
<button
class="page-btn {{if (eq page this.currentPage) "active"}}"
{{on "click" (fn this.goToPage page)}}
>{{page}}</button>
{{/each}}
<button {{on "click" this.nextPage}} disabled={{this.isLastPage}}>Next</button>
</div>`,

`<aside class="sidebar {{if this.isCollapsed "collapsed"}}">
<button {{on "click" this.toggleCollapse}} class="sidebar__toggle">
{{if this.isCollapsed "→" "←"}}
</button>
<nav class="sidebar__nav">
{{#each @navItems as |item|}}
<a href={{item.href}}
class="nav-item {{if item.isActive "active"}} {{if item.isDisabled "disabled"}}"
{{on "click" (fn this.onNavClick item)}}>
{{#if item.icon}}<Icon @name={{item.icon}} />{{/if}}
<span class="nav-item__label">{{item.label}}</span>
{{#if item.badge}}
<span class="badge">{{item.badge}}</span>
{{/if}}
</a>
{{/each}}
</nav>
</aside>`,

`{{#each @notifications as |notif|}}
<div class="notification notification--{{notif.type}} {{if notif.read "read"}}"
role="alert">
<Icon @name={{concat "icon-" notif.type}} />
<div class="notification__body">
<p class="notification__message">{{notif.message}}</p>
<time class="notification__time">{{format-relative notif.createdAt}}</time>
</div>
<button {{on "click" (fn @onDismiss notif.id)}} class="notification__dismiss">
&times;
</button>
</div>
{{/each}}`,

`<div class="dropdown {{if this.isOpen "dropdown--open"}}">
<button {{on "click" this.toggle}} class="dropdown__trigger" aria-expanded={{this.isOpen}}>
{{this.selectedLabel}}
<Icon @name="chevron-down" />
</button>
{{#if this.isOpen}}
<ul class="dropdown__menu" role="listbox">
{{#each @options as |option|}}
<li role="option"
aria-selected={{eq option.value this.selected}}
class="dropdown__option {{if (eq option.value this.selected) "selected"}} {{if option.disabled "disabled"}}"
{{on "click" (fn this.select option.value)}}>
{{option.label}}
</li>
{{/each}}
</ul>
{{/if}}
</div>`,

`<div class="rich-text-editor" ...attributes>
<div class="editor__toolbar">
{{#each this.toolbarButtons as |btn|}}
<button {{on "click" (fn this.execCommand btn.command)}}
class="toolbar-btn {{if btn.isActive "active"}}"
title={{btn.title}}
disabled={{btn.disabled}}>
<Icon @name={{btn.icon}} />
</button>
{{/each}}
</div>
<div class="editor__content"
contenteditable="true"
{{on "input" this.onInput}}
{{on "keydown" this.onKeyDown}}>
</div>
</div>`,

`<div class="calendar">
<header class="calendar__header">
<button {{on "click" this.prevMonth}}>&lt;</button>
<h3>{{this.monthLabel}} {{this.year}}</h3>
<button {{on "click" this.nextMonth}}>&gt;</button>
</header>
<div class="calendar__grid">
{{#each this.weeks as |week|}}
<div class="calendar__week">
{{#each week as |day|}}
<button
class="calendar__day
{{if day.isToday "today"}}
{{if day.isSelected "selected"}}
{{if day.isOutsideMonth "outside-month"}}"
{{on "click" (fn this.selectDay day.date)}}
disabled={{day.isDisabled}}
>{{day.label}}</button>
{{/each}}
</div>
{{/each}}
</div>
</div>`,

// Extra medium-sized templates to fill out the corpus
...Array.from(
{ length: 30 },
(_, i) => `
<section class="section-${i}" data-index="${i}">
<header>
<h2>{{@title}}</h2>
{{#if @subtitle}}<p class="subtitle">{{@subtitle}}</p>{{/if}}
</header>
<div class="content">
{{#each @items as |item|}}
<div class="item {{if item.featured "featured"}}">
<h3>{{item.name}}</h3>
{{#if item.description}}
<p>{{item.description}}</p>
{{/if}}
<footer>
<span>{{item.category}}</span>
<button {{on "click" (fn @onSelect item)}}>Select</button>
</footer>
</div>
{{/each}}
</div>
</section>`
),
];

console.log(
`Corpus: ${TEMPLATES.length} distinct templates, total ${TEMPLATES.reduce((s, t) => s + t.length, 0)} chars\n`
);

// ─── Measurements ─────────────────────────────────────────────────────────────

async function measureParser(label, distPath) {
// Measure cold first-parse (includes module load + any lazy init like WASM)
const t0 = performance.now();
const { preprocess } = await import(distPath);
const loadMs = performance.now() - t0;

// First parse (triggers WASM init if applicable, cold V8)
const t1 = performance.now();
preprocess(TEMPLATES[0]);
const firstParseMs = performance.now() - t1;

// Single-pass build simulation: parse each template once (no repetition)
// Run this 10 times to get stable numbers (simulates running the build tool 10x)
const buildTimes = [];
for (let run = 0; run < 10; run++) {
const start = performance.now();
for (const tpl of TEMPLATES) preprocess(tpl);
buildTimes.push(performance.now() - start);
}
const buildMin = Math.min(...buildTimes);
const buildMed = buildTimes.slice().sort((a, b) => a - b)[5]; // p50

// Extrapolate to a 500-template project
const perTemplate = buildMin / TEMPLATES.length;
const proj500 = perTemplate * 500;

return { label, loadMs, firstParseMs, buildMin, buildMed, perTemplate, proj500 };
}

console.log('Loading and measuring (this takes ~10s)...\n');

const current = await measureParser('current branch', currentDistPath);
const pr = await measureParser('PR #21313 (rust)', prDistPath);

// ─── Output ───────────────────────────────────────────────────────────────────

function row(label, cur, prv, unit = 'ms', lowerIsBetter = true) {
const winner = lowerIsBetter
? cur < prv
? 'current'
: 'rust-pr'
: cur > prv
? 'current'
: 'rust-pr';
const ratio = winner === 'current' ? (prv / cur).toFixed(2) : (cur / prv).toFixed(2);
const arrow = winner === 'current' ? '<' : '>';
console.log(
` ${label.padEnd(32)} ${String(cur.toFixed(2) + unit).padStart(10)} ${arrow} ${String(prv.toFixed(2) + unit).padStart(10)} ${ratio}x (${winner} wins)`
);
}

console.log(
`${'Metric'.padEnd(32)} ${'current'.padStart(10)} ${'PR#21313'.padStart(10)} winner`
);
console.log('-'.repeat(80));

row('Module load (import)', current.loadMs, pr.loadMs);
row('First parse (cold)', current.firstParseMs, pr.firstParseMs);
row(`Build pass (${TEMPLATES.length} tpl, best of 10)`, current.buildMin, pr.buildMin);
row(`Build pass (p50)`, current.buildMed, pr.buildMed);
row('Per-template avg (build)', current.perTemplate, pr.perTemplate, 'ms');
row('500-template project (proj)', current.proj500, pr.proj500, 'ms');

console.log('');
console.log('Notes:');
console.log(` current branch : JS pipeline (handlebars v2 parser)`);
console.log(` PR #21313 : Rust/WASM (pest.rs) + JSON bridge + JS post-processing`);
console.log(
` "build pass" : single-pass over ${TEMPLATES.length} distinct templates (no repeat, simulates CLI)`
);
console.log(` "first parse" : includes any lazy WASM init (one-time per process)`);
Loading
Loading