prefer-array-find.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. 'use strict';
  2. const {isParenthesized, findVariable} = require('@eslint-community/eslint-utils');
  3. const {
  4. not,
  5. methodCallSelector,
  6. notLeftHandSideSelector,
  7. } = require('./selectors/index.js');
  8. const getVariableIdentifiers = require('./utils/get-variable-identifiers.js');
  9. const avoidCapture = require('./utils/avoid-capture.js');
  10. const getScopes = require('./utils/get-scopes.js');
  11. const singular = require('./utils/singular.js');
  12. const {
  13. extendFixRange,
  14. removeMemberExpressionProperty,
  15. removeMethodCall,
  16. renameVariable,
  17. } = require('./fix/index.js');
  18. const ERROR_ZERO_INDEX = 'error-zero-index';
  19. const ERROR_SHIFT = 'error-shift';
  20. const ERROR_POP = 'error-pop';
  21. const ERROR_AT_MINUS_ONE = 'error-at-minus-one';
  22. const ERROR_DESTRUCTURING_DECLARATION = 'error-destructuring-declaration';
  23. const ERROR_DESTRUCTURING_ASSIGNMENT = 'error-destructuring-assignment';
  24. const ERROR_DECLARATION = 'error-variable';
  25. const SUGGESTION_NULLISH_COALESCING_OPERATOR = 'suggest-nullish-coalescing-operator';
  26. const SUGGESTION_LOGICAL_OR_OPERATOR = 'suggest-logical-or-operator';
  27. const messages = {
  28. [ERROR_DECLARATION]: 'Prefer `.find(…)` over `.filter(…)`.',
  29. [ERROR_ZERO_INDEX]: 'Prefer `.find(…)` over `.filter(…)[0]`.',
  30. [ERROR_SHIFT]: 'Prefer `.find(…)` over `.filter(…).shift()`.',
  31. [ERROR_POP]: 'Prefer `.findLast(…)` over `.filter(…).pop()`.',
  32. [ERROR_AT_MINUS_ONE]: 'Prefer `.findLast(…)` over `.filter(…).at(-1)`.',
  33. [ERROR_DESTRUCTURING_DECLARATION]: 'Prefer `.find(…)` over destructuring `.filter(…)`.',
  34. // Same message as `ERROR_DESTRUCTURING_DECLARATION`, but different case
  35. [ERROR_DESTRUCTURING_ASSIGNMENT]: 'Prefer `.find(…)` over destructuring `.filter(…)`.',
  36. [SUGGESTION_NULLISH_COALESCING_OPERATOR]: 'Replace `.filter(…)` with `.find(…) ?? …`.',
  37. [SUGGESTION_LOGICAL_OR_OPERATOR]: 'Replace `.filter(…)` with `.find(…) || …`.',
  38. };
  39. const filterMethodSelectorOptions = {
  40. method: 'filter',
  41. minimumArguments: 1,
  42. maximumArguments: 2,
  43. };
  44. const filterVariableSelector = [
  45. 'VariableDeclaration',
  46. // Exclude `export const foo = [];`
  47. not('ExportNamedDeclaration > .declaration'),
  48. ' > ',
  49. 'VariableDeclarator.declarations',
  50. '[id.type="Identifier"]',
  51. methodCallSelector({
  52. ...filterMethodSelectorOptions,
  53. path: 'init',
  54. }),
  55. ].join('');
  56. const zeroIndexSelector = [
  57. 'MemberExpression',
  58. '[computed!=false]',
  59. '[property.type="Literal"]',
  60. '[property.raw="0"]',
  61. notLeftHandSideSelector(),
  62. methodCallSelector({
  63. ...filterMethodSelectorOptions,
  64. path: 'object',
  65. }),
  66. ].join('');
  67. const shiftSelector = [
  68. methodCallSelector({
  69. method: 'shift',
  70. argumentsLength: 0,
  71. }),
  72. methodCallSelector({
  73. ...filterMethodSelectorOptions,
  74. path: 'callee.object',
  75. }),
  76. ].join('');
  77. const popSelector = [
  78. methodCallSelector({
  79. method: 'pop',
  80. argumentsLength: 0,
  81. }),
  82. methodCallSelector({
  83. ...filterMethodSelectorOptions,
  84. path: 'callee.object',
  85. }),
  86. ].join('');
  87. const atMinusOneSelector = [
  88. methodCallSelector({
  89. method: 'at',
  90. argumentsLength: 1,
  91. }),
  92. '[arguments.0.type="UnaryExpression"]',
  93. '[arguments.0.operator="-"]',
  94. '[arguments.0.prefix]',
  95. '[arguments.0.argument.type="Literal"]',
  96. '[arguments.0.argument.raw=1]',
  97. methodCallSelector({
  98. ...filterMethodSelectorOptions,
  99. path: 'callee.object',
  100. }),
  101. ].join('');
  102. const destructuringDeclaratorSelector = [
  103. 'VariableDeclarator',
  104. '[id.type="ArrayPattern"]',
  105. '[id.elements.length=1]',
  106. '[id.elements.0.type!="RestElement"]',
  107. methodCallSelector({
  108. ...filterMethodSelectorOptions,
  109. path: 'init',
  110. }),
  111. ].join('');
  112. const destructuringAssignmentSelector = [
  113. 'AssignmentExpression',
  114. '[left.type="ArrayPattern"]',
  115. '[left.elements.length=1]',
  116. '[left.elements.0.type!="RestElement"]',
  117. methodCallSelector({
  118. ...filterMethodSelectorOptions,
  119. path: 'right',
  120. }),
  121. ].join('');
  122. // Need add `()` to the `AssignmentExpression`
  123. // - `ObjectExpression`: `[{foo}] = array.filter(bar)` fix to `{foo} = array.find(bar)`
  124. // - `ObjectPattern`: `[{foo = baz}] = array.filter(bar)`
  125. const assignmentNeedParenthesize = (node, sourceCode) => {
  126. const isAssign = node.type === 'AssignmentExpression';
  127. if (!isAssign || isParenthesized(node, sourceCode)) {
  128. return false;
  129. }
  130. const {left} = getDestructuringLeftAndRight(node);
  131. const [element] = left.elements;
  132. const {type} = element.type === 'AssignmentPattern' ? element.left : element;
  133. return type === 'ObjectExpression' || type === 'ObjectPattern';
  134. };
  135. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table
  136. const hasLowerPrecedence = (node, operator) => (
  137. (node.type === 'LogicalExpression' && (
  138. node.operator === operator
  139. // https://tc39.es/proposal-nullish-coalescing/ says
  140. // `??` has lower precedence than `||`
  141. // But MDN says
  142. // `??` has higher precedence than `||`
  143. || (operator === '||' && node.operator === '??')
  144. || (operator === '??' && (node.operator === '||' || node.operator === '&&'))
  145. ))
  146. || node.type === 'ConditionalExpression'
  147. // Lower than `assignment`, should already parenthesized
  148. /* c8 ignore next */
  149. || node.type === 'AssignmentExpression'
  150. || node.type === 'YieldExpression'
  151. || node.type === 'SequenceExpression'
  152. );
  153. const getDestructuringLeftAndRight = node => {
  154. /* c8 ignore next 3 */
  155. if (!node) {
  156. return {};
  157. }
  158. if (node.type === 'AssignmentExpression') {
  159. return node;
  160. }
  161. if (node.type === 'VariableDeclarator') {
  162. return {left: node.id, right: node.init};
  163. }
  164. return {};
  165. };
  166. function * fixDestructuring(node, sourceCode, fixer) {
  167. const {left} = getDestructuringLeftAndRight(node);
  168. const [element] = left.elements;
  169. const leftText = sourceCode.getText(element.type === 'AssignmentPattern' ? element.left : element);
  170. yield fixer.replaceText(left, leftText);
  171. // `AssignmentExpression` always starts with `[` or `(`, so we don't need check ASI
  172. if (assignmentNeedParenthesize(node, sourceCode)) {
  173. yield fixer.insertTextBefore(node, '(');
  174. yield fixer.insertTextAfter(node, ')');
  175. }
  176. }
  177. const hasDefaultValue = node => getDestructuringLeftAndRight(node).left.elements[0].type === 'AssignmentPattern';
  178. const fixDestructuringDefaultValue = (node, sourceCode, fixer, operator) => {
  179. const {left, right} = getDestructuringLeftAndRight(node);
  180. const [element] = left.elements;
  181. const defaultValue = element.right;
  182. let defaultValueText = sourceCode.getText(defaultValue);
  183. if (isParenthesized(defaultValue, sourceCode) || hasLowerPrecedence(defaultValue, operator)) {
  184. defaultValueText = `(${defaultValueText})`;
  185. }
  186. return fixer.insertTextAfter(right, ` ${operator} ${defaultValueText}`);
  187. };
  188. const fixDestructuringAndReplaceFilter = (sourceCode, node) => {
  189. const {property} = getDestructuringLeftAndRight(node).right.callee;
  190. let suggest;
  191. let fix;
  192. if (hasDefaultValue(node)) {
  193. suggest = [
  194. {operator: '??', messageId: SUGGESTION_NULLISH_COALESCING_OPERATOR},
  195. {operator: '||', messageId: SUGGESTION_LOGICAL_OR_OPERATOR},
  196. ].map(({messageId, operator}) => ({
  197. messageId,
  198. * fix(fixer) {
  199. yield fixer.replaceText(property, 'find');
  200. yield fixDestructuringDefaultValue(node, sourceCode, fixer, operator);
  201. yield * fixDestructuring(node, sourceCode, fixer);
  202. },
  203. }));
  204. } else {
  205. fix = function * (fixer) {
  206. yield fixer.replaceText(property, 'find');
  207. yield * fixDestructuring(node, sourceCode, fixer);
  208. };
  209. }
  210. return {fix, suggest};
  211. };
  212. const isAccessingZeroIndex = node =>
  213. node.parent.type === 'MemberExpression'
  214. && node.parent.computed === true
  215. && node.parent.object === node
  216. && node.parent.property.type === 'Literal'
  217. && node.parent.property.raw === '0';
  218. const isDestructuringFirstElement = node => {
  219. const {left, right} = getDestructuringLeftAndRight(node.parent);
  220. return left
  221. && right
  222. && right === node
  223. && left.type === 'ArrayPattern'
  224. && left.elements.length === 1
  225. && left.elements[0].type !== 'RestElement';
  226. };
  227. /** @param {import('eslint').Rule.RuleContext} context */
  228. const create = context => {
  229. const sourceCode = context.getSourceCode();
  230. const {
  231. checkFromLast,
  232. } = {
  233. checkFromLast: false,
  234. ...context.options[0],
  235. };
  236. const listeners = {
  237. [zeroIndexSelector](node) {
  238. return {
  239. node: node.object.callee.property,
  240. messageId: ERROR_ZERO_INDEX,
  241. fix: fixer => [
  242. fixer.replaceText(node.object.callee.property, 'find'),
  243. removeMemberExpressionProperty(fixer, node, sourceCode),
  244. ],
  245. };
  246. },
  247. [shiftSelector](node) {
  248. return {
  249. node: node.callee.object.callee.property,
  250. messageId: ERROR_SHIFT,
  251. fix: fixer => [
  252. fixer.replaceText(node.callee.object.callee.property, 'find'),
  253. ...removeMethodCall(fixer, node, sourceCode),
  254. ],
  255. };
  256. },
  257. [destructuringDeclaratorSelector](node) {
  258. return {
  259. node: node.init.callee.property,
  260. messageId: ERROR_DESTRUCTURING_DECLARATION,
  261. ...fixDestructuringAndReplaceFilter(sourceCode, node),
  262. };
  263. },
  264. [destructuringAssignmentSelector](node) {
  265. return {
  266. node: node.right.callee.property,
  267. messageId: ERROR_DESTRUCTURING_ASSIGNMENT,
  268. ...fixDestructuringAndReplaceFilter(sourceCode, node),
  269. };
  270. },
  271. [filterVariableSelector](node) {
  272. const scope = context.getScope();
  273. const variable = findVariable(scope, node.id);
  274. const identifiers = getVariableIdentifiers(variable).filter(identifier => identifier !== node.id);
  275. if (identifiers.length === 0) {
  276. return;
  277. }
  278. const zeroIndexNodes = [];
  279. const destructuringNodes = [];
  280. for (const identifier of identifiers) {
  281. if (isAccessingZeroIndex(identifier)) {
  282. zeroIndexNodes.push(identifier.parent);
  283. } else if (isDestructuringFirstElement(identifier)) {
  284. destructuringNodes.push(identifier.parent);
  285. } else {
  286. return;
  287. }
  288. }
  289. const problem = {
  290. node: node.init.callee.property,
  291. messageId: ERROR_DECLARATION,
  292. };
  293. // `const [foo = bar] = baz` is not fixable
  294. if (!destructuringNodes.some(node => hasDefaultValue(node))) {
  295. problem.fix = function * (fixer) {
  296. yield fixer.replaceText(node.init.callee.property, 'find');
  297. const singularName = singular(node.id.name);
  298. if (singularName) {
  299. // Rename variable to be singularized now that it refers to a single item in the array instead of the entire array.
  300. const singularizedName = avoidCapture(singularName, getScopes(scope));
  301. yield * renameVariable(variable, singularizedName, fixer);
  302. // Prevent possible variable conflicts
  303. yield * extendFixRange(fixer, sourceCode.ast.range);
  304. }
  305. for (const node of zeroIndexNodes) {
  306. yield removeMemberExpressionProperty(fixer, node, sourceCode);
  307. }
  308. for (const node of destructuringNodes) {
  309. yield * fixDestructuring(node, sourceCode, fixer);
  310. }
  311. };
  312. }
  313. return problem;
  314. },
  315. };
  316. if (!checkFromLast) {
  317. return listeners;
  318. }
  319. return Object.assign(listeners, {
  320. [popSelector](node) {
  321. return {
  322. node: node.callee.object.callee.property,
  323. messageId: ERROR_POP,
  324. fix: fixer => [
  325. fixer.replaceText(node.callee.object.callee.property, 'findLast'),
  326. ...removeMethodCall(fixer, node, sourceCode),
  327. ],
  328. };
  329. },
  330. [atMinusOneSelector](node) {
  331. return {
  332. node: node.callee.object.callee.property,
  333. messageId: ERROR_AT_MINUS_ONE,
  334. fix: fixer => [
  335. fixer.replaceText(node.callee.object.callee.property, 'findLast'),
  336. ...removeMethodCall(fixer, node, sourceCode),
  337. ],
  338. };
  339. },
  340. });
  341. };
  342. const schema = [
  343. {
  344. type: 'object',
  345. additionalProperties: false,
  346. properties: {
  347. checkFromLast: {
  348. type: 'boolean',
  349. // TODO: Change default value to `true`, or remove the option when targeting Node.js 18.
  350. default: false,
  351. },
  352. },
  353. },
  354. ];
  355. /** @type {import('eslint').Rule.RuleModule} */
  356. module.exports = {
  357. create,
  358. meta: {
  359. type: 'suggestion',
  360. docs: {
  361. description: 'Prefer `.find(…)` and `.findLast(…)` over the first or last element from `.filter(…)`.',
  362. },
  363. fixable: 'code',
  364. hasSuggestions: true,
  365. schema,
  366. messages,
  367. },
  368. };