From 10dd1a99aa983754deb76383b224d48ebb2cbe91 Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Tue, 30 Nov 2021 13:06:13 +0800 Subject: [PATCH] Add `vue/component-options-name-casing` rule (#1725) * Add `vue/component-options-name-casing` rule * fix docs * fix demo * refactor * fix auto-fix * fix checking kebab-case * provide suggestions * accept suggestions --- docs/rules/README.md | 1 + docs/rules/component-options-name-casing.md | 165 ++++++ lib/index.js | 1 + lib/rules/component-options-name-casing.js | 115 ++++ .../rules/component-options-name-casing.js | 558 ++++++++++++++++++ 5 files changed, 840 insertions(+) create mode 100644 docs/rules/component-options-name-casing.md create mode 100644 lib/rules/component-options-name-casing.js create mode 100644 tests/lib/rules/component-options-name-casing.js 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 + } + ] +})