-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(eslint-plugin): add rule prefer-find #8216
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
Merged
JoshuaKGoldberg
merged 15 commits into
typescript-eslint:main
from
kirkwaiblinger:prefer-find
Feb 3, 2024
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
57befc5
Add rule prefer-find
kirkwaiblinger 23933e6
address lots of stuff
kirkwaiblinger 7637266
remove console statement
kirkwaiblinger 1ab0477
tweaks
kirkwaiblinger 26a3d69
extract fix to function
kirkwaiblinger eb90e11
improve behavior around nulls
kirkwaiblinger 8d74e43
add comments around array indexing checks
kirkwaiblinger 6fb63d8
messages were backwards
kirkwaiblinger 8ac6fdd
filter syntax
kirkwaiblinger 9f39e63
formatting
kirkwaiblinger 3b4a611
add extra comma operator test
kirkwaiblinger 702116c
pr feedback round 2
kirkwaiblinger a2a832d
Fix the the
kirkwaiblinger c5442c0
fix up imports
kirkwaiblinger b3e6db3
address intersections of arrays
kirkwaiblinger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
--- | ||
description: 'Enforce the use of Array.prototype.find() over Array.prototype.filter() followed by [0] when looking for a single result.' | ||
--- | ||
|
||
> 🛑 This file is source code, not the primary documentation location! 🛑 | ||
> | ||
> See **https://typescript-eslint.io/rules/prefer-find** for documentation. | ||
|
||
When searching for the first item in an array matching a condition, it may be tempting to use code like `arr.filter(x => x > 0)[0]`. | ||
However, it is simpler to use [Array.prototype.find()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) instead, `arr.find(x => x > 0)`, which also returns the first entry matching a condition. | ||
Because the `.find()` only needs to execute the callback until it finds a match, it's also more efficient. | ||
|
||
:::info | ||
|
||
Beware the difference in short-circuiting behavior between the approaches. | ||
`.find()` will only execute the callback on array elements until it finds a match, whereas `.filter()` executes the callback for all array elements. | ||
Therefore, when fixing errors from this rule, be sure that your `.filter()` callbacks do not have side effects. | ||
|
||
::: | ||
|
||
<!--tabs--> | ||
|
||
### ❌ Incorrect | ||
|
||
```ts | ||
[1, 2, 3].filter(x => x > 1)[0]; | ||
|
||
[1, 2, 3].filter(x => x > 1).at(0); | ||
``` | ||
|
||
### ✅ Correct | ||
|
||
```ts | ||
[1, 2, 3].find(x => x > 1); | ||
``` | ||
|
||
## When Not To Use It | ||
|
||
If you intentionally use patterns like `.filter(callback)[0]` to execute side effects in `callback` on all array elements, you will want to avoid this rule. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,320 @@ | ||
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; | ||
import { AST_NODE_TYPES } from '@typescript-eslint/utils'; | ||
import { getScope, getSourceCode } from '@typescript-eslint/utils/eslint-utils'; | ||
import type { | ||
RuleFix, | ||
Scope, | ||
SourceCode, | ||
} from '@typescript-eslint/utils/ts-eslint'; | ||
import * as tsutils from 'ts-api-utils'; | ||
import type { Type } from 'typescript'; | ||
|
||
import { | ||
createRule, | ||
getConstrainedTypeAtLocation, | ||
getParserServices, | ||
getStaticValue, | ||
nullThrows, | ||
} from '../util'; | ||
|
||
export default createRule({ | ||
name: 'prefer-find', | ||
meta: { | ||
docs: { | ||
description: | ||
'Enforce the use of Array.prototype.find() over Array.prototype.filter() followed by [0] when looking for a single result', | ||
requiresTypeChecking: true, | ||
}, | ||
messages: { | ||
preferFind: 'Prefer .find(...) instead of .filter(...)[0].', | ||
preferFindSuggestion: 'Use .find(...) instead of .filter(...)[0].', | ||
}, | ||
schema: [], | ||
type: 'suggestion', | ||
hasSuggestions: true, | ||
}, | ||
|
||
defaultOptions: [], | ||
|
||
create(context) { | ||
const globalScope = getScope(context); | ||
const services = getParserServices(context); | ||
const checker = services.program.getTypeChecker(); | ||
|
||
interface FilterExpressionData { | ||
isBracketSyntaxForFilter: boolean; | ||
filterNode: TSESTree.Node; | ||
} | ||
|
||
function parseIfArrayFilterExpression( | ||
expression: TSESTree.Expression, | ||
): FilterExpressionData | undefined { | ||
if (expression.type === AST_NODE_TYPES.SequenceExpression) { | ||
// Only the last expression in (a, b, [1, 2, 3].filter(condition))[0] matters | ||
const lastExpression = nullThrows( | ||
expression.expressions.at(-1), | ||
'Expected to have more than zero expressions in a sequence expression', | ||
); | ||
return parseIfArrayFilterExpression(lastExpression); | ||
} | ||
|
||
if (expression.type === AST_NODE_TYPES.ChainExpression) { | ||
return parseIfArrayFilterExpression(expression.expression); | ||
} | ||
|
||
// Check if it looks like <<stuff>>(...), but not <<stuff>>?.(...) | ||
if ( | ||
expression.type === AST_NODE_TYPES.CallExpression && | ||
!expression.optional | ||
) { | ||
const callee = expression.callee; | ||
// Check if it looks like <<stuff>>.filter(...) or <<stuff>>['filter'](...), | ||
// or the optional chaining variants. | ||
if (callee.type === AST_NODE_TYPES.MemberExpression) { | ||
const isBracketSyntaxForFilter = callee.computed; | ||
if (isStaticMemberAccessOfValue(callee, 'filter', globalScope)) { | ||
const filterNode = callee.property; | ||
|
||
const filteredObjectType = getConstrainedTypeAtLocation( | ||
services, | ||
callee.object, | ||
); | ||
|
||
// As long as the object is a (possibly nullable) array, | ||
// this is an Array.prototype.filter expression. | ||
if (isArrayish(filteredObjectType)) { | ||
return { | ||
isBracketSyntaxForFilter, | ||
filterNode, | ||
}; | ||
} | ||
} | ||
} | ||
} | ||
|
||
return undefined; | ||
} | ||
|
||
/** | ||
* Tells whether the type is a possibly nullable array/tuple or union thereof. | ||
*/ | ||
function isArrayish(type: Type): boolean { | ||
let isAtLeastOneArrayishComponent = false; | ||
for (const unionPart of tsutils.unionTypeParts(type)) { | ||
bradzacher marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if ( | ||
bradzacher marked this conversation as resolved.
Show resolved
Hide resolved
|
||
tsutils.isIntrinsicNullType(unionPart) || | ||
tsutils.isIntrinsicUndefinedType(unionPart) | ||
) { | ||
continue; | ||
} | ||
|
||
// apparently checker.isArrayType(T[] & S[]) => false. | ||
// so we need to check the intersection parts individually. | ||
const isArrayOrIntersectionThereof = tsutils | ||
.intersectionTypeParts(unionPart) | ||
.every( | ||
intersectionPart => | ||
checker.isArrayType(intersectionPart) || | ||
checker.isTupleType(intersectionPart), | ||
); | ||
|
||
if (!isArrayOrIntersectionThereof) { | ||
// There is a non-array, non-nullish type component, | ||
// so it's not an array. | ||
return false; | ||
} | ||
|
||
isAtLeastOneArrayishComponent = true; | ||
} | ||
|
||
return isAtLeastOneArrayishComponent; | ||
} | ||
|
||
function getObjectIfArrayAtExpression( | ||
node: TSESTree.CallExpression, | ||
): TSESTree.Expression | undefined { | ||
// .at() should take exactly one argument. | ||
if (node.arguments.length !== 1) { | ||
return undefined; | ||
} | ||
|
||
const atArgument = getStaticValue(node.arguments[0], globalScope); | ||
if (atArgument != null && isTreatedAsZeroByArrayAt(atArgument.value)) { | ||
const callee = node.callee; | ||
if ( | ||
callee.type === AST_NODE_TYPES.MemberExpression && | ||
!callee.optional && | ||
isStaticMemberAccessOfValue(callee, 'at', globalScope) | ||
) { | ||
return callee.object; | ||
} | ||
} | ||
|
||
return undefined; | ||
JoshuaKGoldberg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* Implements the algorithm for array indexing by `.at()` method. | ||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at#parameters | ||
*/ | ||
function isTreatedAsZeroByArrayAt(value: unknown): boolean { | ||
const asNumber = Number(value); | ||
|
||
if (isNaN(asNumber)) { | ||
return true; | ||
} | ||
|
||
return Math.trunc(asNumber) === 0; | ||
} | ||
|
||
function isMemberAccessOfZero( | ||
node: TSESTree.MemberExpressionComputedName, | ||
): boolean { | ||
const property = getStaticValue(node.property, globalScope); | ||
// Check if it looks like <<stuff>>[0] or <<stuff>>['0'], but not <<stuff>>?.[0] | ||
return ( | ||
!node.optional && | ||
property != null && | ||
isTreatedAsZeroByMemberAccess(property.value) | ||
); | ||
} | ||
|
||
/** | ||
* Implements the algorithm for array indexing by member operator. | ||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#array_indices | ||
*/ | ||
function isTreatedAsZeroByMemberAccess(value: unknown): boolean { | ||
return String(value) === '0'; | ||
} | ||
|
||
function generateFixToRemoveArrayElementAccess( | ||
fixer: TSESLint.RuleFixer, | ||
arrayNode: TSESTree.Expression, | ||
wholeExpressionBeingFlagged: TSESTree.Expression, | ||
sourceCode: SourceCode, | ||
): RuleFix { | ||
const tokenToStartDeletingFrom = nullThrows( | ||
// The next `.` or `[` is what we're looking for. | ||
// think of (...).at(0) or (...)[0] or even (...)["at"](0). | ||
sourceCode.getTokenAfter( | ||
arrayNode, | ||
token => token.value === '.' || token.value === '[', | ||
), | ||
'Expected to find a member access token!', | ||
); | ||
return fixer.removeRange([ | ||
tokenToStartDeletingFrom.range[0], | ||
wholeExpressionBeingFlagged.range[1], | ||
]); | ||
} | ||
|
||
function generateFixToReplaceFilterWithFind( | ||
fixer: TSESLint.RuleFixer, | ||
filterExpression: FilterExpressionData, | ||
): TSESLint.RuleFix { | ||
return fixer.replaceText( | ||
filterExpression.filterNode, | ||
filterExpression.isBracketSyntaxForFilter ? '"find"' : 'find', | ||
); | ||
} | ||
|
||
return { | ||
// This query will be used to find things like `filteredResults.at(0)`. | ||
CallExpression(node): void { | ||
const object = getObjectIfArrayAtExpression(node); | ||
if (object) { | ||
const filterExpression = parseIfArrayFilterExpression(object); | ||
if (filterExpression) { | ||
context.report({ | ||
node, | ||
messageId: 'preferFind', | ||
suggest: [ | ||
{ | ||
messageId: 'preferFindSuggestion', | ||
fix: (fixer): TSESLint.RuleFix[] => { | ||
const sourceCode = getSourceCode(context); | ||
return [ | ||
generateFixToReplaceFilterWithFind( | ||
fixer, | ||
filterExpression, | ||
), | ||
// Get rid of the .at(0) or ['at'](0). | ||
generateFixToRemoveArrayElementAccess( | ||
fixer, | ||
object, | ||
node, | ||
sourceCode, | ||
), | ||
]; | ||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
} | ||
}, | ||
|
||
// This query will be used to find things like `filteredResults[0]`. | ||
// | ||
// Note: we're always looking for array member access to be "computed", | ||
// i.e. `filteredResults[0]`, since `filteredResults.0` isn't a thing. | ||
['MemberExpression[computed=true]']( | ||
node: TSESTree.MemberExpressionComputedName, | ||
): void { | ||
if (isMemberAccessOfZero(node)) { | ||
const object = node.object; | ||
const filterExpression = parseIfArrayFilterExpression(object); | ||
if (filterExpression) { | ||
context.report({ | ||
node, | ||
messageId: 'preferFind', | ||
suggest: [ | ||
{ | ||
messageId: 'preferFindSuggestion', | ||
fix: (fixer): TSESLint.RuleFix[] => { | ||
const sourceCode = context.sourceCode; | ||
return [ | ||
generateFixToReplaceFilterWithFind( | ||
fixer, | ||
filterExpression, | ||
), | ||
// Get rid of the [0]. | ||
generateFixToRemoveArrayElementAccess( | ||
fixer, | ||
object, | ||
node, | ||
sourceCode, | ||
), | ||
]; | ||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
} | ||
}, | ||
}; | ||
}, | ||
}); | ||
|
||
/** | ||
* Answers whether the member expression looks like | ||
* `x.memberName`, `x['memberName']`, | ||
* or even `const mn = 'memberName'; x[mn]` (or optional variants thereof). | ||
*/ | ||
function isStaticMemberAccessOfValue( | ||
memberExpression: | ||
| TSESTree.MemberExpressionComputedName | ||
| TSESTree.MemberExpressionNonComputedName, | ||
value: string, | ||
scope?: Scope.Scope | undefined, | ||
): boolean { | ||
if (!memberExpression.computed) { | ||
// x.memberName case. | ||
return memberExpression.property.name === value; | ||
} | ||
|
||
// x['memberName'] cases. | ||
const staticValueResult = getStaticValue(memberExpression.property, scope); | ||
return staticValueResult != null && value === staticValueResult.value; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.