prefer-spread.js 13 KB

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