Repository: vuejs/eslint-plugin-vue
Pull request #2381 (2024-01-28 06:28:52)

feat(v-bind-style): add `sameNameShorthand` option
close https://github.com/vuejs/eslint-plugin-vue/issues/2359.

This implementation follows https://github.com/vuejs/eslint-plugin-vue/issues/2359#issue-2072013209 and https://github.com/vuejs/eslint-plugin-vue/pull/2374#issuecomment-1913420004 of adding a `sameNameShorthand` option to the `v-bind-style` rule. 

waynzh: Seems the tests are failing, but they all pass locally. Trying to figure out why 🤔
ota-meshi: Thank you for this PR!!!
The CI error seems to be a bug in the main branch. Don't worry about it now.
FloEdelmann: > Seems the tests are failing

They were fixed in #2382. You can pull the latest changes from `master` (with the "Update branch" button on GitHub) to fix the CI.
waynzh: Thanks for the review! 
FloEdelmann: Thanks for applying the suggestions so quickly!

Patch:
diff --git a/docs/rules/v-bind-style.md b/docs/rules/v-bind-style.md
index 23be4a8c..271506c3 100644
--- a/docs/rules/v-bind-style.md
+++ b/docs/rules/v-bind-style.md
@@ -32,16 +32,20 @@ This rule enforces `v-bind` directive style which you should use shorthand or lo
 
 ## :wrench: Options
 
-Default is set to `shorthand`.
-
 ```json
 {
-  "vue/v-bind-style": ["error", "shorthand" | "longform"]
+  "vue/v-bind-style": ["error", "shorthand" | "longform", {
+    "sameNameShorthand": "ignore" | "always" | "never"
+  }]
 }
 ```
 
 - `"shorthand"` (default) ... requires using shorthand.
 - `"longform"` ... requires using long form.
+- `sameNameShorthand` ... enforce the `v-bind` same-name shorthand style (Vue 3.4+).
+  - `"ignore"` (default) ... ignores the same-name shorthand style.
+  - `"always"` ... always enforces same-name shorthand where possible.
+  - `"never"` ... always disallow same-name shorthand where possible.
 
 ### `"longform"`
 
@@ -59,6 +63,38 @@ Default is set to `shorthand`.
 
 </eslint-code-block>
 
+### `{ "sameNameShorthand": "always" }`
+
+<eslint-code-block fix :rules="{'vue/v-bind-style': ['error', 'shorthand', { 'sameNameShorthand': 'always' }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div :foo />
+
+  <!-- ✗ BAD -->
+  <div :foo="foo" />
+</template>
+```
+
+</eslint-code-block>
+
+### `{ "sameNameShorthand": "never" }`
+
+<eslint-code-block fix :rules="{'vue/v-bind-style': ['error', 'shorthand', { 'sameNameShorthand': 'never' }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div :foo="foo" />
+
+  <!-- ✗ BAD -->
+  <div :foo />
+</template>
+```
+
+</eslint-code-block>
+
 ## :books: Further Reading
 
 - [Style guide - Directive shorthands](https://vuejs.org/style-guide/rules-strongly-recommended.html#directive-shorthands)
diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js
index 18911cfc..847db33a 100644
--- a/lib/rules/v-bind-style.js
+++ b/lib/rules/v-bind-style.js
@@ -6,6 +6,41 @@
 'use strict'
 
 const utils = require('../utils')
+const casing = require('../utils/casing')
+
+/**
+ * @typedef { VDirectiveKey & { name: VIdentifier & { name: 'bind' }, argument: VExpressionContainer | VIdentifier } } VBindDirectiveKey
+ * @typedef { VDirective & { key: VBindDirectiveKey } } VBindDirective
+ */
+
+/**
+ * @param {VBindDirective} node
+ * @returns {boolean}
+ */
+function isSameName(node) {
+  const attrName =
+    node.key.argument.type === 'VIdentifier' ? node.key.argument.rawName : null
+  const valueName =
+    node.value?.expression?.type === 'Identifier'
+      ? node.value.expression.name
+      : null
+  return Boolean(
+    attrName &&
+      valueName &&
+      casing.camelCase(attrName) === casing.camelCase(valueName)
+  )
+}
+
+/**
+ * @param {VBindDirectiveKey} key
+ * @returns {number}
+ */
+function getCutStart(key) {
+  const modifiers = key.modifiers
+  return modifiers.length > 0
+    ? modifiers[modifiers.length - 1].range[1]
+    : key.argument.range[1]
+}
 
 module.exports = {
   meta: {
@@ -16,60 +51,113 @@ module.exports = {
       url: 'https://eslint.vuejs.org/rules/v-bind-style.html'
     },
     fixable: 'code',
-    schema: [{ enum: ['shorthand', 'longform'] }],
+    schema: [
+      { enum: ['shorthand', 'longform'] },
+      {
+        type: 'object',
+        properties: {
+          sameNameShorthand: { enum: ['always', 'never', 'ignore'] }
+        },
+        additionalProperties: false
+      }
+    ],
     messages: {
       expectedLonghand: "Expected 'v-bind' before ':'.",
       unexpectedLonghand: "Unexpected 'v-bind' before ':'.",
-      expectedLonghandForProp: "Expected 'v-bind:' instead of '.'."
+      expectedLonghandForProp: "Expected 'v-bind:' instead of '.'.",
+      expectedShorthand: 'Expected same-name shorthand.',
+      unexpectedShorthand: 'Unexpected same-name shorthand.'
     }
   },
   /** @param {RuleContext} context */
   create(context) {
     const preferShorthand = context.options[0] !== 'longform'
+    /** @type {"always" | "never" | "ignore"} */
+    const sameNameShorthand = context.options[1]?.sameNameShorthand || 'ignore'
 
-    return utils.defineTemplateBodyVisitor(context, {
-      /** @param {VDirective} node */
-      "VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"(
-        node
-      ) {
-        const shorthandProp = node.key.name.rawName === '.'
-        const shorthand = node.key.name.rawName === ':' || shorthandProp
-        if (shorthand === preferShorthand) {
-          return
-        }
+    /** @param {VBindDirective} node */
+    function checkAttributeStyle(node) {
+      const shorthandProp = node.key.name.rawName === '.'
+      const shorthand = node.key.name.rawName === ':' || shorthandProp
+      if (shorthand === preferShorthand) {
+        return
+      }
 
-        let messageId = 'expectedLonghand'
-        if (preferShorthand) {
-          messageId = 'unexpectedLonghand'
-        } else if (shorthandProp) {
-          messageId = 'expectedLonghandForProp'
-        }
+      let messageId = 'expectedLonghand'
+      if (preferShorthand) {
+        messageId = 'unexpectedLonghand'
+      } else if (shorthandProp) {
+        messageId = 'expectedLonghandForProp'
+      }
+
+      context.report({
+        node,
+        loc: node.loc,
+        messageId,
+        *fix(fixer) {
+          if (preferShorthand) {
+            yield fixer.remove(node.key.name)
+          } else {
+            yield fixer.insertTextBefore(node, 'v-bind')
+
+            if (shorthandProp) {
+              // Replace `.` by `:`.
+              yield fixer.replaceText(node.key.name, ':')
 
-        context.report({
-          node,
-          loc: node.loc,
-          messageId,
-          *fix(fixer) {
-            if (preferShorthand) {
-              yield fixer.remove(node.key.name)
-            } else {
-              yield fixer.insertTextBefore(node, 'v-bind')
-
-              if (shorthandProp) {
-                // Replace `.` by `:`.
-                yield fixer.replaceText(node.key.name, ':')
-
-                // Insert `.prop` modifier if it doesn't exist.
-                const modifier = node.key.modifiers[0]
-                const isAutoGeneratedPropModifier =
-                  modifier.name === 'prop' && modifier.rawName === ''
-                if (isAutoGeneratedPropModifier) {
-                  yield fixer.insertTextBefore(modifier, '.prop')
-                }
+              // Insert `.prop` modifier if it doesn't exist.
+              const modifier = node.key.modifiers[0]
+              const isAutoGeneratedPropModifier =
+                modifier.name === 'prop' && modifier.rawName === ''
+              if (isAutoGeneratedPropModifier) {
+                yield fixer.insertTextBefore(modifier, '.prop')
               }
             }
           }
-        })
+        }
+      })
+    }
+
+    /** @param {VBindDirective} node */
+    function checkAttributeSameName(node) {
+      if (sameNameShorthand === 'ignore' || !isSameName(node)) return
+
+      const preferShorthand = sameNameShorthand === 'always'
+      const isShorthand = utils.isVBindSameNameShorthand(node)
+      if (isShorthand === preferShorthand) {
+        return
+      }
+
+      const messageId = preferShorthand
+        ? 'expectedShorthand'
+        : 'unexpectedShorthand'
+
+      context.report({
+        node,
+        loc: node.loc,
+        messageId,
+        *fix(fixer) {
+          if (preferShorthand) {
+            /** @type {Range} */
+            const valueRange = [getCutStart(node.key), node.range[1]]
+
+            yield fixer.removeRange(valueRange)
+          } else if (node.key.argument.type === 'VIdentifier') {
+            yield fixer.insertTextAfter(
+              node,
+              `="${casing.camelCase(node.key.argument.rawName)}"`
+            )
+          }
+        }
+      })
+    }
+
+    return utils.defineTemplateBodyVisitor(context, {
+      /** @param {VBindDirective} node */
+      "VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"(
+        node
+      ) {
+        checkAttributeSameName(node)
+        checkAttributeStyle(node)
       }
     })
   }


Test patch:
diff --git a/tests/lib/rules/v-bind-style.js b/tests/lib/rules/v-bind-style.js
index 26490790..b678bf78 100644
--- a/tests/lib/rules/v-bind-style.js
+++ b/tests/lib/rules/v-bind-style.js
@@ -12,6 +12,9 @@ const tester = new RuleTester({
   languageOptions: { parser: require('vue-eslint-parser'), ecmaVersion: 2015 }
 })
 
+const expectedShorthand = 'Expected same-name shorthand.'
+const unexpectedShorthand = 'Unexpected same-name shorthand.'
+
 tester.run('v-bind-style', rule, {
   valid: [
     {
@@ -34,7 +37,7 @@ tester.run('v-bind-style', rule, {
     {
       filename: 'test.vue',
       code: '<template><div v-bind:foo="foo"></div></template>',
-      options: ['longform']
+      options: ['longform', { sameNameShorthand: 'ignore' }]
     },
 
     // Don't enforce `.prop` shorthand because of experimental.
@@ -55,6 +58,72 @@ tester.run('v-bind-style', rule, {
       filename: 'test.vue',
       code: '<template><div .foo="foo"></div></template>',
       options: ['shorthand']
+    },
+    // same-name shorthand: never
+    {
+      filename: 'test.vue',
+      code: '<template><div :foo="foo" /></template>',
+      options: ['shorthand', { sameNameShorthand: 'never' }]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo="foo" /></template>',
+      options: ['longform', { sameNameShorthand: 'never' }]
+    },
+    {
+      // modifier
+      filename: 'test.vue',
+      code: `
+      <template>
+      <div :foo.prop="foo" />
+      <div .foo="foo" />
+      </template>
+      `,
+      options: ['shorthand', { sameNameShorthand: 'never' }]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo.prop="foo" /></template>',
+      options: ['longform', { sameNameShorthand: 'never' }]
+    },
+    {
+      // camel case
+      filename: 'test.vue',
+      code: '<template><div :foo-bar="fooBar" /></template>',
+      options: ['shorthand', { sameNameShorthand: 'never' }]
+    },
+    // same-name shorthand: always
+    {
+      filename: 'test.vue',
+      code: '<template><div :foo /></template>',
+      options: ['shorthand', { sameNameShorthand: 'always' }]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo /></template>',
+      options: ['longform', { sameNameShorthand: 'always' }]
+    },
+    {
+      // modifier
+      filename: 'test.vue',
+      code: `
+      <template>
+      <div :foo.prop />
+      <div .foo />
+      </template>
+      `,
+      options: ['shorthand', { sameNameShorthand: 'always' }]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo.prop /></template>',
+      options: ['longform', { sameNameShorthand: 'always' }]
+    },
+    {
+      // camel case
+      filename: 'test.vue',
+      code: '<template><div :foo-bar/></template>',
+      options: ['shorthand', { sameNameShorthand: 'always' }]
     }
   ],
   invalid: [
@@ -120,6 +189,89 @@ tester.run('v-bind-style', rule, {
       output: '<template><div v-bind:foo /></template>',
       options: ['longform'],
       errors: ["Expected 'v-bind' before ':'."]
+    },
+    // same-name shorthand: never
+    {
+      filename: 'test.vue',
+      code: '<template><div :foo /></template>',
+      output: '<template><div :foo="foo" /></template>',
+      options: ['shorthand', { sameNameShorthand: 'never' }],
+      errors: [unexpectedShorthand]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo /></template>',
+      output: '<template><div v-bind:foo="foo" /></template>',
+      options: ['longform', { sameNameShorthand: 'never' }],
+      errors: [unexpectedShorthand]
+    },
+    {
+      // modifier
+      filename: 'test.vue',
+      code: '<template><div :foo.prop /></template>',
+      output: '<template><div :foo.prop="foo" /></template>',
+      options: ['shorthand', { sameNameShorthand: 'never' }],
+      errors: [unexpectedShorthand]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div .foo /></template>',
+      output: '<template><div .foo="foo" /></template>',
+      options: ['shorthand', { sameNameShorthand: 'never' }],
+      errors: [unexpectedShorthand]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div .foo /></template>',
+      output: '<template><div v-bind:foo.prop /></template>',
+      options: ['longform', { sameNameShorthand: 'never' }],
+      errors: [unexpectedShorthand, "Expected 'v-bind:' instead of '.'."]
+    },
+    {
+      // camel case
+      filename: 'test.vue',
+      code: '<template><div :foo-bar /></template>',
+      output: '<template><div :foo-bar="fooBar" /></template>',
+      options: ['shorthand', { sameNameShorthand: 'never' }],
+      errors: [unexpectedShorthand]
+    },
+    // same-name shorthand: always
+    {
+      filename: 'test.vue',
+      code: '<template><div :foo="foo" /></template>',
+      output: '<template><div :foo /></template>',
+      options: ['shorthand', { sameNameShorthand: 'always' }],
+      errors: [expectedShorthand]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo="foo" /></template>',
+      output: '<template><div v-bind:foo /></template>',
+      options: ['longform', { sameNameShorthand: 'always' }],
+      errors: [expectedShorthand]
+    },
+    {
+      // modifier
+      filename: 'test.vue',
+      code: '<template><div :foo.prop="foo" /></template>',
+      output: '<template><div :foo.prop /></template>',
+      options: ['shorthand', { sameNameShorthand: 'always' }],
+      errors: [expectedShorthand]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div .foo="foo" /></template>',
+      output: '<template><div .foo /></template>',
+      options: ['shorthand', { sameNameShorthand: 'always' }],
+      errors: [expectedShorthand]
+    },
+    {
+      // camel case
+      filename: 'test.vue',
+      code: '<template><div :foo-bar="fooBar" /></template>',
+      output: '<template><div :foo-bar /></template>',
+      options: ['shorthand', { sameNameShorthand: 'always' }],
+      errors: [expectedShorthand]
     }
   ]
 })