prefer-spread.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. 'use strict';
  2. const {isParenthesized, getStaticValue, isCommaToken, hasSideEffect} = require('@eslint-community/eslint-utils');
  3. const {methodCallSelector} = require('./selectors/index.js');
  4. const needsSemicolon = require('./utils/needs-semicolon.js');
  5. const {getParenthesizedRange, getParenthesizedText} = require('./utils/parentheses.js');
  6. const shouldAddParenthesesToSpreadElementArgument = require('./utils/should-add-parentheses-to-spread-element-argument.js');
  7. const {isNodeMatches} = require('./utils/is-node-matches.js');
  8. const {
  9. replaceNodeOrTokenAndSpacesBefore,
  10. removeSpacesAfter,
  11. removeMethodCall,
  12. } = require('./fix/index.js');
  13. const {isLiteral} = require('./ast/index.js');
  14. const isMethodNamed = require('./utils/is-method-named.js');
  15. const ERROR_ARRAY_FROM = 'array-from';
  16. const ERROR_ARRAY_CONCAT = 'array-concat';
  17. const ERROR_ARRAY_SLICE = 'array-slice';
  18. const ERROR_STRING_SPLIT = 'string-split';
  19. const SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE = 'argument-is-spreadable';
  20. const SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE = 'argument-is-not-spreadable';
  21. const SUGGESTION_CONCAT_TEST_ARGUMENT = 'test-argument';
  22. const SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS = 'spread-all-arguments';
  23. const SUGGESTION_USE_SPREAD = 'use-spread';
  24. const messages = {
  25. [ERROR_ARRAY_FROM]: 'Prefer the spread operator over `Array.from(…)`.',
  26. [ERROR_ARRAY_CONCAT]: 'Prefer the spread operator over `Array#concat(…)`.',
  27. [ERROR_ARRAY_SLICE]: 'Prefer the spread operator over `Array#slice()`.',
  28. [ERROR_STRING_SPLIT]: 'Prefer the spread operator over `String#split(\'\')`.',
  29. [SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE]: 'First argument is an `array`.',
  30. [SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE]: 'First argument is not an `array`.',
  31. [SUGGESTION_CONCAT_TEST_ARGUMENT]: 'Test first argument with `Array.isArray(…)`.',
  32. [SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS]: 'Spread all unknown arguments`.',
  33. [SUGGESTION_USE_SPREAD]: 'Use `...` operator.',
  34. };
  35. const arrayFromCallSelector = [
  36. methodCallSelector({
  37. object: 'Array',
  38. method: 'from',
  39. minimumArguments: 1,
  40. maximumArguments: 3,
  41. }),
  42. // Allow `Array.from({length})`
  43. '[arguments.0.type!="ObjectExpression"]',
  44. ].join('');
  45. const arrayConcatCallSelector = methodCallSelector('concat');
  46. const arraySliceCallSelector = [
  47. methodCallSelector({
  48. method: 'slice',
  49. minimumArguments: 0,
  50. maximumArguments: 1,
  51. }),
  52. '[callee.object.type!="ArrayExpression"]',
  53. ].join('');
  54. const ignoredSliceCallee = [
  55. 'arrayBuffer',
  56. 'blob',
  57. 'buffer',
  58. 'file',
  59. 'this',
  60. ];
  61. const stringSplitCallSelector = methodCallSelector({
  62. method: 'split',
  63. argumentsLength: 1,
  64. });
  65. const isArrayLiteral = node => node.type === 'ArrayExpression';
  66. const isArrayLiteralHasTrailingComma = (node, sourceCode) => {
  67. if (node.elements.length === 0) {
  68. return false;
  69. }
  70. return isCommaToken(sourceCode.getLastToken(node, 1));
  71. };
  72. function fixConcat(node, sourceCode, fixableArguments) {
  73. const array = node.callee.object;
  74. const concatCallArguments = node.arguments;
  75. const arrayParenthesizedRange = getParenthesizedRange(array, sourceCode);
  76. const arrayIsArrayLiteral = isArrayLiteral(array);
  77. const arrayHasTrailingComma = arrayIsArrayLiteral && isArrayLiteralHasTrailingComma(array, sourceCode);
  78. const getArrayLiteralElementsText = (node, keepTrailingComma) => {
  79. if (
  80. !keepTrailingComma
  81. && isArrayLiteralHasTrailingComma(node, sourceCode)
  82. ) {
  83. const start = node.range[0] + 1;
  84. const end = sourceCode.getLastToken(node, 1).range[0];
  85. return sourceCode.text.slice(start, end);
  86. }
  87. return sourceCode.getText(node, -1, -1);
  88. };
  89. const getFixedText = () => {
  90. const nonEmptyArguments = fixableArguments
  91. .filter(({node, isArrayLiteral}) => (!isArrayLiteral || node.elements.length > 0));
  92. const lastArgument = nonEmptyArguments[nonEmptyArguments.length - 1];
  93. let text = nonEmptyArguments
  94. .map(({node, isArrayLiteral, isSpreadable, testArgument}) => {
  95. if (isArrayLiteral) {
  96. return getArrayLiteralElementsText(node, node === lastArgument.node);
  97. }
  98. let text = getParenthesizedText(node, sourceCode);
  99. if (testArgument) {
  100. return `...(Array.isArray(${text}) ? ${text} : [${text}])`;
  101. }
  102. if (isSpreadable) {
  103. if (
  104. !isParenthesized(node, sourceCode)
  105. && shouldAddParenthesesToSpreadElementArgument(node)
  106. ) {
  107. text = `(${text})`;
  108. }
  109. text = `...${text}`;
  110. }
  111. return text || ' ';
  112. })
  113. .join(', ');
  114. if (!text) {
  115. return '';
  116. }
  117. if (arrayIsArrayLiteral) {
  118. if (array.elements.length > 0) {
  119. text = ` ${text}`;
  120. if (!arrayHasTrailingComma) {
  121. text = `,${text}`;
  122. }
  123. if (
  124. arrayHasTrailingComma
  125. && (!lastArgument.isArrayLiteral || !isArrayLiteralHasTrailingComma(lastArgument.node, sourceCode))
  126. ) {
  127. text = `${text},`;
  128. }
  129. }
  130. } else {
  131. text = `, ${text}`;
  132. }
  133. return text;
  134. };
  135. function removeArguments(fixer) {
  136. const [firstArgument] = concatCallArguments;
  137. const lastArgument = concatCallArguments[fixableArguments.length - 1];
  138. const [start] = getParenthesizedRange(firstArgument, sourceCode);
  139. let [, end] = sourceCode.getTokenAfter(lastArgument, isCommaToken).range;
  140. const textAfter = sourceCode.text.slice(end);
  141. const [leadingSpaces] = textAfter.match(/^\s*/);
  142. end += leadingSpaces.length;
  143. return fixer.replaceTextRange([start, end], '');
  144. }
  145. return function * (fixer) {
  146. // Fixed code always starts with `[`
  147. if (
  148. !arrayIsArrayLiteral
  149. && needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')
  150. ) {
  151. yield fixer.insertTextBefore(node, ';');
  152. }
  153. if (concatCallArguments.length - fixableArguments.length === 0) {
  154. yield * removeMethodCall(fixer, node, sourceCode);
  155. } else {
  156. yield removeArguments(fixer);
  157. }
  158. const text = getFixedText();
  159. if (arrayIsArrayLiteral) {
  160. const closingBracketToken = sourceCode.getLastToken(array);
  161. yield fixer.insertTextBefore(closingBracketToken, text);
  162. } else {
  163. // The array is already accessing `.concat`, there should not any case need add extra `()`
  164. yield fixer.insertTextBeforeRange(arrayParenthesizedRange, '[...');
  165. yield fixer.insertTextAfterRange(arrayParenthesizedRange, text);
  166. yield fixer.insertTextAfterRange(arrayParenthesizedRange, ']');
  167. }
  168. };
  169. }
  170. const getConcatArgumentSpreadable = (node, scope) => {
  171. if (node.type === 'SpreadElement') {
  172. return;
  173. }
  174. if (isArrayLiteral(node)) {
  175. return {node, isArrayLiteral: true};
  176. }
  177. const result = getStaticValue(node, scope);
  178. if (!result) {
  179. return;
  180. }
  181. const isSpreadable = Array.isArray(result.value);
  182. return {node, isSpreadable};
  183. };
  184. function getConcatFixableArguments(argumentsList, scope) {
  185. const fixableArguments = [];
  186. for (const node of argumentsList) {
  187. const result = getConcatArgumentSpreadable(node, scope);
  188. if (result) {
  189. fixableArguments.push(result);
  190. } else {
  191. break;
  192. }
  193. }
  194. return fixableArguments;
  195. }
  196. function fixArrayFrom(node, sourceCode) {
  197. const [object] = node.arguments;
  198. function getObjectText() {
  199. if (isArrayLiteral(object)) {
  200. return sourceCode.getText(object);
  201. }
  202. const [start, end] = getParenthesizedRange(object, sourceCode);
  203. let text = sourceCode.text.slice(start, end);
  204. if (
  205. !isParenthesized(object, sourceCode)
  206. && shouldAddParenthesesToSpreadElementArgument(object)
  207. ) {
  208. text = `(${text})`;
  209. }
  210. return `[...${text}]`;
  211. }
  212. function * removeObject(fixer) {
  213. yield * replaceNodeOrTokenAndSpacesBefore(object, '', fixer, sourceCode);
  214. const commaToken = sourceCode.getTokenAfter(object, isCommaToken);
  215. yield * replaceNodeOrTokenAndSpacesBefore(commaToken, '', fixer, sourceCode);
  216. yield removeSpacesAfter(commaToken, sourceCode, fixer);
  217. }
  218. return function * (fixer) {
  219. // Fixed code always starts with `[`
  220. if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) {
  221. yield fixer.insertTextBefore(node, ';');
  222. }
  223. const objectText = getObjectText();
  224. if (node.arguments.length === 1) {
  225. yield fixer.replaceText(node, objectText);
  226. return;
  227. }
  228. // `Array.from(object, mapFunction, thisArgument)` -> `[...object].map(mapFunction, thisArgument)`
  229. yield fixer.replaceText(node.callee.object, objectText);
  230. yield fixer.replaceText(node.callee.property, 'map');
  231. yield * removeObject(fixer);
  232. };
  233. }
  234. function methodCallToSpread(node, sourceCode) {
  235. return function * (fixer) {
  236. // Fixed code always starts with `[`
  237. if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) {
  238. yield fixer.insertTextBefore(node, ';');
  239. }
  240. yield fixer.insertTextBefore(node, '[...');
  241. yield fixer.insertTextAfter(node, ']');
  242. // The array is already accessing `.slice` or `.split`, there should not any case need add extra `()`
  243. yield * removeMethodCall(fixer, node, sourceCode);
  244. };
  245. }
  246. function isClassName(node) {
  247. if (node.type === 'MemberExpression') {
  248. node = node.property;
  249. }
  250. if (node.type !== 'Identifier') {
  251. return false;
  252. }
  253. const {name} = node;
  254. return /^[A-Z]./.test(name) && name.toUpperCase() !== name;
  255. }
  256. function isNotArray(node, scope) {
  257. if (
  258. node.type === 'TemplateLiteral'
  259. || node.type === 'Literal'
  260. || node.type === 'BinaryExpression'
  261. || isClassName(node)
  262. // `foo.join()`
  263. || (isMethodNamed(node, 'join') && node.arguments.length <= 1)
  264. ) {
  265. return true;
  266. }
  267. const staticValue = getStaticValue(node, scope);
  268. if (staticValue && !Array.isArray(staticValue.value)) {
  269. return true;
  270. }
  271. return false;
  272. }
  273. /** @param {import('eslint').Rule.RuleContext} context */
  274. const create = context => {
  275. const sourceCode = context.getSourceCode();
  276. return {
  277. [arrayFromCallSelector](node) {
  278. return {
  279. node,
  280. messageId: ERROR_ARRAY_FROM,
  281. fix: fixArrayFrom(node, sourceCode),
  282. };
  283. },
  284. [arrayConcatCallSelector](node) {
  285. const {object} = node.callee;
  286. if (isNotArray(object, context.getScope())) {
  287. return;
  288. }
  289. const scope = context.getScope();
  290. const staticResult = getStaticValue(object, scope);
  291. if (staticResult && !Array.isArray(staticResult.value)) {
  292. return;
  293. }
  294. const problem = {
  295. node: node.callee.property,
  296. messageId: ERROR_ARRAY_CONCAT,
  297. };
  298. const fixableArguments = getConcatFixableArguments(node.arguments, scope);
  299. if (fixableArguments.length > 0 || node.arguments.length === 0) {
  300. problem.fix = fixConcat(node, sourceCode, fixableArguments);
  301. return problem;
  302. }
  303. const [firstArgument, ...restArguments] = node.arguments;
  304. if (firstArgument.type === 'SpreadElement') {
  305. return problem;
  306. }
  307. const fixableArgumentsAfterFirstArgument = getConcatFixableArguments(restArguments, scope);
  308. const suggestions = [
  309. {
  310. messageId: SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE,
  311. isSpreadable: true,
  312. },
  313. {
  314. messageId: SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE,
  315. isSpreadable: false,
  316. },
  317. ];
  318. if (!hasSideEffect(firstArgument, sourceCode)) {
  319. suggestions.push({
  320. messageId: SUGGESTION_CONCAT_TEST_ARGUMENT,
  321. testArgument: true,
  322. });
  323. }
  324. problem.suggest = suggestions.map(({messageId, isSpreadable, testArgument}) => ({
  325. messageId,
  326. fix: fixConcat(
  327. node,
  328. sourceCode,
  329. // When apply suggestion, we also merge fixable arguments after the first one
  330. [
  331. {
  332. node: firstArgument,
  333. isSpreadable,
  334. testArgument,
  335. },
  336. ...fixableArgumentsAfterFirstArgument,
  337. ],
  338. ),
  339. }));
  340. if (
  341. fixableArgumentsAfterFirstArgument.length < restArguments.length
  342. && restArguments.every(({type}) => type !== 'SpreadElement')
  343. ) {
  344. problem.suggest.push({
  345. messageId: SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS,
  346. fix: fixConcat(
  347. node,
  348. sourceCode,
  349. node.arguments.map(node => getConcatArgumentSpreadable(node, scope) || {node, isSpreadable: true}),
  350. ),
  351. });
  352. }
  353. return problem;
  354. },
  355. [arraySliceCallSelector](node) {
  356. if (isNodeMatches(node.callee.object, ignoredSliceCallee)) {
  357. return;
  358. }
  359. const [firstArgument] = node.arguments;
  360. if (firstArgument && !isLiteral(firstArgument, 0)) {
  361. return;
  362. }
  363. return {
  364. node: node.callee.property,
  365. messageId: ERROR_ARRAY_SLICE,
  366. fix: methodCallToSpread(node, sourceCode),
  367. };
  368. },
  369. [stringSplitCallSelector](node) {
  370. const [separator] = node.arguments;
  371. if (!isLiteral(separator, '')) {
  372. return;
  373. }
  374. const string = node.callee.object;
  375. const staticValue = getStaticValue(string, context.getScope());
  376. let hasSameResult = false;
  377. if (staticValue) {
  378. const {value} = staticValue;
  379. if (typeof value !== 'string') {
  380. return;
  381. }
  382. // eslint-disable-next-line unicorn/prefer-spread
  383. const resultBySplit = value.split('');
  384. const resultBySpread = [...value];
  385. hasSameResult = resultBySplit.length === resultBySpread.length
  386. && resultBySplit.every((character, index) => character === resultBySpread[index]);
  387. }
  388. const problem = {
  389. node: node.callee.property,
  390. messageId: ERROR_STRING_SPLIT,
  391. };
  392. if (hasSameResult) {
  393. problem.fix = methodCallToSpread(node, sourceCode);
  394. } else {
  395. problem.suggest = [
  396. {
  397. messageId: SUGGESTION_USE_SPREAD,
  398. fix: methodCallToSpread(node, sourceCode),
  399. },
  400. ];
  401. }
  402. return problem;
  403. },
  404. };
  405. };
  406. /** @type {import('eslint').Rule.RuleModule} */
  407. module.exports = {
  408. create,
  409. meta: {
  410. type: 'suggestion',
  411. docs: {
  412. description: 'Prefer the spread operator over `Array.from(…)`, `Array#concat(…)`, `Array#slice()` and `String#split(\'\')`.',
  413. },
  414. fixable: 'code',
  415. hasSuggestions: true,
  416. messages,
  417. },
  418. };