Skip to content

Commit 2fb1f30

Browse files
authored
feat(prefer-true-attribute-shorthand): add except option (#2694)
1 parent 82f7e2b commit 2fb1f30

File tree

3 files changed

+271
-57
lines changed

3 files changed

+271
-57
lines changed

docs/rules/prefer-true-attribute-shorthand.md

+27-1
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,18 @@ Default options is `"always"`.
8181

8282
```json
8383
{
84-
"vue/prefer-true-attribute-shorthand": ["error", "always" | "never"]
84+
"vue/prefer-true-attribute-shorthand": ["error",
85+
"always" | "never",
86+
{
87+
except: []
88+
}
89+
]
8590
}
8691
```
8792

8893
- `"always"` (default) ... requires shorthand form.
8994
- `"never"` ... requires long form.
95+
- `except` (`string[]`) ... specifies a list of attribute names that should be treated differently.
9096

9197
### `"never"`
9298

@@ -105,6 +111,26 @@ Default options is `"always"`.
105111

106112
</eslint-code-block>
107113

114+
### `"never", { 'except': ['value', '/^foo-/'] }`
115+
116+
<eslint-code-block :rules="{'vue/prefer-true-attribute-shorthand': ['error', 'never', { 'except': ['value', '/^foo-/'] }]}">
117+
118+
```vue
119+
<template>
120+
<!-- ✗ BAD -->
121+
<MyComponent show />
122+
<MyComponent :value="true" />
123+
<MyComponent :foo-bar="true" />
124+
125+
<!-- ✓ GOOD -->
126+
<MyComponent :show="true" />
127+
<MyComponent value />
128+
<MyComponent foo-bar />
129+
</template>
130+
```
131+
132+
</eslint-code-block>
133+
108134
## :couple: Related Rules
109135

110136
- [vue/no-boolean-default](./no-boolean-default.md)

lib/rules/prefer-true-attribute-shorthand.js

+134-56
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,60 @@
44
*/
55
'use strict'
66

7+
const { toRegExp } = require('../utils/regexp')
78
const utils = require('../utils')
89

10+
/**
11+
* @typedef { 'always' | 'never' } PreferOption
12+
*/
13+
14+
/**
15+
* @param {VDirective | VAttribute} node
16+
* @returns {string | null}
17+
*/
18+
function getAttributeName(node) {
19+
if (!node.directive) {
20+
return node.key.rawName
21+
}
22+
23+
if (
24+
(node.key.name.name === 'bind' || node.key.name.name === 'model') &&
25+
node.key.argument &&
26+
node.key.argument.type === 'VIdentifier'
27+
) {
28+
return node.key.argument.rawName
29+
}
30+
31+
return null
32+
}
33+
/**
34+
* @param {VAttribute | VDirective} node
35+
* @param {boolean} isExcepted
36+
* @param {PreferOption} option
37+
*/
38+
function shouldConvertToLongForm(node, isExcepted, option) {
39+
return (
40+
!node.directive &&
41+
!node.value &&
42+
(option === 'always' ? isExcepted : !isExcepted)
43+
)
44+
}
45+
46+
/**
47+
* @param {VAttribute | VDirective} node
48+
* @param {boolean} isExcepted
49+
* @param {PreferOption} option
50+
*/
51+
function shouldConvertToShortForm(node, isExcepted, option) {
52+
const isLiteralTrue =
53+
node.directive &&
54+
node.value?.expression?.type === 'Literal' &&
55+
node.value.expression.value === true &&
56+
Boolean(node.key.argument)
57+
58+
return isLiteralTrue && (option === 'always' ? !isExcepted : isExcepted)
59+
}
60+
961
module.exports = {
1062
meta: {
1163
type: 'suggestion',
@@ -17,7 +69,20 @@ module.exports = {
1769
},
1870
fixable: null,
1971
hasSuggestions: true,
20-
schema: [{ enum: ['always', 'never'] }],
72+
schema: [
73+
{ enum: ['always', 'never'] },
74+
{
75+
type: 'object',
76+
properties: {
77+
except: {
78+
type: 'array',
79+
items: { type: 'string' },
80+
uniqueItems: true
81+
}
82+
},
83+
additionalProperties: false
84+
}
85+
],
2186
messages: {
2287
expectShort:
2388
"Boolean prop with 'true' value should be written in shorthand form.",
@@ -34,68 +99,81 @@ module.exports = {
3499
create(context) {
35100
/** @type {'always' | 'never'} */
36101
const option = context.options[0] || 'always'
102+
/** @type {RegExp[]} */
103+
const exceptReg = (context.options[1]?.except || []).map(toRegExp)
104+
105+
/**
106+
* @param {VAttribute | VDirective} node
107+
* @param {string} messageId
108+
* @param {string} longVuePropText
109+
* @param {string} longHtmlAttrText
110+
*/
111+
function reportLongForm(
112+
node,
113+
messageId,
114+
longVuePropText,
115+
longHtmlAttrText
116+
) {
117+
context.report({
118+
node,
119+
messageId,
120+
suggest: [
121+
{
122+
messageId: 'rewriteIntoLongVueProp',
123+
fix: (fixer) => fixer.replaceText(node, longVuePropText)
124+
},
125+
{
126+
messageId: 'rewriteIntoLongHtmlAttr',
127+
fix: (fixer) => fixer.replaceText(node, longHtmlAttrText)
128+
}
129+
]
130+
})
131+
}
132+
133+
/**
134+
* @param {VAttribute | VDirective} node
135+
* @param {string} messageId
136+
* @param {string} shortFormText
137+
*/
138+
function reportShortForm(node, messageId, shortFormText) {
139+
context.report({
140+
node,
141+
messageId,
142+
suggest: [
143+
{
144+
messageId: 'rewriteIntoShort',
145+
fix: (fixer) => fixer.replaceText(node, shortFormText)
146+
}
147+
]
148+
})
149+
}
37150

38151
return utils.defineTemplateBodyVisitor(context, {
39152
VAttribute(node) {
40-
if (!utils.isCustomComponent(node.parent.parent)) {
41-
return
42-
}
43-
44-
if (option === 'never' && !node.directive && !node.value) {
45-
context.report({
46-
node,
47-
messageId: 'expectLong',
48-
suggest: [
49-
{
50-
messageId: 'rewriteIntoLongVueProp',
51-
fix: (fixer) =>
52-
fixer.replaceText(node, `:${node.key.rawName}="true"`)
53-
},
54-
{
55-
messageId: 'rewriteIntoLongHtmlAttr',
56-
fix: (fixer) =>
57-
fixer.replaceText(
58-
node,
59-
`${node.key.rawName}="${node.key.rawName}"`
60-
)
61-
}
62-
]
63-
})
64-
return
65-
}
153+
if (!utils.isCustomComponent(node.parent.parent)) return
66154

67-
if (option !== 'always') {
68-
return
69-
}
155+
const name = getAttributeName(node)
156+
if (name === null) return
70157

71-
if (
72-
!node.directive ||
73-
!node.value ||
74-
!node.value.expression ||
75-
node.value.expression.type !== 'Literal' ||
76-
node.value.expression.value !== true
77-
) {
78-
return
79-
}
158+
const isExcepted = exceptReg.some((re) => re.test(name))
80159

81-
const { argument } = node.key
82-
if (!argument) {
83-
return
160+
if (shouldConvertToLongForm(node, isExcepted, option)) {
161+
const key = /** @type {VIdentifier} */ (node.key)
162+
reportLongForm(
163+
node,
164+
'expectLong',
165+
`:${key.rawName}="true"`,
166+
`${key.rawName}="${key.rawName}"`
167+
)
168+
} else if (shouldConvertToShortForm(node, isExcepted, option)) {
169+
const directiveKey = /** @type {VDirectiveKey} */ (node.key)
170+
if (
171+
directiveKey.argument &&
172+
directiveKey.argument.type === 'VIdentifier'
173+
) {
174+
reportShortForm(node, 'expectShort', directiveKey.argument.rawName)
175+
}
84176
}
85-
86-
context.report({
87-
node,
88-
messageId: 'expectShort',
89-
suggest: [
90-
{
91-
messageId: 'rewriteIntoShort',
92-
fix: (fixer) => {
93-
const sourceCode = context.getSourceCode()
94-
return fixer.replaceText(node, sourceCode.getText(argument))
95-
}
96-
}
97-
]
98-
})
99177
}
100178
})
101179
}

tests/lib/rules/prefer-true-attribute-shorthand.js

+110
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,24 @@ tester.run('prefer-true-attribute-shorthand', rule, {
148148
</template>
149149
`,
150150
options: ['never']
151+
},
152+
{
153+
filename: 'test.vue',
154+
code: `
155+
<template>
156+
<input :value="true" :foo-bar="true" />
157+
</template>
158+
`,
159+
options: ['always', { except: ['value', '/^foo-/'] }]
160+
},
161+
{
162+
filename: 'test.vue',
163+
code: `
164+
<template>
165+
<input value foo-bar />
166+
</template>
167+
`,
168+
options: ['never', { except: ['value', '/^foo-/'] }]
151169
}
152170
],
153171
invalid: [
@@ -280,6 +298,98 @@ tester.run('prefer-true-attribute-shorthand', rule, {
280298
]
281299
}
282300
]
301+
},
302+
{
303+
filename: 'test.vue',
304+
code: `
305+
<template>
306+
<MyComp value foo-bar />
307+
</template>`,
308+
output: null,
309+
options: ['always', { except: ['value', '/^foo-/'] }],
310+
errors: [
311+
{
312+
messageId: 'expectLong',
313+
line: 3,
314+
column: 17,
315+
suggestions: [
316+
{
317+
messageId: 'rewriteIntoLongVueProp',
318+
output: `
319+
<template>
320+
<MyComp :value="true" foo-bar />
321+
</template>`
322+
},
323+
{
324+
messageId: 'rewriteIntoLongHtmlAttr',
325+
output: `
326+
<template>
327+
<MyComp value="value" foo-bar />
328+
</template>`
329+
}
330+
]
331+
},
332+
{
333+
messageId: 'expectLong',
334+
line: 3,
335+
column: 23,
336+
suggestions: [
337+
{
338+
messageId: 'rewriteIntoLongVueProp',
339+
output: `
340+
<template>
341+
<MyComp value :foo-bar="true" />
342+
</template>`
343+
},
344+
{
345+
messageId: 'rewriteIntoLongHtmlAttr',
346+
output: `
347+
<template>
348+
<MyComp value foo-bar="foo-bar" />
349+
</template>`
350+
}
351+
]
352+
}
353+
]
354+
},
355+
{
356+
filename: 'test.vue',
357+
code: `
358+
<template>
359+
<MyComp :value="true" :foo-bar="true" />
360+
</template>`,
361+
output: null,
362+
options: ['never', { except: ['value', '/^foo-/'] }],
363+
errors: [
364+
{
365+
messageId: 'expectShort',
366+
line: 3,
367+
column: 17,
368+
suggestions: [
369+
{
370+
messageId: 'rewriteIntoShort',
371+
output: `
372+
<template>
373+
<MyComp value :foo-bar="true" />
374+
</template>`
375+
}
376+
]
377+
},
378+
{
379+
messageId: 'expectShort',
380+
line: 3,
381+
column: 31,
382+
suggestions: [
383+
{
384+
messageId: 'rewriteIntoShort',
385+
output: `
386+
<template>
387+
<MyComp :value="true" foo-bar />
388+
</template>`
389+
}
390+
]
391+
}
392+
]
283393
}
284394
]
285395
})

0 commit comments

Comments
 (0)