Skip to content

Commit c4de195

Browse files
committed
test: port @glimmer/syntax parser tests from #15, add smoke-test regression fixtures
Two additions: 1. Port the full parser-node-test.ts from #15 (unified single-pass parser). That file was built by migrating @handlebars/parser/spec/ mocha tests to qunit during #15's package cleanup; this PR deletes the same package, so the same coverage is needed. +259 lines. 2. New test module "Prettier smoke-test regression fixtures" covering the inputs the prettier smoke test found regressing vs Jison: - empty mustache {{}} - unclosed mustache {{@name} - bare tilde {{~}}, {{~~}} - reserved-named-argument parse errors: {{@}}, {{@<digit>}}, {{@@}} etc. Tests only assert a parse error is thrown (not the exact message text), so they stay valid whether v2-parser is later adjusted to emit Jison- compatible error strings or not. They lock in the throw-vs-accept behavior as regression coverage.
1 parent 8eda63c commit c4de195

1 file changed

Lines changed: 347 additions & 0 deletions

File tree

packages/@glimmer/syntax/test/parser-node-test.ts

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,353 @@ test('number literal as path throws error', (assert) => {
10831083
);
10841084
});
10851085

1086+
// ── Backslash escape sequences ─────────────────────────────────────────────────
1087+
// These tests document the Jison-matching escape behaviour of parser.ts.
1088+
// See: packages/@glimmer/syntax/lib/parser/parser.ts § scanTextNode
1089+
1090+
QUnit.module('[glimmer-syntax] Parser - backslash escape sequences');
1091+
1092+
// k=1: \{{ → escape. Backslash consumed, {{content}} becomes literal text.
1093+
test('\\{{ produces literal {{ in a TextNode', () => {
1094+
// Input file: \{{foo}}
1095+
astEqual('\\{{foo}}', b.template([b.text('{{foo}}')]));
1096+
});
1097+
1098+
test('\\{{ merges escaped content with following text (emu-state behaviour)', () => {
1099+
// Input file: \{{foo}} bar baz → one TextNode: "{{foo}} bar baz"
1100+
astEqual('\\{{foo}} bar baz', b.template([b.text('{{foo}} bar baz')]));
1101+
});
1102+
1103+
test('text before \\{{ is emitted as a separate TextNode', () => {
1104+
// Input file: prefix\{{foo}} suffix → "prefix" + "{{foo}} suffix"
1105+
astEqual('prefix\\{{foo}} suffix', b.template([b.text('prefix'), b.text('{{foo}} suffix')]));
1106+
});
1107+
1108+
test('\\{{ followed by a real mustache stops the emu-state merge', () => {
1109+
// Input file: \{{foo}}{{bar}} → TextNode "{{foo}}" + MustacheStatement bar
1110+
astEqual('\\{{foo}}{{bar}}', b.template([b.text('{{foo}}'), b.mustache(b.path('bar'))]));
1111+
});
1112+
1113+
test('emu-state merge stops at \\{{ (another escape)', () => {
1114+
// Input file: \{{foo}} text \{{bar}} done {{baz}}
1115+
// → TextNode "{{foo}} text " + TextNode "{{bar}} done " + Mustache baz
1116+
astEqual(
1117+
'\\{{foo}} text \\{{bar}} done {{baz}}',
1118+
b.template([b.text('{{foo}} text '), b.text('{{bar}} done '), b.mustache(b.path('baz'))])
1119+
);
1120+
});
1121+
1122+
// k=2: \\{{ → real mustache, ONE literal backslash emitted as TextNode.
1123+
test('\\\\{{ emits one literal backslash and a real mustache', () => {
1124+
// Input file: \\{{foo}} → TextNode "\" + MustacheStatement foo
1125+
astEqual('\\\\{{foo}}', b.template([b.text('\\'), b.mustache(b.path('foo'))]));
1126+
});
1127+
1128+
// k=3: \\\{{ → real mustache, TWO literal backslashes emitted as TextNode.
1129+
test('\\\\\\{{ emits two literal backslashes and a real mustache', () => {
1130+
// Input file: \\\{{foo}} → TextNode "\\" + MustacheStatement foo
1131+
astEqual('\\\\\\{{foo}}', b.template([b.text('\\\\'), b.mustache(b.path('foo'))]));
1132+
});
1133+
1134+
test('full escaped.hbs sequence produces correct AST', () => {
1135+
// Input file (raw):
1136+
// an escaped mustache:\n\{{my-component}}\na non-escaped mustache:\n\\{{my-component}}\nanother non-escaped mustache:\n\\\{{my-component}}\n
1137+
const input =
1138+
'an escaped mustache:\n\\{{my-component}}\na non-escaped mustache:\n' +
1139+
'\\\\{{my-component}}\nanother non-escaped mustache:\n\\\\\\{{my-component}}\n';
1140+
astEqual(
1141+
input,
1142+
b.template([
1143+
b.text('an escaped mustache:\n'),
1144+
b.text('{{my-component}}\na non-escaped mustache:\n'),
1145+
b.text('\\'),
1146+
b.mustache(b.path('my-component')),
1147+
b.text('\nanother non-escaped mustache:\n\\\\'),
1148+
b.mustache(b.path('my-component')),
1149+
b.text('\n'),
1150+
])
1151+
);
1152+
});
1153+
1154+
// ── Inside HTML elements ───────────────────────────────────────────────────────
1155+
1156+
test('\\{{ in element text content produces literal {{', () => {
1157+
// Input file: <div>\{{foo}}</div>
1158+
astEqual('<div>\\{{foo}}</div>', b.template([element('div', ['body', b.text('{{foo}}')])]));
1159+
});
1160+
1161+
test('\\\\{{ in element text content produces one backslash + real mustache', () => {
1162+
// Input file: <div>\\{{foo}}</div>
1163+
astEqual(
1164+
'<div>\\\\{{foo}}</div>',
1165+
b.template([element('div', ['body', b.text('\\'), b.mustache(b.path('foo'))])])
1166+
);
1167+
});
1168+
1169+
// ── Inside quoted attribute values ─────────────────────────────────────────────
1170+
1171+
test('\\{{ inside a quoted attribute value emits {{ as literal text', () => {
1172+
// Input file: <div title="foo \{{"></div>
1173+
// The attr value TextNode should have chars "foo {{"
1174+
const ast = parse('<div title="foo \\{{"></div>');
1175+
const el = ast.body[0] as ASTv1.ElementNode;
1176+
const attr = el.attributes[0] as ASTv1.AttrNode;
1177+
const value = attr.value as ASTv1.TextNode;
1178+
QUnit.assert.strictEqual(value.chars, 'foo {{');
1179+
});
1180+
1181+
// ── Backslash NOT before {{ passes through unchanged ───────────────────────────
1182+
1183+
test('plain backslash not before {{ is preserved in text', () => {
1184+
// Input file: foo\bar → TextNode "foo\bar"
1185+
astEqual('foo\\bar', b.template([b.text('foo\\bar')]));
1186+
});
1187+
1188+
test('double backslash not before {{ is preserved in text', () => {
1189+
// Input file: foo\\bar → TextNode "foo\\bar"
1190+
astEqual('foo\\\\bar', b.template([b.text('foo\\\\bar')]));
1191+
});
1192+
1193+
test('triple backslash not before {{ is preserved in text (backslashes.hbs)', () => {
1194+
// Input file: <p>\\\</p> → TextNode "\\\"
1195+
astEqual('<p>\\\\\\</p>', b.template([element('p', ['body', b.text('\\\\\\')])]));
1196+
});
1197+
1198+
test('triple backslash + \\\\{{ in element text (backslashes.hbs)', () => {
1199+
// Input file: <p>\\\ \\{{foo}}</p> → TextNode "\\\ \" + Mustache foo
1200+
astEqual(
1201+
'<p>\\\\\\ \\\\{{foo}}</p>',
1202+
b.template([element('p', ['body', b.text('\\\\\\ \\'), b.mustache(b.path('foo'))])])
1203+
);
1204+
});
1205+
1206+
test('plain backslash in attribute value is preserved (backslashes-in-attributes.hbs)', () => {
1207+
// Input file: <p data-attr="backslash \ in an attribute"></p>
1208+
const ast = parse('<p data-attr="backslash \\\\ in an attribute"></p>');
1209+
const attr = (ast.body[0] as ASTv1.ElementNode).attributes[0] as ASTv1.AttrNode;
1210+
QUnit.assert.strictEqual((attr.value as ASTv1.TextNode).chars, 'backslash \\\\ in an attribute');
1211+
});
1212+
1213+
test('\\{{ in quoted class attribute value (mustache.hbs)', () => {
1214+
// Input file: <div class=" bar \{{"> → attr value TextNode " bar {{"
1215+
const ast = parse('<div class=" bar \\{{"></div>');
1216+
const attr = (ast.body[0] as ASTv1.ElementNode).attributes[0] as ASTv1.AttrNode;
1217+
QUnit.assert.strictEqual((attr.value as ASTv1.TextNode).chars, ' bar {{');
1218+
});
1219+
1220+
// ── Unclosed escape (\\{{ with no }}) ─────────────────────────────────────────
1221+
1222+
test('\\{{ without closing }} emits {{ and following text up to end', () => {
1223+
// Input file: \{{ unclosed → TextNode "{{ unclosed"
1224+
astEqual('\\{{ unclosed', b.template([b.text('{{ unclosed')]));
1225+
});
1226+
1227+
test('\\{{ without closing }} stops at < (HTML element boundary)', () => {
1228+
// Input file: <div>\{{ unclosed</div>
1229+
// The escape has no }}, so it emits {{ ... up to the < of </div>
1230+
astEqual(
1231+
'<div>\\{{ unclosed</div>',
1232+
b.template([element('div', ['body', b.text('{{ unclosed')])])
1233+
);
1234+
});
1235+
1236+
// ── Ported from @handlebars/parser spec/ast.js ────────────────────────────────
1237+
1238+
QUnit.module('[glimmer-syntax] Parser - whitespace control (tilde and standalone)');
1239+
1240+
// ── Tilde (whitespace stripping) ──────────────────────────────────────────────
1241+
1242+
test('tilde on mustache strips adjacent whitespace text nodes, which are then removed', (assert) => {
1243+
// {{~comment~}}: leftStrip strips trailing WS from ' ', rightStrip strips leading WS from ' '.
1244+
// Both text nodes become '' and are removed by the post-pass filter.
1245+
const ast = parse(' {{~comment~}} ');
1246+
assert.strictEqual(ast.body.length, 1, 'empty text nodes are removed after tilde stripping');
1247+
assert.strictEqual(ast.body[0]?.type, 'MustacheStatement');
1248+
});
1249+
1250+
test('tilde on block open/close strips program body content', (assert) => {
1251+
// Use a non-standalone block (prefix 'x' prevents standalone detection) so that
1252+
// only tilde stripping applies, with no interaction with standalone stripping.
1253+
// {{# comment~}}: openStrip.close strips leading WS from program body.
1254+
// {{~/comment}}: closeStrip.open strips trailing WS from program body.
1255+
const ast = parse('x{{# comment~}} \nfoo\n {{~/comment}}y');
1256+
const block = ast.body[1] as ASTv1.BlockStatement;
1257+
assert.strictEqual((block.program.body[0] as ASTv1.TextNode).chars, 'foo');
1258+
});
1259+
1260+
// ── ignoreStandalone (parseWithoutProcessing equivalent) ─────────────────────
1261+
1262+
test('ignoreStandalone: tilde still strips adjacent text nodes', (assert) => {
1263+
// ignoreStandalone only skips standalone-line detection; tilde stripping still runs.
1264+
const ast = parse(' {{~comment~}} ', { parseOptions: { ignoreStandalone: true } });
1265+
assert.strictEqual(
1266+
ast.body.length,
1267+
1,
1268+
'tilde-stripped empty nodes are removed even without standalone detection'
1269+
);
1270+
assert.strictEqual(ast.body[0]?.type, 'MustacheStatement');
1271+
});
1272+
1273+
// ── Standalone block detection ────────────────────────────────────────────────
1274+
1275+
test('standalone block: surrounding whitespace text nodes are removed after stripping', (assert) => {
1276+
// Leading ' ' and trailing ' ' are stripped to '' by standalone detection, then removed.
1277+
const ast = parse(' {{#comment}} \nfoo\n {{/comment}} ');
1278+
assert.strictEqual(ast.body.length, 1, 'surrounding empty text nodes are removed');
1279+
const block = ast.body[0] as ASTv1.BlockStatement;
1280+
assert.strictEqual((block.program.body[0] as ASTv1.TextNode).chars, 'foo\n');
1281+
});
1282+
1283+
test('standalone block with else: surrounding nodes removed, inner content standalone-stripped', (assert) => {
1284+
const ast = parse(' {{#comment}} \nfoo\n {{else}} \n bar \n {{/comment}} ');
1285+
assert.strictEqual(ast.body.length, 1);
1286+
const block = ast.body[0] as ASTv1.BlockStatement;
1287+
assert.strictEqual((block.program.body[0] as ASTv1.TextNode).chars, 'foo\n');
1288+
assert.strictEqual(((block.inverse as ASTv1.Block).body[0] as ASTv1.TextNode).chars, ' bar \n');
1289+
});
1290+
1291+
test('standalone block at start of line: program body strips leading newline', (assert) => {
1292+
const ast = parse('{{#comment}} \nfoo\n {{/comment}}');
1293+
const block = ast.body[0] as ASTv1.BlockStatement;
1294+
assert.strictEqual((block.program.body[0] as ASTv1.TextNode).chars, 'foo\n');
1295+
});
1296+
1297+
test('standalone block containing mustache: surrounding text is stripped and empty node removed', (assert) => {
1298+
// Program body: [TextNode(' \n'), MustacheStatement(foo), TextNode('\n ')].
1299+
// Standalone strips first child to '' (removed) and last child to '\n'.
1300+
const ast = parse('{{#comment}} \n{{foo}}\n {{/comment}}');
1301+
const block = ast.body[0] as ASTv1.BlockStatement;
1302+
assert.strictEqual(block.program.body.length, 2);
1303+
assert.strictEqual(block.program.body[0]?.type, 'MustacheStatement');
1304+
assert.strictEqual((block.program.body[1] as ASTv1.TextNode).chars, '\n');
1305+
});
1306+
1307+
test('non-standalone block (inline): whitespace is NOT stripped', (assert) => {
1308+
// When a block open tag shares a line with other mustaches, it is not standalone
1309+
const ast = parse('{{#foo}} {{#comment}} \nfoo\n {{/comment}} {{/foo}}');
1310+
const outerBlock = ast.body[0] as ASTv1.BlockStatement;
1311+
// The inner block is not standalone because the line has other content
1312+
const innerBlock = outerBlock.program.body[1] as ASTv1.BlockStatement;
1313+
assert.strictEqual(innerBlock.type, 'BlockStatement');
1314+
// The program body of the non-standalone inner block keeps its whitespace
1315+
assert.strictEqual((innerBlock.program.body[0] as ASTv1.TextNode).chars, ' \nfoo\n ');
1316+
});
1317+
1318+
// ── Standalone comment detection ──────────────────────────────────────────────
1319+
1320+
test('standalone comment: trailing whitespace node is removed after stripping', (assert) => {
1321+
// Trailing ' ' is stripped to '' and removed; only the comment node remains.
1322+
const ast = parse('{{! comment }} ');
1323+
assert.strictEqual(ast.body.length, 1);
1324+
assert.strictEqual(ast.body[0]?.type, 'MustacheCommentStatement');
1325+
});
1326+
1327+
test('standalone comment: both surrounding text nodes are removed after stripping', (assert) => {
1328+
// ' ' and ' ' both stripped to '' and removed.
1329+
const ast = parse(' {{! comment }} ');
1330+
assert.strictEqual(ast.body.length, 1);
1331+
assert.strictEqual(ast.body[0]?.type, 'MustacheCommentStatement');
1332+
});
1333+
1334+
// ── ignoreStandalone: standalone detection is skipped ────────────────────────
1335+
1336+
test('ignoreStandalone: standalone block is NOT stripped', (assert) => {
1337+
const ast = parse('{{#comment}} \nfoo\n {{/comment}}', {
1338+
parseOptions: { ignoreStandalone: true },
1339+
});
1340+
const block = ast.body[0] as ASTv1.BlockStatement;
1341+
// Without standalone detection the raw content is preserved
1342+
assert.strictEqual((block.program.body[0] as ASTv1.TextNode).chars, ' \nfoo\n ');
1343+
});
1344+
1345+
// ── Prettier smoke-test regression fixtures ────────────────────────────────────
1346+
// Each test below corresponds to a fixture in prettier's tests/format/handlebars
1347+
// suite that the default parser must parse-error on. The exact error text is
1348+
// parser-specific (Jison vs v2-parser emit different strings), so these tests
1349+
// only assert that *some* parse error is thrown. This locks in the throw-vs-
1350+
// accept behavior without coupling to a specific parser's wording.
1351+
1352+
QUnit.module('[glimmer-syntax] Parser - prettier smoke-test regression fixtures');
1353+
1354+
// prettier tests/format/handlebars/_errors_/invalid-3.hbs
1355+
test('empty mustache {{}} is a parse error (invalid-3.hbs)', (assert) => {
1356+
assert.throws(
1357+
() => {
1358+
parse('<a>\n\n{{}}\n', { meta: { moduleName: 'test-module' } });
1359+
},
1360+
/./u,
1361+
'empty mustache should throw a parse error'
1362+
);
1363+
});
1364+
1365+
// prettier tests/format/handlebars/_errors_/invalid.hbs
1366+
test('unclosed mustache {{@name} is a parse error (invalid.hbs)', (assert) => {
1367+
assert.throws(
1368+
() => {
1369+
parse('<A >\nx, {{@name}\n', { meta: { moduleName: 'test-module' } });
1370+
},
1371+
/./u,
1372+
'unclosed mustache should throw a parse error'
1373+
);
1374+
});
1375+
1376+
// prettier tests/format/handlebars/_errors_/tilde-comments-1.hbs
1377+
test('bare tilde mustache {{~}} is a parse error (tilde-comments-1.hbs)', (assert) => {
1378+
assert.throws(
1379+
() => {
1380+
parse('{{~}}\n', { meta: { moduleName: 'test-module' } });
1381+
},
1382+
/./u,
1383+
'bare tilde mustache should throw a parse error'
1384+
);
1385+
});
1386+
1387+
// prettier tests/format/handlebars/_errors_/tilde-comments-2.hbs
1388+
test('double tilde mustache {{~~}} is a parse error (tilde-comments-2.hbs)', (assert) => {
1389+
assert.throws(
1390+
() => {
1391+
parse('{{~~}}\n', { meta: { moduleName: 'test-module' } });
1392+
},
1393+
/./u,
1394+
'double tilde mustache should throw a parse error'
1395+
);
1396+
});
1397+
1398+
// assert-reserved-named-arguments-test: '@' alone is reserved / parse error
1399+
test('mustache with bare @ is a parse error ({{@}})', (assert) => {
1400+
assert.throws(
1401+
() => {
1402+
parse('{{@}}', { meta: { moduleName: 'test-module' } });
1403+
},
1404+
/./u,
1405+
'mustache with bare @ should throw a parse error'
1406+
);
1407+
});
1408+
1409+
// assert-reserved-named-arguments-test: '@0' is not a valid path
1410+
test('mustache with @<digit> is a parse error ({{@0}})', (assert) => {
1411+
assert.throws(
1412+
() => {
1413+
parse('{{@0}}', { meta: { moduleName: 'test-module' } });
1414+
},
1415+
/./u,
1416+
'@<digit> is not a valid identifier'
1417+
);
1418+
});
1419+
1420+
// assert-reserved-named-arguments-test: '@@', '@=', '@!' etc.
1421+
test('mustache with @<non-id-char> is a parse error ({{@@}}, {{@=}}, {{@!}})', (assert) => {
1422+
for (const input of ['{{@@}}', '{{@=}}', '{{@!}}']) {
1423+
assert.throws(
1424+
() => {
1425+
parse(input, { meta: { moduleName: 'test-module' } });
1426+
},
1427+
/./u,
1428+
`${input} should throw a parse error`
1429+
);
1430+
}
1431+
});
1432+
10861433
export function strip(strings: TemplateStringsArray, ...args: string[]) {
10871434
return strings
10881435
.map((str: string, i: number) => {

0 commit comments

Comments
 (0)