UNPKG

eslint-plugin-unicorn

Version:
256 lines (213 loc) 7.13 kB
import { isMethodCall, isMemberExpression, isStringLiteral, isCallExpression, isExpressionStatement, } from './ast/index.js'; import { replaceMemberExpressionProperty, fixSpaceAroundKeyword, } from './fix/index.js'; import { isSameReference, isParenthesized, getParenthesizedText, shouldAddParenthesesToUnaryExpressionArgument, needsSemicolon, } from './utils/index.js'; const MESSAGE_ID_ERROR = 'prefer-classlist-toggle/error'; const MESSAGE_ID_SUGGESTION = 'prefer-classlist-toggle/suggestion'; const messages = { [MESSAGE_ID_ERROR]: 'Prefer using `Element#classList.toggle()` to toggle class names.', [MESSAGE_ID_SUGGESTION]: 'Replace with `Element#classList.toggle()`.', }; const isClassList = node => isMemberExpression(node, { property: 'classList', computed: false, }); const getProblem = (valueNode, fix, reportNode) => { const problem = { node: reportNode ?? valueNode, messageId: MESSAGE_ID_ERROR, }; const shouldUseSuggestion = valueNode.type !== 'IfStatement' && !(isExpressionStatement(valueNode) || isExpressionStatement(valueNode.parent)); if (shouldUseSuggestion) { problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION, fix, }, ]; } else { problem.fix = fix; } return problem; }; const getConditionText = (node, context, isNegative) => { const {sourceCode} = context; let text = getParenthesizedText(node, context); if (isNegative) { if ( !isParenthesized(node, sourceCode) && shouldAddParenthesesToUnaryExpressionArgument(node, '!') ) { text = `(${text})`; } text = `!${text}`; return text; } if ( !isParenthesized(node, sourceCode) && node.type === 'SequenceExpression' ) { text = `(${text})`; } return text; }; const isClassListMethodCall = (node, methods) => isMethodCall(node, { methods, argumentsLength: 1, optionalCall: false, optionalMember: false, }) && isClassList(node.callee.object); const isSameElementAndClassName = (callExpressionA, callExpressionB) => isSameReference(callExpressionA.callee.object, callExpressionB.callee.object) && isSameReference(callExpressionA.arguments[0], callExpressionB.arguments[0]); const getClassListContainsCall = (conditionNode, isNegative, addOrRemoveCall) => { if (!isNegative) { if (conditionNode.type === 'UnaryExpression' && conditionNode.operator === '!' && conditionNode.prefix) { return getClassListContainsCall(conditionNode.argument, !isNegative, addOrRemoveCall); } return; } if (conditionNode.type === 'ChainExpression') { conditionNode = conditionNode.expression; } if ( isClassListMethodCall(conditionNode, ['contains']) && isSameElementAndClassName(conditionNode, addOrRemoveCall) ) { return conditionNode; } }; /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const {sourceCode} = context; /* ```js if (condition) { element.classList.add('className'); } else { element.classList.remove('className'); } ``` ```js condition ? element.classList.add('className'); : element.classList.remove('className'); ``` */ context.on(['IfStatement', 'ConditionalExpression'], node => { const clauses = [node.consequent, node.alternate] .map(node => { if (!node) { return; } if (node.type === 'BlockStatement' && node.body.length === 1) { node = node.body[0]; } if (node.type === 'ExpressionStatement') { node = node.expression; } if (node.type === 'ChainExpression') { node = node.expression; } return node; }); // `element.classList.add('className');` // `element.classList.remove('className');` if (!clauses.every(node => isClassListMethodCall(node, ['add', 'remove']))) { return; } const [consequent, alternate] = clauses; if ( (consequent.callee.property.name === alternate.callee.property.name) || !isSameElementAndClassName(consequent, alternate) ) { return; } /** @param {import('eslint').Rule.RuleFixer} fixer */ function * fix(fixer) { const elementText = getParenthesizedText(consequent.callee.object.object, context); const classNameText = getParenthesizedText(consequent.arguments[0], context); const isExpression = node.type === 'ConditionalExpression'; const isNegative = consequent.callee.property.name === 'remove'; const conditionNode = node.test; const classListContainsCall = getClassListContainsCall(conditionNode, isNegative, consequent); const conditionText = classListContainsCall ? '' : getConditionText(conditionNode, context, isNegative); const isOptional = consequent.callee.object.optional || alternate.callee.object.optional || classListContainsCall?.callee.object.optional; let text = `${elementText}${isOptional ? '?' : ''}.classList.toggle(${classNameText}${conditionText ? `, ${conditionText}` : ''})`; if (!isExpression) { text = `${text};`; } if (needsSemicolon(sourceCode.getTokenBefore(node), context, text)) { text = `;${text}`; } yield fixer.replaceText(node, text); if (isExpression) { yield fixSpaceAroundKeyword(fixer, node, context); } } return getProblem(node, fix); }); // `element.classList[condition ? 'add' : 'remove']('className')` context.on('ConditionalExpression', conditionalExpression => { const clauses = [conditionalExpression.consequent, conditionalExpression.alternate]; if (!( clauses.every(node => isStringLiteral(node) && (node.value === 'add' || node.value === 'remove')) && clauses[0].value !== clauses[1].value && conditionalExpression.parent.type === 'MemberExpression' && conditionalExpression.parent.computed && !conditionalExpression.parent.optional && conditionalExpression.parent.property === conditionalExpression && isClassList(conditionalExpression.parent.object) && isCallExpression(conditionalExpression.parent.parent, {optional: false, argumentsLength: 1}) && conditionalExpression.parent.parent.callee === conditionalExpression.parent )) { return; } const classListMethod = conditionalExpression.parent; const callExpression = classListMethod.parent; /** @param {import('eslint').Rule.RuleFixer} fixer */ function * fix(fixer) { const isNegative = conditionalExpression.consequent.value === 'remove'; const conditionNode = conditionalExpression.test; const classListContainsCall = getClassListContainsCall(conditionNode, isNegative, callExpression); const conditionText = classListContainsCall ? '' : getConditionText(conditionNode, context, isNegative); if (conditionText) { yield fixer.insertTextAfter(callExpression.arguments[0], `, ${conditionText}`); } yield replaceMemberExpressionProperty(fixer, classListMethod, context, '.toggle'); } return getProblem(callExpression, fix, conditionalExpression); }); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer using `Element#classList.toggle()` to toggle class names.', recommended: 'unopinionated', }, fixable: 'code', hasSuggestions: true, messages, }, }; export default config;