UNPKG

eslint-plugin-unicorn

Version:
596 lines (493 loc) • 15.6 kB
import { findVariable, getPropertyName, getStaticValue, hasSideEffect, isCommaToken, isCommentToken, } from '@eslint-community/eslint-utils'; import { isCallExpression, isNewExpression, isReferenceIdentifier, } from './ast/index.js'; import { getArgumentRemovalRange, removeObjectProperty, } from './fix/index.js'; import { getBaseTypes, getTypeSymbol, getStaticValueForControlFlow, getStaticValueIfNoSideEffects, isBranchExpression, hasPotentiallyMutableMemberAccess, hasSideEffectfulConstInitializer, isDefaultLibrarySymbol, isGlobalIdentifier, unwrapTypeScriptExpression, getVisitorChildNodes, hasCommentInRange, withTypeInformation, } from './utils/index.js'; const MESSAGE_ID_EMPTY_OPTIONS = 'empty-options'; const MESSAGE_ID_PROPERTY = 'property'; const messages = { [MESSAGE_ID_EMPTY_OPTIONS]: 'Remove unnecessary empty fetch options.', [MESSAGE_ID_PROPERTY]: 'Remove unnecessary `{{property}}` fetch option.', }; const requestInitProperties = new Set([ 'attributionReporting', 'body', 'browsingTopics', 'cache', 'credentials', 'duplex', 'headers', 'integrity', 'keepalive', 'method', 'mode', 'priority', 'redirect', 'referrer', 'referrerPolicy', 'signal', 'window', ]); const defaultValues = new Map([ ['cache', 'default'], ['credentials', 'same-origin'], ['integrity', ''], ['keepalive', false], ['method', 'GET'], ['mode', 'cors'], ['redirect', 'follow'], ['referrer', 'about:client'], ['referrerPolicy', ''], ]); const request = 'request'; const nonRequest = 'non-request'; const unknown = 'unknown'; const isGlobalIdentifierNamed = (node, name, context) => node.type === 'Identifier' && node.name === name && isGlobalIdentifier(node, context); const isStaticStringLiteral = node => node.type === 'Literal' && typeof node.value === 'string'; const getStaticPropertyName = (property, context) => property.type === 'Property' ? getPropertyName(property, context.sourceCode.getScope(property)) ?? undefined : undefined; const getStaticValueForNode = (node, context) => { const staticValueNode = unwrapTypeScriptExpression(node); const isBranch = isBranchExpression(staticValueNode); if ( isBranch || hasPotentiallyMutableMemberAccess(staticValueNode, context) || hasSideEffectfulConstInitializer(staticValueNode, context) ) { return isBranch ? getStaticValueForControlFlow(staticValueNode, context) : getStaticValueIfNoSideEffects(staticValueNode, context); } return getStaticValue(staticValueNode, context.sourceCode.getScope(node)); }; const hasCommentsInside = (node, context) => context.sourceCode.getCommentsInside(node).length > 0; const hasSideEffectValue = (node, context) => hasSideEffect(unwrapTypeScriptExpression(node), context.sourceCode, {considerGetters: true}); const hasSideEffectProperty = (property, context) => hasSideEffectValue(property.value, context) || ( property.computed && hasSideEffectValue(property.key, context) ); function hasReferenceDeclaredAfter(node, context) { node = unwrapTypeScriptExpression(node); const {sourceCode} = context; if (isReferenceIdentifier(node)) { const variable = findVariable(sourceCode.getScope(node), node); const definition = variable?.defs[0]?.name; return definition ? sourceCode.getRange(definition)[0] > sourceCode.getRange(node)[0] : false; } return getVisitorChildNodes(node, sourceCode.visitorKeys).some(child => hasReferenceDeclaredAfter(child, context)); } const isUnsafeToRemoveProperty = (property, context) => hasSideEffectProperty(property, context) || hasReferenceDeclaredAfter(property.value, context) || ( property.computed && hasReferenceDeclaredAfter(property.key, context) ); function getFinalArgumentRemovalRange(node, context) { const {sourceCode} = context; const range = getArgumentRemovalRange(node, context); const tokenAfter = sourceCode.getTokenAfter(node); if (isCommaToken(tokenAfter)) { range[1] = sourceCode.getRange(tokenAfter)[1]; } return range; } function hasCommentAfterTrailingComma(node, context) { const {sourceCode} = context; const tokenAfter = sourceCode.getTokenAfter(node); return isCommaToken(tokenAfter) && isCommentToken(sourceCode.getTokenAfter(tokenAfter, {includeComments: true})); } const canRemoveFinalArgumentWithoutComments = (node, context) => !hasCommentInRange(context, getFinalArgumentRemovalRange(node, context)) && !isCommentToken(context.sourceCode.getTokenAfter(node, {includeComments: true})) && !hasCommentAfterTrailingComma(node, context); const removeFinalArgument = (fixer, node, context) => fixer.removeRange(getFinalArgumentRemovalRange(node, context)); function getPropertyLineRemovalRange(property, context) { const {sourceCode} = context; const location = sourceCode.getLoc(property); if (location.start.line !== location.end.line) { return; } const line = sourceCode.lines[location.start.line - 1]; if (line.slice(0, location.start.column).trim() !== '') { return; } const tokenAfter = sourceCode.getTokenAfter(property); const tokenAfterLocation = sourceCode.getLoc(tokenAfter); const commaOnSameLine = isCommaToken(tokenAfter) && tokenAfterLocation.start.line === location.end.line; if ( property.parent.properties.at(-1) !== property && !commaOnSameLine ) { return; } const endColumn = commaOnSameLine ? tokenAfterLocation.end.column : location.end.column; if (line.slice(endColumn).trim() !== '') { return; } // The closing brace follows the property, so its line is never the last one return [ sourceCode.getIndexFromLoc({line: location.start.line, column: 0}), sourceCode.getIndexFromLoc({line: location.start.line + 1, column: 0}), ]; } // A property that shares its line with the previous one is preceded by the whitespace that separated them, which would be left behind as trailing whitespace when the property is removed up to the end of the line. function getSpacesStartBefore(index, context) { const {text} = context.sourceCode; while (index > 0 && /[^\S\n\r]/u.test(text[index - 1])) { index--; } return index; } function getPropertyInlineRemovalRange(property, context) { const {sourceCode} = context; const location = sourceCode.getLoc(property); if (location.start.line !== location.end.line) { return; } const previousToken = sourceCode.getTokenBefore(property); const nextToken = sourceCode.getTokenAfter(property); const isLastProperty = property.parent.properties.at(-1) === property; // There are at least two properties, so the comma after the previous property comes before the last one, and one comes after every other property if (isLastProperty) { const previousTokenLocation = sourceCode.getLoc(previousToken); if (previousTokenLocation.end.line !== location.start.line) { return; } const endToken = isCommaToken(nextToken) ? sourceCode.getTokenAfter(nextToken) : nextToken; const end = sourceCode.getLoc(endToken).start.line === location.end.line ? sourceCode.getRange(endToken)[0] : sourceCode.getRange(property)[1]; return [ sourceCode.getRange(previousToken)[0], end, ]; } const nextTokenLocation = sourceCode.getLoc(nextToken); if (nextTokenLocation.start.line !== location.end.line) { return; } const [start] = sourceCode.getRange(property); const tokenAfterComma = sourceCode.getTokenAfter(nextToken); return tokenAfterComma && sourceCode.getLoc(tokenAfterComma).start.line === location.end.line ? [start, sourceCode.getRange(tokenAfterComma)[0]] : [getSpacesStartBefore(start, context), sourceCode.getRange(nextToken)[1]]; } const isStaticUndefined = (node, context) => { const result = getStaticValueForNode(node, context); return result ? result.value === undefined : false; }; const isStaticNull = (node, context) => getStaticValueForNode(node, context)?.value === null; const isEmptyHeaders = (node, context) => { node = unwrapTypeScriptExpression(node); if (node.type === 'ObjectExpression') { return node.properties.length === 0; } if (node.type === 'ArrayExpression') { return node.elements.length === 0; } return isNewExpression(node, { name: 'Headers', argumentsLength: 0, }) && isGlobalIdentifierNamed(node.callee, 'Headers', context); }; function getObjectPropertyNames(properties, context) { const names = []; const seen = new Set(); for (const property of properties) { const name = getStaticPropertyName(property, context); if (name === undefined || seen.has(name)) { return; } seen.add(name); names.push(name); } return names; } const getTypeName = (type, program) => { const symbol = getTypeSymbol(type); return isDefaultLibrarySymbol(symbol, program) ? symbol.getName() : undefined; }; function combineTypeStates(states) { if (states.includes(request)) { return request; } if (states.length > 0 && states.every(state => state === nonRequest)) { return nonRequest; } return unknown; } function getInputTypeState(type, checker, program, seen = new Set()) { type = checker.getNonNullableType(type); if (seen.has(type)) { return unknown; } seen = new Set(seen); seen.add(type); if (type.isUnion()) { return combineTypeStates(type.types.map(type => getInputTypeState(type, checker, program, seen))); } if (type.isIntersection()) { return combineTypeStates(type.types.map(type => getInputTypeState(type, checker, program, seen))); } const constraint = checker.getBaseConstraintOfType(type); if (constraint) { return getInputTypeState(constraint, checker, program, seen); } if (type.intrinsicName === 'string' || type.isStringLiteral?.()) { return nonRequest; } const typeName = getTypeName(type, program); if (typeName === 'Request') { return request; } if (typeName === 'URL' || typeName === 'String') { return nonRequest; } return combineTypeStates(getBaseTypes(type, checker).map(type => getInputTypeState(type, checker, program, seen))); } function getInputState(node, context) { node = unwrapTypeScriptExpression(node); if ( isStaticStringLiteral(node) || ( node.type === 'TemplateLiteral' && node.expressions.length === 0 ) || ( isNewExpression(node, { name: 'URL', minimumArguments: 1, }) && isGlobalIdentifierNamed(node.callee, 'URL', context) ) ) { return nonRequest; } if ( isNewExpression(node, { name: 'Request', minimumArguments: 1, }) && isGlobalIdentifierNamed(node.callee, 'Request', context) ) { return request; } return withTypeInformation(node, context, ({type, checker, program}) => getInputTypeState(type, checker, program)) ?? unknown; } const isDefaultValue = (propertyName, value, context) => { if (!defaultValues.has(propertyName)) { return false; } const staticValue = getStaticValueForNode(value, context)?.value; const defaultValue = defaultValues.get(propertyName); return propertyName === 'method' && typeof staticValue === 'string' ? staticValue.toUpperCase() === defaultValue : staticValue === defaultValue; }; function isUnnecessaryProperty(property, propertyName, inputState, context) { if ( requestInitProperties.has(propertyName) && isStaticUndefined(property.value, context) ) { return true; } if ( propertyName === 'body' && isStaticNull(property.value, context) ) { return true; } if (inputState !== nonRequest) { return false; } if ( propertyName === 'headers' && isEmptyHeaders(property.value, context) ) { return true; } return isDefaultValue(propertyName, property.value, context); } const getFix = (property, optionsNode, optionsArgument, context) => function * (fixer, {abort}) { if ( hasCommentsInside(optionsNode, context) || isUnsafeToRemoveProperty(property, context) ) { return abort(); } // `getWholeOptionsFix()` removes a single removable property, so it only gets here when a comment blocks removing the final argument if (optionsNode.properties.length === 1) { return abort(); } const lineRemovalRange = getPropertyLineRemovalRange(property, context); if (lineRemovalRange) { yield fixer.removeRange(lineRemovalRange); return; } const inlineRemovalRange = getPropertyInlineRemovalRange(property, context); if (inlineRemovalRange) { yield fixer.removeRange(inlineRemovalRange); return; } yield removeObjectProperty(fixer, property, context); }; function getWholeOptionsFix(unnecessaryProperties, optionsNode, optionsArgument, context) { if ( unnecessaryProperties.length !== optionsNode.properties.length || hasCommentsInside(optionsNode, context) || unnecessaryProperties.some(({property}) => isUnsafeToRemoveProperty(property, context)) ) { return; } if (optionsArgument.parent.arguments.at(-1) !== optionsArgument) { return fixer => fixer.replaceText(optionsNode, '{}'); } if (!canRemoveFinalArgumentWithoutComments(optionsArgument, context)) { return; } return fixer => removeFinalArgument(fixer, optionsArgument, context); } function * getOptionsProblems(input, optionsArgument, context) { const optionsNode = unwrapTypeScriptExpression(optionsArgument); if (optionsNode.type !== 'ObjectExpression') { return; } const {properties} = optionsNode; if (properties.length === 0) { if (optionsArgument.parent.arguments.at(-1) !== optionsArgument) { return; } yield { node: optionsNode, messageId: MESSAGE_ID_EMPTY_OPTIONS, fix: canRemoveFinalArgumentWithoutComments(optionsArgument, context) ? fixer => removeFinalArgument(fixer, optionsArgument, context) : undefined, }; return; } const propertyNames = getObjectPropertyNames(properties, context); if (!propertyNames) { return; } const inputState = getInputState(input, context); const unnecessaryProperties = []; for (const [index, property] of properties.entries()) { const propertyName = propertyNames[index]; if (!isUnnecessaryProperty(property, propertyName, inputState, context)) { continue; } unnecessaryProperties.push({property, propertyName}); } const wholeOptionsFix = getWholeOptionsFix(unnecessaryProperties, optionsNode, optionsArgument, context); for (const [index, {property, propertyName}] of unnecessaryProperties.entries()) { const fix = index === 0 && wholeOptionsFix ? wholeOptionsFix : getFix(property, optionsNode, optionsArgument, context); yield { node: property.key, messageId: MESSAGE_ID_PROPERTY, data: {property: propertyName}, fix, }; } } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { context.on('CallExpression', function * (callExpression) { if ( !isCallExpression(callExpression, { name: 'fetch', minimumArguments: 2, optional: false, }) || !isGlobalIdentifierNamed(callExpression.callee, 'fetch', context) ) { return; } yield * getOptionsProblems(callExpression.arguments[0], callExpression.arguments[1], context); }); context.on('NewExpression', function * (newExpression) { if ( !isNewExpression(newExpression, { name: 'Request', minimumArguments: 2, }) || !isGlobalIdentifierNamed(newExpression.callee, 'Request', context) ) { return; } yield * getOptionsProblems(newExpression.arguments[0], newExpression.arguments[1], context); }); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Disallow unnecessary options in `fetch()` and `new Request()`.', recommended: 'unopinionated', }, fixable: 'code', schema: [], messages, languages: [ 'js/js', ], }, }; export default config;