Skip to content

Commit f9db871

Browse files
NullVoxPopuliclaude
andcommitted
Add template-lint-disable pragma for template regions
Implements a `template-lint-disable` comment directive that suppresses lint errors on the next line in template regions of gjs/gts/hbs files. This works like `eslint-disable-next-line` but uses the template-lint naming convention familiar to ember-template-lint users. Supported comment formats: {{! template-lint-disable }} {{!-- template-lint-disable rule-name --}} <!-- template-lint-disable rule-name --> Rule names can be specified as either eslint rule IDs (e.g. `no-undef`) or with `ember/template-` prefix (e.g. `ember/template-no-bare-strings`). When no rule is specified, all rules are suppressed on the next line. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent c97528a commit f9db871

3 files changed

Lines changed: 265 additions & 2 deletions

File tree

lib/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
const requireIndex = require('requireindex');
4-
const noop = require('ember-eslint-parser/noop');
4+
const templateLintDisableProcessor = require('./processors/template-lint-disable');
55
const pkg = require('../package.json'); // eslint-disable-line import/extensions
66

77
module.exports = {
@@ -16,6 +16,6 @@ module.exports = {
1616
},
1717
processors: {
1818
// https://eslint.org/docs/developer-guide/working-with-plugins#file-extension-named-processor
19-
noop,
19+
noop: templateLintDisableProcessor,
2020
},
2121
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use strict';
2+
3+
const noop = require('ember-eslint-parser/noop');
4+
5+
/**
6+
* Regex patterns for template-lint-disable comments:
7+
* {{! template-lint-disable rule1 rule2 }}
8+
* {{!-- template-lint-disable rule1 rule2 --}}
9+
* <!-- template-lint-disable rule1 rule2 -->
10+
*/
11+
const TEMPLATE_LINT_DISABLE_REGEX =
12+
/(?:{{!-*\s*template-lint-disable\s*([\s\S]*?)-*}}|<!--\s*template-lint-disable\s*([\s\S]*?)-->)/g;
13+
14+
// Store disable directives per file
15+
const fileDisableDirectives = new Map();
16+
17+
/**
18+
* Parse template-lint-disable comments from source text and store
19+
* which lines/rules should be suppressed.
20+
*
21+
* template-lint-disable means "disable next line" (like eslint-disable-next-line).
22+
*/
23+
function parseDisableDirectives(text) {
24+
const directives = [];
25+
const lines = text.split('\n');
26+
27+
for (let i = 0; i < lines.length; i++) {
28+
const line = lines[i];
29+
TEMPLATE_LINT_DISABLE_REGEX.lastIndex = 0;
30+
let match;
31+
32+
while ((match = TEMPLATE_LINT_DISABLE_REGEX.exec(line)) !== null) {
33+
const rulesPart = (match[1] || match[2] || '').trim();
34+
// Strip trailing -- from mustache block comments
35+
const cleaned = rulesPart.replace(/-+$/, '').trim();
36+
37+
const rules = cleaned
38+
? cleaned.split(/[\s,]+/).filter(Boolean)
39+
: []; // empty = disable all
40+
41+
directives.push({
42+
// comment is on line i+1 (1-indexed), next line is i+2
43+
line: i + 2,
44+
rules,
45+
});
46+
}
47+
}
48+
49+
return directives;
50+
}
51+
52+
/**
53+
* Map a rule name from template-lint format to eslint-plugin-ember format.
54+
* e.g. "no-bare-strings" -> "ember/template-no-bare-strings"
55+
*
56+
* Also accepts already-qualified names like "ember/template-no-bare-strings".
57+
*/
58+
function matchesRule(ruleId, disableRuleName) {
59+
if (ruleId === disableRuleName) {
60+
return true;
61+
}
62+
// Map template-lint name to eslint-plugin-ember name
63+
if (ruleId === `ember/template-${disableRuleName}`) {
64+
return true;
65+
}
66+
return false;
67+
}
68+
69+
function shouldSuppressMessage(message, directives) {
70+
for (const directive of directives) {
71+
if (message.line !== directive.line) {
72+
continue;
73+
}
74+
// No rules specified = suppress all
75+
if (directive.rules.length === 0) {
76+
return true;
77+
}
78+
// Check if any specified rule matches this message's rule
79+
if (directive.rules.some((rule) => matchesRule(message.ruleId, rule))) {
80+
return true;
81+
}
82+
}
83+
return false;
84+
}
85+
86+
module.exports = {
87+
registerParsedFile: noop.registerParsedFile,
88+
89+
preprocess(text, fileName) {
90+
const directives = parseDisableDirectives(text);
91+
if (directives.length > 0) {
92+
fileDisableDirectives.set(fileName, directives);
93+
} else {
94+
fileDisableDirectives.delete(fileName);
95+
}
96+
// Return text as-is (single code block)
97+
return [text];
98+
},
99+
100+
postprocess(messages, fileName) {
101+
// First, apply noop's postprocess logic (config validation)
102+
const msgs = noop.postprocess(messages, fileName);
103+
104+
const directives = fileDisableDirectives.get(fileName);
105+
if (!directives) {
106+
return msgs;
107+
}
108+
109+
fileDisableDirectives.delete(fileName);
110+
return msgs.filter((message) => !shouldSuppressMessage(message, directives));
111+
},
112+
113+
supportsAutofix: true,
114+
};

tests/lib/rules-preprocessor/gjs-gts-parser-test.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,3 +886,152 @@ describe('multiple tokens in same file', () => {
886886
expect(resultErrors[2].message).toBe("Use 'String#startsWith' method instead.");
887887
});
888888
});
889+
890+
describe('supports template-lint-disable directive', () => {
891+
it('disables all rules on the next line with mustache comment', async () => {
892+
const eslint = initESLint();
893+
const code = `
894+
<template>
895+
<div>
896+
{{! template-lint-disable }}
897+
{{test}}
898+
</div>
899+
</template>
900+
`;
901+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
902+
const resultErrors = results.flatMap((result) => result.messages);
903+
// {{test}} would normally trigger no-undef, but should be suppressed
904+
expect(resultErrors).toHaveLength(0);
905+
});
906+
907+
it('disables all rules on the next line with mustache block comment', async () => {
908+
const eslint = initESLint();
909+
const code = `
910+
<template>
911+
<div>
912+
{{!-- template-lint-disable --}}
913+
{{test}}
914+
</div>
915+
</template>
916+
`;
917+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
918+
const resultErrors = results.flatMap((result) => result.messages);
919+
expect(resultErrors).toHaveLength(0);
920+
});
921+
922+
it('disables all rules on the next line with HTML comment', async () => {
923+
const eslint = initESLint();
924+
const code = `
925+
<template>
926+
<div>
927+
<!-- template-lint-disable -->
928+
{{test}}
929+
</div>
930+
</template>
931+
`;
932+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
933+
const resultErrors = results.flatMap((result) => result.messages);
934+
expect(resultErrors).toHaveLength(0);
935+
});
936+
937+
it('disables a specific rule by eslint rule name', async () => {
938+
const eslint = initESLint();
939+
const code = `
940+
<template>
941+
<div>
942+
{{! template-lint-disable no-undef }}
943+
{{test}}
944+
{{other}}
945+
</div>
946+
</template>
947+
`;
948+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
949+
const resultErrors = results.flatMap((result) => result.messages);
950+
// {{test}} on line after disable should be suppressed
951+
// {{other}} on the line after that should NOT be suppressed
952+
expect(resultErrors).toHaveLength(1);
953+
expect(resultErrors[0].message).toBe("'other' is not defined.");
954+
});
955+
956+
it('only disables the next line, not subsequent lines', async () => {
957+
const eslint = initESLint();
958+
const code = `
959+
<template>
960+
<div>
961+
{{! template-lint-disable }}
962+
{{test}}
963+
{{other}}
964+
</div>
965+
</template>
966+
`;
967+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
968+
const resultErrors = results.flatMap((result) => result.messages);
969+
// {{test}} suppressed, {{other}} NOT suppressed
970+
expect(resultErrors).toHaveLength(1);
971+
expect(resultErrors[0].message).toBe("'other' is not defined.");
972+
});
973+
974+
it('does not suppress unrelated rules when a specific rule is named', async () => {
975+
const eslint = initESLint();
976+
const code = `
977+
<template>
978+
<div>
979+
{{! template-lint-disable ember/template-no-bare-strings }}
980+
{{test}}
981+
</div>
982+
</template>
983+
`;
984+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
985+
const resultErrors = results.flatMap((result) => result.messages);
986+
// no-undef should still fire since we only disabled template-no-bare-strings
987+
expect(resultErrors).toHaveLength(1);
988+
expect(resultErrors[0].ruleId).toBe('no-undef');
989+
});
990+
991+
it('supports template-lint rule name format (maps to ember/ prefix)', async () => {
992+
const eslint = initESLint();
993+
const code = `
994+
<template>
995+
<div>
996+
{{! template-lint-disable no-undef }}
997+
{{test}}
998+
</div>
999+
</template>
1000+
`;
1001+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
1002+
const resultErrors = results.flatMap((result) => result.messages);
1003+
expect(resultErrors).toHaveLength(0);
1004+
});
1005+
1006+
it('supports multiple rule names', async () => {
1007+
const eslint = initESLint();
1008+
const code = `
1009+
<template>
1010+
<div>
1011+
{{! template-lint-disable no-undef quotes }}
1012+
{{test}}
1013+
</div>
1014+
</template>
1015+
`;
1016+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
1017+
const resultErrors = results.flatMap((result) => result.messages);
1018+
expect(resultErrors).toHaveLength(0);
1019+
});
1020+
1021+
it('works with multiple disable comments in the same file', async () => {
1022+
const eslint = initESLint();
1023+
const code = `
1024+
<template>
1025+
<div>
1026+
{{! template-lint-disable }}
1027+
{{test}}
1028+
{{! template-lint-disable }}
1029+
{{other}}
1030+
</div>
1031+
</template>
1032+
`;
1033+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
1034+
const resultErrors = results.flatMap((result) => result.messages);
1035+
expect(resultErrors).toHaveLength(0);
1036+
});
1037+
});

0 commit comments

Comments
 (0)