Skip to content

Commit 5cf4ba5

Browse files
committed
feat(v-bind-style): add sameNameShorthand option
1 parent 037ada2 commit 5cf4ba5

File tree

2 files changed

+297
-41
lines changed

2 files changed

+297
-41
lines changed

lib/rules/v-bind-style.js

+144-40
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,56 @@
66
'use strict'
77

88
const utils = require('../utils')
9+
const casing = require('../utils/casing')
10+
11+
/**
12+
* @typedef { VDirectiveKey & { name: VIdentifier & { name: 'bind' }, argument: VExpressionContainer | VIdentifier } } VBindDirectiveKey
13+
* @typedef { VDirective & { key: VBindDirectiveKey } } VBindDirective
14+
*/
15+
16+
/**
17+
* @param {VBindDirective} node
18+
* @returns {boolean}
19+
*/
20+
function isSameName(node) {
21+
/** @returns {string | null} */
22+
function getAttributeName() {
23+
// not support VExpressionContainer e.g. :[attribute]
24+
if (node.key.argument.type === 'VIdentifier') {
25+
return node.key.argument.rawName
26+
}
27+
28+
return null
29+
}
30+
31+
/** @returns {string | null} */
32+
function getValueName() {
33+
if (node.value?.expression?.type === 'Identifier') {
34+
return node.value.expression.name
35+
}
36+
37+
return null
38+
}
39+
40+
const attrName = getAttributeName()
41+
const valueName = getValueName()
42+
return Boolean(
43+
attrName &&
44+
valueName &&
45+
casing.camelCase(attrName) === casing.camelCase(valueName)
46+
)
47+
}
48+
49+
/**
50+
* @param {VBindDirectiveKey} key
51+
* @returns {number}
52+
*/
53+
function getCutStart(key) {
54+
const modifiers = key.modifiers
55+
return modifiers.length > 0
56+
? modifiers[modifiers.length - 1].range[1]
57+
: key.argument.range[1]
58+
}
959

1060
module.exports = {
1161
meta: {
@@ -16,60 +66,114 @@ module.exports = {
1666
url: 'https://eslint.vuejs.org/rules/v-bind-style.html'
1767
},
1868
fixable: 'code',
19-
schema: [{ enum: ['shorthand', 'longform'] }],
69+
schema: [
70+
{ enum: ['shorthand', 'longform'] },
71+
{
72+
type: 'object',
73+
properties: {
74+
sameNameShorthand: { enum: ['always', 'never', 'ignore'] }
75+
},
76+
additionalProperties: false
77+
}
78+
],
2079
messages: {
2180
expectedLonghand: "Expected 'v-bind' before ':'.",
2281
unexpectedLonghand: "Unexpected 'v-bind' before ':'.",
23-
expectedLonghandForProp: "Expected 'v-bind:' instead of '.'."
82+
expectedLonghandForProp: "Expected 'v-bind:' instead of '.'.",
83+
expectedShorthand: 'Expected shorthand same name.',
84+
unexpectedShorthand: 'Unexpected shorthand same name.'
2485
}
2586
},
2687
/** @param {RuleContext} context */
2788
create(context) {
2889
const preferShorthand = context.options[0] !== 'longform'
90+
/** @type {"always" | "never" | "ignore"} */
91+
const sameNameShorthand = context.options[1]?.sameNameShorthand || 'ignore'
2992

30-
return utils.defineTemplateBodyVisitor(context, {
31-
/** @param {VDirective} node */
32-
"VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"(
33-
node
34-
) {
35-
const shorthandProp = node.key.name.rawName === '.'
36-
const shorthand = node.key.name.rawName === ':' || shorthandProp
37-
if (shorthand === preferShorthand) {
38-
return
39-
}
93+
/** @param {VBindDirective} node */
94+
function checkPropForm(node) {
95+
const shorthandProp = node.key.name.rawName === '.'
96+
const shorthand = node.key.name.rawName === ':' || shorthandProp
97+
if (shorthand === preferShorthand) {
98+
return
99+
}
40100

41-
let messageId = 'expectedLonghand'
42-
if (preferShorthand) {
43-
messageId = 'unexpectedLonghand'
44-
} else if (shorthandProp) {
45-
messageId = 'expectedLonghandForProp'
46-
}
101+
let messageId = 'expectedLonghand'
102+
if (preferShorthand) {
103+
messageId = 'unexpectedLonghand'
104+
} else if (shorthandProp) {
105+
messageId = 'expectedLonghandForProp'
106+
}
107+
108+
context.report({
109+
node,
110+
loc: node.loc,
111+
messageId,
112+
*fix(fixer) {
113+
if (preferShorthand) {
114+
yield fixer.remove(node.key.name)
115+
} else {
116+
yield fixer.insertTextBefore(node, 'v-bind')
117+
118+
if (shorthandProp) {
119+
// Replace `.` by `:`.
120+
yield fixer.replaceText(node.key.name, ':')
47121

48-
context.report({
49-
node,
50-
loc: node.loc,
51-
messageId,
52-
*fix(fixer) {
53-
if (preferShorthand) {
54-
yield fixer.remove(node.key.name)
55-
} else {
56-
yield fixer.insertTextBefore(node, 'v-bind')
57-
58-
if (shorthandProp) {
59-
// Replace `.` by `:`.
60-
yield fixer.replaceText(node.key.name, ':')
61-
62-
// Insert `.prop` modifier if it doesn't exist.
63-
const modifier = node.key.modifiers[0]
64-
const isAutoGeneratedPropModifier =
65-
modifier.name === 'prop' && modifier.rawName === ''
66-
if (isAutoGeneratedPropModifier) {
67-
yield fixer.insertTextBefore(modifier, '.prop')
68-
}
122+
// Insert `.prop` modifier if it doesn't exist.
123+
const modifier = node.key.modifiers[0]
124+
const isAutoGeneratedPropModifier =
125+
modifier.name === 'prop' && modifier.rawName === ''
126+
if (isAutoGeneratedPropModifier) {
127+
yield fixer.insertTextBefore(modifier, '.prop')
69128
}
70129
}
71130
}
72-
})
131+
}
132+
})
133+
}
134+
135+
/** @param {VBindDirective} node */
136+
function checkPropSameName(node) {
137+
if (sameNameShorthand === 'ignore' || !isSameName(node)) return
138+
139+
const preferShorthand = sameNameShorthand === 'always'
140+
const isShortHand = utils.isVBindSameNameShorthand(node)
141+
if (isShortHand === preferShorthand) {
142+
return
143+
}
144+
145+
let messageId = 'unexpectedShorthand'
146+
if (preferShorthand) {
147+
messageId = 'expectedShorthand'
148+
}
149+
150+
context.report({
151+
node,
152+
loc: node.loc,
153+
messageId,
154+
*fix(fixer) {
155+
if (preferShorthand) {
156+
/** @type {Range} */
157+
const valueRange = [getCutStart(node.key), node.range[1]]
158+
159+
yield fixer.removeRange(valueRange)
160+
} else if (node.key.argument.type === 'VIdentifier') {
161+
yield fixer.insertTextAfter(
162+
node,
163+
`="${casing.camelCase(node.key.argument.rawName)}"`
164+
)
165+
}
166+
}
167+
})
168+
}
169+
170+
return utils.defineTemplateBodyVisitor(context, {
171+
/** @param {VBindDirective} node */
172+
"VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"(
173+
node
174+
) {
175+
checkPropSameName(node)
176+
checkPropForm(node)
73177
}
74178
})
75179
}

tests/lib/rules/v-bind-style.js

+153-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const tester = new RuleTester({
1212
languageOptions: { parser: require('vue-eslint-parser'), ecmaVersion: 2015 }
1313
})
1414

15+
const expectedShorthand = 'Expected shorthand same name.'
16+
const unexpectedShorthand = 'Unexpected shorthand same name.'
17+
1518
tester.run('v-bind-style', rule, {
1619
valid: [
1720
{
@@ -34,7 +37,7 @@ tester.run('v-bind-style', rule, {
3437
{
3538
filename: 'test.vue',
3639
code: '<template><div v-bind:foo="foo"></div></template>',
37-
options: ['longform']
40+
options: ['longform', { sameNameShorthand: 'ignore' }]
3841
},
3942

4043
// Don't enforce `.prop` shorthand because of experimental.
@@ -55,6 +58,72 @@ tester.run('v-bind-style', rule, {
5558
filename: 'test.vue',
5659
code: '<template><div .foo="foo"></div></template>',
5760
options: ['shorthand']
61+
},
62+
// same-name shorthand: never
63+
{
64+
filename: 'test.vue',
65+
code: '<template><div :foo="foo" /></template>',
66+
options: ['shorthand', { sameNameShorthand: 'never' }]
67+
},
68+
{
69+
filename: 'test.vue',
70+
code: '<template><div v-bind:foo="foo" /></template>',
71+
options: ['longform', { sameNameShorthand: 'never' }]
72+
},
73+
{
74+
// modifier
75+
filename: 'test.vue',
76+
code: `
77+
<template>
78+
<div :foo.prop="foo" />
79+
<div .foo="foo" />
80+
</template>
81+
`,
82+
options: ['shorthand', { sameNameShorthand: 'never' }]
83+
},
84+
{
85+
filename: 'test.vue',
86+
code: '<template><div v-bind:foo.prop="foo" /></template>',
87+
options: ['longform', { sameNameShorthand: 'never' }]
88+
},
89+
{
90+
// camel case
91+
filename: 'test.vue',
92+
code: '<template><div :foo-bar="fooBar" /></template>',
93+
options: ['shorthand', { sameNameShorthand: 'never' }]
94+
},
95+
// same-name shorthand: always
96+
{
97+
filename: 'test.vue',
98+
code: '<template><div :foo /></template>',
99+
options: ['shorthand', { sameNameShorthand: 'always' }]
100+
},
101+
{
102+
filename: 'test.vue',
103+
code: '<template><div v-bind:foo /></template>',
104+
options: ['longform', { sameNameShorthand: 'always' }]
105+
},
106+
{
107+
// modifier
108+
filename: 'test.vue',
109+
code: `
110+
<template>
111+
<div :foo.prop />
112+
<div .foo />
113+
</template>
114+
`,
115+
options: ['shorthand', { sameNameShorthand: 'always' }]
116+
},
117+
{
118+
filename: 'test.vue',
119+
code: '<template><div v-bind:foo.prop /></template>',
120+
options: ['longform', { sameNameShorthand: 'always' }]
121+
},
122+
{
123+
// camel case
124+
filename: 'test.vue',
125+
code: '<template><div :foo-bar/></template>',
126+
options: ['shorthand', { sameNameShorthand: 'always' }]
58127
}
59128
],
60129
invalid: [
@@ -120,6 +189,89 @@ tester.run('v-bind-style', rule, {
120189
output: '<template><div v-bind:foo /></template>',
121190
options: ['longform'],
122191
errors: ["Expected 'v-bind' before ':'."]
192+
},
193+
// same-name shorthand: never
194+
{
195+
filename: 'test.vue',
196+
code: '<template><div :foo /></template>',
197+
output: '<template><div :foo="foo" /></template>',
198+
options: ['shorthand', { sameNameShorthand: 'never' }],
199+
errors: [unexpectedShorthand]
200+
},
201+
{
202+
filename: 'test.vue',
203+
code: '<template><div v-bind:foo /></template>',
204+
output: '<template><div v-bind:foo="foo" /></template>',
205+
options: ['longform', { sameNameShorthand: 'never' }],
206+
errors: [unexpectedShorthand]
207+
},
208+
{
209+
// modifier
210+
filename: 'test.vue',
211+
code: '<template><div :foo.prop /></template>',
212+
output: '<template><div :foo.prop="foo" /></template>',
213+
options: ['shorthand', { sameNameShorthand: 'never' }],
214+
errors: [unexpectedShorthand]
215+
},
216+
{
217+
filename: 'test.vue',
218+
code: '<template><div .foo /></template>',
219+
output: '<template><div .foo="foo" /></template>',
220+
options: ['shorthand', { sameNameShorthand: 'never' }],
221+
errors: [unexpectedShorthand]
222+
},
223+
{
224+
filename: 'test.vue',
225+
code: '<template><div .foo /></template>',
226+
output: '<template><div v-bind:foo.prop /></template>',
227+
options: ['longform', { sameNameShorthand: 'never' }],
228+
errors: [unexpectedShorthand, "Expected 'v-bind:' instead of '.'."]
229+
},
230+
{
231+
// camel case
232+
filename: 'test.vue',
233+
code: '<template><div :foo-bar /></template>',
234+
output: '<template><div :foo-bar="fooBar" /></template>',
235+
options: ['shorthand', { sameNameShorthand: 'never' }],
236+
errors: [unexpectedShorthand]
237+
},
238+
// same-name shorthand: always
239+
{
240+
filename: 'test.vue',
241+
code: '<template><div :foo="foo" /></template>',
242+
output: '<template><div :foo /></template>',
243+
options: ['shorthand', { sameNameShorthand: 'always' }],
244+
errors: [expectedShorthand]
245+
},
246+
{
247+
filename: 'test.vue',
248+
code: '<template><div v-bind:foo="foo" /></template>',
249+
output: '<template><div v-bind:foo /></template>',
250+
options: ['longform', { sameNameShorthand: 'always' }],
251+
errors: [expectedShorthand]
252+
},
253+
{
254+
// modifier
255+
filename: 'test.vue',
256+
code: '<template><div :foo.prop="foo" /></template>',
257+
output: '<template><div :foo.prop /></template>',
258+
options: ['shorthand', { sameNameShorthand: 'always' }],
259+
errors: [expectedShorthand]
260+
},
261+
{
262+
filename: 'test.vue',
263+
code: '<template><div .foo="foo" /></template>',
264+
output: '<template><div .foo /></template>',
265+
options: ['shorthand', { sameNameShorthand: 'always' }],
266+
errors: [expectedShorthand]
267+
},
268+
{
269+
// camel case
270+
filename: 'test.vue',
271+
code: '<template><div :foo-bar="fooBar" /></template>',
272+
output: '<template><div :foo-bar /></template>',
273+
options: ['shorthand', { sameNameShorthand: 'always' }],
274+
errors: [expectedShorthand]
123275
}
124276
]
125277
})

0 commit comments

Comments
 (0)