UNPKG

eslint-plugin-unicorn-x

Version:
553 lines (466 loc) 13.7 kB
import { getStaticValue, isCommaToken, hasSideEffect, } from '@eslint-community/eslint-utils'; import { getParenthesizedRange, getParenthesizedText, needsSemicolon, isNodeMatches, isMethodNamed, hasOptionalChainElement, } from './utils/index.js'; import {removeMethodCall} from './fix/index.js'; import {isLiteral} from './ast/index.js'; import { isMethodCall, memberExpressionHasObject, memberExpressionHasProperty, callExpressionHasArguments, callExpressionHasSpread, } from './ast/guards.js'; const ERROR_ARRAY_FROM = 'array-from'; const ERROR_ARRAY_CONCAT = 'array-concat'; const ERROR_ARRAY_SLICE = 'array-slice'; const ERROR_ARRAY_TO_SPLICED = 'array-to-spliced'; const ERROR_STRING_SPLIT = 'string-split'; const SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE = 'argument-is-spreadable'; const SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE = 'argument-is-not-spreadable'; const SUGGESTION_CONCAT_TEST_ARGUMENT = 'test-argument'; const SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS = 'spread-all-arguments'; const SUGGESTION_USE_SPREAD = 'use-spread'; const messages = { [ERROR_ARRAY_FROM]: 'Prefer the spread operator over `Array.from(…)`.', [ERROR_ARRAY_CONCAT]: 'Prefer the spread operator over `Array#concat(…)`.', [ERROR_ARRAY_SLICE]: 'Prefer the spread operator over `Array#slice()`.', [ERROR_ARRAY_TO_SPLICED]: 'Prefer the spread operator over `Array#toSpliced()`.', [ERROR_STRING_SPLIT]: "Prefer the spread operator over `String#split('')`.", [SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE]: 'First argument is an `array`.', [SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE]: 'First argument is not an `array`.', [SUGGESTION_CONCAT_TEST_ARGUMENT]: 'Test first argument with `Array.isArray(…)`.', [SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS]: 'Spread all unknown arguments`.', [SUGGESTION_USE_SPREAD]: 'Use `...` operator.', }; const ignoredSliceCallee = ['arrayBuffer', 'blob', 'buffer', 'file', 'this']; const isArrayLiteral = (node) => node.type === 'ArrayExpression'; const isArrayLiteralHasTrailingComma = (node, sourceCode) => { if (node.elements.length === 0) { return false; } return isCommaToken(sourceCode.getLastToken(node, 1)); }; function fixConcat(node, sourceCode, fixableArguments) { const array = node.callee.object; const concatCallArguments = node.arguments; const arrayParenthesizedRange = getParenthesizedRange(array, sourceCode); const arrayIsArrayLiteral = isArrayLiteral(array); const arrayHasTrailingComma = arrayIsArrayLiteral && isArrayLiteralHasTrailingComma(array, sourceCode); const getArrayLiteralElementsText = (node, keepTrailingComma) => { if ( !keepTrailingComma && isArrayLiteralHasTrailingComma(node, sourceCode) ) { const start = sourceCode.getRange(node)[0] + 1; const [end] = sourceCode.getRange(sourceCode.getLastToken(node, 1)); return sourceCode.text.slice(start, end); } return sourceCode.getText(node, -1, -1); }; const getFixedText = () => { const nonEmptyArguments = fixableArguments.filter( ({node, isArrayLiteral}) => !isArrayLiteral || node.elements.length > 0, ); const lastArgument = nonEmptyArguments.at(-1); let text = nonEmptyArguments .map(({node, isArrayLiteral, isSpreadable, testArgument}) => { if (isArrayLiteral) { return getArrayLiteralElementsText(node, node === lastArgument.node); } let text = getParenthesizedText(node, sourceCode); if (testArgument) { return `...(Array.isArray(${text}) ? ${text} : [${text}])`; } if (isSpreadable) { text = `...${text}`; } return text || ' '; }) .join(', '); if (!text) { return ''; } if (arrayIsArrayLiteral) { if (array.elements.length > 0) { text = ` ${text}`; if (!arrayHasTrailingComma) { text = `,${text}`; } if ( arrayHasTrailingComma && (!lastArgument.isArrayLiteral || !isArrayLiteralHasTrailingComma(lastArgument.node, sourceCode)) ) { text = `${text},`; } } } else { text = `, ${text}`; } return text; }; function removeArguments(fixer) { const [firstArgument] = concatCallArguments; const lastArgument = concatCallArguments[fixableArguments.length - 1]; const [start] = getParenthesizedRange(firstArgument, sourceCode); let [, end] = sourceCode.getRange( sourceCode.getTokenAfter(lastArgument, isCommaToken), ); const textAfter = sourceCode.text.slice(end); const [leadingSpaces] = textAfter.match(/^\s*/); end += leadingSpaces.length; return fixer.removeRange([start, end]); } return function* (fixer) { // Fixed code always starts with `[` if ( !arrayIsArrayLiteral && needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[') ) { yield fixer.insertTextBefore(node, ';'); } if (concatCallArguments.length - fixableArguments.length === 0) { yield* removeMethodCall(fixer, node, sourceCode); } else { yield removeArguments(fixer); } const text = getFixedText(); if (arrayIsArrayLiteral) { const closingBracketToken = sourceCode.getLastToken(array); yield fixer.insertTextBefore(closingBracketToken, text); } else { // The array is already accessing `.concat`, there should not any case need add extra `()` yield fixer.insertTextBeforeRange(arrayParenthesizedRange, '[...'); yield fixer.insertTextAfterRange(arrayParenthesizedRange, text); yield fixer.insertTextAfterRange(arrayParenthesizedRange, ']'); } }; } const getConcatArgumentSpreadable = (node, scope) => { if (node.type === 'SpreadElement') { return; } if (isArrayLiteral(node)) { return {node, isArrayLiteral: true}; } const result = getStaticValue(node, scope); if (!result) { return; } const isSpreadable = Array.isArray(result.value); return {node, isSpreadable}; }; function getConcatFixableArguments(argumentsList, scope) { const fixableArguments = []; for (const node of argumentsList) { const result = getConcatArgumentSpreadable(node, scope); if (result) { fixableArguments.push(result); } else { break; } } return fixableArguments; } function fixArrayFrom(node, sourceCode) { const [object] = node.arguments; function getObjectText() { if (isArrayLiteral(object)) { return sourceCode.getText(object); } const [start, end] = getParenthesizedRange(object, sourceCode); const text = sourceCode.text.slice(start, end); return `[...${text}]`; } return function* (fixer) { // Fixed code always starts with `[` if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) { yield fixer.insertTextBefore(node, ';'); } const objectText = getObjectText(); yield fixer.replaceText(node, objectText); }; } function methodCallToSpread(node, sourceCode) { return function* (fixer) { // Fixed code always starts with `[` if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) { yield fixer.insertTextBefore(node, ';'); } yield fixer.insertTextBefore(node, '[...'); yield fixer.insertTextAfter(node, ']'); // The array is already accessing `.slice` or `.split`, there should not any case need add extra `()` yield* removeMethodCall(fixer, node, sourceCode); }; } function isClassName(node) { if (node.type === 'MemberExpression') { node = node.property; } if (node.type !== 'Identifier') { return false; } const {name} = node; return /^[A-Z]./.test(name) && name.toUpperCase() !== name; } function isNotArray(node, scope) { if ( node.type === 'TemplateLiteral' || node.type === 'Literal' || node.type === 'BinaryExpression' || isClassName(node) || // `foo.join()` (isMethodNamed(node, 'join') && node.arguments.length <= 1) ) { return true; } const staticValue = getStaticValue(node, scope); if (staticValue && !Array.isArray(staticValue.value)) { return true; } return false; } const checkConcatCall = (node, context) => { const {object} = node.callee; const scope = context.sourceCode.getScope(object); if (isNotArray(object, scope)) { return; } const staticResult = getStaticValue(object, scope); if (staticResult && !Array.isArray(staticResult.value)) { return; } const problem = { node: node.callee.property, messageId: ERROR_ARRAY_CONCAT, }; const fixableArguments = getConcatFixableArguments(node.arguments, scope); if (fixableArguments.length > 0 || node.arguments.length === 0) { problem.fix = fixConcat(node, context.sourceCode, fixableArguments); context.report(problem); return; } const [firstArgument, ...restArguments] = node.arguments; if (firstArgument.type === 'SpreadElement') { context.report(problem); return; } const fixableArgumentsAfterFirstArgument = getConcatFixableArguments( restArguments, scope, ); const suggestions = [ { messageId: SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE, isSpreadable: true, }, { messageId: SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE, isSpreadable: false, }, ]; if (!hasSideEffect(firstArgument, context.sourceCode)) { suggestions.push({ messageId: SUGGESTION_CONCAT_TEST_ARGUMENT, testArgument: true, }); } problem.suggest = suggestions.map( ({messageId, isSpreadable, testArgument}) => ({ messageId, fix: fixConcat( node, context.sourceCode, // When apply suggestion, we also merge fixable arguments after the first one [ { node: firstArgument, isSpreadable, testArgument, }, ...fixableArgumentsAfterFirstArgument, ], ), }), ); if ( fixableArgumentsAfterFirstArgument.length < restArguments.length && restArguments.every(({type}) => type !== 'SpreadElement') ) { problem.suggest.push({ messageId: SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS, fix: fixConcat( node, context.sourceCode, node.arguments.map( (node) => getConcatArgumentSpreadable(node, scope) || { node, isSpreadable: true, }, ), ), }); } context.report(problem); }; const checkSliceCall = (node, context) => { if (isNodeMatches(node.callee.object, ignoredSliceCallee)) { return; } const [firstArgument] = node.arguments; if (firstArgument && !isLiteral(firstArgument, 0)) { return; } context.report({ node: node.callee.property, messageId: ERROR_ARRAY_SLICE, fix: methodCallToSpread(node, context.sourceCode), }); }; const checkSplitCall = (node, context) => { const [separator] = node.arguments; if (!isLiteral(separator, '')) { return; } const string = node.callee.object; const staticValue = getStaticValue( string, context.sourceCode.getScope(string), ); let hasSameResult = false; if (staticValue) { const {value} = staticValue; if (typeof value !== 'string') { return; } // eslint-disable-next-line unicorn-x/prefer-spread const resultBySplit = value.split(''); const resultBySpread = [...value]; hasSameResult = resultBySplit.length === resultBySpread.length && resultBySplit.every( (character, index) => character === resultBySpread[index], ); } const problem = { node: node.callee.property, messageId: ERROR_STRING_SPLIT, }; if (hasSameResult) { problem.fix = methodCallToSpread(node, context.sourceCode); } else { problem.suggest = [ { messageId: SUGGESTION_USE_SPREAD, fix: methodCallToSpread(node, context.sourceCode), }, ]; } context.report(problem); }; /** @param {import('eslint').Rule.RuleContext} context */ const create = (context) => { const {sourceCode} = context; return { CallExpression(node) { if (!isMethodCall(node)) { return; } const hasSpread = callExpressionHasSpread(node); // `Array.from()` if ( memberExpressionHasObject(node.callee, 'Array') && memberExpressionHasProperty(node.callee, 'from') && callExpressionHasArguments(node, 1) && !hasSpread && node.optional !== true && node.callee.optional !== true && // Allow `Array.from({length})` node.arguments[0].type !== 'ObjectExpression' ) { context.report({ node, messageId: ERROR_ARRAY_FROM, fix: fixArrayFrom(node, sourceCode), }); } // `array.concat()` if ( memberExpressionHasProperty(node.callee, 'concat') && node.optional !== true && node.callee.optional !== true ) { checkConcatCall(node, context); } // `array.slice()` if ( memberExpressionHasProperty(node.callee, 'slice') && callExpressionHasArguments(node, [0, 1]) && !hasSpread && node.optional !== true && node.callee.optional !== true && !isArrayLiteral(node.callee.object) && !hasOptionalChainElement(node.callee.object) ) { checkSliceCall(node, context); } // `array.toSpliced()` if ( memberExpressionHasProperty(node.callee, 'toSpliced') && callExpressionHasArguments(node, 0) && !hasSpread && node.optional !== true && node.callee.optional !== true && node.callee.object.type !== 'ArrayExpression' ) { context.report({ node: node.callee.property, messageId: ERROR_ARRAY_TO_SPLICED, fix: methodCallToSpread(node, sourceCode), }); } // `string.split()` if ( memberExpressionHasProperty(node.callee, 'split') && callExpressionHasArguments(node, 1) && !hasSpread && node.optional !== true && node.callee.optional !== true ) { checkSplitCall(node, context); } }, }; }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: "Prefer the spread operator over `Array.from(…)`, `Array#concat(…)`, `Array#{slice,toSpliced}()` and `String#split('')`.", recommended: true, }, fixable: 'code', hasSuggestions: true, messages, }, }; export default config;