diff --git a/README.md b/README.md index c62c1626ce..412f91abcc 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,80 @@ rules in templates can be disabled with eslint directives with mustache or html ``` +### template-lint-disable (experimental) + +> [!WARNING] +> This processor is experimental and not yet covered by semver guarantees. The API may change in future releases. + +For users migrating from `ember-template-lint`, this plugin provides an opt-in `template-lint-disable` processor that recognizes `{{! template-lint-disable }}` comments. This can be useful for suppressing lint errors in `.hbs` files or in gjs/gts templates using familiar ember-template-lint syntax. + +To enable it, set the processor and parser in your ESLint config: + +```js +// eslint.config.js (flat config) +const ember = require('eslint-plugin-ember'); + +module.exports = [ + { + files: ['**/*.hbs'], + parser: 'ember-eslint-parser/hbs', + plugins: { ember }, + processor: 'ember/template-lint-disable', + // ... + }, +]; +``` + +```js +// .eslintrc.js (legacy config) +module.exports = { + overrides: [ + { + files: ['**/*.hbs'], + parser: 'ember-eslint-parser/hbs', + plugins: ['ember'], + processor: 'ember/template-lint-disable', + // ... + }, + ], +}; +``` + +Usage: + +```hbs +{{! suppress all rules on the next line }} +{{! template-lint-disable }} +Hello world + +{{! suppress a specific rule }} +{{!-- template-lint-disable no-bare-strings --}} +Hello world +``` + +Rule names can be specified as: + +- Template-lint names: `no-bare-strings` (maps to `ember/template-no-bare-strings`) +- Plugin-qualified names: `template-no-bare-strings` (maps to `ember/template-no-bare-strings`) +- Full ESLint rule IDs: `ember/template-no-bare-strings`, `no-undef` + +> [!NOTE] +> Unlike `ember-template-lint`, this directive only suppresses the **next line** (and the comment line itself). It does not disable rules for the rest of the scope. `template-lint-enable`, `template-lint-disable-next-line`, and `template-lint-disable-tree` are not supported. + +Standard ESLint directives also work natively in mustache comments and can be used alongside `template-lint-disable`: + +```hbs +{{! suppress a single line }} +{{! eslint-disable-next-line ember/template-no-bare-strings }} +Hello world + +{{! suppress a range of lines }} +{{! eslint-disable ember/template-no-bare-strings }} +Hello world +Another bare string +{{! eslint-enable ember/template-no-bare-strings }} +``` + ## 🧰 Configurations diff --git a/lib/index.js b/lib/index.js index bfb49a4471..638e4d840b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,6 +2,7 @@ const requireIndex = require('requireindex'); const noop = require('ember-eslint-parser/noop'); +const templateLintDisableProcessor = require('./processors/template-lint-disable'); const pkg = require('../package.json'); // eslint-disable-line import/extensions module.exports = { @@ -17,5 +18,6 @@ module.exports = { processors: { // https://eslint.org/docs/developer-guide/working-with-plugins#file-extension-named-processor noop, + 'template-lint-disable': templateLintDisableProcessor, }, }; diff --git a/lib/processors/template-lint-disable.js b/lib/processors/template-lint-disable.js new file mode 100644 index 0000000000..af18ce5a64 --- /dev/null +++ b/lib/processors/template-lint-disable.js @@ -0,0 +1,153 @@ +'use strict'; + +const noop = require('ember-eslint-parser/noop'); + +/** + * Regex patterns for template-lint-disable mustache comments. + * Two separate patterns to avoid polynomial backtracking (ReDoS): + * {{! template-lint-disable rule1 rule2 }} + * {{!-- template-lint-disable rule1 rule2 --}} + */ +// Lookahead (?=\s|…) ensures we match exactly "template-lint-disable" +// and not "template-lint-disable-next-line" or "template-lint-disable-tree". +// \s+ after ! — required because {{!text}} is valid Handlebars (comment with no space) +const MUSTACHE_COMMENT_REGEX = /{{!\s+template-lint-disable(?=\s|}})([\s\w,/@-]*)}}/g; +// \s* after -- — the dashes already delimit, so {{!--template-lint-disable--}} is fine +const MUSTACHE_BLOCK_COMMENT_REGEX = /{{!--\s*template-lint-disable(?=\s|--)([\s\w,/@-]*)--}}/g; + +const GJS_GTS_EXT = /\.(gjs|gts)$/; + +// Store disable directives per file +const fileDisableDirectives = new Map(); + +/** + * Parse template-lint-disable comments from source text and store + * which lines/rules should be suppressed. + * + * template-lint-disable means "disable next line" (like eslint-disable-next-line). + * + * NOTE: In ember-template-lint, `template-lint-disable` disables from that point + * for the rest of the scope (until `template-lint-enable`). This implementation + * intentionally uses "next line only" semantics to match ESLint conventions. + * `template-lint-enable` is not supported and will be silently ignored. + * `template-lint-disable-next-line` and `template-lint-disable-tree` are also + * not matched — only the exact `template-lint-disable` directive is recognized. + * + * Errors on the comment line itself are also suppressed (similar to + * eslint-disable-line), and errors on the next line are suppressed + * (similar to eslint-disable-next-line). This dual behavior exists because + * template AST TextNodes can start on the same line as the comment. + * + * CAVEAT: The processor runs on the entire file text, not just template regions. + * A JS string literal containing `{{! template-lint-disable }}` in a gjs file + * would match and suppress errors on the same/next line. This is unlikely in + * practice but impossible to fix without region-aware parsing. + */ +function collectMatches(line, lineIndex, directives) { + for (const regex of [MUSTACHE_COMMENT_REGEX, MUSTACHE_BLOCK_COMMENT_REGEX]) { + regex.lastIndex = 0; + let match; + + while ((match = regex.exec(line)) !== null) { + const rulesPart = (match[1] || '').trim(); + const rules = rulesPart ? rulesPart.split(/[\s,]+/).filter(Boolean) : []; // empty = disable all + + // Comment is on line lineIndex+1 (1-indexed), next line is lineIndex+2. + // In template ASTs, TextNodes can start on the same line as the comment + // (e.g. the newline after {{! template-lint-disable }} is part of the + // following TextNode), so we suppress both the comment line and the next. + directives.push({ + commentLine: lineIndex + 1, + nextLine: lineIndex + 2, + rules, + }); + } + } +} + +function parseDisableDirectives(text) { + const directives = []; + const lines = text.split('\n'); + + for (const [i, line] of lines.entries()) { + collectMatches(line, i, directives); + } + + return directives; +} + +/** + * Map a rule name from template-lint format to eslint-plugin-ember format. + * Accepts three forms: + * "no-bare-strings" -> "ember/template-no-bare-strings" + * "template-no-bare-strings" -> "ember/template-no-bare-strings" + * "ember/template-no-bare-strings" -> exact match + */ +function matchesRule(ruleId, disableRuleName) { + if (ruleId === disableRuleName) { + return true; + } + // e.g. "template-no-bare-strings" -> "ember/template-no-bare-strings" + if (ruleId === `ember/${disableRuleName}`) { + return true; + } + // e.g. "no-bare-strings" -> "ember/template-no-bare-strings" + if (ruleId === `ember/template-${disableRuleName}`) { + return true; + } + return false; +} + +function shouldSuppressMessage(message, directives) { + for (const directive of directives) { + if (message.line !== directive.commentLine && message.line !== directive.nextLine) { + continue; + } + // No rules specified = suppress all + if (directive.rules.length === 0) { + return true; + } + // Check if any specified rule matches this message's rule + if (directive.rules.some((rule) => matchesRule(message.ruleId, rule))) { + return true; + } + } + return false; +} + +module.exports = { + preprocess(text, fileName) { + if (!text.includes('template-lint-disable')) { + fileDisableDirectives.delete(fileName); + return [text]; + } + + const directives = parseDisableDirectives(text); + if (directives.length > 0) { + fileDisableDirectives.set(fileName, directives); + } else { + fileDisableDirectives.delete(fileName); + } + // Return text as-is (single code block) + return [text]; + }, + + postprocess(messages, fileName) { + // Only run noop's postprocess for gjs/gts files — it appends gjs/gts setup + // instructions on parse failures, which corrupts hbs error messages since + // the hbs parser never calls registerParsedFile. + const msgs = GJS_GTS_EXT.test(fileName) + ? noop.postprocess(messages, fileName) + : messages.flat(); + + const directives = fileDisableDirectives.get(fileName); + if (!directives) { + return msgs; + } + + fileDisableDirectives.delete(fileName); + return msgs.filter((message) => !shouldSuppressMessage(message, directives)); + }, + + supportsAutofix: true, +}; diff --git a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js index cda7c36a34..c11edc76b0 100644 --- a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js +++ b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js @@ -886,3 +886,167 @@ describe('multiple tokens in same file', () => { expect(resultErrors[2].message).toBe("Use 'String#startsWith' method instead."); }); }); + +function initESLintWithTemplateLintDisable() { + return new ESLint({ + ignore: false, + useEslintrc: false, + plugins: { ember: plugin }, + overrideConfig: { + root: true, + env: { + browser: true, + }, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + parser: gjsGtsParser, + plugins: ['ember'], + processor: 'ember/template-lint-disable', + rules: { + 'no-undef': 'error', + 'no-unused-vars': 'error', + quotes: ['error', 'single'], + }, + }, + }); +} + +describe('supports template-lint-disable directive', () => { + it('disables all rules on the next line with mustache comment', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + // {{test}} would normally trigger no-undef, but should be suppressed + expect(resultErrors).toHaveLength(0); + }); + + it('disables all rules on the next line with mustache block comment', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('disables a specific rule by eslint rule name', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + // {{test}} on line after disable should be suppressed + // {{other}} on the line after that should NOT be suppressed + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].message).toBe("'other' is not defined."); + }); + + it('only disables the next line, not subsequent lines', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + // {{test}} suppressed, {{other}} NOT suppressed + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].message).toBe("'other' is not defined."); + }); + + it('does not suppress unrelated rules when a specific rule is named', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + // no-undef should still fire since we only disabled template-no-bare-strings + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('no-undef'); + }); + + it('supports multiple rule names', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('works with multiple disable comments in the same file', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('eslint-disable/eslint-enable range works in gjs templates', async () => { + const eslint = initESLintWithTemplateLintDisable(); + const code = ` + + `; + const results = await eslint.lintText(code, { filePath: 'my-component.gjs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].message).toBe("'shouldError' is not defined."); + }); +}); diff --git a/tests/lib/rules-preprocessor/hbs-parser-test.js b/tests/lib/rules-preprocessor/hbs-parser-test.js new file mode 100644 index 0000000000..b5ee7b71c7 --- /dev/null +++ b/tests/lib/rules-preprocessor/hbs-parser-test.js @@ -0,0 +1,207 @@ +'use strict'; + +const { ESLint } = require('eslint'); +const plugin = require('../../../lib'); + +const hbsParser = require.resolve('ember-eslint-parser/hbs'); + +function initHbsESLint() { + return new ESLint({ + ignore: false, + useEslintrc: false, + plugins: { ember: plugin }, + overrideConfig: { + root: true, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + plugins: ['ember'], + overrides: [ + { + files: ['**/*.hbs'], + parser: hbsParser, + processor: 'ember/template-lint-disable', + rules: { + 'ember/template-no-bare-strings': 'error', + }, + }, + ], + }, + }); +} + +describe('supports template-lint-disable directive in hbs files', () => { + it('disables all rules on the next line with mustache comment', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('disables all rules on the next line with mustache block comment', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{!-- template-lint-disable --}} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('only disables the next line, not subsequent lines', async () => { + const eslint = initHbsESLint(); + const code = `{{! template-lint-disable }} +
Hello world
+
Bare string here too
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // Line 2 "Hello world" suppressed, but line 3 "Bare string here too" should still error + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].line).toBe(3); + }); + + it('disables a specific rule by name', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable ember/template-no-bare-strings }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('supports template-lint rule name format (maps to ember/ prefix)', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable no-bare-strings }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('does not suppress unrelated rules when a specific rule is named', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable ember/template-no-html-comments }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // no-bare-strings should still fire since we only disabled no-html-comments + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings'); + }); + + it('works with multiple disable comments in the same file', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable }} + Hello world + {{! template-lint-disable }} + Another bare string +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('bare strings without disable comment still trigger errors', async () => { + const eslint = initHbsESLint(); + const code = `
+ Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings'); + // Verify error message is not corrupted with gjs/gts setup instructions + expect(resultErrors[0].message).not.toContain('To lint Gjs/Gts files'); + }); + + it('suppresses errors on the comment line itself', async () => { + const eslint = initHbsESLint(); + const code = 'Hello {{! template-lint-disable }}'; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // "Hello" is on the same line as the disable comment — suppressed + expect(resultErrors).toHaveLength(0); + }); + + it('does not match template-lint-disable-next-line (different directive)', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable-next-line }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // template-lint-disable-next-line is NOT a recognized directive — error should fire + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings'); + }); + + it('matches template-no-bare-strings middle form (ember/ prefix mapping)', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable template-no-bare-strings }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(0); + }); + + it('eslint-disable-next-line works in hbs templates', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! eslint-disable-next-line ember/template-no-bare-strings }} + Hello world + Still bare +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // "Hello world" on the next line is suppressed, "Still bare" still errors + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings'); + expect(resultErrors[0].line).toBe(4); + }); + + it('eslint-disable/eslint-enable ranges work in hbs templates', async () => { + const eslint = initHbsESLint(); + const code = `{{! eslint-disable ember/template-no-bare-strings }} +Hello world +Another bare string +{{! eslint-enable ember/template-no-bare-strings }} +This should error`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // Lines 2-3 are inside the eslint-disable range — suppressed + // "This should error" after eslint-enable triggers the rule + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings'); + }); + + it('parses @-scoped rule names without breaking the regex', async () => { + const eslint = initHbsESLint(); + const code = `
+ {{! template-lint-disable @ember/template-no-bare-strings }} + Hello world +
`; + const results = await eslint.lintText(code, { filePath: 'my-template.hbs' }); + const resultErrors = results.flatMap((result) => result.messages); + // @ember/template-no-bare-strings won't match ruleId ember/template-no-bare-strings + // (different string), so the error still fires — this tests that @ is accepted + // by the regex without breaking parsing + expect(resultErrors).toHaveLength(1); + expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings'); + }); +});