Skip to content

Commit a1912f7

Browse files
Merge branch 'master' into remove/template-no-negated-comparison
2 parents aad310c + 8d8490b commit a1912f7

File tree

4 files changed

+838
-0
lines changed

4 files changed

+838
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ rules in templates can be disabled with eslint directives with mustache or html
249249
| [template-no-model-argument-in-route-templates](docs/rules/template-no-model-argument-in-route-templates.md) | disallow @model argument in route templates | | 🔧 | |
250250
| [template-no-multiple-empty-lines](docs/rules/template-no-multiple-empty-lines.md) | disallow multiple consecutive empty lines in templates | | 🔧 | |
251251
| [template-no-mut-helper](docs/rules/template-no-mut-helper.md) | disallow usage of (mut) helper | | | |
252+
| [template-no-negated-condition](docs/rules/template-no-negated-condition.md) | disallow negated conditions in if/unless | | 🔧 | |
252253
| [template-no-nested-splattributes](docs/rules/template-no-nested-splattributes.md) | disallow nested ...attributes usage | | | |
253254
| [template-no-obscure-array-access](docs/rules/template-no-obscure-array-access.md) | disallow obscure array access patterns like `objectPath.@each.property` | | 🔧 | |
254255
| [template-no-obsolete-elements](docs/rules/template-no-obsolete-elements.md) | disallow obsolete HTML elements | | | |
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ember/template-no-negated-condition
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Disallow negated conditions in `{{#if}}` blocks. Use `{{#unless}}` instead or rewrite the condition.
8+
9+
## Rule Details
10+
11+
This rule discourages the use of `{{#if (not condition)}}` in favor of `{{#unless condition}}` for better readability.
12+
13+
## Examples
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```gjs
18+
<template>
19+
{{#if (not isValid)}}
20+
Invalid
21+
{{/if}}
22+
</template>
23+
```
24+
25+
Examples of **correct** code for this rule:
26+
27+
```gjs
28+
<template>
29+
{{#unless isValid}}
30+
Invalid
31+
{{/unless}}
32+
</template>
33+
34+
<template>
35+
{{#if isValid}}
36+
Valid
37+
{{/if}}
38+
</template>
39+
```
40+
41+
## Options
42+
43+
| Name | Type | Default | Description |
44+
| ----------------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
45+
| `simplifyHelpers` | `boolean` | `true` | When `true`, also reports negated comparison helpers (e.g. `(not (eq ...))`) and suggests using `(not-eq ...)` instead. |
46+
47+
## Related Rules
48+
49+
- [simple-unless](template-simple-unless.md)
50+
51+
## References
52+
53+
- [no-negated-condition](https://eslint.org/docs/rules/no-negated-condition) from eslint
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
const ERROR_MESSAGE_FLIP_IF =
2+
'Change `{{if (not condition)}} ... {{else}} ... {{/if}}` to `{{if condition}} ... {{else}} ... {{/if}}`.';
3+
const ERROR_MESSAGE_USE_IF = 'Change `unless (not condition)` to `if condition`.';
4+
const ERROR_MESSAGE_USE_UNLESS = 'Change `if (not condition)` to `unless condition`.';
5+
const ERROR_MESSAGE_NEGATED_HELPER = 'Simplify unnecessary negation of helper.';
6+
7+
const INVERTIBLE_HELPERS = new Set(['not', 'eq', 'not-eq', 'gt', 'gte', 'lt', 'lte']);
8+
9+
const HELPER_INVERSIONS = {
10+
not: null, // special case
11+
eq: 'not-eq',
12+
'not-eq': 'eq',
13+
gt: 'lte',
14+
gte: 'lt',
15+
lt: 'gte',
16+
lte: 'gt',
17+
};
18+
19+
function isIf(node) {
20+
return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'if';
21+
}
22+
23+
function isUnless(node) {
24+
return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unless';
25+
}
26+
27+
function hasNotHelper(node) {
28+
return (
29+
node.params?.length > 0 &&
30+
node.params[0].type === 'GlimmerSubExpression' &&
31+
node.params[0].path?.type === 'GlimmerPathExpression' &&
32+
node.params[0].path.original === 'not'
33+
);
34+
}
35+
36+
function hasNestedFixableHelper(node) {
37+
const inner = node.params[0]?.params?.[0];
38+
return (
39+
inner &&
40+
inner.path?.type === 'GlimmerPathExpression' &&
41+
INVERTIBLE_HELPERS.has(inner.path.original)
42+
);
43+
}
44+
45+
function escapeRegExp(string) {
46+
return string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
47+
}
48+
49+
/** @type {import('eslint').Rule.RuleModule} */
50+
module.exports = {
51+
meta: {
52+
type: 'suggestion',
53+
docs: {
54+
description: 'disallow negated conditions in if/unless',
55+
category: 'Best Practices',
56+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-negated-condition.md',
57+
templateMode: 'both',
58+
},
59+
fixable: 'code',
60+
schema: [
61+
{
62+
type: 'object',
63+
properties: {
64+
simplifyHelpers: { type: 'boolean' },
65+
},
66+
additionalProperties: false,
67+
},
68+
],
69+
messages: {
70+
flipIf: ERROR_MESSAGE_FLIP_IF,
71+
useIf: ERROR_MESSAGE_USE_IF,
72+
useUnless: ERROR_MESSAGE_USE_UNLESS,
73+
negatedHelper: ERROR_MESSAGE_NEGATED_HELPER,
74+
},
75+
originallyFrom: {
76+
name: 'ember-template-lint',
77+
rule: 'lib/rules/no-negated-condition.js',
78+
docs: 'docs/rule/no-negated-condition.md',
79+
tests: 'test/unit/rules/no-negated-condition-test.js',
80+
},
81+
},
82+
83+
create(context) {
84+
const options = context.options[0] || {};
85+
const simplifyHelpers = options.simplifyHelpers === undefined ? true : options.simplifyHelpers;
86+
const sourceCode = context.sourceCode;
87+
88+
/**
89+
* Get the source text for the inner condition of a (not ...) sub-expression,
90+
* with the nested helper optionally inverted.
91+
*/
92+
function getUnwrappedConditionText(notExpr, invertHelper) {
93+
const inner = notExpr.params[0];
94+
if (invertHelper && inner.path?.type === 'GlimmerPathExpression') {
95+
const helperName = inner.path.original;
96+
const inverted = HELPER_INVERSIONS[helperName];
97+
if (inverted !== undefined) {
98+
if (inverted === null) {
99+
if (inner.params.length > 1) {
100+
// (not (not c1 c2)) -> (or c1 c2)
101+
const paramsText = inner.params.map((p) => sourceCode.getText(p)).join(' ');
102+
return `(or ${paramsText})`;
103+
}
104+
// (not (not x)) -> just x
105+
return sourceCode.getText(inner.params[0]);
106+
}
107+
// (not (eq a b)) -> (not-eq a b)
108+
const innerText = sourceCode.getText(inner);
109+
return innerText.replace(`(${helperName} `, `(${inverted} `);
110+
}
111+
}
112+
return sourceCode.getText(inner);
113+
}
114+
115+
/**
116+
* Build a fix function for block statements.
117+
*/
118+
function buildBlockFix(node, messageId) {
119+
return function fix(fixer) {
120+
const fullText = sourceCode.getText(node);
121+
const keyword = node.path.original;
122+
const notExpr = node.params[0];
123+
124+
if (messageId === 'negatedHelper') {
125+
const conditionText = getUnwrappedConditionText(notExpr, true);
126+
const newText = fullText.replace(sourceCode.getText(notExpr), conditionText);
127+
return fixer.replaceText(node, newText);
128+
}
129+
130+
if (messageId === 'flipIf') {
131+
// {{#if (not x)}}A{{else}}B{{/if}} -> {{#if x}}B{{else}}A{{/if}}
132+
const conditionText = getUnwrappedConditionText(notExpr, false);
133+
const programBody = node.program.body.map((n) => sourceCode.getText(n)).join('');
134+
const inverseBody = node.inverse.body.map((n) => sourceCode.getText(n)).join('');
135+
136+
return fixer.replaceText(
137+
node,
138+
`{{#${keyword} ${conditionText}}}${inverseBody}{{else}}${programBody}{{/${keyword}}}`
139+
);
140+
}
141+
142+
if (messageId === 'useIf' || messageId === 'useUnless') {
143+
const newKeyword = keyword === 'unless' ? 'if' : 'unless';
144+
const conditionText = getUnwrappedConditionText(notExpr, false);
145+
const notExprText = escapeRegExp(sourceCode.getText(notExpr));
146+
const newText = fullText
147+
.replace(
148+
new RegExp(`^\\{\\{#${keyword} ${notExprText}`),
149+
`{{#${newKeyword} ${conditionText}`
150+
)
151+
.replace(new RegExp(`\\{\\{/${keyword}\\}\\}$`), `{{/${newKeyword}}}`);
152+
return fixer.replaceText(node, newText);
153+
}
154+
155+
return null;
156+
};
157+
}
158+
159+
/**
160+
* Build a fix function for inline (mustache/subexpression) statements.
161+
*/
162+
function buildInlineFix(node, messageId) {
163+
return function fix(fixer) {
164+
const fullText = sourceCode.getText(node);
165+
const keyword = node.path.original;
166+
const notExpr = node.params[0];
167+
168+
if (messageId === 'negatedHelper') {
169+
const conditionText = getUnwrappedConditionText(notExpr, true);
170+
const newText = fullText.replace(sourceCode.getText(notExpr), conditionText);
171+
return fixer.replaceText(node, newText);
172+
}
173+
174+
if (messageId === 'flipIf') {
175+
const conditionText = getUnwrappedConditionText(notExpr, false);
176+
const param1Text = sourceCode.getText(node.params[1]);
177+
const param2Text = sourceCode.getText(node.params[2]);
178+
const isSubExpr = node.type === 'GlimmerSubExpression';
179+
const open = isSubExpr ? '(' : '{{';
180+
const close = isSubExpr ? ')' : '}}';
181+
return fixer.replaceText(
182+
node,
183+
`${open}${keyword} ${conditionText} ${param2Text} ${param1Text}${close}`
184+
);
185+
}
186+
187+
if (messageId === 'useIf' || messageId === 'useUnless') {
188+
const newKeyword = keyword === 'unless' ? 'if' : 'unless';
189+
const conditionText = getUnwrappedConditionText(notExpr, false);
190+
const isSubExpr = node.type === 'GlimmerSubExpression';
191+
const open = isSubExpr ? '(' : '{{';
192+
const close = isSubExpr ? ')' : '}}';
193+
const remainingParams = node.params
194+
.slice(1)
195+
.map((p) => sourceCode.getText(p))
196+
.join(' ');
197+
return fixer.replaceText(
198+
node,
199+
`${open}${newKeyword} ${conditionText} ${remainingParams}${close}`
200+
);
201+
}
202+
203+
return null;
204+
};
205+
}
206+
207+
// eslint-disable-next-line complexity
208+
function checkNode(node) {
209+
const nodeIsIf = isIf(node);
210+
const nodeIsUnless = isUnless(node);
211+
212+
if (!nodeIsIf && !nodeIsUnless) {
213+
return;
214+
}
215+
216+
// Skip `{{else if ...}}` / `{{else unless ...}}` chains for the outer check,
217+
// unless they have a fixable negated helper inside
218+
if (node.type === 'GlimmerBlockStatement') {
219+
const text = sourceCode.getText(node);
220+
if (text.startsWith('{{else ')) {
221+
if (!simplifyHelpers || !hasNotHelper(node) || !hasNestedFixableHelper(node)) {
222+
return;
223+
}
224+
context.report({
225+
node: node.params[0],
226+
messageId: 'negatedHelper',
227+
fix: buildBlockFix(node, 'negatedHelper'),
228+
});
229+
return;
230+
}
231+
232+
// Ignore `if ... else if ...` (chained) to avoid forcing negation
233+
if (
234+
node.inverse?.body?.length > 0 &&
235+
node.inverse.body[0].type === 'GlimmerBlockStatement' &&
236+
nodeIsIf &&
237+
isIf(node.inverse.body[0])
238+
) {
239+
return;
240+
}
241+
}
242+
243+
if (!hasNotHelper(node)) {
244+
return;
245+
}
246+
247+
const notExpr = node.params[0];
248+
const hasFixableHelper = hasNestedFixableHelper(node);
249+
250+
// If it's `if (not (someHelper ...))` and we can't simplify the helper,
251+
// don't suggest converting to `unless` (simple-unless rule would reject it)
252+
if (
253+
nodeIsIf &&
254+
notExpr.params?.[0]?.type === 'GlimmerSubExpression' &&
255+
(!simplifyHelpers || !hasFixableHelper)
256+
) {
257+
return;
258+
}
259+
260+
// (not a b c) with multiple params — can't simply remove negation
261+
if (notExpr.params?.length > 1) {
262+
return;
263+
}
264+
265+
// Determine message
266+
const isIfElseBlock = node.type === 'GlimmerBlockStatement' && node.inverse?.body?.length > 0;
267+
const isIfElseInline = node.type !== 'GlimmerBlockStatement' && node.params?.length === 3;
268+
const shouldFlip = isIfElseBlock || isIfElseInline;
269+
270+
let messageId;
271+
if (hasFixableHelper && simplifyHelpers) {
272+
messageId = 'negatedHelper';
273+
} else if (shouldFlip && nodeIsIf) {
274+
messageId = 'flipIf';
275+
} else if (nodeIsUnless) {
276+
messageId = 'useIf';
277+
} else {
278+
messageId = 'useUnless';
279+
}
280+
281+
const isBlock = node.type === 'GlimmerBlockStatement';
282+
context.report({
283+
node: notExpr,
284+
messageId,
285+
fix: isBlock ? buildBlockFix(node, messageId) : buildInlineFix(node, messageId),
286+
});
287+
}
288+
289+
return {
290+
GlimmerBlockStatement: checkNode,
291+
GlimmerMustacheStatement: checkNode,
292+
GlimmerSubExpression: checkNode,
293+
};
294+
},
295+
};

0 commit comments

Comments
 (0)