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
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Test

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22.x, 24.x]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
Comment thread
coderabbitai[bot] marked this conversation as resolved.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.cache
node_modules/
handlebars.sublime-project
handlebars.sublime-workspace
npm-debug.log
12 changes: 6 additions & 6 deletions grammars/Handlebars.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@
"include": "source.yaml"
}
],
"begin": "(?<!\\s)---\\n$",
"end": "^---\\s",
"begin": "\\A-{3}$",
"end": "^-{3}$",
"name": "markup.raw.yaml.front-matter"
}
]
Expand All @@ -374,8 +374,8 @@
"include": "#comments"
}
],
"begin": "\\{\\{!",
"end": "\\}\\}",
"begin": "\\{\\{~?!",
"end": "~?\\}\\}",
"name": "comment.block.handlebars"
},
{
Expand Down Expand Up @@ -408,8 +408,8 @@
"include": "#comments"
}
],
"begin": "\\{\\{!--",
"end": "--\\}\\}",
"begin": "\\{\\{~?!--",
"end": "--~?\\}\\}",
"name": "comment.block.handlebars"
},
{
Expand Down
12 changes: 6 additions & 6 deletions grammars/Handlebars.sublime-syntax
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ contexts:
- include: html_tags
- include: scope:text.html.basic
block_comments:
- match: '\{\{!--'
- match: '\{\{~?!--'
push:
- meta_scope: comment.block.handlebars
- match: '--\}\}'
- match: '--~?\}\}'
pop: true
- match: '@\w*'
scope: keyword.annotation.handlebars
Expand Down Expand Up @@ -69,10 +69,10 @@ contexts:
- include: string
- include: handlebars_attribute
comments:
- match: '\{\{!'
- match: '\{\{~?!'
push:
- meta_scope: comment.block.handlebars
- match: '\}\}'
- match: '~?\}\}'
pop: true
- match: '@\w*'
scope: keyword.annotation.handlebars
Expand Down Expand Up @@ -454,9 +454,9 @@ contexts:
pop: true
- include: string
yfm:
- match: (?<!\s)---\n$
- match: \A-{3}$
push:
- meta_scope: markup.raw.yaml.front-matter
- match: ^---\s
- match: ^-{3}$
pop: true
- include: scope:source.yaml
12 changes: 6 additions & 6 deletions grammars/Handlebars.tmLanguage
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@
<array>
<dict>
<key>begin</key>
<string>\{\{!--</string>
<string>\{\{~?!--</string>
<key>end</key>
<string>--\}\}</string>
<string>--~?\}\}</string>
<key>name</key>
<string>comment.block.handlebars</string>
<key>patterns</key>
Expand Down Expand Up @@ -193,9 +193,9 @@
<array>
<dict>
<key>begin</key>
<string>\{\{!</string>
<string>\{\{~?!</string>
<key>end</key>
<string>\}\}</string>
<string>~?\}\}</string>
<key>name</key>
<string>comment.block.handlebars</string>
<key>patterns</key>
Expand Down Expand Up @@ -1299,9 +1299,9 @@
<array>
<dict>
<key>begin</key>
<string>(?&lt;!\s)---\n$</string>
<string>\A-{3}$</string>
<key>end</key>
<string>^---\s</string>
<string>^-{3}$</string>
<key>name</key>
<string>markup.raw.yaml.front-matter</string>
<key>patterns</key>
Expand Down
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@
"engines": {
"atom": ">0.50.0"
},
"dependencies": {}
"scripts": {
"test": "node --test \"test/*.test.js\""
},
"dependencies": {},
"devDependencies": {
"vscode-oniguruma": "^2.0.1",
"vscode-textmate": "^9.3.2"
}
}
75 changes: 75 additions & 0 deletions test/blocks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

// Coverage for Handlebars block expressions: opening helpers (#if, #each,
// #unless, #with, custom), block parameters (as |x|), closing tags (/if), and
// the {{else}} / {{else if}} inverse sections.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { scopesOf } = 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)}`
);
}

test('block open {{#if}} is a block.start with keyword.control name', async () => {
const src = '{{#if condition}}';
await assertScope(src, '{{', 'meta.function.block.start.handlebars');
await assertScope(src, '#', 'keyword.control');
await assertScope(src, 'if', 'keyword.control');
await assertScope(src, 'condition', 'variable.parameter.handlebars');
});

for (const helper of ['each', 'unless', 'with']) {
test(`built-in block helper {{#${helper}}}`, async () => {
const src = `{{#${helper} value}}`;
await assertScope(src, '#', 'keyword.control');
await assertScope(src, helper, 'keyword.control');
await assertScope(src, 'value', 'variable.parameter.handlebars');
});
}

test('custom block helper {{#myHelper}}', async () => {
const src = '{{#myHelper arg}}';
await assertScope(src, '#', 'keyword.control');
await assertScope(src, 'myHelper', 'keyword.control');
});

test('block parameters: {{#each items as |item|}}', async () => {
const src = '{{#each items as |item|}}';
await assertScope(src, 'items', 'variable.parameter.handlebars');
await assertScope(src, 'item', 'variable.parameter.handlebars');
});

test('block close {{/if}} is a block.end with keyword.control', async () => {
const src = '{{/if}}';
await assertScope(src, '{{', 'meta.function.block.end.handlebars');
await assertScope(src, '/', 'keyword.control');
await assertScope(src, 'if', 'keyword.control');
});

test('{{else}} is an inline else section', async () => {
const src = '{{else}}';
await assertScope(src, '{{', 'meta.function.inline.else.handlebars');
await assertScope(src, 'else', 'keyword.control');
});

test('{{else if other}} keeps the else section scope', async () => {
const src = '{{else if other}}';
await assertScope(src, 'else', 'keyword.control');
await assertScope(src, '{{', 'meta.function.inline.else.handlebars');
});

test('whitespace control on a block: {{~#if x~}} ... {{~/if~}}', async () => {
await assertScope('{{~#if x~}}', '{{', 'meta.function.block.start.handlebars');
await assertScope('{{~#if x~}}', '~#', 'keyword.control');
await assertScope('{{~#if x~}}', '~}}', 'support.constant.handlebars');
await assertScope('{{~/if~}}', '{{', 'meta.function.block.end.handlebars');
await assertScope('{{~/if~}}', '~/', 'keyword.control');
await assertScope('{{~/if~}}', '~}}', 'support.constant.handlebars');
Comment on lines +68 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the exact {{~ token text in the whitespace-control block assertions.

scopesOf() does an exact t.text lookup, and the sibling expression suite already treats the whitespace-control prefix as {{~ (test/expressions.test.js:70). With the current {{ lookup here, these assertions will throw no token with text "{{" as soon as block delimiters are tokenized the same way.

Suggested fix
 test('whitespace control on a block: {{~`#if` x~}} ... {{~/if~}}', async () => {
-  await assertScope('{{~`#if` x~}}', '{{', 'meta.function.block.start.handlebars');
+  await assertScope('{{~`#if` x~}}', '{{~', 'meta.function.block.start.handlebars');
   await assertScope('{{~`#if` x~}}', '~#', 'keyword.control');
   await assertScope('{{~`#if` x~}}', '~}}', 'support.constant.handlebars');
-  await assertScope('{{~/if~}}', '{{', 'meta.function.block.end.handlebars');
+  await assertScope('{{~/if~}}', '{{~', 'meta.function.block.end.handlebars');
   await assertScope('{{~/if~}}', '~/', 'keyword.control');
   await assertScope('{{~/if~}}', '~}}', 'support.constant.handlebars');
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('whitespace control on a block: {{~#if x~}} ... {{~/if~}}', async () => {
await assertScope('{{~#if x~}}', '{{', 'meta.function.block.start.handlebars');
await assertScope('{{~#if x~}}', '~#', 'keyword.control');
await assertScope('{{~#if x~}}', '~}}', 'support.constant.handlebars');
await assertScope('{{~/if~}}', '{{', 'meta.function.block.end.handlebars');
await assertScope('{{~/if~}}', '~/', 'keyword.control');
await assertScope('{{~/if~}}', '~}}', 'support.constant.handlebars');
test('whitespace control on a block: {{~`#if` x~}} ... {{~/if~}}', async () => {
await assertScope('{{~`#if` x~}}', '{{~', 'meta.function.block.start.handlebars');
await assertScope('{{~`#if` x~}}', '~#', 'keyword.control');
await assertScope('{{~`#if` x~}}', '~}}', 'support.constant.handlebars');
await assertScope('{{~/if~}}', '{{~', 'meta.function.block.end.handlebars');
await assertScope('{{~/if~}}', '~/', 'keyword.control');
await assertScope('{{~/if~}}', '~}}', 'support.constant.handlebars');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/blocks.test.js` around lines 68 - 74, The whitespace-control block
assertions in blocks.test.js are using the wrong token text for the opening
delimiter. Update the relevant assertScope calls in the whitespace-control block
test to look up the exact `{{~` token text, matching the behavior already used
in the expression whitespace-control suite, so `scopesOf()` can find the token
consistently for both the block start and end cases.

});
53 changes: 53 additions & 0 deletions test/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';

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

const COMMENT = 'comment.block.handlebars';

// Each case is [description, source, expectedCommentPerLine].
// expectedCommentPerLine[i] === true means line i+1 should carry a Handlebars
// comment scope. The recurring shape `[..., false]` asserts that the line after
// a comment is NOT swallowed by it — the heart of the whitespace-control bug.
const cases = [
// Regression: the originally reported bug (microsoft/vscode#320133).
['block comment with trailing ~ does not leak to next line',
'{{!-- This is a comment --~}}\nBut this is not', [true, false]],

['plain block comment still closes',
'{{!-- comment --}}\nnot a comment', [true, false]],

['block comment with leading ~',
'{{~!-- comment --}}\nnot a comment', [true, false]],

['block comment with leading and trailing ~',
'{{~!-- comment --~}}\nnot a comment', [true, false]],

['multi-line block comment closing with ~',
'{{!--\nstill comment\n--~}}\nnot a comment', [true, true, true, false]],

['inline comment with trailing ~',
'{{! comment ~}}\nnot a comment', [true, false]],

['inline comment with leading ~',
'{{~! comment }}\nnot a comment', [true, false]],

['plain inline comment still closes',
'{{! comment }}\nnot a comment', [true, false]],
];

for (const [name, source, expected] of cases) {
test(name, async () => {
for (let i = 0; i < expected.length; i++) {
const lineNo = i + 1;
const actual = await lineHasScope(source, lineNo, COMMENT);
assert.equal(
actual,
expected[i],
`line ${lineNo} (${JSON.stringify(source.split('\n')[i])}) ` +
`should ${expected[i] ? '' : 'NOT '}be a comment`
);
}
});
}
76 changes: 76 additions & 0 deletions test/embedding.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict';

// Coverage for how Handlebars is embedded into the surrounding document:
// expressions inside HTML attributes, inline <script> templates, the
// layout-extends preprocessor, and a note on YAML front-matter.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { scopesOf, lineHasScope, tokenizeLines } = 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)}`
);
}

test('expression inside an HTML attribute value is highlighted', async () => {
const src = '<div class="{{cls}}">';
// The mustache keeps its own scopes while nested in the quoted attribute.
await assertScope(src, '{{', 'meta.function.inline.other.handlebars');
await assertScope(src, 'cls', 'variable.parameter.handlebars');
await assertScope(src, '}}', 'support.constant.handlebars');
// ...and the surrounding HTML tag is still recognised as HTML.
await assertScope(src, 'div', 'entity.name.tag.block.any.html');
});

test('layout extends preprocessor: {{!< layout}}', async () => {
const src = '{{!< layout}}';
await assertScope(src, '{{!<', 'meta.preprocessor.handlebars');
await assertScope(src, '{{!<', 'support.function.handlebars');
await assertScope(src, 'layout', 'support.class.handlebars');
await assertScope(src, '}}', 'support.function.handlebars');
});

test('inline <script> template embeds Handlebars', async () => {
const openTag = '<script type="text/x-handlebars" id="t">';
const body = ' <h1>{{title}}</h1>';
const closeTag = '</script>';
const src = [openTag, body, closeTag].join('\n');
const lines = await tokenizeLines(src);
// Assert specifically on the template body (line 2), not the <script>/
// </script> boundary lines, so the check targets the embedded content itself.
const bodyTokens = lines[1];
for (const tok of bodyTokens) {
assert.ok(
tok.scopes.includes('source.handlebars.embedded.html'),
`body token ${JSON.stringify(tok.text)} missing embedded scope: ${JSON.stringify(tok.scopes)}`
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The expression inside the template is still highlighted as Handlebars.
await assertScope(src, 'title', 'variable.parameter.handlebars');
});

test('plain HTML outside any script tag is NOT treated as embedded', async () => {
const src = '<p>{{body}}</p>';
const tok = (await tokenizeLines(src)).flat().find((t) => t.text === 'p');
assert.ok(!tok.scopes.includes('source.handlebars.embedded.html'));
});

test('YAML front-matter at the top of the document is highlighted', async () => {
const src = '---\ntitle: Hello\n---\n<p>{{x}}</p>';
// Opening fence, content and closing fence are all in the front-matter block.
assert.equal(await lineHasScope(src, 1, 'markup.raw.yaml.front-matter'), true);
assert.equal(await lineHasScope(src, 2, 'markup.raw.yaml.front-matter'), true);
assert.equal(await lineHasScope(src, 3, 'markup.raw.yaml.front-matter'), true);
// The body after the front-matter still highlights normally.
assert.equal(await lineHasScope(src, 4, 'variable.parameter.handlebars'), true);
});

test('a bare --- not at the document start is NOT front-matter', async () => {
const src = '<p>hi</p>\n---\nstill body';
assert.equal(await lineHasScope(src, 2, 'markup.raw.yaml.front-matter'), false);
});
Loading
Loading