Repository: vuejs/eslint-plugin-vue
Pull request #1719 (2021-11-18 07:51:15)

Add support for ts4.5 to `vue/script-indent` rule


Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7bb64653..80e1f88f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,7 +10,7 @@
         "json",
         "jsonc"
     ],
-    "typescript.tsdk": "node_modules/typescript/lib",
+    "typescript.tsdk": "./node_modules/typescript/lib",
     "vetur.validation.script": false,
     "[typescript]": {
         "editor.formatOnSave": true,
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 5794cc3e..2af1c06c 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -311,6 +311,7 @@ For example:
 | [vue/block-tag-newline](./block-tag-newline.md) | enforce line breaks after opening and before closing block-level tags | :wrench: |
 | [vue/component-api-style](./component-api-style.md) | enforce component API style |  |
 | [vue/component-name-in-template-casing](./component-name-in-template-casing.md) | enforce specific casing for the component naming style in template | :wrench: |
+| [vue/component-options-name-casing](./component-options-name-casing.md) | enforce the casing of component name in `components` options | :wrench::bulb: |
 | [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce specific casing for custom event name |  |
 | [vue/html-button-has-type](./html-button-has-type.md) | disallow usage of button without an explicit type attribute |  |
 | [vue/html-comment-content-newline](./html-comment-content-newline.md) | enforce unified line brake in HTML comments | :wrench: |
diff --git a/docs/rules/component-options-name-casing.md b/docs/rules/component-options-name-casing.md
new file mode 100644
index 00000000..9a956553
--- /dev/null
+++ b/docs/rules/component-options-name-casing.md
@@ -0,0 +1,165 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/component-options-name-casing
+description: enforce the casing of component name in `components` options
+---
+# vue/component-options-name-casing
+
+> enforce the casing of component name in `components` options
+
+- :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.
+- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
+
+## :book: Rule Details
+
+This rule aims to enforce casing of the component names in `components` options.
+
+## :wrench: Options
+
+```json
+{
+  "vue/component-options-name-casing": ["error", "PascalCase" | "kebab-case" | "camelCase"]
+}
+```
+
+This rule has an option which can be one of these values:
+
+- `"PascalCase"` (default) ... enforce component names to pascal case.
+- `"kebab-case"` ... enforce component names to kebab case.
+- `"camelCase"` ... enforce component names to camel case.
+
+Please note that if you use kebab case in `components` options,
+you can **only** use kebab case in template;
+and if you use camel case in `components` options,
+you **can't** use pascal case in template.
+
+For demonstration, the code example is invalid:
+
+```vue
+<template>
+  <div>
+    <!-- All are invalid. DO NOT use like these. -->
+    <KebabCase />
+    <kebabCase />
+    <CamelCase />
+  </div>
+</template>
+
+<script>
+export default {
+  components: {
+    camelCase: MyComponent,
+    'kebab-case': MyComponent
+  }
+}
+</script>
+```
+
+### `"PascalCase"` (default)
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error']}">
+
+```vue
+<script>
+export default {
+  /* ✓ GOOD */
+  components: {
+    AppHeader,
+    AppSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error']}">
+
+```vue
+<script>
+export default {
+  /* ✗ BAD */
+  components: {
+    appHeader,
+    'app-sidebar': AppSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+### `"kebab-case"`
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'kebab-case']}">
+
+```vue
+<script>
+export default {
+  /* ✓ GOOD */
+  components: {
+    'app-header': AppHeader,
+    'app-sidebar': appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'kebab-case']}">
+
+```vue
+<script>
+export default {
+  /* ✗ BAD */
+  components: {
+    AppHeader,
+    appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+### `"camelCase"`
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'camelCase']}">
+
+```vue
+<script>
+export default {
+  /* ✓ GOOD */
+  components: {
+    appHeader,
+    appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'camelCase']}">
+
+```vue
+<script>
+export default {
+  /* ✗ BAD */
+  components: {
+    AppHeader,
+    'app-sidebar': appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/component-options-name-casing.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/component-options-name-casing.js)
diff --git a/lib/index.js b/lib/index.js
index c9969917..0adbf3f7 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -24,6 +24,7 @@ module.exports = {
     'component-api-style': require('./rules/component-api-style'),
     'component-definition-name-casing': require('./rules/component-definition-name-casing'),
     'component-name-in-template-casing': require('./rules/component-name-in-template-casing'),
+    'component-options-name-casing': require('./rules/component-options-name-casing'),
     'component-tags-order': require('./rules/component-tags-order'),
     'custom-event-name-casing': require('./rules/custom-event-name-casing'),
     'dot-location': require('./rules/dot-location'),
diff --git a/lib/rules/component-options-name-casing.js b/lib/rules/component-options-name-casing.js
new file mode 100644
index 00000000..ad70c154
--- /dev/null
+++ b/lib/rules/component-options-name-casing.js
@@ -0,0 +1,115 @@
+/**
+ * @author Pig Fang
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+const casing = require('../utils/casing')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * @param {import('../../typings/eslint-plugin-vue/util-types/ast').Expression} node
+ * @returns {string | null}
+ */
+function getOptionsComponentName(node) {
+  if (node.type === 'Identifier') {
+    return node.name
+  }
+  if (node.type === 'Literal') {
+    return typeof node.value === 'string' ? node.value : null
+  }
+  return null
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'enforce the casing of component name in `components` options',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/component-options-name-casing.html'
+    },
+    fixable: 'code',
+    hasSuggestions: true,
+    schema: [{ enum: casing.allowedCaseOptions }],
+    messages: {
+      caseNotMatched: 'Component name "{{component}}" is not {{caseType}}.',
+      possibleRenaming: 'Rename component name to be in {{caseType}}.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const caseType = context.options[0] || 'PascalCase'
+
+    const canAutoFix = caseType === 'PascalCase'
+    const checkCase = casing.getChecker(caseType)
+    const convert = casing.getConverter(caseType)
+
+    return utils.executeOnVue(context, (obj) => {
+      const node = utils.findProperty(obj, 'components')
+      if (!node || node.value.type !== 'ObjectExpression') {
+        return
+      }
+
+      node.value.properties.forEach((property) => {
+        if (property.type !== 'Property') {
+          return
+        }
+
+        const name = getOptionsComponentName(property.key)
+        if (!name || checkCase(name)) {
+          return
+        }
+
+        context.report({
+          node: property.key,
+          messageId: 'caseNotMatched',
+          data: {
+            component: name,
+            caseType
+          },
+          fix: canAutoFix
+            ? (fixer) => {
+                const converted = convert(name)
+                return property.shorthand
+                  ? fixer.replaceText(property, `${converted}: ${name}`)
+                  : fixer.replaceText(property.key, converted)
+              }
+            : undefined,
+          suggest: canAutoFix
+            ? undefined
+            : [
+                {
+                  messageId: 'possibleRenaming',
+                  data: { caseType },
+                  fix: (fixer) => {
+                    const converted = convert(name)
+                    if (caseType === 'kebab-case') {
+                      return property.shorthand
+                        ? fixer.replaceText(property, `'${converted}': ${name}`)
+                        : fixer.replaceText(property.key, `'${converted}'`)
+                    }
+                    return property.shorthand
+                      ? fixer.replaceText(property, `${converted}: ${name}`)
+                      : fixer.replaceText(property.key, converted)
+                  }
+                }
+              ]
+        })
+      })
+    })
+  }
+}
diff --git a/lib/rules/no-dupe-keys.js b/lib/rules/no-dupe-keys.js
index 61ffda40..8b5e8e54 100644
--- a/lib/rules/no-dupe-keys.js
+++ b/lib/rules/no-dupe-keys.js
@@ -14,14 +14,7 @@ const utils = require('../utils')
 // Rule Definition
 // ------------------------------------------------------------------------------
 /** @type {GroupName[]} */
-const GROUP_NAMES = [
-  'props',
-  'computed',
-  'data',
-  'asyncData',
-  'methods',
-  'setup'
-]
+const GROUP_NAMES = ['props', 'computed', 'data', 'methods', 'setup']
 
 module.exports = {
   meta: {
diff --git a/lib/utils/indent-common.js b/lib/utils/indent-common.js
index 39ef5867..d208d5a0 100644
--- a/lib/utils/indent-common.js
+++ b/lib/utils/indent-common.js
@@ -19,7 +19,8 @@ const {
   isNotOpeningBraceToken,
   isOpeningBracketToken,
   isClosingBracketToken,
-  isSemicolonToken
+  isSemicolonToken,
+  isNotSemicolonToken
 } = require('eslint-utils')
 const {
   isComment,
@@ -1288,13 +1289,13 @@ module.exports.defineVisitor = function create(
     },
     /** @param {ExportAllDeclaration} node */
     ExportAllDeclaration(node) {
-      const tokens = tokenStore.getTokens(node)
-      const firstToken = /** @type {Token} */ (tokens.shift())
-      if (isSemicolonToken(tokens[tokens.length - 1])) {
-        tokens.pop()
-      }
+      const exportToken = tokenStore.getFirstToken(node)
+      const tokens = [
+        ...tokenStore.getTokensBetween(exportToken, node.source),
+        tokenStore.getFirstToken(node.source)
+      ]
       if (!node.exported) {
-        setOffset(tokens, 1, firstToken)
+        setOffset(tokens, 1, exportToken)
       } else {
         // export * as foo from "mod"
         const starToken = /** @type {Token} */ (tokens.find(isWildcard))
@@ -1302,10 +1303,28 @@ module.exports.defineVisitor = function create(
         const exportedToken = tokenStore.getTokenAfter(asToken)
         const afterTokens = tokens.slice(tokens.indexOf(exportedToken) + 1)
 
-        setOffset(starToken, 1, firstToken)
+        setOffset(starToken, 1, exportToken)
         setOffset(asToken, 1, starToken)
         setOffset(exportedToken, 1, starToken)
-        setOffset(afterTokens, 1, firstToken)
+        setOffset(afterTokens, 1, exportToken)
+      }
+
+      // assertions
+      const lastToken = /** @type {Token} */ (
+        tokenStore.getLastToken(node, isNotSemicolonToken)
+      )
+      const assertionTokens = tokenStore.getTokensBetween(
+        node.source,
+        lastToken
+      )
+      if (assertionTokens.length) {
+        const assertToken = /** @type {Token} */ (assertionTokens.shift())
+        setOffset(assertToken, 0, exportToken)
+        const assertionOpen = assertionTokens.shift()
+        if (assertionOpen) {
+          setOffset(assertionOpen, 1, assertToken)
+          processNodeList(assertionTokens, assertionOpen, lastToken, 1)
+        }
       }
     },
     /** @param {ExportDefaultDeclaration} node */
@@ -1328,28 +1347,66 @@ module.exports.defineVisitor = function create(
         const firstSpecifier = node.specifiers[0]
         if (!firstSpecifier || firstSpecifier.type === 'ExportSpecifier') {
           // export {foo, bar}; or export {foo, bar} from "mod";
-          const leftParenToken = tokenStore.getFirstToken(node, 1)
-          const rightParenToken = /** @type {Token} */ (
-            tokenStore.getLastToken(node, isClosingBraceToken)
+          const leftBraceTokens = firstSpecifier
+            ? tokenStore.getTokensBetween(exportToken, firstSpecifier)
+            : [tokenStore.getTokenAfter(exportToken)]
+          const rightBraceToken = /** @type {Token} */ (
+            node.source
+              ? tokenStore.getTokenBefore(node.source, isClosingBraceToken)
+              : tokenStore.getLastToken(node, isClosingBraceToken)
+          )
+          setOffset(leftBraceTokens, 0, exportToken)
+          processNodeList(
+            node.specifiers,
+            /** @type {Token} */ (last(leftBraceTokens)),
+            rightBraceToken,
+            1
           )
-          setOffset(leftParenToken, 0, exportToken)
-          processNodeList(node.specifiers, leftParenToken, rightParenToken, 1)
-
-          const maybeFromToken = tokenStore.getTokenAfter(rightParenToken)
-          if (maybeFromToken != null && maybeFromToken.value === 'from') {
-            const fromToken = maybeFromToken
-            const nameToken = tokenStore.getTokenAfter(fromToken)
-            setOffset([fromToken, nameToken], 1, exportToken)
+
+          if (node.source) {
+            const tokens = tokenStore.getTokensBetween(
+              rightBraceToken,
+              node.source
+            )
+            setOffset(
+              [...tokens, sourceCode.getFirstToken(node.source)],
+              1,
+              exportToken
+            )
+
+            // assertions
+            const lastToken = /** @type {Token} */ (
+              tokenStore.getLastToken(node, isNotSemicolonToken)
+            )
+            const assertionTokens = tokenStore.getTokensBetween(
+              node.source,
+              lastToken
+            )
+            if (assertionTokens.length) {
+              const assertToken = /** @type {Token} */ (assertionTokens.shift())
+              setOffset(assertToken, 0, exportToken)
+              const assertionOpen = assertionTokens.shift()
+              if (assertionOpen) {
+                setOffset(assertionOpen, 1, assertToken)
+                processNodeList(assertionTokens, assertionOpen, lastToken, 1)
+              }
+            }
           }
         } else {
           // maybe babel parser
         }
       }
     },
-    /** @param {ExportSpecifier} node */
-    ExportSpecifier(node) {
+    /** @param {ExportSpecifier | ImportSpecifier} node */
+    'ExportSpecifier, ImportSpecifier'(node) {
       const tokens = tokenStore.getTokens(node)
-      const firstToken = /** @type {Token} */ (tokens.shift())
+      let firstToken = /** @type {Token} */ (tokens.shift())
+      if (firstToken.value === 'type') {
+        const typeToken = firstToken
+        firstToken = /** @type {Token} */ (tokens.shift())
+        setOffset(firstToken, 0, typeToken)
+      }
+
       setOffset(tokens, 1, firstToken)
     },
     /** @param {ForInStatement | ForOfStatement} node */
@@ -1540,13 +1597,23 @@ module.exports.defineVisitor = function create(
         setOffset(fromToken, 1, importToken)
         setOffset(afterTokens, 0, fromToken)
       }
-    },
-    /** @param {ImportSpecifier} node */
-    ImportSpecifier(node) {
-      if (node.local.range[0] !== node.imported.range[0]) {
-        const tokens = tokenStore.getTokens(node)
-        const firstToken = /** @type {Token} */ (tokens.shift())
-        setOffset(tokens, 1, firstToken)
+
+      // assertions
+      const lastToken = /** @type {Token} */ (
+        tokenStore.getLastToken(node, isNotSemicolonToken)
+      )
+      const assertionTokens = tokenStore.getTokensBetween(
+        node.source,
+        lastToken
+      )
+      if (assertionTokens.length) {
+        const assertToken = /** @type {Token} */ (assertionTokens.shift())
+        setOffset(assertToken, 0, importToken)
+        const assertionOpen = assertionTokens.shift()
+        if (assertionOpen) {
+          setOffset(assertionOpen, 1, assertToken)
+          processNodeList(assertionTokens, assertionOpen, lastToken, 1)
+        }
       }
     },
     /** @param {ImportNamespaceSpecifier} node */
diff --git a/lib/utils/indent-ts.js b/lib/utils/indent-ts.js
index b98e9d10..496e892c 100644
--- a/lib/utils/indent-ts.js
+++ b/lib/utils/indent-ts.js
@@ -1302,6 +1302,27 @@ function defineVisitor({
         setOffset(atToken, 0, tokenStore.getFirstToken(decorators[0]))
       }
     },
+    ImportAttribute(node) {
+      const firstToken = tokenStore.getFirstToken(node)
+      const keyTokens = getFirstAndLastTokens(node.key)
+      const prefixTokens = tokenStore.getTokensBetween(
+        firstToken,
+        keyTokens.firstToken
+      )
+      setOffset(prefixTokens, 0, firstToken)
+
+      setOffset(keyTokens.firstToken, 0, firstToken)
+
+      const initToken = tokenStore.getFirstToken(node.value)
+      setOffset(
+        [
+          ...tokenStore.getTokensBetween(keyTokens.lastToken, initToken),
+          initToken
+        ],
+        1,
+        keyTokens.lastToken
+      )
+    },
 
     // ----------------------------------------------------------------------
     // DEPRECATED  NODES
diff --git a/package.json b/package.json
index 2c9bcbf1..96da9be8 100644
--- a/package.json
+++ b/package.json
@@ -65,7 +65,7 @@
     "@types/natural-compare": "^1.4.0",
     "@types/node": "^13.13.5",
     "@types/semver": "^7.2.0",
-    "@typescript-eslint/parser": "^5.4.0",
+    "@typescript-eslint/parser": "^5.5.0",
     "@vuepress/plugin-pwa": "^1.4.1",
     "acorn": "^8.5.0",
     "env-cmd": "^10.1.0",
@@ -82,7 +82,7 @@
     "mocha": "^7.1.2",
     "nyc": "^15.1.0",
     "prettier": "^2.4.1",
-    "typescript": "^4.5.0-0",
+    "typescript": "^4.5.0",
     "vue-eslint-editor": "^1.1.0",
     "vuepress": "^1.8.2"
   }


Test patch:
diff --git a/tests/fixtures/script-indent/ts-import-assertion-01.vue b/tests/fixtures/script-indent/ts-import-assertion-01.vue
new file mode 100644
index 00000000..f1476184
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-01.vue
@@ -0,0 +1,9 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+import foo from "./foo.json" assert
+  {
+    type
+      :
+      "json"
+  }
+</script>
diff --git a/tests/fixtures/script-indent/ts-import-assertion-02.vue b/tests/fixtures/script-indent/ts-import-assertion-02.vue
new file mode 100644
index 00000000..86fe80b4
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-02.vue
@@ -0,0 +1,15 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+import(
+  "./foo.json",
+  {
+    assert
+      :
+      {
+        type
+          :
+          "json"
+      }
+  }
+)
+</script>
diff --git a/tests/fixtures/script-indent/ts-import-assertion-03.vue b/tests/fixtures/script-indent/ts-import-assertion-03.vue
new file mode 100644
index 00000000..ee88e92c
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-03.vue
@@ -0,0 +1,10 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+export * from "mod" assert
+  {
+    type
+      :
+      "json"
+  }
+;
+</script>
diff --git a/tests/fixtures/script-indent/ts-import-assertion-04.vue b/tests/fixtures/script-indent/ts-import-assertion-04.vue
new file mode 100644
index 00000000..76d2f286
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-04.vue
@@ -0,0 +1,10 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+export { foo } from "mod" assert
+  {
+    type
+      :
+      "json"
+  }
+;
+</script>
diff --git a/tests/fixtures/script-indent/ts-type-only-import-export-01.vue b/tests/fixtures/script-indent/ts-type-only-import-export-01.vue
new file mode 100644
index 00000000..97c6dc19
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-type-only-import-export-01.vue
@@ -0,0 +1,17 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.0 || ^5.4.1-0" } }-->
+<script lang="ts">
+import
+  type
+  Foo
+  from
+  'foo'
+import
+  type
+  {
+    Foo
+  }
+  from
+  'foo';
+export
+  type Foo = {}
+</script>
diff --git a/tests/fixtures/script-indent/ts-type-only-import-export-02.vue b/tests/fixtures/script-indent/ts-type-only-import-export-02.vue
new file mode 100644
index 00000000..0ef5f110
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-type-only-import-export-02.vue
@@ -0,0 +1,23 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.0 || ^5.4.1-0" } }-->
+<script lang="ts">
+import
+{
+  type
+  Foo
+}
+  from
+  'foo';
+export
+{
+  type
+  Bar
+}
+  from
+  'bar';
+export
+{
+  type
+  Baz
+}
+;
+</script>
diff --git a/tests/fixtures/script-indent/ts-type-only-import-export-03.vue b/tests/fixtures/script-indent/ts-type-only-import-export-03.vue
new file mode 100644
index 00000000..ca280f03
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-type-only-import-export-03.vue
@@ -0,0 +1,26 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.0 || ^5.4.1-0" } }-->
+<script lang="ts">
+import
+{
+  type
+  Foo
+    as Foo2
+}
+  from
+  'foo';
+export
+{
+  type
+  Bar
+    as Bar2
+}
+  from
+  'bar';
+export
+{
+  type
+  Baz
+    as Baz2
+}
+;
+</script>
diff --git a/tests/lib/rules/component-options-name-casing.js b/tests/lib/rules/component-options-name-casing.js
new file mode 100644
index 00000000..504d98b0
--- /dev/null
+++ b/tests/lib/rules/component-options-name-casing.js
@@ -0,0 +1,558 @@
+/**
+ * @author Pig Fang
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/component-options-name-casing')
+
+const tester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('component-options-name-casing', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          ...components
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `,
+      options: ['PascalCase']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      options: ['camelCase']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      options: ['camelCase']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `,
+      options: ['kebab-case']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `,
+      options: ['kebab-case']
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      options: ['PascalCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      options: ['PascalCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `,
+      options: ['PascalCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'foo-bar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 22
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `,
+      options: ['camelCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'camelCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'camelCase' },
+              output: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `,
+      options: ['camelCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'camelCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'camelCase' },
+              output: `
+        export default {
+          components: {
+            fooBar: fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `,
+      options: ['camelCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'foo-bar',
+            caseType: 'camelCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 22,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'camelCase' },
+              output: `
+        export default {
+          components: {
+            fooBar: fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar,
+            'my-component': MyComponent
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': FooBar,
+            'my-component': MyComponent
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-dupe-keys.js b/tests/lib/rules/no-dupe-keys.js
index 9e01f085..2abae46d 100644
--- a/tests/lib/rules/no-dupe-keys.js
+++ b/tests/lib/rules/no-dupe-keys.js
@@ -380,6 +380,24 @@ ruleTester.run('no-dupe-keys', rule, {
         }
       }
       `
+    },
+    {
+      // https://github.com/vuejs/eslint-plugin-vue/issues/1687
+      filename: 'test.vue',
+      code: `
+        export default {
+          asyncData() {
+            return {
+              foo: 1
+            }
+          },
+          data() {
+            return {
+              foo: 2
+            }
+          },
+        }
+      `
     }
   ],
 
@@ -709,29 +727,6 @@ ruleTester.run('no-dupe-keys', rule, {
         }
       ]
     },
-    {
-      filename: 'test.vue',
-      code: `
-        export default {
-          asyncData() {
-            return {
-              foo: 1
-            }
-          },
-          data() {
-            return {
-              foo: 2
-            }
-          },
-        }
-      `,
-      errors: [
-        {
-          message: "Duplicated key 'foo'.",
-          line: 10
-        }
-      ]
-    },
     {
       filename: 'test.js',
       code: `