Skip to content

Commit d3e4b80

Browse files
axetroyfisker
andauthored
Add consistent-existence-index-check rule (#2425)
Co-authored-by: fisker <[email protected]>
1 parent 461b01c commit d3e4b80

11 files changed

+764
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Enforce consistent style for element existence checks with `indexOf()`, `lastIndexOf()`, `findIndex()`, and `findLastIndex()`
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs-eslintconfigjs).
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Enforce consistent style for element existence checks with `indexOf()`, `lastIndexOf()`, `findIndex()`, and `findLastIndex()`.
11+
12+
Prefer using `index === -1` to check if an element does not exist and `index !== -1` to check if an element does exist.
13+
14+
Similar to the [`explicit-length-check`](explicit-length-check.md) rule.
15+
16+
## Examples
17+
18+
```js
19+
const index = foo.indexOf('bar');
20+
21+
//
22+
if (index < 0) {}
23+
24+
//
25+
if (index === -1) {}
26+
```
27+
28+
```js
29+
const index = foo.indexOf('bar');
30+
31+
//
32+
if (index >= 0) {}
33+
34+
//
35+
if (index !== -1) {}
36+
```
37+
38+
```js
39+
const index = foo.indexOf('bar');
40+
41+
//
42+
if (index > -1) {}
43+
44+
//
45+
if (index !== -1) {}
46+
```
47+
48+
```js
49+
const index = foo.lastIndexOf('bar');
50+
51+
//
52+
if (index >= 0) {}
53+
54+
//
55+
if (index !== -1) {}
56+
```
57+
58+
```js
59+
const index = array.findIndex(element => element > 10);
60+
61+
//
62+
if (index < 0) {}
63+
64+
//
65+
if (index === -1) {}
66+
```
67+
68+
```js
69+
const index = array.findLastIndex(element => element > 10);
70+
71+
//
72+
if (index < 0) {}
73+
74+
//
75+
if (index === -1) {}
76+
```

readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c
114114
| [catch-error-name](docs/rules/catch-error-name.md) | Enforce a specific parameter name in catch clauses. || 🔧 | |
115115
| [consistent-destructuring](docs/rules/consistent-destructuring.md) | Use destructured variables over properties. | | 🔧 | 💡 |
116116
| [consistent-empty-array-spread](docs/rules/consistent-empty-array-spread.md) | Prefer consistent types when spreading a ternary in an array literal. || 🔧 | |
117+
| [consistent-existence-index-check](docs/rules/consistent-existence-index-check.md) | Enforce consistent style for element existence checks with `indexOf()`, `lastIndexOf()`, `findIndex()`, and `findLastIndex()`. || 🔧 | |
117118
| [consistent-function-scoping](docs/rules/consistent-function-scoping.md) | Move function definitions to the highest possible scope. || | |
118119
| [custom-error-definition](docs/rules/custom-error-definition.md) | Enforce correct `Error` subclassing. | | 🔧 | |
119120
| [empty-brace-spaces](docs/rules/empty-brace-spaces.md) | Enforce no spaces between braces. || 🔧 | |

rules/ast/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ module.exports = {
3131
isFunction: require('./is-function.js'),
3232
isMemberExpression: require('./is-member-expression.js'),
3333
isMethodCall: require('./is-method-call.js'),
34+
isNegativeOne: require('./is-negative-one.js'),
3435
isNewExpression,
3536
isReferenceIdentifier: require('./is-reference-identifier.js'),
3637
isStaticRequire: require('./is-static-require.js'),

rules/ast/is-negative-one.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use strict';
2+
3+
const {isNumberLiteral} = require('./literal.js');
4+
5+
function isNegativeOne(node) {
6+
return node?.type === 'UnaryExpression'
7+
&& node.operator === '-'
8+
&& isNumberLiteral(node.argument)
9+
&& node.argument.value === 1;
10+
}
11+
12+
module.exports = isNegativeOne;
+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use strict';
2+
const toLocation = require('./utils/to-location.js');
3+
const {isMethodCall, isNegativeOne, isNumberLiteral} = require('./ast/index.js');
4+
5+
const MESSAGE_ID = 'consistent-existence-index-check';
6+
const messages = {
7+
[MESSAGE_ID]: 'Prefer `{{replacementOperator}} {{replacementValue}}` over `{{originalOperator}} {{originalValue}}` to check {{existenceOrNonExistence}}.',
8+
};
9+
10+
const isZero = node => isNumberLiteral(node) && node.value === 0;
11+
12+
/**
13+
@param {parent: import('estree').BinaryExpression} binaryExpression
14+
@returns {{
15+
replacementOperator: string,
16+
replacementValue: string,
17+
originalOperator: string,
18+
originalValue: string,
19+
} | undefined}
20+
*/
21+
function getReplacement(binaryExpression) {
22+
const {operator, right} = binaryExpression;
23+
24+
if (operator === '<' && isZero(right)) {
25+
return {
26+
replacementOperator: '===',
27+
replacementValue: '-1',
28+
originalOperator: operator,
29+
originalValue: '0',
30+
};
31+
}
32+
33+
if (operator === '>' && isNegativeOne(right)) {
34+
return {
35+
replacementOperator: '!==',
36+
replacementValue: '-1',
37+
originalOperator: operator,
38+
originalValue: '-1',
39+
};
40+
}
41+
42+
if (operator === '>=' && isZero(right)) {
43+
return {
44+
replacementOperator: '!==',
45+
replacementValue: '-1',
46+
originalOperator: operator,
47+
originalValue: '0',
48+
};
49+
}
50+
}
51+
52+
/** @param {import('eslint').Rule.RuleContext} context */
53+
const create = context => ({
54+
/** @param {import('estree').VariableDeclarator} variableDeclarator */
55+
* VariableDeclarator(variableDeclarator) {
56+
if (!(
57+
variableDeclarator.parent.type === 'VariableDeclaration'
58+
&& variableDeclarator.parent.kind === 'const'
59+
&& variableDeclarator.id.type === 'Identifier'
60+
&& isMethodCall(variableDeclarator.init, {methods: ['indexOf', 'lastIndexOf', 'findIndex', 'findLastIndex']})
61+
)) {
62+
return;
63+
}
64+
65+
const variableIdentifier = variableDeclarator.id;
66+
const variables = context.sourceCode.getDeclaredVariables(variableDeclarator);
67+
const [variable] = variables;
68+
69+
// Just for safety
70+
if (
71+
variables.length !== 1
72+
|| variable.identifiers.length !== 1
73+
|| variable.identifiers[0] !== variableIdentifier
74+
) {
75+
return;
76+
}
77+
78+
for (const {identifier} of variable.references) {
79+
/** @type {{parent: import('estree').BinaryExpression}} */
80+
const binaryExpression = identifier.parent;
81+
82+
if (binaryExpression.type !== 'BinaryExpression' || binaryExpression.left !== identifier) {
83+
continue;
84+
}
85+
86+
const replacement = getReplacement(binaryExpression);
87+
88+
if (!replacement) {
89+
return;
90+
}
91+
92+
const {left, operator, right} = binaryExpression;
93+
const {sourceCode} = context;
94+
95+
const operatorToken = sourceCode.getTokenAfter(
96+
left,
97+
token => token.type === 'Punctuator' && token.value === operator,
98+
);
99+
100+
yield {
101+
node: binaryExpression,
102+
loc: toLocation([operatorToken.range[0], right.range[1]], sourceCode),
103+
messageId: MESSAGE_ID,
104+
data: {
105+
...replacement,
106+
existenceOrNonExistence: `${replacement.replacementOperator === '===' ? 'non-' : ''}existence`,
107+
},
108+
* fix(fixer) {
109+
yield fixer.replaceText(operatorToken, replacement.replacementOperator);
110+
111+
if (replacement.replacementValue !== replacement.originalValue) {
112+
yield fixer.replaceText(right, replacement.replacementValue);
113+
}
114+
},
115+
};
116+
}
117+
},
118+
});
119+
120+
/** @type {import('eslint').Rule.RuleModule} */
121+
module.exports = {
122+
create,
123+
meta: {
124+
type: 'problem',
125+
docs: {
126+
description:
127+
'Enforce consistent style for element existence checks with `indexOf()`, `lastIndexOf()`, `findIndex()`, and `findLastIndex()`.',
128+
recommended: true,
129+
},
130+
fixable: 'code',
131+
messages,
132+
},
133+
};

rules/prefer-includes.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22
const isMethodNamed = require('./utils/is-method-named.js');
33
const simpleArraySearchRule = require('./shared/simple-array-search-rule.js');
4-
const {isLiteral} = require('./ast/index.js');
4+
const {isLiteral, isNegativeOne} = require('./ast/index.js');
55

66
const MESSAGE_ID = 'prefer-includes';
77
const messages = {
@@ -10,7 +10,6 @@ const messages = {
1010
// Ignore `{_,lodash,underscore}.{indexOf,lastIndexOf}`
1111
const ignoredVariables = new Set(['_', 'lodash', 'underscore']);
1212
const isIgnoredTarget = node => node.type === 'Identifier' && ignoredVariables.has(node.name);
13-
const isNegativeOne = node => node.type === 'UnaryExpression' && node.operator === '-' && node.argument && node.argument.type === 'Literal' && node.argument.value === 1;
1413
const isLiteralZero = node => isLiteral(node, 0);
1514
const isNegativeResult = node => ['===', '==', '<'].includes(node.operator);
1615

rules/utils/resolve-variable-name.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
Finds a variable named `name` in the scope `scope` (or it's parents).
55
66
@param {string} name - The variable name to be resolve.
7-
@param {Scope} scope - The scope to look for the variable in.
8-
@returns {Variable?} - The found variable, if any.
7+
@param {import('eslint').Scope.Scope} scope - The scope to look for the variable in.
8+
@returns {import('eslint').Scope.Variable | void} - The found variable, if any.
99
*/
1010
module.exports = function resolveVariableName(name, scope) {
1111
while (scope) {

0 commit comments

Comments
 (0)