-
Notifications
You must be signed in to change notification settings - Fork 48
Add parsing for end of comment when using whitespace control + add tooling for testing #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Use the exact
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| 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` | ||
| ); | ||
| } | ||
| }); | ||
| } |
| 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)}` | ||
| ); | ||
| } | ||
|
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); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.