Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 13 additions & 2 deletions grammars/Handlebars.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{
"name": "Handlebars",
"repository": {
"escaped_backslash": {
"comment": "A backslash escapes the following backslash, so each \\\\ pair renders as a single literal backslash. Only pairs that lead up to a mustache are meaningful for escaping (the lookahead), which is what lets an odd number of backslashes fall through to escaped_expression: the pairs are consumed here and the lone leftover backslash escapes the mustache.",
"match": "\\\\\\\\(?=\\\\*\\{{2,3})",
"name": "constant.character.escape.handlebars"
},
"escaped_expression": {
"comment": "A backslash escapes a mustache so it renders literally (\\{{foo}}); the escaped braces are consumed so the rest is plain text, not an expression. A double backslash (\\\\{{foo}}) escapes the backslash itself, leaving the mustache to evaluate, so the leading (?<!\\\\) refuses to match there.",
"match": "(?<!\\\\)\\\\\\{{2,3}",
"comment": "A backslash escapes a mustache so it renders literally (\\{{foo}}); the escaped braces are consumed so the rest is plain text, not an expression. Any escaped-backslash pairs in front are consumed by escaped_backslash first, so this only ever sees the single leftover backslash of an odd-length run — an even run (e.g. \\\\{{foo}}) leaves no backslash here and the mustache evaluates normally.",
"match": "\\\\\\{{2,3}",
"name": "constant.character.escape.handlebars"
},
"html_tags": {
Expand Down Expand Up @@ -730,6 +735,9 @@
},
"end": "(</)((?i:script))",
"patterns": [
{
"include": "#escaped_backslash"
},
{
"include": "#escaped_expression"
},
Expand Down Expand Up @@ -831,6 +839,9 @@
},
"scopeName": "text.html.handlebars",
"patterns": [
{
"include": "#escaped_backslash"
},
{
"include": "#escaped_expression"
},
Expand Down
18 changes: 15 additions & 3 deletions grammars/Handlebars.sublime-syntax
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ file_extensions:
scope: text.html.handlebars
contexts:
main:
- include: escaped_backslash
- include: escaped_expression
- include: yfm
- include: extends
Expand Down Expand Up @@ -358,6 +359,7 @@ contexts:
1: punctuation.definition.tag.html
2: entity.name.tag.script.html
pop: true
- include: escaped_backslash
- include: escaped_expression
- include: block_comments
- include: comments
Expand All @@ -367,12 +369,22 @@ contexts:
- include: partial_and_var
- include: html_tags
- include: scope:text.html.basic
escaped_backslash:
# A backslash escapes the following backslash, so each \\ pair renders as a
# single literal backslash. Only pairs leading up to a mustache matter for
# escaping (the lookahead), which is what lets an odd number of backslashes
# fall through to escaped_expression: the pairs are consumed here and the
# lone leftover backslash escapes the mustache.
- match: '\\\\(?=\\*\{{2,3})'
scope: constant.character.escape.handlebars
escaped_expression:
# A backslash escapes a mustache so it renders literally (\{{foo}}); the
# escaped braces are consumed so the rest is plain text, not an expression.
# A double backslash (\\{{foo}}) escapes the backslash itself, leaving the
# mustache to evaluate, so the leading (?<!\\) refuses to match there.
- match: '(?<!\\)\\\{{2,3}'
# Any escaped-backslash pairs in front are consumed by escaped_backslash
# first, so this only ever sees the single leftover backslash of an odd-length
# run — an even run (e.g. \\{{foo}}) leaves no backslash here and the mustache
# evaluates normally.
- match: '\\\{{2,3}'
scope: constant.character.escape.handlebars
partial_and_var:
- match: '(\{\{~?\{*(>|!<)*)\s*(@?[-\p{L}\p{N}$_\./]+)*'
Expand Down
21 changes: 19 additions & 2 deletions grammars/Handlebars.tmLanguage
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
<string>Handlebars</string>
<key>patterns</key>
<array>
<dict>
<key>include</key>
<string>#escaped_backslash</string>
</dict>
<dict>
<key>include</key>
<string>#escaped_expression</string>
Expand Down Expand Up @@ -73,12 +77,21 @@
</array>
<key>repository</key>
<dict>
<key>escaped_backslash</key>
<dict>
<key>comment</key>
<string>A backslash escapes the following backslash, so each \\ pair renders as a single literal backslash. Only pairs that lead up to a mustache are meaningful for escaping (the lookahead), which is what lets an odd number of backslashes fall through to escaped_expression: the pairs are consumed here and the lone leftover backslash escapes the mustache.</string>
<key>match</key>
<string>\\\\(?=\\*\{{2,3})</string>
<key>name</key>
<string>constant.character.escape.handlebars</string>
</dict>
<key>escaped_expression</key>
<dict>
<key>comment</key>
<string>A backslash escapes a mustache so it renders literally (\{{foo}}); the escaped braces are consumed so the rest is plain text, not an expression. A double backslash (\\{{foo}}) escapes the backslash itself, leaving the mustache to evaluate, so the leading (?&lt;!\\) refuses to match there.</string>
<string>A backslash escapes a mustache so it renders literally (\{{foo}}); the escaped braces are consumed so the rest is plain text, not an expression. Any escaped-backslash pairs in front are consumed by escaped_backslash first, so this only ever sees the single leftover backslash of an odd-length run — an even run (e.g. \\{{foo}}) leaves no backslash here and the mustache evaluates normally.</string>
<key>match</key>
<string>(?&lt;!\\)\\\{{2,3}</string>
<string>\\\{{2,3}</string>
<key>name</key>
<string>constant.character.escape.handlebars</string>
</dict>
Expand Down Expand Up @@ -993,6 +1006,10 @@
<string>(&lt;/)((?i:script))</string>
<key>patterns</key>
<array>
<dict>
<key>include</key>
<string>#escaped_backslash</string>
</dict>
<dict>
<key>include</key>
<string>#escaped_expression</string>
Expand Down
52 changes: 47 additions & 5 deletions test/escaping.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
// Coverage for escaped mustaches (issues #67 and #106). In Handlebars a leading
// backslash escapes a mustache so it renders literally rather than being
// evaluated, e.g. `\{{foo}}` outputs the text "{{foo}}". The grammar must
// therefore NOT highlight an escaped mustache as an expression. A *double*
// backslash escapes the backslash itself, so `\\{{foo}}` still evaluates the
// mustache — that case must keep its normal expression highlighting.
// therefore NOT highlight an escaped mustache as an expression.
//
// Backslashes also escape each other, so what matters is the *parity* of the run
// of backslashes immediately before the mustache: an odd run escapes the mustache
// (the last backslash has no partner), while an even run leaves the mustache to
// evaluate (every backslash is paired off). Each `\\` pair renders as one literal
// backslash and is highlighted as a character escape; the lone leftover backslash
// of an odd run escapes the mustache. So `\{{foo}}` and `\\\{{foo}}` are escaped,
// while `\\{{foo}}` and `\\\\{{foo}}` still evaluate (issue raised in PR #116).

const { test } = require('node:test');
const assert = require('node:assert/strict');
Expand Down Expand Up @@ -57,8 +63,44 @@ test('a double backslash does NOT escape: the mustache still evaluates', async (
const src = '\\\\{{foo}}'; // two backslashes then {{foo}}
await assertScope(src, '{{', 'support.constant.handlebars');
await assertScope(src, 'foo', 'variable.parameter.handlebars');
// The backslashes themselves are not a mustache escape.
assert.equal(await anyTokenHasScope(src, 'constant.character.escape.handlebars'), false);
// The pair of backslashes is itself an escaped backslash, but it does NOT
// escape the mustache — the escape scope belongs to the `\\`, not the braces.
await assertScope(src, '\\\\', 'constant.character.escape.handlebars');
});

test('an escaped backslash is highlighted as a character escape', async () => {
await assertScope('\\\\{{foo}}', '\\\\', 'constant.character.escape.handlebars');
});

test('a triple backslash escapes the mustache (odd run)', async () => {
// \\\{{foo}} == escaped backslash (\\) + escaped mustache (\{{). The mustache
// is escaped, so `foo` must stay plain text rather than becoming a variable.
const src = '\\\\\\{{foo}}'; // three backslashes then {{foo}}
await assertScope(src, '\\\\', 'constant.character.escape.handlebars');
await assertScope(src, '\\{{', 'constant.character.escape.handlebars');
assert.equal(
await anyTokenHasScope(src, 'variable.parameter.handlebars'),
false,
'a triple backslash escapes the mustache, so it is not an expression'
);
});

test('a quadruple backslash does NOT escape: the mustache still evaluates', async () => {
// Four backslashes are two escaped-backslash pairs; nothing is left to escape
// the mustache, so it evaluates as usual.
const src = '\\\\\\\\{{foo}}'; // four backslashes then {{foo}}
await assertScope(src, '{{', 'support.constant.handlebars');
await assertScope(src, 'foo', 'variable.parameter.handlebars');
});

test('backslashes not preceding a mustache are left as plain text', async () => {
// Outside of a mustache context a backslash is an ordinary character (e.g. a
// Windows path in template text), so it must not be highlighted as an escape.
assert.equal(
await anyTokenHasScope('C:\\\\path\\\\to', 'constant.character.escape.handlebars'),
false,
'bare backslashes in plain text should not be a character escape'
);
});

test('a normal mustache is unaffected', async () => {
Expand Down
Loading