eslint-plugin-vue
Version:
Official ESLint plugin for Vue.js
381 lines (347 loc) • 11 kB
JavaScript
/**
* @fileoverview disallow duplication of class names in class attributes
* @author Yizack Rangel
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @param {VDirective} node
* @param {Expression} [expression]
* @param {boolean} [unconditional=true] whether the expression is unconditional
* @param {Expression} [parentExpr] parent expression for context
* @return {IterableIterator<{ node: Literal | TemplateElement, unconditional: boolean, parentExpr?: Expression }>}
*/
function* extractClassNodes(
node,
expression,
unconditional = true,
parentExpr
) {
const nodeExpression = expression ?? node.value?.expression
if (!nodeExpression) return
switch (nodeExpression.type) {
case 'Literal': {
yield { node: nodeExpression, unconditional, parentExpr }
break
}
case 'ObjectExpression': {
for (const prop of nodeExpression.properties) {
if (
prop.type === 'Property' &&
prop.key?.type === 'Literal' &&
typeof prop.key.value === 'string'
) {
yield {
node: prop.key,
unconditional: false,
parentExpr: nodeExpression
}
}
}
break
}
case 'ArrayExpression': {
for (const element of nodeExpression.elements) {
if (!element || element.type === 'SpreadElement') continue
yield* extractClassNodes(node, element, unconditional, nodeExpression)
}
break
}
case 'ConditionalExpression': {
yield* extractClassNodes(
node,
nodeExpression.consequent,
false,
nodeExpression
)
yield* extractClassNodes(
node,
nodeExpression.alternate,
false,
nodeExpression
)
break
}
case 'TemplateLiteral': {
for (const quasi of nodeExpression.quasis) {
yield { node: quasi, unconditional, parentExpr: nodeExpression }
}
for (const expr of nodeExpression.expressions) {
yield* extractClassNodes(node, expr, unconditional, nodeExpression)
}
break
}
case 'BinaryExpression': {
if (nodeExpression.operator === '+') {
yield* extractClassNodes(
node,
nodeExpression.left,
unconditional,
nodeExpression
)
yield* extractClassNodes(
node,
nodeExpression.right,
unconditional,
nodeExpression
)
}
break
}
case 'LogicalExpression': {
yield* extractClassNodes(
node,
nodeExpression.left,
unconditional,
nodeExpression
)
yield* extractClassNodes(
node,
nodeExpression.right,
false,
nodeExpression
)
break
}
}
}
/**
* @param {string} classList
* @returns {string[]}
*/
function getClassNames(classList) {
return classList.split(/\s+/).filter(Boolean)
}
/**
* @param {string} raw - raw class names string including quotes
* @returns {string}
*/
function removeDuplicateClassNames(raw) {
const quote = raw[0]
const inner = raw.slice(1, -1)
const tokens = inner.split(/(\s+)/)
/** @type {string[]} */
const kept = []
const used = new Set()
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (!token) continue
const isWhitespace = /^\s+$/.test(token)
if (isWhitespace) {
// add whitespace to the last kept item or as leading whitespace
if (kept.length > 0) {
kept[kept.length - 1] += token
} else {
kept.push(token)
}
} else if (used.has(token)) {
// handle duplicate class name
const nextToken = tokens[i + 1]
const hasNextWhitespace =
kept.length > 0 && i + 1 < tokens.length && /^\s+$/.test(nextToken)
if (hasNextWhitespace) {
// update spaces of the last non-whitespace item
for (let j = kept.length - 1; j >= 0; j--) {
const isNotWhitespace = !/^\s+$/.test(kept[j])
if (isNotWhitespace) {
const parts = kept[j].split(/(\s+)/)
kept[j] = parts[0] + nextToken
break
}
}
i++ // skip the whitespace token
}
} else {
kept.push(token)
used.add(token)
}
}
// remove trailing whitespace from the last item if it's not purely whitespace
// unless the original string ended with whitespace
const endsWithSpace = /\s$/.test(inner)
if (kept.length > 0 && !endsWithSpace) {
const lastItem = kept[kept.length - 1]
const isLastWhitespace = /^\s+$/.test(lastItem)
if (!isLastWhitespace) {
const parts = lastItem.split(/(\s+)/)
kept[kept.length - 1] = parts[0]
}
}
return quote + kept.join('') + quote
}
/** @param {VLiteral | Literal | TemplateElement | null} node */
function getRawValue(node) {
if (!node?.value) return null
return typeof node.value === 'object' && 'raw' in node.value
? node.value.raw
: node.value
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
url: 'https://eslint.vuejs.org/rules/no-duplicate-class-names.html',
description: 'disallow duplication of class names in class attributes',
categories: undefined
},
fixable: 'code',
schema: [],
messages: {
duplicateClassNames: 'Duplicate class name{{plural}} {{names}}.'
}
},
/** @param {RuleContext} context */
create: (context) => {
/**
* @param {VLiteral | Literal | TemplateElement | null} node
*/
function reportDuplicateClasses(node) {
if (!node?.value) return
const classList = getRawValue(node)
if (typeof classList !== 'string') return
const classNames = getClassNames(classList)
if (classNames.length <= 1) return
const seen = new Set()
const duplicates = new Set()
for (const className of classNames) {
if (seen.has(className)) {
duplicates.add(className)
} else {
seen.add(className)
}
}
if (duplicates.size === 0) return
context.report({
node,
messageId: 'duplicateClassNames',
data: {
names: [...duplicates].map((name) => `'${name}'`).join(', '),
plural: duplicates.size > 1 ? 's' : ''
},
fix: (fixer) => {
const sourceCode = context.sourceCode
const raw = sourceCode.text.slice(node.range[0], node.range[1])
return fixer.replaceText(node, removeDuplicateClassNames(raw))
}
})
return duplicates
}
return utils.defineTemplateBodyVisitor(context, {
/** @param {VAttribute} node */
"VAttribute[directive=false][key.name='class'][value.type='VLiteral']"(
node
) {
reportDuplicateClasses(node.value)
},
/** @param {VDirective} node */
"VAttribute[directive=true][key.argument.name='class'][value.type='VExpressionContainer']"(
node
) {
const parent = node.parent
const attrs = parent.attributes || []
const staticAttr = attrs.find(
(attr) =>
attr.key &&
attr.key.name === 'class' &&
attr.value &&
attr.value.type === 'VLiteral'
)
// get static classes
/** @type {Set<string> | null} */
let staticClasses = null
if (
staticAttr &&
staticAttr.value &&
staticAttr.value.type === 'VLiteral'
) {
staticClasses = new Set(getClassNames(String(staticAttr.value.value)))
}
/** @type {Set<string>} */
const reported = new Set()
/** @type {Set<string>} */
const duplicatesInExpression = new Set()
/** @type {Map<string, ASTNode>} */
const seen = new Map()
/** @type {Map<string, {node: ASTNode, unconditional: boolean, parentExpr?: Expression}>} */
const collected = new Map()
const classNodes = extractClassNodes(node)
for (const {
node: reportNode,
unconditional,
parentExpr
} of classNodes) {
// report fixable duplicates and collect reported class names
const reportedClasses = reportDuplicateClasses(reportNode)
if (reportedClasses) {
for (const reportedClass of reportedClasses)
reported.add(reportedClass)
}
// collect all class names and check for cross nodes duplicates
const classList = getRawValue(reportNode)
if (typeof classList !== 'string') continue
const classNames = getClassNames(classList)
for (const className of classNames) {
// skip if already reported by reportDuplicateClasses
if (reported.has(className)) continue
const existing = collected.get(className)
if (existing) {
// only add duplicate if at least one is unconditional, or share the same combining parent
const isSameParent =
parentExpr &&
existing.parentExpr === parentExpr &&
(parentExpr.type === 'BinaryExpression' ||
parentExpr.type === 'TemplateLiteral')
if (existing.unconditional || unconditional || isSameParent) {
duplicatesInExpression.add(className)
}
} else {
collected.set(className, {
node: reportNode.parent,
unconditional,
parentExpr
})
}
// track unconditional duplicates separately for reporting
if (unconditional) {
if (seen.has(className)) {
duplicatesInExpression.add(className)
} else {
seen.set(className, reportNode.parent)
}
}
}
// report cross attribute duplicates
if (staticClasses) {
const intersection = classNames.filter((n) => staticClasses.has(n))
if (intersection.length > 0 && parent) {
context.report({
node: parent,
messageId: 'duplicateClassNames',
data: {
names: intersection.map((name) => `'${name}'`).join(', '),
plural: intersection.length > 1 ? 's' : ''
}
})
}
}
}
// report cross node duplicates
for (const className of duplicatesInExpression) {
const reportNode =
seen.get(className) || collected.get(className)?.node
if (reportNode) {
context.report({
node: reportNode,
messageId: 'duplicateClassNames',
data: {
names: `'${className}'`,
plural: ''
}
})
}
}
}
})
}
}