@@ -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+
10861433export function strip ( strings : TemplateStringsArray , ...args : string [ ] ) {
10871434 return strings
10881435 . map ( ( str : string , i : number ) => {
0 commit comments