From 561e88dd2436a4eb1f7a7565bf6f17634f4caa9f Mon Sep 17 00:00:00 2001 From: ota Date: Wed, 27 May 2020 15:54:28 +0900 Subject: [PATCH] Add `vue/custom-event-name-casing` rule --- docs/rules/README.md | 2 + docs/rules/custom-event-name-casing.md | 63 ++++++ lib/configs/essential.js | 1 + lib/configs/vue3-essential.js | 1 + lib/index.js | 1 + lib/rules/custom-event-name-casing.js | 221 ++++++++++++++++++++ tests/lib/rules/custom-event-name-casing.js | 221 ++++++++++++++++++++ 7 files changed, 510 insertions(+) create mode 100644 docs/rules/custom-event-name-casing.md create mode 100644 lib/rules/custom-event-name-casing.js create mode 100644 tests/lib/rules/custom-event-name-casing.js diff --git a/docs/rules/README.md b/docs/rules/README.md index 9b32f097d..2df1e96a0 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -38,6 +38,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi | Rule ID | Description | | |:--------|:------------|:---| +| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" | | | [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher | | | [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties | | | [vue/no-deprecated-data-object-declaration](./no-deprecated-data-object-declaration.md) | disallow using deprecated object declaration on data (in Vue.js 3.0.0+) | :wrench: | @@ -160,6 +161,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi | Rule ID | Description | | |:--------|:------------|:---| +| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" | | | [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher | | | [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties | | | [vue/no-custom-modifiers-on-v-model](./no-custom-modifiers-on-v-model.md) | disallow custom modifiers on v-model used on the component | | diff --git a/docs/rules/custom-event-name-casing.md b/docs/rules/custom-event-name-casing.md new file mode 100644 index 000000000..cc0153c81 --- /dev/null +++ b/docs/rules/custom-event-name-casing.md @@ -0,0 +1,63 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/custom-event-name-casing +description: enforce custom event names always use "kebab-case" +--- +# vue/custom-event-name-casing +> enforce custom event names always use "kebab-case" + +- :gear: This rule is included in all of `"plugin:vue/vue3-essential"`, `"plugin:vue/essential"`, `"plugin:vue/vue3-strongly-recommended"`, `"plugin:vue/strongly-recommended"`, `"plugin:vue/vue3-recommended"` and `"plugin:vue/recommended"`. + +## :book: Rule Details + +This rule enforces using kebab-case custom event names. + +> Event names will never be used as variable or property names in JavaScript, so there’s no reason to use camelCase or PascalCase. Additionally, `v-on` event listeners inside DOM templates will be automatically transformed to lowercase (due to HTML’s case-insensitivity), so `v-on:myEvent` would become `v-on:myevent` – making `myEvent` impossible to listen to. +> +> For these reasons, we recommend you **always use kebab-case for event names**. + +See [Guide - Custom Events] for more details. + + + +```vue + + +``` + + + +## :wrench: Options + +Nothing. + +## :books: Further Reading + +- [Guide - Custom Events] + +[Guide - Custom Events]: https://vuejs.org/v2/guide/components-custom-events.html + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/custom-event-name-casing.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/custom-event-name-casing.js) diff --git a/lib/configs/essential.js b/lib/configs/essential.js index 92a9e7405..9005982b7 100644 --- a/lib/configs/essential.js +++ b/lib/configs/essential.js @@ -6,6 +6,7 @@ module.exports = { extends: require.resolve('./base'), rules: { + 'vue/custom-event-name-casing': 'error', 'vue/no-arrow-functions-in-watch': 'error', 'vue/no-async-in-computed-properties': 'error', 'vue/no-custom-modifiers-on-v-model': 'error', diff --git a/lib/configs/vue3-essential.js b/lib/configs/vue3-essential.js index 9ae00e5e1..b2aec42d8 100644 --- a/lib/configs/vue3-essential.js +++ b/lib/configs/vue3-essential.js @@ -6,6 +6,7 @@ module.exports = { extends: require.resolve('./base'), rules: { + 'vue/custom-event-name-casing': 'error', 'vue/no-arrow-functions-in-watch': 'error', 'vue/no-async-in-computed-properties': 'error', 'vue/no-deprecated-data-object-declaration': 'error', diff --git a/lib/index.js b/lib/index.js index 6b5ea2011..8f6e7f4d0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,6 +21,7 @@ module.exports = { 'component-definition-name-casing': require('./rules/component-definition-name-casing'), 'component-name-in-template-casing': require('./rules/component-name-in-template-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'), eqeqeq: require('./rules/eqeqeq'), 'html-closing-bracket-newline': require('./rules/html-closing-bracket-newline'), diff --git a/lib/rules/custom-event-name-casing.js b/lib/rules/custom-event-name-casing.js new file mode 100644 index 000000000..cec6f55d7 --- /dev/null +++ b/lib/rules/custom-event-name-casing.js @@ -0,0 +1,221 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +'use strict' + +/** + * @typedef {import('vue-eslint-parser').AST.ESLintLiteral} Literal + * @typedef {import('vue-eslint-parser').AST.ESLintCallExpression} CallExpression + */ + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const { findVariable } = require('eslint-utils') +const utils = require('../utils') +const { isKebabCase } = require('../utils/casing') + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +/** + * Check whether the given event name is valid. + * @param {string} name The name to check. + * @returns {boolean} `true` if the given event name is valid. + */ +function isValidEventName(name) { + return isKebabCase(name) || name.startsWith('update:') +} + +/** + * Get the name param node from the given CallExpression + * @param {CallExpression} node CallExpression + * @returns { Literal & { value: string } } + */ +function getNameParamNode(node) { + const nameLiteralNode = node.arguments[0] + if ( + !nameLiteralNode || + nameLiteralNode.type !== 'Literal' || + typeof nameLiteralNode.value !== 'string' + ) { + // cannot check + return null + } + + return nameLiteralNode +} +/** + * Get the callee member node from the given CallExpression + * @param {CallExpression} node CallExpression + */ +function getCalleeMemberNode(node) { + const callee = node.callee + + if (callee.type === 'MemberExpression') { + const name = utils.getStaticPropertyName(callee) + if (name) { + return { name, member: callee } + } + } + return null +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'enforce custom event names always use "kebab-case"', + categories: ['vue3-essential', 'essential'], + url: 'https://eslint.vuejs.org/rules/custom-event-name-casing.html' + }, + fixable: null, + schema: [], + messages: { + unexpected: "Custom event name '{{name}}' must be kebab-case." + } + }, + + create(context) { + const setupContexts = new Map() + + /** + * @param { Literal & { value: string } } nameLiteralNode + */ + function verify(nameLiteralNode) { + const name = nameLiteralNode.value + if (isValidEventName(name)) { + return + } + context.report({ + node: nameLiteralNode, + messageId: 'unexpected', + data: { + name + } + }) + } + + return utils.defineTemplateBodyVisitor( + context, + { + CallExpression(node) { + const callee = node.callee + const nameLiteralNode = getNameParamNode(node) + if (!nameLiteralNode) { + // cannot check + return + } + if (callee.type === 'Identifier' && callee.name === '$emit') { + verify(nameLiteralNode) + } + } + }, + utils.compositingVisitors( + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = node.params[1] + if (!contextParam) { + // no arguments + return + } + if (contextParam.type === 'RestElement') { + // cannot check + return + } + if (contextParam.type === 'ArrayPattern') { + // cannot check + return + } + const contextReferenceIds = new Set() + const emitReferenceIds = new Set() + if (contextParam.type === 'ObjectPattern') { + const emitProperty = contextParam.properties.find( + (p) => + p.type === 'Property' && + utils.getStaticPropertyName(p) === 'emit' + ) + if (!emitProperty) { + return + } + const emitParam = emitProperty.value + // `setup(props, {emit})` + const variable = findVariable(context.getScope(), emitParam) + if (!variable) { + return + } + for (const reference of variable.references) { + emitReferenceIds.add(reference.identifier) + } + } else { + // `setup(props, context)` + const variable = findVariable(context.getScope(), contextParam) + if (!variable) { + return + } + for (const reference of variable.references) { + contextReferenceIds.add(reference.identifier) + } + } + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds + }) + }, + CallExpression(node, { node: vueNode }) { + const nameLiteralNode = getNameParamNode(node) + if (!nameLiteralNode) { + // cannot check + return + } + + // verify setup context + const setupContext = setupContexts.get(vueNode) + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext + if (emitReferenceIds.has(node.callee)) { + // verify setup(props,{emit}) {emit()} + verify(nameLiteralNode) + } else { + const emit = getCalleeMemberNode(node) + if ( + emit && + emit.name === 'emit' && + contextReferenceIds.has(emit.member.object) + ) { + // verify setup(props,context) {context.emit()} + verify(nameLiteralNode) + } + } + } + }, + onVueObjectExit(node) { + setupContexts.delete(node) + } + }), + { + CallExpression(node) { + const nameLiteralNode = getNameParamNode(node) + if (!nameLiteralNode) { + // cannot check + return + } + const emit = getCalleeMemberNode(node) + // verify $emit + if (emit && emit.name === '$emit') { + // verify this.$emit() + verify(nameLiteralNode) + } + } + } + ) + ) + } +} diff --git a/tests/lib/rules/custom-event-name-casing.js b/tests/lib/rules/custom-event-name-casing.js new file mode 100644 index 000000000..b767223e2 --- /dev/null +++ b/tests/lib/rules/custom-event-name-casing.js @@ -0,0 +1,221 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/custom-event-name-casing') + +const tester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module' + } +}) + +tester.run('custom-event-name-casing', rule, { + valid: [ + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: "Custom event name 'fooBar' must be kebab-case.", + line: 4, + column: 25, + endLine: 4, + endColumn: 33 + }, + { + message: "Custom event name 'barBaz' must be kebab-case.", + line: 11, + column: 28, + endLine: 11, + endColumn: 36 + }, + { + message: "Custom event name 'bazQux' must be kebab-case.", + line: 17, + column: 24, + endLine: 17, + endColumn: 32 + } + ] + } + ] +})