Repository: vuejs/eslint-plugin-vue (JavaScript)

Issue:
New rule: `component-options-name-casing`
<!--
  Before proposing new rule, please make sure it hasn't been posted already.
  You can see all open propositions here:
  https://github.com/vuejs/eslint-plugin-vue/issues?q=is%3Aopen+is%3Aissue+label%3A%22new+rule+proposition%22
-->

**Please describe what the rule should do:**

Enforce the casing of component name in `components` options.

This rule should allow two options: `PascalCase` and `camelCase`, and default option is `PascalCase`.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[ ] Enforces code style (layout)
[ ] Warns about a potential error (problem)
[x] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

```vue
<script>
import myComponent from './myComponent.vue'

export default {
  components: {
    myComponent  // <-- should be `MyComponent`
  }
}
</script>
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->




Discussion:
Thank you for rule proposal!

I don't know if any users like camelCase, but the rule of checking the casing sounds good to me :+1:



Patch:
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: <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 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)
+                  }
+                }
+              ]
+        })
+      })
+    })
+  }
+}


Test patch:
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
+    }
+  ]
+})