From 0ffd0a2d95bfc052c3ff29a3eb4dd3749957b466 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:13:46 +0000 Subject: [PATCH] Fix escaped-mustache highlighting for odd backslash runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The escaped_expression rule added in #116 used a negative lookbehind (? template body across all three grammar files. Extends test/escaping.test.js with coverage for three- and four-backslash runs, escaped-backslash highlighting, and bare backslashes in text. --- grammars/Handlebars.json | 15 +++++++-- grammars/Handlebars.sublime-syntax | 18 +++++++++-- grammars/Handlebars.tmLanguage | 21 ++++++++++-- test/escaping.test.js | 52 +++++++++++++++++++++++++++--- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/grammars/Handlebars.json b/grammars/Handlebars.json index d99febb..bf321b6 100644 --- a/grammars/Handlebars.json +++ b/grammars/Handlebars.json @@ -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 (?|!<)*)\s*(@?[-\p{L}\p{N}$_\./]+)*' diff --git a/grammars/Handlebars.tmLanguage b/grammars/Handlebars.tmLanguage index f529695..2b18168 100644 --- a/grammars/Handlebars.tmLanguage +++ b/grammars/Handlebars.tmLanguage @@ -22,6 +22,10 @@ Handlebars patterns + + include + #escaped_backslash + include #escaped_expression @@ -73,12 +77,21 @@ 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. + 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} + \\\{{2,3} name constant.character.escape.handlebars @@ -993,6 +1006,10 @@ (</)((?i:script)) patterns + + include + #escaped_backslash + include #escaped_expression diff --git a/test/escaping.test.js b/test/escaping.test.js index a106a28..5499a6b 100644 --- a/test/escaping.test.js +++ b/test/escaping.test.js @@ -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'); @@ -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 () => {