Skip to content
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

Add prefer-includes rule - fixes #8 #70

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/rules/prefer-includes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Prefer .includes() over .indexOf() when checking for existence.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap the methods in backticks and drop the . at the end.


Since ESLint has no type analysis we'll have to assume all properties named .indexOf() have a .includes() counterpart.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would form it a bit different:

The rule assume all properties named .indexOf() have a .includes() counterpart, as ESLint doesn't do type analysis. This is luckily true for all built-ins: String#includes, Array#includes, TypedArray#includes, Buffer#includes.


This is luckily true for all builtins: String#includes, Array#includes, TypedArray#includes, Buffer#includes.


## Fail

```js
const str = 'foobar';

str.indexOf('foo') !== -1
str.indexOf('foo') != -1
str.indexOf('foo') > -1
str.indexOf('foo') >= 0
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semicolons

```

```js
const str = 'foobar';

!str.includes === -1
!str.includes == -1
!str.includes < 0
```


## Pass

```js
const str = 'foobar';

str.includes('foo')
```

TODO:

Would also be useful to catch cases where .includes() would be better than a regex:

```js
/\r\n/.test(foo);
```

Could be:

```js
foo.includes('\r\n');
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, includes is not available in Node v4 (nor in some browsers), so it might be a good thing to add a When not to use it section like here.

1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ module.exports = {
'unicorn/throw-new-error': 'error',
'unicorn/number-literal-case': 'error',
'unicorn/no-array-instanceof': 'error',
'unicorn/prefer-includes': 'error',
'unicorn/no-new-buffer': 'error',
'unicorn/no-hex-escape': 'error'
}
Expand Down
116 changes: 116 additions & 0 deletions rules/prefer-includes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict';

const isIndexOfCallExpression = node => {
if (node.type !== 'CallExpression') {
return false;
}

const property = node.callee.property;

return property.name === 'indexOf';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will crash, because property is not always there. Here, you assume that callee will be a MemberExpression (something like a.b()), but it could be anything, like simply a Identifier (a()). Please add indexOf(foo) as a valid test and fix to make sure it won't break.

Also, property may not always be a Identifier, so it may not always have a name. It could be a Literal (where the "name" is found under the value property), or it could be something else. The absence of the name field may not crash with this implementation or cause false positives, but I'd suggest checking that property is an Identifier (or a Literal and compare that accordingly, with new tests :) )

PS : Not that it will apply here because there are a few changes to make anyway, but you could have written this in a more concise way like this

 const isIndexOfCallExpression = node => {
   return node.type === 'CallExpression' &&
     node.callee.property.name === 'indexOf';
};

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I totally understand. Could you provide an example in context where indexOf(foo) wouldn't be a MemberExpression?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function indexOf(input) {
  // ...
}

indexOf(foo);

But in this implementation, you assume that any CallExpression's callee is a MemberExpression, which will cause a crash before you even check whether the property is indexOf. Add indexOf(foo) and a() in your valid tests and the problems should be made clear right awaya IIRC.

};

const isUnaryNotExpression = node => (
node.type === 'UnaryExpression' && node.operator === '!'
);

const isNegativeOne = (operator, value) => operator === '-' && value === 1;

const getSourceCode = (context, node) => (
context.getSourceCode().text.slice(node.range[0], node.range[1])
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better to use context.getSourceCode().getText(node)

);

const report = (context, node, target, pattern) => {
const targetSource = getSourceCode(context, target);
const patternSource = getSourceCode(context, pattern);
context.report({
node,
message: 'Use `.includes()`, not .indexOf(), when checking for existence.',
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use .includes() rather than .indexOf() when checking for existence.

fix: fixer => fixer.replaceText(node, `${targetSource}.includes(${patternSource})`)
});
};

const create = context => ({
BinaryExpression: node => {
const left = node.left;
const right = node.right;

if (isIndexOfCallExpression(left)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find these conditions to be really big, and I would extract them into their own function (you can pass them the context arg so as not to keep it under create).

Another benefit would be that you could make the same comparisons but with inversed expressions -1 !== foo.indexOf(bar). Example: (all names hereafter to be improved 😅)

function checkIndexOf(context, nodeA, nodeB) {
  if (isIndexOfCallExpression(nodeA)) {
    if (nodeB.type === 'UnaryExpression') {
      // ..
    }
  }
}

const create = context => {
  BinaryExpression: node => {
    checkIndexOf(context, node.left, node.right);
    checkIndexOf(context, node.right, node.left);
  }
};

Should you do this (and I would love to see this), you may need to pass the operators you will compare to too, as in the first call, you'll need to check >, and for the second one, you'll need to check against <. Write tests first to make sure you don't get odd results 😅

const target = left.callee.object;
const pattern = left.arguments[0];

if (right.type === 'UnaryExpression') {
const argument = right.argument;

if (argument.type !== 'Literal') {
return false;
}

const value = argument.value;

if (node.operator === '!==' && isNegativeOne(right.operator, value)) {
report(context, node, target, pattern);
}
if (node.operator === '!=' && isNegativeOne(right.operator, value)) {
report(context, node, target, pattern);
}
if (node.operator === '>' && isNegativeOne(right.operator, value)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shorter:

if (['!==', '!=', '>'].indexOf(node.operator) !== -1 && isNegativeOne(right.operator, value)) {
  report(context, node, target, pattern);
}

report(context, node, target, pattern);
}
}

if (right.type !== 'Literal') {
return false;
}

if (node.operator === '>=' && right.value === 0) {
report(context, node, target, pattern);
}

return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (right.type === 'Literal' && node.operator === '>=' && right.value === 0) {
  report(context, node, target, pattern);
}
return;

}

if (isUnaryNotExpression(left)) {
const argument = left.argument;

if (isIndexOfCallExpression(argument)) {
const target = argument.callee.object;
const pattern = argument.arguments[0];

if (right.type === 'UnaryExpression') {
const argument = right.argument;

if (argument.type !== 'Literal') {
return false;
}

const value = argument.value;

if (node.operator === '===' && isNegativeOne(right.operator, value)) {
report(context, node, target, pattern);
}
if (node.operator === '==' && isNegativeOne(right.operator, value)) {
report(context, node, target, pattern);
}
}

if (right.type !== 'Literal') {
return false;
}

if (node.operator === '<' && right.value === 0) {
report(context, node, target, pattern);
}

return false;
}
}
}
});

module.exports = {
create,
meta: {
fixable: 'code'
}
};
69 changes: 69 additions & 0 deletions test/prefer-includes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import test from 'ava';
import avaRuleTester from 'eslint-ava-rule-tester';
import rule from '../rules/prefer-includes';

const ruleTester = avaRuleTester(test, {
env: {
es6: true
}
});

const errors = [{
ruleId: 'prefer-includes',
message: 'Use `.includes()`, not .indexOf(), when checking for existence.'
}];

ruleTester.run('prefer-includes', rule, {
valid: [
'str.indexOf(\'foo\') !== -n',
'str.indexOf(\'foo\') !== 1',
'!str.indexOf(\'foo\') === 1',
'!str.indexOf(\'foo\') === -n',
'str.includes(\'foo\')',
'\'foobar\'.includes(\'foo\')',
'[1,2,3].includes(4)',
'"str.indexOf(\'foo\') !== -1"'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add, just to be sure that nothing odds is happening, the following tests:

`a + b` // you target binary expressions. If your checks have gaps, code like this could crash the plugin or create false positives
`indexOf !== -1`
// And whatever else you feel like adding :D

],
invalid: [
{
code: '\'foobar\'.indexOf(\'foo\') !== -1',
output: '\'foobar\'.includes(\'foo\')',
errors
},
{
code: 'str.indexOf(\'foo\') != -1',
output: 'str.includes(\'foo\')',
errors
},
{
code: 'str.indexOf(\'foo\') > -1',
output: 'str.includes(\'foo\')',
errors
},
{
code: '\'foobar\'.indexOf(\'foo\') >= 0',
output: '\'foobar\'.includes(\'foo\')',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of escaping \' everywhere, you can use backticks around the whole string.

output: `'foobar'.includes('foo')`

errors
},
{
code: '!str.indexOf(\'foo\') === -1',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add another example with two !!: !str.indexOf(\'foo\') === -1. I want to make sure the result is correct.

output: 'str.includes(\'foo\')',
errors
},
{
code: '!str.indexOf(\'foo\') == -1',
output: 'str.includes(\'foo\')',
errors
},
{
code: '!\'foobar\'.indexOf(\'foo\') < 0',
output: '\'foobar\'.includes(\'foo\')',
errors
},
{
code: '[1,2,3].indexOf(4) !== -1',
output: '[1,2,3].includes(4)',
errors
}
]
});