import { hasSideEffect, isCommaToken, isSemicolonToken, findVariable, } from '@eslint-community/eslint-utils'; import { isMethodCall, isMemberExpression, isNewExpression, } from './ast/index.js'; import { removeExpressionStatement, removeArgument, } from './fix/index.js'; import { getNextNode, getCallExpressionArgumentsText, getParenthesizedText, getVariableIdentifiers, getNewExpressionTokens, isNewExpressionWithParentheses, } from './utils/index.js'; /** @import {TSESTree as ESTree} from '@typescript-eslint/types'; @import * as ESLint from 'eslint'; */ const MESSAGE_ID_ERROR = 'error'; const MESSAGE_ID_SUGGESTION_ARRAY = 'suggestion/array'; const MESSAGE_ID_SUGGESTION_OBJECT = 'suggestion/object'; const MESSAGE_ID_SUGGESTION_OBJECT_ASSIGN = 'suggestion/object-assign'; const MESSAGE_ID_SUGGESTION_SET = 'suggestion/set'; const MESSAGE_ID_SUGGESTION_MAP = 'suggestion/map'; const messages = { [MESSAGE_ID_ERROR]: 'Immediate mutation on {{objectType}} is not allowed.', [MESSAGE_ID_SUGGESTION_ARRAY]: '{{operation}} the elements to the {{assignType}}.', [MESSAGE_ID_SUGGESTION_OBJECT]: 'Move this property to the {{assignType}}.', [MESSAGE_ID_SUGGESTION_OBJECT_ASSIGN]: '{{description}} the {{assignType}}.', [MESSAGE_ID_SUGGESTION_SET]: 'Move the element to the {{assignType}}.', [MESSAGE_ID_SUGGESTION_MAP]: 'Move the entry to the {{assignType}}.', }; const hasVariableInNodes = (variable, nodes, context) => { const {sourceCode} = context; const identifiers = getVariableIdentifiers(variable); return nodes.some(node => { const range = sourceCode.getRange(node); return identifiers.some(identifier => { const [start, end] = sourceCode.getRange(identifier); return start >= range[0] && end <= range[1]; }); }); }; function isCallExpressionWithOptionalArrayExpression(newExpression, names) { if (!isNewExpression( newExpression, {names, maximumArguments: 1}, )) { return false; } // `new Set();` and `new Set([]);` const [iterable] = newExpression.arguments; return (!iterable || iterable.type === 'ArrayExpression'); } function * removeExpressionStatementAfterAssign(expressionStatement, context, fixer) { const tokenBefore = context.sourceCode.getTokenBefore(expressionStatement); const shouldPreserveSemiColon = !isSemicolonToken(tokenBefore); yield removeExpressionStatement(expressionStatement, context, fixer, shouldPreserveSemiColon); } function appendListTextToArrayExpressionOrObjectExpression( context, fixer, arrayOrObjectExpression, listText, ) { const {sourceCode} = context; const [ penultimateToken, closingBracketToken, ] = sourceCode.getLastTokens(arrayOrObjectExpression, 2); const list = arrayOrObjectExpression.type === 'ArrayExpression' ? arrayOrObjectExpression.elements : arrayOrObjectExpression.properties; const shouldInsertComma = list.length > 0 && !isCommaToken(penultimateToken); return fixer.insertTextBefore( closingBracketToken, `${shouldInsertComma ? ',' : ''} ${listText}`, ); } function * appendElementsTextToSetConstructor({ context, fixer, newExpression, elementsText, nextExpressionStatement, }) { if (isNewExpressionWithParentheses(newExpression, context)) { const [setInitialValue] = newExpression.arguments; if (setInitialValue) { yield appendListTextToArrayExpressionOrObjectExpression(context, fixer, setInitialValue, elementsText); } else { const { openingParenthesisToken, } = getNewExpressionTokens(newExpression, context); yield fixer.insertTextAfter(openingParenthesisToken, `[${elementsText}]`); } } else { /* The new expression doesn't have parentheses ``` const set = (( new (( Set )) )); set.add(1); ``` */ yield fixer.insertTextAfter(newExpression, `([${elementsText}])`); } yield * removeExpressionStatementAfterAssign(nextExpressionStatement, context, fixer); } function getObjectExpressionPropertiesText(objectExpression, context) { const {sourceCode} = context; const openingBraceToken = sourceCode.getFirstToken(objectExpression); const [penultimateToken, closingBraceToken] = sourceCode.getLastTokens(objectExpression, 2); const [, start] = sourceCode.getRange(openingBraceToken); const [end] = sourceCode.getRange(isCommaToken(penultimateToken) ? penultimateToken : closingBraceToken); return sourceCode.text.slice(start, end); } /** @typedef {ESTree.VariableDeclarator['init'] | ESTree.AssignmentExpression['right']} ValueNode @typedef {(information: ViolationCaseInformation, arguments: any)} GetFix @typedef {Parameters[0]} Problem @typedef {(information: ViolationCaseInformation) => ESTree.Node} GetProblematicNode @typedef {{ context: ESLint.Rule.RuleContext, variable: ESLint.Scope.Variable, variableNode: ESTree.Identifier, valueNode: ValueNode, statement: ESTree.VariableDeclaration | ESTree.ExpressionStatement, nextExpressionStatement: ESTree.ExpressionStatement, assignType: 'assignment' | 'declaration', getFix: GetFix, }} ViolationCaseInformation @typedef {{ testValue: (value: ValueNode) => boolean, getProblematicNode: GetProblematicNode, getProblem: (node: ReturnType, information: ViolationCaseInformation) => Problem, getFix: GetFix, }} ViolationCase */ // `Array` /** @type {ViolationCase} */ const arrayMutationSettings = { testValue: value => value?.type === 'ArrayExpression', getProblematicNode({ context, variable, nextExpressionStatement, }) { const callExpression = nextExpressionStatement.expression; if (!( isMethodCall(callExpression, { object: variable.name, methods: ['push', 'unshift'], optionalMember: false, optionalCall: false, }) && callExpression.arguments.length > 0 )) { return; } if (hasVariableInNodes(variable, callExpression.arguments, context)) { return; } return callExpression; }, getProblem(callExpression, information) { const { context, assignType, getFix, } = information; const {sourceCode} = context; const memberExpression = callExpression.callee; const method = memberExpression.property; const problem = { node: memberExpression, messageId: MESSAGE_ID_ERROR, data: {objectType: 'array'}, }; const isPrepend = method.name === 'unshift'; const fix = getFix(information, { callExpression, isPrepend, }); if (callExpression.arguments.some(element => hasSideEffect(element, sourceCode))) { problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION_ARRAY, fix, data: {operation: isPrepend ? 'Prepend' : 'Append', assignType}, }, ]; } else { problem.fix = fix; } return problem; }, getFix: ( { context, valueNode: arrayExpression, nextExpressionStatement, }, { callExpression, isPrepend, }, ) => function * (fixer) { const text = getCallExpressionArgumentsText(context, callExpression, /* includeTrailingComma */ false); yield ( isPrepend ? fixer.insertTextAfter( context.sourceCode.getFirstToken(arrayExpression), `${text}, `, ) : appendListTextToArrayExpressionOrObjectExpression(context, fixer, arrayExpression, text) ); yield * removeExpressionStatementAfterAssign( nextExpressionStatement, context, fixer, ); }, }; // `Object` + `AssignmentExpression` /** @type {ViolationCase} */ const objectWithAssignmentExpressionSettings = { testValue: value => value?.type === 'ObjectExpression', getProblematicNode({ context, variable, nextExpressionStatement, }) { const assignmentExpression = nextExpressionStatement.expression; if (!( assignmentExpression.type === 'AssignmentExpression' && assignmentExpression.operator === '=' && isMemberExpression(assignmentExpression.left, {object: variable.name, optional: false}) )) { return; } const value = assignmentExpression.right; const memberExpression = assignmentExpression.left; const {property} = memberExpression; if ( hasVariableInNodes( variable, memberExpression.computed ? [property, value] : [value], context, ) ) { return; } return assignmentExpression; }, getProblem(assignmentExpression, information) { const { context, assignType, getFix, } = information; const {sourceCode} = context; const { left: memberExpression, right: value, } = assignmentExpression; const {property} = memberExpression; const operatorToken = sourceCode.getTokenAfter(memberExpression, token => token.type === 'Punctuator' && token.value === assignmentExpression.operator); const problem = { node: assignmentExpression, loc: { start: sourceCode.getLoc(assignmentExpression).start, end: sourceCode.getLoc(operatorToken).end, }, messageId: MESSAGE_ID_ERROR, data: {objectType: 'object'}, }; const fix = getFix(information, { assignmentExpression, memberExpression, property, value, }); if ( (memberExpression.computed && hasSideEffect(property, sourceCode)) || hasSideEffect(value, sourceCode) ) { problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION_OBJECT, data: {assignType}, fix, }, ]; } else { problem.fix = fix; } return problem; }, getFix: ( { context, valueNode: objectExpression, nextExpressionStatement, }, { memberExpression, property, value, }, ) => function * (fixer) { let propertyText = getParenthesizedText(property, context); if (memberExpression.computed) { propertyText = `[${propertyText}]`; } const valueText = getParenthesizedText(value, context); const text = `${propertyText}: ${valueText},`; const [ penultimateToken, closingBraceToken, ] = context.sourceCode.getLastTokens(objectExpression, 2); const shouldInsertComma = objectExpression.properties.length > 0 && !isCommaToken(penultimateToken); yield fixer.insertTextBefore( closingBraceToken, `${shouldInsertComma ? ',' : ''} ${text}`, ); yield * removeExpressionStatementAfterAssign( nextExpressionStatement, context, fixer, ); }, }; // `Object` + `Object.assign()` /** @type {ViolationCase} */ const objectWithObjectAssignSettings = { testValue: value => value?.type === 'ObjectExpression', getProblematicNode({ context, variable, nextExpressionStatement, }) { const callExpression = nextExpressionStatement.expression; if (!isMethodCall(callExpression, { object: 'Object', method: 'assign', minimumArguments: 2, optionalMember: false, optionalCall: false, })) { return; } const [object, firstValue] = callExpression.arguments; if ( !(object.type === 'Identifier' && object.name === variable.name) || firstValue.type === 'SpreadElement' || hasVariableInNodes(variable, [firstValue], context) ) { return; } return callExpression; }, getProblem(callExpression, information) { const { context, assignType, getFix, } = information; const {sourceCode} = context; const [, firstValue] = callExpression.arguments; const problem = { node: callExpression.callee, messageId: MESSAGE_ID_ERROR, data: {objectType: 'object'}, }; const fix = getFix(information, { callExpression, firstValue, }); if (hasSideEffect(firstValue, sourceCode)) { const description = firstValue.type === 'ObjectExpression' ? 'Move properties to' : 'Spread properties in'; problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION_OBJECT_ASSIGN, data: {description, assignType}, fix, }, ]; } else { problem.fix = fix; } return problem; }, getFix: ( { context, valueNode: objectExpression, nextExpressionStatement, }, { callExpression, firstValue, }, ) => function * (fixer) { let text; if (firstValue.type === 'ObjectExpression') { if (firstValue.properties.length > 0) { text = getObjectExpressionPropertiesText(firstValue, context); } } else { text = `...${getParenthesizedText(firstValue, context)}`; } if (text) { yield appendListTextToArrayExpressionOrObjectExpression(context, fixer, objectExpression, text); } if (callExpression.arguments.length !== 2) { yield removeArgument(fixer, firstValue, context); return; } yield * removeExpressionStatementAfterAssign( nextExpressionStatement, context, fixer, ); }, }; // `Set` and `WeakSet` /** @type {ViolationCase} */ const setMutationSettings = { testValue: value => isCallExpressionWithOptionalArrayExpression(value, ['Set', 'WeakSet']), getProblematicNode({ context, variable, nextExpressionStatement, }) { let callExpression = nextExpressionStatement.expression; if (callExpression.type === 'ChainExpression') { callExpression = callExpression.expression; } if (!isMethodCall(callExpression, { object: variable.name, method: 'add', argumentsLength: 1, optionalMember: false, optionalCall: false, })) { return; } if (hasVariableInNodes(variable, callExpression.arguments, context)) { return; } return callExpression; }, getProblem(callExpression, information) { const { context, assignType, valueNode: newExpression, getFix, } = information; const {sourceCode} = context; const memberExpression = callExpression.callee; const problem = { node: memberExpression, messageId: MESSAGE_ID_ERROR, data: {objectType: `\`${newExpression.callee.name}\``}, }; const fix = getFix(information, { callExpression, newExpression, }); if (callExpression.arguments.some(element => hasSideEffect(element, sourceCode))) { problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION_SET, data: {assignType}, fix, }, ]; } else { problem.fix = fix; } return problem; }, getFix: ( { context, nextExpressionStatement, }, { callExpression, newExpression, }, ) => fixer => { const elementsText = getCallExpressionArgumentsText( context, callExpression, /* IncludeTrailingComma */ false, ); return appendElementsTextToSetConstructor({ context, fixer, newExpression, elementsText, nextExpressionStatement, }); }, }; // `Map` and `WeakMap` /** @type {ViolationCase} */ const mapMutationSettings = { testValue: value => isCallExpressionWithOptionalArrayExpression(value, ['Map', 'WeakMap']), getProblematicNode({ context, variable, nextExpressionStatement, }) { const callExpression = nextExpressionStatement.expression; if (!isMethodCall(callExpression, { object: variable.name, method: 'set', argumentsLength: 2, optionalCall: false, })) { return; } if (hasVariableInNodes(variable, callExpression.arguments, context)) { return; } return callExpression; }, getProblem(callExpression, information) { const { context, assignType, valueNode: newExpression, getFix, } = information; const {sourceCode} = context; const memberExpression = callExpression.callee; const problem = { node: memberExpression, messageId: MESSAGE_ID_ERROR, data: {objectType: `\`${newExpression.callee.name}\``}, }; const fix = getFix(information, { callExpression, newExpression, }); if (callExpression.arguments.some(element => hasSideEffect(element, sourceCode))) { problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION_MAP, data: {assignType}, fix, }, ]; } else { problem.fix = fix; } return problem; }, getFix: ( { context, nextExpressionStatement, }, { callExpression, newExpression, }, ) => fixer => { const argumentsText = getCallExpressionArgumentsText( context, callExpression, /* IncludeTrailingComma */ false, ); const entryText = `[${argumentsText}]`; return appendElementsTextToSetConstructor({ context, fixer, newExpression, elementsText: entryText, nextExpressionStatement, }); }, }; const cases = [ arrayMutationSettings, objectWithAssignmentExpressionSettings, objectWithObjectAssignSettings, setMutationSettings, mapMutationSettings, ]; function isLastDeclarator(variableDeclarator) { const variableDeclaration = variableDeclarator.parent; return ( variableDeclaration.type === 'VariableDeclaration' && variableDeclaration.declarations.at(-1) === variableDeclarator ); } const getVariable = (node, context) => { if (node.type === 'VariableDeclarator') { return context.sourceCode.getDeclaredVariables(node) .find(variable => variable.defs.length === 1 && variable.defs[0].name === node.id); } return findVariable(context.sourceCode.getScope(node), node.left.name); }; function getCaseProblem( context, assignNode, { testValue, getProblematicNode, getProblem, getFix, }, ) { const isAssignment = assignNode.type === 'AssignmentExpression'; const [variableNode, valueNode] = (isAssignment ? ['left', 'right'] : ['id', 'init']) .map(property => assignNode[property]); // eslint-disable-next-line no-warning-comments // TODO[@fisker]: `AssignmentExpression` should not limit to `Identifier` if (!(variableNode.type === 'Identifier' && testValue(valueNode))) { return; } const statement = assignNode.parent; if (!( // eslint-disable-next-line no-warning-comments // TODO[@fisker]: `AssignmentExpression` should support `a = b = c` too ( isAssignment && assignNode.operator === '=' && statement.type === 'ExpressionStatement' && statement.expression === assignNode) || (!isAssignment && isLastDeclarator(assignNode)) )) { return; } const nextExpressionStatement = getNextNode(statement, context); if (nextExpressionStatement?.type !== 'ExpressionStatement') { return; } const variable = getVariable(assignNode, context); /* c8 ignore next */ if (!variable) { return; } const information = { context, variable, variableNode, valueNode, statement, nextExpressionStatement, assignType: isAssignment ? 'assignment' : 'declaration', getFix, }; const problematicNode = getProblematicNode(information); if (!problematicNode) { return; } return getProblem(problematicNode, information); } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { for (const caseSettings of cases) { context.on( [ 'VariableDeclarator', 'AssignmentExpression', ], assignNode => getCaseProblem(context, assignNode, caseSettings), ); } }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Disallow immediate mutation after variable assignment.', recommended: true, }, fixable: 'code', hasSuggestions: true, messages, }, }; export default config;