diff --git a/docs/rules/README.md b/docs/rules/README.md
index 5794cc3ed..2af1c06ca 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 000000000..9a956553b
--- /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: ***This rule has not been released yet.***
+- :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
+
+
+
+
+
+
+
+
+
+
+```
+
+### `"PascalCase"` (default)
+
+
+
+```vue
+
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+### `"kebab-case"`
+
+
+
+```vue
+
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+### `"camelCase"`
+
+
+
+```vue
+
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+## :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 c9969917c..0adbf3f74 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 000000000..ad70c1546
--- /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/tests/lib/rules/component-options-name-casing.js b/tests/lib/rules/component-options-name-casing.js
new file mode 100644
index 000000000..504d98b0c
--- /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
+ }
+ ]
+})