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
11 changes: 11 additions & 0 deletions grammars/Handlebars.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"name": "Handlebars",
"repository": {
"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}",
"name": "constant.character.escape.handlebars"
},
"html_tags": {
"patterns": [
{
Expand Down Expand Up @@ -731,6 +736,9 @@
},
"end": "(</)((?i:script))",
"patterns": [
{
"include": "#escaped_expression"
},
{
"include": "#block_comments"
},
Expand Down Expand Up @@ -829,6 +837,9 @@
},
"scopeName": "text.html.handlebars",
"patterns": [
{
"include": "#escaped_expression"
},
{
"include": "#yfm"
},
Expand Down
9 changes: 9 additions & 0 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_expression
- include: yfm
- include: extends
- include: block_comments
Expand Down Expand Up @@ -359,6 +360,7 @@ contexts:
1: punctuation.definition.tag.html
2: entity.name.tag.script.html
pop: true
- include: escaped_expression
- include: block_comments
- include: comments
- include: block_helper
Expand All @@ -367,6 +369,13 @@ contexts:
- include: partial_and_var
- include: html_tags
- include: scope:text.html.basic
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}'
scope: constant.character.escape.handlebars
partial_and_var:
- match: '(\{\{~?\{*(>|!<)*)\s*(@?[-a-zA-Z0-9$_\./]+)*'
captures:
Expand Down
17 changes: 17 additions & 0 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_expression</string>
</dict>
<dict>
<key>include</key>
<string>#yfm</string>
Expand Down Expand Up @@ -69,6 +73,15 @@
</array>
<key>repository</key>
<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>
<key>match</key>
<string>(?&lt;!\\)\\\{{2,3}</string>
<key>name</key>
<string>constant.character.escape.handlebars</string>
</dict>
<key>block_comments</key>
<dict>
<key>patterns</key>
Expand Down Expand Up @@ -990,6 +1003,10 @@
<string>(&lt;/)((?i:script))</string>
<key>patterns</key>
<array>
<dict>
<key>include</key>
<string>#escaped_expression</string>
</dict>
<dict>
<key>include</key>
<string>#block_comments</string>
Expand Down
73 changes: 73 additions & 0 deletions test/escaping.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

// 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.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { scopesOf, allTokens } = require('./helpers/grammar');

async function assertScope(source, text, scope) {
const scopes = await scopesOf(source, text);
assert.ok(
scopes.some((s) => s === scope || s.split(' ').includes(scope)),
`token ${JSON.stringify(text)} in ${JSON.stringify(source)}\n` +
` expected scope ${JSON.stringify(scope)}\n got ${JSON.stringify(scopes)}`
);
}

// Returns true if any token in the source carries the given scope fragment.
async function anyTokenHasScope(source, scopeFragment) {
const tokens = await allTokens(source);
return tokens.some((t) => t.scopes.some((s) => s.includes(scopeFragment)));
}

test('escaped mustache marks the opening as a character escape', async () => {
await assertScope('\\{{foo}}', '\\{{', 'constant.character.escape.handlebars');
});

test('escaped mustache is NOT highlighted as an expression', async () => {
// The `foo` inside an escaped mustache must stay plain text, not a variable.
assert.equal(
await anyTokenHasScope('\\{{foo}}', 'variable.parameter.handlebars'),
false,
'escaped mustache should not produce a variable token'
);
});

test('escaped triple-stash is also escaped', async () => {
await assertScope('\\{{{foo}}}', '\\{{{', 'constant.character.escape.handlebars');
assert.equal(await anyTokenHasScope('\\{{{foo}}}', 'variable.parameter.handlebars'), false);
});

test('escaped block helper is not treated as a block', async () => {
await assertScope('\\{{#with foo}}', '\\{{', 'constant.character.escape.handlebars');
assert.equal(
await anyTokenHasScope('\\{{#with foo}}', 'meta.function.block.start.handlebars'),
false,
'escaped block should not open a block-helper scope'
);
});

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);
});

test('a normal mustache is unaffected', async () => {
await assertScope('{{foo}}', 'foo', 'variable.parameter.handlebars');
assert.equal(await anyTokenHasScope('{{foo}}', 'constant.character.escape.handlebars'), false);
});

test('escaped mustache inside an inline <script> template', async () => {
const src = ['<script type="text/x-handlebars" id="t">', '\\{{escaped}}', '</script>'].join('\n');
await assertScope(src, '\\{{', 'constant.character.escape.handlebars');
assert.equal(await anyTokenHasScope(src, 'variable.parameter.handlebars'), false);
});
Loading