eslint-plugin-unicorn
Version:
More than 100 powerful ESLint rules
256 lines (213 loc) • 7.13 kB
JavaScript
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;