eslint-plugin-unicorn
Version:
More than 100 powerful ESLint rules
343 lines (303 loc) • 9.33 kB
JavaScript
import {findVariable} from '@eslint-community/eslint-utils';
import {
getParenthesizedText,
isArrayPrototypeProperty,
isNodeMatches,
isNodeMatchesNameOrPath,
isParenthesized,
isSameIdentifier,
needsSemicolon,
shouldAddParenthesesToMemberExpressionObject,
} from './utils/index.js';
import {fixSpaceAroundKeyword} from './fix/index.js';
import {isMethodCall, isCallExpression, isEmptyArrayExpression} from './ast/index.js';
const MESSAGE_ID = 'prefer-array-flat';
const messages = {
[MESSAGE_ID]: 'Prefer `Array#flat()` over `{{description}}` to flatten an array.',
};
// `array.flatMap(x => x)`
const arrayFlatMap = {
getArrayNode: node => node.callee.object,
isOptionalArray: node => node.callee.optional,
description: 'Array#flatMap()',
testFunction(node, context) {
if (!isMethodCall(node, {
method: 'flatMap',
argumentsLength: 1,
optionalCall: false,
})) {
return false;
}
const [firstArgument] = node.arguments;
return (
firstArgument.type === 'ArrowFunctionExpression'
&& !firstArgument.async
&& firstArgument.params.length === 1
&& isSameIdentifier(firstArgument.params[0], firstArgument.body)
&& !isObviouslyNonArrayFlatMapReceiver(node.callee.object, context)
);
},
};
// `array.reduce((a, b) => a.concat(b), [])`
// `array?.reduce((a, b) => a.concat(b), [])`
// `array.reduce((a, b) => [...a, ...b], [])`
// `array?.reduce((a, b) => [...a, ...b], [])`
const arrayReduce = {
testFunction(node) {
if (!isMethodCall(node, {
method: 'reduce',
argumentsLength: 2,
optionalCall: false,
})) {
return false;
}
const [firstArgument, secondArgument] = node.arguments;
if (!(
firstArgument.type === 'ArrowFunctionExpression'
&& !firstArgument.async
&& firstArgument.params.length === 2
&& isEmptyArrayExpression(secondArgument)
)) {
return false;
}
const firstArgumentBody = firstArgument.body;
const [firstParameter, secondParameter] = firstArgument.params;
return (
// `(a, b) => a.concat(b)`
(
isMethodCall(firstArgumentBody, {
method: 'concat',
argumentsLength: 1,
optionalCall: false,
optionalMember: false,
})
&& isSameIdentifier(firstParameter, firstArgumentBody.callee.object)
&& isSameIdentifier(secondParameter, firstArgumentBody.arguments[0])
)
// `(a, b) => [...a, ...b]`
|| (
firstArgumentBody.type === 'ArrayExpression'
&& firstArgumentBody.elements.length === 2
&& firstArgumentBody.elements.every((node, index) =>
node?.type === 'SpreadElement'
&& node.argument.type === 'Identifier'
&& isSameIdentifier(firstArgument.params[index], node.argument))
)
);
},
getArrayNode: node => node.callee.object,
isOptionalArray: node => node.callee.optional,
description: 'Array#reduce()',
};
// `[].concat(maybeArray)`
// `[].concat(...array)`
const emptyArrayConcat = {
testFunction(node) {
return isMethodCall(node, {
method: 'concat',
argumentsLength: 1,
allowSpreadElement: true,
optionalCall: false,
optionalMember: false,
})
&& isEmptyArrayExpression(node.callee.object);
},
getArrayNode(node) {
const argumentNode = node.arguments[0];
return argumentNode.type === 'SpreadElement' ? argumentNode.argument : argumentNode;
},
description: '[].concat()',
shouldSwitchToArray: node => node.arguments[0].type !== 'SpreadElement',
};
// - `[].concat.apply([], array)` and `Array.prototype.concat.apply([], array)`
// - `[].concat.call([], maybeArray)` and `Array.prototype.concat.call([], maybeArray)`
// - `[].concat.call([], ...array)` and `Array.prototype.concat.call([], ...array)`
const arrayPrototypeConcat = {
testFunction(node) {
if (!(
isMethodCall(node, {
methods: ['apply', 'call'],
argumentsLength: 2,
allowSpreadElement: true,
optionalCall: false,
optionalMember: false,
})
&& isArrayPrototypeProperty(node.callee.object, {
property: 'concat',
})
)) {
return false;
}
const [firstArgument, secondArgument] = node.arguments;
return isEmptyArrayExpression(firstArgument)
&& (
node.callee.property.name === 'call'
|| secondArgument.type !== 'SpreadElement'
);
},
getArrayNode(node) {
const argumentNode = node.arguments[1];
return argumentNode.type === 'SpreadElement' ? argumentNode.argument : argumentNode;
},
description: 'Array.prototype.concat()',
shouldSwitchToArray: node => node.arguments[1].type !== 'SpreadElement' && node.callee.property.name === 'call',
};
const lodashFlattenFunctions = [
'_.flatten',
'lodash.flatten',
'underscore.flatten',
];
const pascalCaseNamePattern = /^\p{Lu}/u;
const isPascalCaseIdentifier = node =>
node.type === 'Identifier'
&& pascalCaseNamePattern.test(node.name);
const isKnownNonArrayConstruction = node =>
node.type === 'NewExpression'
&& node.callee.type === 'Identifier'
&& node.callee.name !== 'Array';
const isDefinitelyArrayExpression = node => (
node.type === 'ArrayExpression'
|| (
node.type === 'NewExpression'
&& node.callee.type === 'Identifier'
&& node.callee.name === 'Array'
)
);
const isDefinitelyNonArrayExpression = node => (
node.type === 'ObjectExpression'
|| node.type === 'Literal'
|| node.type === 'TemplateLiteral'
|| node.type === 'ArrowFunctionExpression'
|| node.type === 'FunctionExpression'
|| node.type === 'ClassExpression'
|| isKnownNonArrayConstruction(node)
);
const getConstVariableInitializer = (node, context) => {
if (node.type !== 'Identifier') {
return;
}
const variable = findVariable(context.sourceCode.getScope(node), node);
if (!variable || variable.defs.length !== 1) {
return;
}
const [definition] = variable.defs;
if (
definition.type !== 'Variable'
|| definition.node.type !== 'VariableDeclarator'
|| definition.parent.type !== 'VariableDeclaration'
|| definition.parent.kind !== 'const'
) {
return;
}
return definition.node.init;
};
const isConstNonArrayVariable = (node, context) => {
const initializer = getConstVariableInitializer(node, context);
return Boolean(initializer && isDefinitelyNonArrayExpression(initializer));
};
const isConstArrayVariable = (node, context) => {
const initializer = getConstVariableInitializer(node, context);
return Boolean(initializer && isDefinitelyArrayExpression(initializer));
};
const isObviouslyNonArrayFlatMapReceiver = (node, context) =>
(isPascalCaseIdentifier(node) && !isConstArrayVariable(node, context))
|| isConstNonArrayVariable(node, context);
function fix(node, array, context, shouldSwitchToArray, optional) {
if (typeof shouldSwitchToArray === 'function') {
shouldSwitchToArray = shouldSwitchToArray(node);
}
return function * (fixer) {
const {sourceCode} = context;
let fixed = getParenthesizedText(array, context);
if (shouldSwitchToArray) {
// `array` is an argument, when it changes to `array[]`, we don't need to add extra parentheses
fixed = `[${fixed}]`;
// And we don't need to add parentheses to the new array to call `.flat()`
} else if (
!isParenthesized(array, sourceCode)
&& shouldAddParenthesesToMemberExpressionObject(array, context)
) {
fixed = `(${fixed})`;
}
fixed = `${fixed}${optional ? '?' : ''}.flat()`;
const tokenBefore = sourceCode.getTokenBefore(node);
if (needsSemicolon(tokenBefore, context, fixed)) {
fixed = `;${fixed}`;
}
yield fixer.replaceText(node, fixed);
yield fixSpaceAroundKeyword(fixer, node, context);
};
}
function create(context) {
const {functions: configFunctions} = context.options[0];
const functions = [...configFunctions, ...lodashFlattenFunctions];
const cases = [
arrayFlatMap,
arrayReduce,
emptyArrayConcat,
arrayPrototypeConcat,
{
testFunction: node => isCallExpression(node, {
argumentsLength: 1,
optional: false,
}) && isNodeMatches(node.callee, functions),
getArrayNode: node => node.arguments[0],
description: node => `${functions.find(nameOrPath => isNodeMatchesNameOrPath(node.callee, nameOrPath)).trim()}()`,
},
];
context.on('CallExpression', function * (node) {
for (const {testFunction, description, getArrayNode, shouldSwitchToArray, isOptionalArray} of cases) {
if (!testFunction(node, context)) {
continue;
}
const array = getArrayNode(node);
const optional = isOptionalArray?.(node);
const data = {
description: typeof description === 'string' ? description : description(node),
};
const problem = {
node,
messageId: MESSAGE_ID,
data,
};
const {sourceCode} = context;
// Don't fix if it has comments.
if (
sourceCode.getCommentsInside(node).length
=== sourceCode.getCommentsInside(array).length
) {
problem.fix = fix(node, array, context, shouldSwitchToArray, optional);
}
yield problem;
}
});
}
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
functions: {
type: 'array',
uniqueItems: true,
description: 'Additional functions to check.',
},
},
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer `Array#flat()` over legacy techniques to flatten arrays.',
recommended: 'unopinionated',
},
fixable: 'code',
schema,
defaultOptions: [{functions: []}],
messages,
},
};
export default config;