Skip to content

Commit 1b04b2a

Browse files
committed
feat: support BinaryExpression + preserve spaces
1 parent 535b73a commit 1b04b2a

File tree

3 files changed

+119
-3
lines changed

3 files changed

+119
-3
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This rule prevents the same class name from appearing multiple times within the
2626
<div :class="{ 'foo bar': true }"></div>
2727
<div :class="['foo', 'bar']"></div>
2828
<div :class="isActive ? 'foo' : 'bar'"></div>
29+
<div :class="'foo ' + 'bar'"></div>
2930
3031
<!-- ✗ BAD -->
3132
<div class="foo foo"></div>
@@ -36,6 +37,7 @@ This rule prevents the same class name from appearing multiple times within the
3637
<div :class="['foo foo']"></div>
3738
<div :class="['foo foo', { 'bar bar baz': true }]"></div>
3839
<div :class="isActive ? 'foo foo' : 'bar'"></div>
40+
<div :class="'foo foo ' + 'bar'"></div>
3941
</template>
4042
```
4143

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

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,79 @@ function* extractDuplicateNode(node, expression) {
5757
}
5858
break
5959
}
60+
case 'BinaryExpression': {
61+
if (nodeExpression.operator === '+') {
62+
yield* extractDuplicateNode(node, nodeExpression.left)
63+
yield* extractDuplicateNode(node, nodeExpression.right)
64+
}
65+
break
66+
}
67+
}
68+
}
69+
70+
/**
71+
* @param {string} raw - raw class names string including quotes
72+
* @returns {string}
73+
*/
74+
function dedupePreserveSpaces(raw) {
75+
const inner = raw.slice(1, -1)
76+
const tokens = inner.split(/(\s+)/)
77+
78+
/** @type {string[]} */
79+
const kept = []
80+
const used = new Set()
81+
82+
for (let i = 0; i < tokens.length; i++) {
83+
const token = tokens[i]
84+
if (!token) continue
85+
86+
const isWhitespace = /^\s+$/.test(token)
87+
88+
if (isWhitespace) {
89+
// add whitespace to the last kept item or as leading whitespace
90+
if (kept.length > 0) {
91+
kept[kept.length - 1] += token
92+
} else {
93+
kept.push(token)
94+
}
95+
} else if (used.has(token)) {
96+
// handle duplicate class name
97+
const nextToken = tokens[i + 1]
98+
const hasNextWhitespace =
99+
kept.length > 0 && i + 1 < tokens.length && /^\s+$/.test(nextToken)
100+
101+
if (hasNextWhitespace) {
102+
// update spaces of the last non-whitespace item
103+
for (let j = kept.length - 1; j >= 0; j--) {
104+
const isNotWhitespace = !/^\s+$/.test(kept[j])
105+
if (isNotWhitespace) {
106+
const parts = kept[j].split(/(\s+)/)
107+
kept[j] = parts[0] + nextToken
108+
break
109+
}
110+
}
111+
i++ // skip the whitespace token
112+
}
113+
} else {
114+
kept.push(token)
115+
used.add(token)
116+
}
60117
}
118+
119+
// remove trailing whitespace from the last item if it's not purely whitespace
120+
// unless the original string ended with whitespace
121+
const endsWithSpace = /\s$/.test(inner)
122+
if (kept.length > 0 && !endsWithSpace) {
123+
const lastItem = kept[kept.length - 1]
124+
const isLastWhitespace = /^\s+$/.test(lastItem)
125+
if (!isLastWhitespace) {
126+
const parts = lastItem.split(/(\s+)/)
127+
kept[kept.length - 1] = parts[0]
128+
}
129+
}
130+
131+
const quote = raw[0]
132+
return quote + kept.join('') + quote
61133
}
62134

63135
module.exports = {
@@ -113,11 +185,9 @@ module.exports = {
113185
messageId: 'duplicateClassName',
114186
data: { name: duplicates.join(', ') },
115187
fix: (fixer) => {
116-
const unique = [...seen].join(' ')
117188
const sourceCode = context.getSourceCode()
118189
const raw = sourceCode.text.slice(node.range[0], node.range[1])
119-
const quote = raw[0]
120-
return fixer.replaceText(node, `${quote}${unique}${quote}`)
190+
return fixer.replaceText(node, dedupePreserveSpaces(raw))
121191
}
122192
})
123193
}

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,50 @@ tester.run('no-duplicate-class-names', rule, {
184184
type: 'Literal'
185185
}
186186
]
187+
},
188+
{
189+
filename: 'duplicate-class-binary-expression.vue',
190+
code: `<template><div :class="'foo foo ' + ' bar'"></div></template>`,
191+
output: `<template><div :class="'foo ' + ' bar'"></div></template>`,
192+
errors: [
193+
{
194+
message: "Duplicate class name 'foo'.",
195+
type: 'Literal'
196+
}
197+
]
198+
},
199+
{
200+
filename: 'duplicate-class-preserved-spaces-1.vue',
201+
code: `<template><div class="foo foo bar"></div></template>`,
202+
output: `<template><div class="foo bar"></div></template>`,
203+
errors: [
204+
{
205+
message: "Duplicate class name 'foo'.",
206+
type: 'VLiteral'
207+
}
208+
]
209+
},
210+
{
211+
filename: 'duplicate-class-preserved-spaces-2.vue',
212+
code: `<template><div class="foo bar baz foo"></div></template>`,
213+
output: `<template><div class="foo bar baz"></div></template>`,
214+
errors: [
215+
{
216+
message: "Duplicate class name 'foo'.",
217+
type: 'VLiteral'
218+
}
219+
]
220+
},
221+
{
222+
filename: 'duplicate-class-preserved-spaces-3.vue',
223+
code: `<template><div class="foo bar foo baz"></div></template>`,
224+
output: `<template><div class="foo bar baz"></div></template>`,
225+
errors: [
226+
{
227+
message: "Duplicate class name 'foo'.",
228+
type: 'VLiteral'
229+
}
230+
]
187231
}
188232
]
189233
})

0 commit comments

Comments
 (0)