Repository: vuejs/eslint-plugin-vue
Pull request #1388 (2020-12-25 12:22:33)

Add vue/v-on-event-hyphenation rule
This PR adds `vue/v-on-event-hyphenation` rule.

`vue/v-on-event-hyphenation` rule enforces using hyphenated v-on event names on custom components in Vue templates.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 0d1fccd3..b9d6be74 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -321,6 +321,7 @@ For example:
 | [vue/sort-keys](./sort-keys.md) | enforce sort-keys in a manner that is compatible with order-in-components |  |
 | [vue/static-class-names-order](./static-class-names-order.md) | enforce static class names order | :wrench: |
 | [vue/v-for-delimiter-style](./v-for-delimiter-style.md) | enforce `v-for` directive's delimiter style | :wrench: |
+| [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md) | enforce v-on event naming style on custom components in template | :wrench: |
 | [vue/v-on-function-call](./v-on-function-call.md) | enforce or forbid parentheses after method calls without arguments in `v-on` directives | :wrench: |
 
 ### Extension Rules
diff --git a/docs/rules/attribute-hyphenation.md b/docs/rules/attribute-hyphenation.md
index fc7bba75..cc639183 100644
--- a/docs/rules/attribute-hyphenation.md
+++ b/docs/rules/attribute-hyphenation.md
@@ -47,6 +47,7 @@ Default casing is set to `always` with `['data-', 'aria-', 'slot-scope']` set to
 - `"ignore"` ... Array of ignored names
 
 ### `"always"`
+
 It errors on upper case letters.
 
 <eslint-code-block fix :rules="{'vue/attribute-hyphenation': ['error', 'always']}">
@@ -64,6 +65,7 @@ It errors on upper case letters.
 </eslint-code-block>
 
 ### `"never"`
+
 It errors on hyphens except `data-`, `aria-` and `slot-scope`.
 
 <eslint-code-block fix :rules="{'vue/attribute-hyphenation': ['error', 'never']}">
@@ -84,6 +86,7 @@ It errors on hyphens except `data-`, `aria-` and `slot-scope`.
 </eslint-code-block>
 
 ### `"never", { "ignore": ["custom-prop"] }`
+
 Don't use hyphenated name but allow custom attributes
 
 <eslint-code-block fix :rules="{'vue/attribute-hyphenation': ['error', 'never', { ignore: ['custom-prop']}]}">
@@ -104,6 +107,11 @@ Don't use hyphenated name but allow custom attributes
 
 </eslint-code-block>
 
+## :couple: Related Rules
+
+- [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md)
+- [vue/prop-name-casing](./prop-name-casing.md)
+
 ## :rocket: Version
 
 This rule was introduced in eslint-plugin-vue v3.9.0
diff --git a/docs/rules/attributes-order.md b/docs/rules/attributes-order.md
index e5c8a91f..8bcb8b8e 100644
--- a/docs/rules/attributes-order.md
+++ b/docs/rules/attributes-order.md
@@ -91,7 +91,33 @@ This rule aims to enforce ordering of component attributes. The default order is
 
 </eslint-code-block>
 
+Note that `v-bind="object"` syntax is considered to be the same as the next or previous attribute categories.
+
+<eslint-code-block fix :rules="{'vue/attributes-order': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD (`v-bind="object"` is considered GLOBAL category) -->
+  <MyComponent
+    v-bind="object"
+    id="x"
+    v-model="x"
+    v-bind:foo="x">
+  </MyComponent>
+
+  <!-- ✗ BAD (`v-bind="object"` is considered UNIQUE category) -->
+  <MyComponent
+    key="x"
+    v-model="x"
+    v-bind="object">
+  </MyComponent>
+</template>
+```
+
+</eslint-code-block>
+
 ## :wrench: Options
+
 ```json
 {
   "vue/attributes-order": ["error", {
@@ -113,7 +139,7 @@ This rule aims to enforce ordering of component attributes. The default order is
 }
 ```
 
-### `"alphabetical": true` 
+### `"alphabetical": true`
 
 <eslint-code-block fix :rules="{'vue/attributes-order': ['error', {alphabetical: true}]}">
 
diff --git a/docs/rules/custom-event-name-casing.md b/docs/rules/custom-event-name-casing.md
index 16a46dfd..ae08fc25 100644
--- a/docs/rules/custom-event-name-casing.md
+++ b/docs/rules/custom-event-name-casing.md
@@ -23,9 +23,9 @@ Vue 2 recommends using kebab-case for custom event names.
 
 See [Guide (for v2) - Custom Events] for more details.
 
-Vue 3 recommends using camelCase for custom event names.
+In Vue 3, using either camelCase or kebab-case for your custom event name does not limit its use in v-on. However, following JavaScript conventions, camelCase is more natural.
 
-See [vuejs/docs-next#656](https://github.com/vuejs/docs-next/issues/656) for more details.
+See [Guide - Custom Events] for more details.
 
 This rule enforces kebab-case by default.
 
@@ -171,6 +171,11 @@ export default {
 [Guide - Custom Events]: https://v3.vuejs.org/guide/component-custom-events.html
 [Guide (for v2) - Custom Events]: https://vuejs.org/v2/guide/components-custom-events.html
 
+## :couple: Related Rules
+
+- [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md)
+- [vue/prop-name-casing](./prop-name-casing.md)
+
 ## :rocket: Version
 
 This rule was introduced in eslint-plugin-vue v7.0.0
diff --git a/docs/rules/prop-name-casing.md b/docs/rules/prop-name-casing.md
index 3cfee5b0..81f19518 100644
--- a/docs/rules/prop-name-casing.md
+++ b/docs/rules/prop-name-casing.md
@@ -70,6 +70,11 @@ export default {
 
 - [Style guide - Prop name casing](https://v3.vuejs.org/style-guide/#prop-name-casing-strongly-recommended)
 
+## :couple: Related Rules
+
+- [vue/attribute-hyphenation](./attribute-hyphenation.md)
+- [vue/custom-event-name-casing](./custom-event-name-casing.md)
+
 ## :rocket: Version
 
 This rule was introduced in eslint-plugin-vue v4.3.0
diff --git a/docs/rules/v-on-event-hyphenation.md b/docs/rules/v-on-event-hyphenation.md
new file mode 100644
index 00000000..92caed69
--- /dev/null
+++ b/docs/rules/v-on-event-hyphenation.md
@@ -0,0 +1,119 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/v-on-event-hyphenation
+description: enforce v-on event naming style on custom components in template
+---
+# vue/v-on-event-hyphenation
+
+> enforce v-on event naming style on custom components in template
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule enforces using hyphenated v-on event names on custom components in Vue templates.
+
+<eslint-code-block fix :rules="{'vue/v-on-event-hyphenation': ['error', 'always', { autofix: true }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <MyComponent v-on:custom-event="handleEvent"/>
+  <MyComponent @custom-event="handleEvent"/>
+
+  <!-- ✗ BAD -->
+  <MyComponent v-on:customEvent="handleEvent"/>
+  <MyComponent @customEvent="handleEvent"/>
+</template>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "vue/v-on-event-hyphenation": ["error", "always" | "never", {
+    "autofix": false,
+    "ignore": []
+  }]
+}
+```
+
+- `"always"` (default) ... Use hyphenated name.
+- `"never"` ... Don't use hyphenated name.
+- `"ignore"` ... Array of ignored names
+- `"autofix"` ... If `true`, enable autofix. If you are using Vue 2, we recommend that you do not use it due to its side effects.
+
+### `"always"`
+
+It errors on upper case letters.
+
+<eslint-code-block fix :rules="{'vue/v-on-event-hyphenation': ['error', 'always', { autofix: true }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <MyComponent v-on:custom-event="handleEvent"/>
+
+  <!-- ✗ BAD -->
+  <MyComponent v-on:customEvent="handleEvent"/>
+</template>
+```
+
+</eslint-code-block>
+
+### `"never"`
+
+It errors on hyphens.
+
+<eslint-code-block fix :rules="{'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <MyComponent v-on:customEvent="handleEvent"/>
+
+  <!-- ✗ BAD -->
+  <MyComponent v-on:custom-event="handleEvent"/>
+</template>
+```
+
+</eslint-code-block>
+
+### `"never", { "ignore": ["custom-event"] }`
+
+Don't use hyphenated name but allow custom event names
+
+<eslint-code-block fix :rules="{'vue/v-on-event-hyphenation': ['error', 'never', { ignore: ['custom-event'], autofix: true }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <MyComponent v-on:custom-event="handleEvent"/>
+  <MyComponent v-on:myEvent="handleEvent"/>
+
+  <!-- ✗ BAD -->
+  <MyComponent v-on:my-event="handleEvent"/>
+</template>
+```
+
+</eslint-code-block>
+
+## :books: Further Reading
+
+- [Guide - Custom Events]
+
+[Guide - Custom Events]: https://v3.vuejs.org/guide/component-custom-events.html
+
+## :couple: Related Rules
+
+- [vue/custom-event-name-casing](./custom-event-name-casing.md)
+- [vue/attribute-hyphenation](./attribute-hyphenation.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/v-on-event-hyphenation.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/v-on-event-hyphenation.js)
diff --git a/lib/index.js b/lib/index.js
index 0cc2ef27..30f51716 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -157,6 +157,7 @@ module.exports = {
     'use-v-on-exact': require('./rules/use-v-on-exact'),
     'v-bind-style': require('./rules/v-bind-style'),
     'v-for-delimiter-style': require('./rules/v-for-delimiter-style'),
+    'v-on-event-hyphenation': require('./rules/v-on-event-hyphenation'),
     'v-on-function-call': require('./rules/v-on-function-call'),
     'v-on-style': require('./rules/v-on-style'),
     'v-slot-style': require('./rules/v-slot-style'),
diff --git a/lib/rules/attribute-hyphenation.js b/lib/rules/attribute-hyphenation.js
index 1fd7b5e9..cba9dc24 100644
--- a/lib/rules/attribute-hyphenation.js
+++ b/lib/rules/attribute-hyphenation.js
@@ -87,7 +87,7 @@ module.exports = {
      */
     function isIgnoredAttribute(value) {
       const isIgnored = ignoredAttributes.some((attr) => {
-        return value.indexOf(attr) !== -1
+        return value.includes(attr)
       })
 
       if (isIgnored) {
diff --git a/lib/rules/attributes-order.js b/lib/rules/attributes-order.js
index 34a9d7d3..2441ee49 100644
--- a/lib/rules/attributes-order.js
+++ b/lib/rules/attributes-order.js
@@ -8,6 +8,11 @@ const utils = require('../utils')
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
+
+/**
+ * @typedef { VDirective & { key: VDirectiveKey & { name: VIdentifier & { name: 'bind' } } } } VBindDirective
+ */
+
 const ATTRS = {
   DEFINITION: 'DEFINITION',
   LIST_RENDERING: 'LIST_RENDERING',
@@ -22,13 +27,47 @@ const ATTRS = {
   CONTENT: 'CONTENT'
 }
 
+/**
+ * Check whether the given attribute is `v-bind` directive.
+ * @param {VAttribute | VDirective | undefined | null} node
+ * @returns { node is VBindDirective }
+ */
+function isVBind(node) {
+  return Boolean(node && node.directive && node.key.name.name === 'bind')
+}
+/**
+ * Check whether the given attribute is plain attribute.
+ * @param {VAttribute | VDirective | undefined | null} node
+ * @returns { node is VAttribute }
+ */
+function isVAttribute(node) {
+  return Boolean(node && !node.directive)
+}
+/**
+ * Check whether the given attribute is plain attribute or `v-bind` directive.
+ * @param {VAttribute | VDirective | undefined | null} node
+ * @returns { node is VAttribute }
+ */
+function isVAttributeOrVBind(node) {
+  return isVAttribute(node) || isVBind(node)
+}
+
+/**
+ * Check whether the given attribute is `v-bind="..."` directive.
+ * @param {VAttribute | VDirective | undefined | null} node
+ * @returns { node is VBindDirective }
+ */
+function isVBindObject(node) {
+  return isVBind(node) && node.key.argument == null
+}
+
 /**
  * @param {VAttribute | VDirective} attribute
  * @param {SourceCode} sourceCode
  */
 function getAttributeName(attribute, sourceCode) {
   if (attribute.directive) {
-    if (attribute.key.name.name === 'bind') {
+    if (isVBind(attribute)) {
       return attribute.key.argument
         ? sourceCode.getText(attribute.key.argument)
         : ''
@@ -62,7 +101,7 @@ function getDirectiveKeyName(directiveKey, sourceCode) {
 function getAttributeType(attribute, sourceCode) {
   let propName
   if (attribute.directive) {
-    if (attribute.key.name.name !== 'bind') {
+    if (!isVBind(attribute)) {
       const name = attribute.key.name.name
       if (name === 'for') {
         return ATTRS.LIST_RENDERING
@@ -130,24 +169,14 @@ function getPosition(attribute, attributePosition, sourceCode) {
  * @param {SourceCode} sourceCode
  */
 function isAlphabetical(prevNode, currNode, sourceCode) {
-  const isSameType =
-    getAttributeType(prevNode, sourceCode) ===
-    getAttributeType(currNode, sourceCode)
-  if (isSameType) {
-    const prevName = getAttributeName(prevNode, sourceCode)
-    const currName = getAttributeName(currNode, sourceCode)
-    if (prevName === currName) {
-      const prevIsBind = Boolean(
-        prevNode.directive && prevNode.key.name.name === 'bind'
-      )
-      const currIsBind = Boolean(
-        currNode.directive && currNode.key.name.name === 'bind'
-      )
-      return prevIsBind <= currIsBind
-    }
-    return prevName < currName
+  const prevName = getAttributeName(prevNode, sourceCode)
+  const currName = getAttributeName(currNode, sourceCode)
+  if (prevName === currName) {
+    const prevIsBind = isVBind(prevNode)
+    const currIsBind = isVBind(currNode)
+    return prevIsBind <= currIsBind
   }
-  return true
+  return prevName < currName
 }
 
 /**
@@ -186,16 +215,6 @@ function create(context) {
     } else attributePosition[item] = i
   })
 
-  /**
-   * @typedef {object} State
-   * @property {number} currentPosition
-   * @property {VAttribute | VDirective} previousNode
-   */
-  /**
-   * @type {State | null}
-   */
-  let state
-
   /**
    * @param {VAttribute | VDirective} node
    * @param {VAttribute | VDirective} previousNode
@@ -213,43 +232,112 @@ function create(context) {
 
       fix(fixer) {
         const attributes = node.parent.attributes
-        const shiftAttrs = attributes.slice(
+
+        /** @type { (node: VAttribute | VDirective | undefined) => boolean } */
+        let isMoveUp
+
+        if (isVBindObject(node)) {
+          // prev, v-bind:foo, v-bind -> v-bind:foo, v-bind, prev
+          isMoveUp = isVAttributeOrVBind
+        } else if (isVAttributeOrVBind(node)) {
+          // prev, v-bind, v-bind:foo -> v-bind, v-bind:foo, prev
+          isMoveUp = isVBindObject
+        } else {
+          isMoveUp = () => false
+        }
+
+        const previousNodes = attributes.slice(
           attributes.indexOf(previousNode),
-          attributes.indexOf(node) + 1
+          attributes.indexOf(node)
         )
+        const moveUpNodes = [node]
+        const moveDownNodes = []
+        let index = 0
+        while (previousNodes[index]) {
+          const node = previousNodes[index++]
+          if (isMoveUp(node)) {
+            moveUpNodes.unshift(node)
+          } else {
+            moveDownNodes.push(node)
+          }
+        }
+        const moveNodes = [...moveUpNodes, ...moveDownNodes]
 
-        return shiftAttrs.map((attr, i) => {
-          const text =
-            attr === previousNode
-              ? sourceCode.getText(node)
-              : sourceCode.getText(shiftAttrs[i - 1])
-          return fixer.replaceText(attr, text)
+        return moveNodes.map((moveNode, index) => {
+          const text = sourceCode.getText(moveNode)
+          return fixer.replaceText(previousNodes[index] || node, text)
         })
       }
     })
   }
 
   return utils.defineTemplateBodyVisitor(context, {
-    VStartTag() {
-      state = null
-    },
-    VAttribute(node) {
-      let inAlphaOrder = true
-      if (state && alphabetical) {
-        inAlphaOrder = isAlphabetical(state.previousNode, node, sourceCode)
+    VStartTag(node) {
+      const attributes = node.attributes.filter((node, index, attributes) => {
+        if (
+          isVBindObject(node) &&
+          (isVAttributeOrVBind(attributes[index - 1]) ||
+            isVAttributeOrVBind(attributes[index + 1]))
+        ) {
+          // In Vue 3, ignore the `v-bind:foo=" ... "` and `v-bind ="object"` syntax
+          // as they behave differently if you change the order.
+          return false
+        }
+        return true
+      })
+      if (attributes.length <= 1) {
+        return
       }
-      if (
-        !state ||
-        (state.currentPosition <=
-          getPosition(node, attributePosition, sourceCode) &&
-          inAlphaOrder)
-      ) {
-        state = {
-          currentPosition: getPosition(node, attributePosition, sourceCode),
-          previousNode: node
+
+      let previousNode = attributes[0]
+      let previousPosition = getPositionFromAttrIndex(0)
+      for (let index = 1; index < attributes.length; index++) {
+        const node = attributes[index]
+        const position = getPositionFromAttrIndex(index)
+
+        let valid = previousPosition <= position
+        if (valid && alphabetical && previousPosition === position) {
+          valid = isAlphabetical(previousNode, node, sourceCode)
         }
-      } else {
-        reportIssue(node, state.previousNode)
+        if (valid) {
+          previousNode = node
+          previousPosition = position
+        } else {
+          reportIssue(node, previousNode)
+        }
+      }
+
+      /**
+       * @param {number} index
+       * @returns {number}
+       */
+      function getPositionFromAttrIndex(index) {
+        const node = attributes[index]
+        if (isVBindObject(node)) {
+          // node is `v-bind ="object"` syntax
+
+          // In Vue 3, if change the order of `v-bind:foo=" ... "` and `v-bind ="object"`,
+          // the behavior will be different, so adjust so that there is no change in behavior.
+
+          const len = attributes.length
+          for (let nextIndex = index + 1; nextIndex < len; nextIndex++) {
+            const next = attributes[nextIndex]
+
+            if (isVAttributeOrVBind(next) && !isVBindObject(next)) {
+              // It is considered to be in the same order as the next bind prop node.
+              return getPositionFromAttrIndex(nextIndex)
+            }
+          }
+          for (let prevIndex = index - 1; prevIndex >= 0; prevIndex--) {
+            const prev = attributes[prevIndex]
+
+            if (isVAttributeOrVBind(prev) && !isVBindObject(prev)) {
+              // It is considered to be in the same order as the prev bind prop node.
+              return getPositionFromAttrIndex(prevIndex)
+            }
+          }
+        }
+        return getPosition(node, attributePosition, sourceCode)
       }
     }
   })
diff --git a/lib/rules/v-on-event-hyphenation.js b/lib/rules/v-on-event-hyphenation.js
new file mode 100644
index 00000000..60823e01
--- /dev/null
+++ b/lib/rules/v-on-event-hyphenation.js
@@ -0,0 +1,113 @@
+'use strict'
+
+const utils = require('../utils')
+const casing = require('../utils/casing')
+
+module.exports = {
+  meta: {
+    docs: {
+      description:
+        'enforce v-on event naming style on custom components in template',
+      // TODO Change with major version.
+      // categories: ['vue3-strongly-recommended'],
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/v-on-event-hyphenation.html'
+    },
+    fixable: 'code',
+    schema: [
+      {
+        enum: ['always', 'never']
+      },
+      {
+        type: 'object',
+        properties: {
+          autofix: { type: 'boolean' },
+          ignore: {
+            type: 'array',
+            items: {
+              allOf: [
+                { type: 'string' },
+                { not: { type: 'string', pattern: ':exit$' } },
+                { not: { type: 'string', pattern: '^\\s*$' } }
+              ]
+            },
+            uniqueItems: true,
+            additionalItems: false
+          }
+        },
+        additionalProperties: false
+      }
+    ],
+    type: 'suggestion'
+  },
+
+  /** @param {RuleContext} context */
+  create(context) {
+    const sourceCode = context.getSourceCode()
+    const option = context.options[0]
+    const optionsPayload = context.options[1]
+    const useHyphenated = option !== 'never'
+    /** @type {string[]} */
+    const ignoredAttributes = (optionsPayload && optionsPayload.ignore) || []
+    const autofix = Boolean(optionsPayload && optionsPayload.autofix)
+
+    const caseConverter = casing.getExactConverter(
+      useHyphenated ? 'kebab-case' : 'camelCase'
+    )
+
+    /**
+     * @param {VDirective} node
+     * @param {string} name
+     */
+    function reportIssue(node, name) {
+      const text = sourceCode.getText(node.key)
+
+      context.report({
+        node: node.key,
+        loc: node.loc,
+        message: useHyphenated
+          ? "v-on event '{{text}}' must be hyphenated."
+          : "v-on event '{{text}}' can't be hyphenated.",
+        data: {
+          text
+        },
+        fix: autofix
+          ? (fixer) =>
+              fixer.replaceText(
+                node.key,
+                text.replace(name, caseConverter(name))
+              )
+          : null
+      })
+    }
+
+    /**
+     * @param {string} value
+     */
+    function isIgnoredAttribute(value) {
+      const isIgnored = ignoredAttributes.some((attr) => {
+        return value.includes(attr)
+      })
+
+      if (isIgnored) {
+        return true
+      }
+
+      return useHyphenated ? value.toLowerCase() === value : !/-/.test(value)
+    }
+
+    return utils.defineTemplateBodyVisitor(context, {
+      "VAttribute[directive=true][key.name.name='on']"(node) {
+        if (!utils.isCustomComponent(node.parent.parent)) return
+
+        const name =
+          node.key.argument &&
+          node.key.argument.type === 'VIdentifier' &&
+          node.key.argument.rawName
+        if (!name || isIgnoredAttribute(name)) return
+
+        reportIssue(node, name)
+      }
+    })
+  }
+}


Test patch:
diff --git a/tests/lib/rules/attributes-order.js b/tests/lib/rules/attributes-order.js
index 87e72b56..399a859b 100644
--- a/tests/lib/rules/attributes-order.js
+++ b/tests/lib/rules/attributes-order.js
@@ -373,6 +373,54 @@ tester.run('attributes-order', rule, {
           </div>
         </template>`,
       options: [{ alphabetical: true }]
+    },
+
+    // v-bind="..."
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind:id="a"
+          v-bind="b">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind="b"
+          v-bind:id="a">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-if="x"
+          v-bind:id="a"
+          v-bind="b">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-if="x"
+          v-bind="b"
+          v-bind:id="a">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }]
     }
   ],
 
@@ -943,6 +991,228 @@ tester.run('attributes-order', rule, {
           message: 'Attribute "v-is" should go before "v-cloak".'
         }
       ]
+    },
+
+    // v-bind="..."
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind:id="a"
+          v-bind="b"
+          v-if="x">
+        </div>
+      </template>`,
+      output: `
+      <template>
+        <div
+          v-if="x"
+          v-bind:id="a"
+          v-bind="b">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-if" should go before "v-bind:id".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind:id="a"
+          v-if="x"
+          v-bind="b">
+        </div>
+      </template>`,
+      output: `
+      <template>
+        <div
+          v-if="x"
+          v-bind:id="a"
+          v-bind="b">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-if" should go before "v-bind:id".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind="b"
+          v-bind:id="a"
+          v-if="x">
+        </div>
+      </template>`,
+      output: `
+      <template>
+        <div
+          v-bind="b"
+          v-if="x"
+          v-bind:id="a">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-if" should go before "v-bind:id".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-on:click="x"
+          v-bind:id="a"
+          v-bind="b">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-bind:id="a"
+          v-on:click="x"
+          v-bind="b">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-bind:id" should go before "v-on:click".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind:id="a"
+          v-on:click="x"
+          v-bind="b">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-bind:id="a"
+          v-bind="b"
+          v-on:click="x">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-bind" should go before "v-on:click".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-on:click="x"
+          v-bind="b"
+          v-bind:id="a">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-bind="b"
+          v-bind:id="a"
+          v-on:click="x">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-bind:id" should go before "v-on:click".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-on:click="x"
+          v-bind:a="x"
+          v-bind="x"
+          v-if="x">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-bind:a="x"
+          v-on:click="x"
+          v-bind="x"
+          v-if="x">
+        </div>
+      </template>`,
+      errors: [
+        'Attribute "v-bind:a" should go before "v-on:click".',
+        'Attribute "v-if" should go before "v-on:click".'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-bind:a="x"
+          v-on:click="x"
+          v-bind="x"
+          v-if="x">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-bind:a="x"
+          v-bind="x"
+          v-on:click="x"
+          v-if="x">
+        </div>
+      </template>`,
+      errors: [
+        'Attribute "v-bind" should go before "v-on:click".',
+        'Attribute "v-if" should go before "v-on:click".'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          a="x"
+          v-bind="x"
+          v-if="x">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-if="x"
+          a="x"
+          v-bind="x">
+        </div>
+      </template>`,
+      errors: ['Attribute "v-if" should go before "a".']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div
+          v-on:click="x"
+          v-bind="x"
+          v-if="x">
+        </div>
+      </template>`,
+      options: [{ alphabetical: true }],
+      output: `
+      <template>
+        <div
+          v-bind="x"
+          v-on:click="x"
+          v-if="x">
+        </div>
+      </template>`,
+      errors: [
+        'Attribute "v-bind" should go before "v-on:click".',
+        'Attribute "v-if" should go before "v-on:click".'
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/v-on-event-hyphenation.js b/tests/lib/rules/v-on-event-hyphenation.js
new file mode 100644
index 00000000..c7744997
--- /dev/null
+++ b/tests/lib/rules/v-on-event-hyphenation.js
@@ -0,0 +1,107 @@
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/v-on-event-hyphenation.js')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2019
+  }
+})
+
+tester.run('v-on-event-hyphenation', rule, {
+  valid: [
+    `
+    <template>
+        <VueComponent @custom-event="onEvent"/>
+    </template>
+    `,
+    `
+    <template>
+        <VueComponent :customEvent="onEvent"/>
+    </template>
+    `,
+    `
+    <template>
+        <VueComponent v-on="events"/>
+    </template>
+    `,
+    `
+    <template>
+        <div v-on:unknownEvent="onEvent"/>
+    </template>
+    `,
+    {
+      code: `
+      <template>
+          <VueComponent v-on:customEvent="events"/>
+      </template>
+      `,
+      options: ['never']
+    },
+    {
+      code: `
+      <template>
+          <VueComponent v-on:customEvent="events"/>
+      </template>
+      `,
+      options: ['never', { ignore: ['custom'] }]
+    }
+  ],
+  invalid: [
+    {
+      code: `
+      <template>
+          <VueComponent @customEvent="onEvent"/>
+      </template>
+      `,
+      output: null,
+      errors: [
+        {
+          message: "v-on event '@customEvent' must be hyphenated.",
+          line: 3,
+          column: 25,
+          endLine: 3,
+          endColumn: 47
+        }
+      ]
+    },
+    {
+      code: `
+      <template>
+          <VueComponent @customEvent="onEvent"/>
+      </template>
+      `,
+      options: ['always', { autofix: true }],
+      output: `
+      <template>
+          <VueComponent @custom-event="onEvent"/>
+      </template>
+      `,
+      errors: [
+        {
+          message: "v-on event '@customEvent' must be hyphenated.",
+          line: 3,
+          column: 25,
+          endLine: 3,
+          endColumn: 47
+        }
+      ]
+    },
+    {
+      code: `
+      <template>
+          <VueComponent v-on:custom-event="events"/>
+      </template>
+      `,
+      options: ['never', { autofix: true }],
+      output: `
+      <template>
+          <VueComponent v-on:customEvent="events"/>
+      </template>
+      `,
+      errors: ["v-on event 'v-on:custom-event' can't be hyphenated."]
+    }
+  ]
+})