UNPKG

eslint-plugin-unicorn

Version:
423 lines (356 loc) 10.5 kB
import {findVariable} from '@eslint-community/eslint-utils'; import { isEmptyArrayExpression, isMethodCall, } from './ast/index.js'; import { containsSuspensionPoint, getNextNode, getParenthesizedText, getStaticValueForControlFlow, getVariableIdentifiers, isStringMappingType, isTemplateLiteralType, isUniqueSymbolType, unwrapTypeScriptExpression, wouldRemoveComments, } from './utils/index.js'; const MESSAGE_ID = 'prefer-array-from-async'; const MESSAGE_ID_SUGGESTION = 'prefer-array-from-async/suggestion'; const messages = { [MESSAGE_ID]: 'Prefer `Array.fromAsync()` over array accumulation loops.', [MESSAGE_ID_SUGGESTION]: 'Replace the loop with `Array.fromAsync()`.', }; const arrowBodyParenthesizedExpressionTypes = new Set([ 'AssignmentExpression', 'ObjectExpression', 'SequenceExpression', 'TSAsExpression', 'TSNonNullExpression', 'TSSatisfiesExpression', 'TSTypeAssertion', ]); const isIdentifierNamed = (node, name) => node.type === 'Identifier' && node.name === name; const getEmptyArrayDeclarator = node => { if ( node.declarations.length !== 1 || (node.kind !== 'const' && node.kind !== 'let') ) { return; } const [declarator] = node.declarations; if ( declarator.id.type !== 'Identifier' || !declarator.init || !isEmptyArrayExpression(declarator.init) ) { return; } return declarator; }; const getOnlyExpression = node => { if (node.type === 'ExpressionStatement') { return node.expression; } if ( node.type === 'BlockStatement' && node.body.length === 1 && node.body[0].type === 'ExpressionStatement' ) { return node.body[0].expression; } }; const getSingleForOfBinding = node => { if ( node.left.type !== 'VariableDeclaration' || node.left.declarations.length !== 1 || (node.left.kind !== 'const' && node.left.kind !== 'let') ) { return; } const [{id, init}] = node.left.declarations; if (init) { return; } if (id.type !== 'Identifier') { return; } return id; }; const referencesVariable = (variable, node, context) => { const range = context.sourceCode.getRange(node); return getVariableIdentifiers(variable).some(identifier => { const [start, end] = context.sourceCode.getRange(identifier); return start >= range[0] && end <= range[1]; }); }; const isReferenceInsideNode = (reference, node, context) => { const [referenceStart, referenceEnd] = context.sourceCode.getRange(reference.identifier); const [nodeStart, nodeEnd] = context.sourceCode.getRange(node); return referenceStart >= nodeStart && referenceEnd <= nodeEnd; }; const hasWriteReferenceInsideNode = (variable, node, context) => variable.references.some(reference => !reference.init && reference.isWrite() && isReferenceInsideNode(reference, node, context), ); const isGlobalArrayAvailable = (node, context) => { const variable = findVariable(context.sourceCode.getScope(node), 'Array'); return !variable || variable.defs.length === 0; }; const getArrowBodyText = (node, context) => { const text = context.sourceCode.getText(node); return arrowBodyParenthesizedExpressionTypes.has(node.type) ? `(${text})` : text; }; const getVariableTargetText = (declarator, context) => { const {sourceCode} = context; const equalsToken = sourceCode.getTokenBefore(declarator.init, token => token.value === '='); const [start] = sourceCode.getRange(declarator.id); const [end] = sourceCode.getRange(equalsToken); return sourceCode.text.slice(start, end).trimEnd(); }; const getArrayFromAsyncText = ({ iterable, binding, body, context, }) => { let text = `Array.fromAsync(${getParenthesizedText(iterable, context)}`; if (body) { text += `, ${context.sourceCode.getText(binding)} => ${getArrowBodyText(body, context)}`; } return `${text})`; }; const isDirectElementPush = (pushArgument, binding) => isIdentifierNamed(pushArgument, binding.name); const getMapperBody = ({ pushArgument, variable, context, }) => { if ( pushArgument.type !== 'AwaitExpression' || referencesVariable(variable, pushArgument.argument, context) || containsSuspensionPoint(pushArgument.argument, context.sourceCode.visitorKeys) ) { return; } return pushArgument.argument; }; const primitiveTypeNames = new Set([ 'string', 'number', 'boolean', 'bigint', 'symbol', 'null', 'undefined', ]); const isPrimitiveType = (type, checker) => { const constraint = checker.getBaseConstraintOfType(type); if (constraint && constraint !== type) { return isPrimitiveType(constraint, checker); } if (type.isUnion()) { return type.types.every(type => isPrimitiveType(type, checker)); } if (type.isIntersection()) { return type.types.some(type => isPrimitiveType(type, checker)); } if (type.isLiteral()) { return true; } return primitiveTypeNames.has(checker.getBaseTypeOfLiteralType(type).intrinsicName) || isTemplateLiteralType(type) || isStringMappingType(type) || isUniqueSymbolType(type); }; const isPrimitiveIterableType = (type, checker) => { const constraint = checker.getBaseConstraintOfType(type); // Require a primitive constraint because an array-constrained subtype may add an async iterator that `Array.fromAsync()` would prefer. if (constraint && constraint !== type) { return isPrimitiveType(constraint, checker) && isPrimitiveIterableType(constraint, checker); } if (type.isUnion()) { return type.types.every(type => isPrimitiveIterableType(type, checker)); } if ( checker.getBaseTypeOfLiteralType(type).intrinsicName === 'string' || isTemplateLiteralType(type) || isStringMappingType(type) ) { return true; } if (!checker.isArrayType(type) && !checker.isTupleType(type)) { return false; } // TypeScript's IndexKind.Number is 1. const elementType = checker.getIndexTypeOfType(type, 1); return Boolean(elementType && isPrimitiveType(elementType, checker)); }; const getVariableDeclarationVariable = (node, context) => { if (node.type !== 'Identifier') { return; } const variable = findVariable(context.sourceCode.getScope(node), node); if (!variable?.defs.some(definition => definition.type === 'Variable')) { return; } return variable; }; const isKnownPrimitiveIterable = (node, context) => { const {sourceCode} = context; const typeNode = node; node = unwrapTypeScriptExpression(node); const variable = getVariableDeclarationVariable(node, context); const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; const initializer = definition?.parent.kind === 'const' && definition.node.id.type === 'Identifier' && definition.node.init ? unwrapTypeScriptExpression(definition.node.init) : undefined; const hasOtherReferences = Boolean(variable?.references.some(reference => !reference.init && reference.identifier !== node)); if (typeof getStaticValueForControlFlow(node, context)?.value === 'string') { return true; } const {parserServices} = sourceCode; if (parserServices?.program) { try { const checker = parserServices.program.getTypeChecker(); const type = parserServices.getTypeAtLocation(typeNode); // Local array bindings require static const analysis below; primitive-valued bindings are safe. if ( isPrimitiveIterableType(type, checker) && (!variable || isPrimitiveType(type, checker)) ) { return true; } } catch {} } if (node.type === 'Identifier') { if (!initializer || hasOtherReferences) { return false; } // Limit static arrays to constants used only by this loop; other references could mutate or expose them. node = initializer; } return node.type === 'ArrayExpression' && node.elements.every(element => { if (!element) { return true; } if (element.type === 'SpreadElement') { return false; } const result = getStaticValueForControlFlow(element, context); return Boolean(result && (result.value === null || !['object', 'function'].includes(typeof result.value))); }); }; const getLoopProblem = (declaration, context) => { const declarator = getEmptyArrayDeclarator(declaration); if (!declarator || !isGlobalArrayAvailable(declaration, context)) { return; } const loop = getNextNode(declaration, context); if (loop?.type !== 'ForOfStatement') { return; } const expression = getOnlyExpression(loop.body); if (!expression) { return; } const binding = getSingleForOfBinding(loop); if (!binding) { return; } const {sourceCode} = context; const arrayName = declarator.id.name; const variable = sourceCode.getDeclaredVariables(declarator)[0]; const [bindingVariable] = sourceCode.getDeclaredVariables(loop.left); if ( binding.name === arrayName || referencesVariable(variable, loop.right, context) ) { return; } if (!isMethodCall(expression, { method: 'push', argumentsLength: 1, optionalCall: false, optionalMember: false, computed: false, }) || !isIdentifierNamed(expression.callee.object, arrayName)) { return; } const [pushArgument] = expression.arguments; const isDirectCollection = isDirectElementPush(pushArgument, binding); let body; if (!isDirectCollection) { body = getMapperBody({ pushArgument, variable, context, }); if ( !body || ( loop.left.kind === 'const' && hasWriteReferenceInsideNode(bindingVariable, body, context) ) ) { return; } } if (!loop.await && (!body || !isKnownPrimitiveIterable(loop.right, context))) { return; } const replaceRange = [ sourceCode.getRange(declaration)[0], sourceCode.getRange(loop)[1], ]; if (wouldRemoveComments(context, replaceRange)) { return; } const fix = fixer => fixer.replaceTextRange( replaceRange, `${declaration.kind} ${getVariableTargetText(declarator, context)} = await ${getArrayFromAsyncText({ iterable: loop.right, binding, body, context, })};`, ); const problem = { node: loop, messageId: MESSAGE_ID, }; if (loop.await) { problem.fix = fix; } else { problem.suggest = [{messageId: MESSAGE_ID_SUGGESTION, fix}]; } return problem; }; /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { context.on('VariableDeclaration', declaration => getLoopProblem(declaration, context)); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer `Array.fromAsync()` over array accumulation loops.', recommended: true, }, fixable: 'code', hasSuggestions: true, schema: [], messages, languages: [ 'js/js', ], }, }; export default config;