Skip to content

Commit c7dcaca

Browse files
committed
fix: find duplicates within unconditional expression nodes + add comments
related to #2932 discussion
1 parent 47b5065 commit c7dcaca

File tree

3 files changed

+109
-37
lines changed

3 files changed

+109
-37
lines changed

docs/rules/no-duplicate-class-names.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ This rule prevents the same class name from appearing multiple times within the
3939
<div :class="isActive ? 'foo foo' : 'bar'"></div>
4040
<div :class="'foo foo ' + 'bar'"></div>
4141
<div class="foo" :class="'foo'"></div>
42+
<div :class="['foo', 'foo']"></div>
43+
<div :class="'foo ' + 'foo'"></div>
4244
</template>
4345
```
4446

lib/rules/no-duplicate-class-names.js

Lines changed: 77 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ const utils = require('../utils')
1010
/**
1111
* @param {VDirective} node
1212
* @param {Expression} [expression]
13-
* @return {IterableIterator<{ node: Literal | TemplateElement }>}
13+
* @param {boolean} [unconditional=true] whether the expression is unconditional
14+
* @return {IterableIterator<{ node: Literal | TemplateElement, unconditional: boolean }>}
1415
*/
15-
function* extractClassNodes(node, expression) {
16+
function* extractClassNodes(node, expression, unconditional = true) {
1617
const nodeExpression = expression ?? node.value?.expression
1718
if (!nodeExpression) return
1819

1920
switch (nodeExpression.type) {
2021
case 'Literal': {
21-
yield { node: nodeExpression }
22+
yield { node: nodeExpression, unconditional }
2223
break
2324
}
2425
case 'ObjectExpression': {
@@ -28,37 +29,36 @@ function* extractClassNodes(node, expression) {
2829
prop.key?.type === 'Literal' &&
2930
typeof prop.key.value === 'string'
3031
) {
31-
yield { node: prop.key }
32+
yield { node: prop.key, unconditional: false }
3233
}
3334
}
3435
break
3536
}
3637
case 'ArrayExpression': {
3738
for (const element of nodeExpression.elements) {
3839
if (!element || element.type === 'SpreadElement') continue
39-
40-
yield* extractClassNodes(node, element)
40+
yield* extractClassNodes(node, element, unconditional)
4141
}
4242
break
4343
}
4444
case 'ConditionalExpression': {
45-
yield* extractClassNodes(node, nodeExpression.consequent)
46-
yield* extractClassNodes(node, nodeExpression.alternate)
45+
yield* extractClassNodes(node, nodeExpression.consequent, false)
46+
yield* extractClassNodes(node, nodeExpression.alternate, false)
4747
break
4848
}
4949
case 'TemplateLiteral': {
5050
for (const quasi of nodeExpression.quasis) {
51-
yield { node: quasi }
51+
yield { node: quasi, unconditional }
5252
}
5353
for (const expr of nodeExpression.expressions) {
54-
yield* extractClassNodes(node, expr)
54+
yield* extractClassNodes(node, expr, unconditional)
5555
}
5656
break
5757
}
5858
case 'BinaryExpression': {
5959
if (nodeExpression.operator === '+') {
60-
yield* extractClassNodes(node, nodeExpression.left)
61-
yield* extractClassNodes(node, nodeExpression.right)
60+
yield* extractClassNodes(node, nodeExpression.left, unconditional)
61+
yield* extractClassNodes(node, nodeExpression.right, unconditional)
6262
}
6363
break
6464
}
@@ -138,6 +138,14 @@ function removeDuplicateClassNames(raw) {
138138
return quote + kept.join('') + quote
139139
}
140140

141+
/** @param {VLiteral | Literal | TemplateElement | null} node */
142+
function getRawValue(node) {
143+
if (!node?.value) return null
144+
return typeof node.value === 'object' && 'raw' in node.value
145+
? node.value.raw
146+
: node.value
147+
}
148+
141149
module.exports = {
142150
meta: {
143151
type: 'suggestion',
@@ -160,11 +168,7 @@ module.exports = {
160168
function reportDuplicateClasses(node) {
161169
if (!node?.value) return
162170

163-
const classList =
164-
typeof node.value === 'object' && 'raw' in node.value
165-
? node.value.raw
166-
: node.value
167-
171+
const classList = getRawValue(node)
168172
if (typeof classList !== 'string') return
169173

170174
const classNames = getClassNames(classList)
@@ -193,6 +197,8 @@ module.exports = {
193197
return fixer.replaceText(node, removeDuplicateClassNames(raw))
194198
}
195199
})
200+
201+
return duplicates
196202
}
197203

198204
return utils.defineTemplateBodyVisitor(context, {
@@ -208,7 +214,6 @@ module.exports = {
208214
) {
209215
const parent = node.parent
210216
const attrs = parent.attributes || []
211-
212217
const staticAttr = attrs.find(
213218
(attr) =>
214219
attr.key &&
@@ -217,9 +222,9 @@ module.exports = {
217222
attr.value.type === 'VLiteral'
218223
)
219224

225+
// get static classes
220226
/** @type {Set<string> | null} */
221227
let staticClasses = null
222-
223228
if (
224229
staticAttr &&
225230
staticAttr.value &&
@@ -228,30 +233,65 @@ module.exports = {
228233
staticClasses = new Set(getClassNames(String(staticAttr.value.value)))
229234
}
230235

231-
for (const { node: reportNode } of extractClassNodes(node)) {
232-
reportDuplicateClasses(reportNode)
236+
const reported = new Set()
237+
const duplicatesInExpression = new Set()
238+
/** @type {Map<string, ASTNode>} */
239+
const seen = new Map()
233240

234-
if (staticClasses) {
235-
const classList =
236-
reportNode.value &&
237-
typeof reportNode.value === 'object' &&
238-
'raw' in reportNode.value
239-
? reportNode.value.raw
240-
: reportNode.value
241+
const classNodes = extractClassNodes(node)
242+
for (const { node: reportNode, unconditional } of classNodes) {
243+
// report fixable duplicates and collect reported class names
244+
const reportedClasses = reportDuplicateClasses(reportNode)
245+
if (reportedClasses) {
246+
for (const classes of reportedClasses) reported.add(classes)
247+
}
241248

242-
if (typeof classList !== 'string') continue
249+
// collect duplicates within the expression nodes
250+
if (unconditional) {
251+
const classList = getRawValue(reportNode)
252+
if (typeof classList === 'string') {
253+
const classNames = getClassNames(classList)
254+
for (const className of classNames) {
255+
if (seen.has(className)) {
256+
duplicatesInExpression.add(className)
257+
} else {
258+
seen.set(className, reportNode.parent)
259+
}
260+
}
261+
}
262+
}
243263

244-
const classNames = getClassNames(classList)
245-
const intersection = classNames.filter((n) => staticClasses.has(n))
246-
if (intersection.length > 0 && parent) {
247-
context.report({
248-
node: parent,
249-
messageId: 'duplicateClassName',
250-
data: { name: intersection.join(', ') }
251-
})
264+
// report duplicates between static and dynamic class attributes
265+
if (staticClasses) {
266+
const classList = getRawValue(reportNode)
267+
if (typeof classList === 'string') {
268+
const classNames = getClassNames(classList)
269+
const intersection = classNames.filter((n) =>
270+
staticClasses.has(n)
271+
)
272+
if (intersection.length > 0 && parent) {
273+
context.report({
274+
node: parent,
275+
messageId: 'duplicateClassName',
276+
data: { name: intersection.join(', ') }
277+
})
278+
}
252279
}
253280
}
254281
}
282+
283+
// report duplicates between dynamic class nodes excluding already reported
284+
for (const r of reported) duplicatesInExpression.delete(r)
285+
for (const className of duplicatesInExpression) {
286+
const reportNode = seen.get(className)
287+
if (reportNode) {
288+
context.report({
289+
node: reportNode,
290+
messageId: 'duplicateClassName',
291+
data: { name: className }
292+
})
293+
}
294+
}
255295
}
256296
})
257297
}

tests/lib/rules/no-duplicate-class-names.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ tester.run('no-duplicate-class-names', rule, {
4848
{
4949
filename: 'class-conditional-expression.vue',
5050
code: `<template><div :class="isActive ? 'foo' : 'bar'"></div></template>`
51+
},
52+
{
53+
filename: 'class-conditional-duplicate-expression-value.vue',
54+
code: `<template><div :class="isActive ? 'foo' : 'foo'"></div></template>`
55+
},
56+
{
57+
filename: 'class-object-duplicate-value.vue',
58+
code: `<div :class="{ 'foo bar': isActive, 'foo': isAnotherActive }"></div>`
5159
}
5260
],
5361
invalid: [
@@ -315,6 +323,28 @@ tester.run('no-duplicate-class-names', rule, {
315323
type: 'VStartTag'
316324
}
317325
]
326+
},
327+
{
328+
filename: 'duplicate-class-cross-node-array.vue',
329+
code: `<template><div :class="['foo', 'foo']"></div></template>`,
330+
output: null,
331+
errors: [
332+
{
333+
message: "Duplicate class name 'foo'.",
334+
type: 'ArrayExpression'
335+
}
336+
]
337+
},
338+
{
339+
filename: 'duplicate-class-cross-node-binary.vue',
340+
code: `<template><div :class="'foo ' + 'foo'"></div></template>`,
341+
output: null,
342+
errors: [
343+
{
344+
message: "Duplicate class name 'foo'.",
345+
type: 'BinaryExpression'
346+
}
347+
]
318348
}
319349
]
320350
})

0 commit comments

Comments
 (0)