From c855389c7748db1a603b8b1bd0cae7c633fd6cad Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Mon, 29 Jun 2026 23:11:08 +0100 Subject: [PATCH] Add highlighting for escaped mustaches (#67, #106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leading backslash escapes a mustache in Handlebars so it renders literally: \{{foo}} outputs the text {{foo}} rather than evaluating it. The grammar highlighted these as normal expressions, which is misleading. Add an escaped_expression rule that matches a backslash followed by 2-3 opening braces and scopes it as constant.character.escape.handlebars. By consuming the escaped braces, the remainder is left as plain text instead of an expression (covering \{{foo}}, \{{{foo}}} and escaped blocks like \{{#with}}). A negative lookbehind (? template body across all three grammar files. Adds test/escaping.test.js. --- grammars/Handlebars.json | 11 +++++ grammars/Handlebars.sublime-syntax | 9 ++++ grammars/Handlebars.tmLanguage | 17 +++++++ test/escaping.test.js | 73 ++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 test/escaping.test.js diff --git a/grammars/Handlebars.json b/grammars/Handlebars.json index 2a5ff3c..6b056b2 100644 --- a/grammars/Handlebars.json +++ b/grammars/Handlebars.json @@ -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 (?|!<)*)\s*(@?[-a-zA-Z0-9$_\./]+)*' captures: diff --git a/grammars/Handlebars.tmLanguage b/grammars/Handlebars.tmLanguage index 9d0c1f5..55b389b 100644 --- a/grammars/Handlebars.tmLanguage +++ b/grammars/Handlebars.tmLanguage @@ -22,6 +22,10 @@ Handlebars patterns + + include + #escaped_expression + include #yfm @@ -69,6 +73,15 @@ 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 + block_comments patterns @@ -990,6 +1003,10 @@ (</)((?i:script)) patterns + + include + #escaped_expression + include #block_comments diff --git a/test/escaping.test.js b/test/escaping.test.js new file mode 100644 index 0000000..a106a28 --- /dev/null +++ b/test/escaping.test.js @@ -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 '].join('\n'); + await assertScope(src, '\\{{', 'constant.character.escape.handlebars'); + assert.equal(await anyTokenHasScope(src, 'variable.parameter.handlebars'), false); +});