Skip to content

Commit

Permalink
Add vue/custom-event-name-casing rule (#1166)
Browse files Browse the repository at this point in the history
  • Loading branch information
ota-meshi committed May 30, 2020
1 parent 0045596 commit b89adfa
Show file tree
Hide file tree
Showing 7 changed files with 510 additions and 0 deletions.
2 changes: 2 additions & 0 deletions docs/rules/README.md
Expand Up @@ -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: |
Expand Down Expand Up @@ -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 | |
Expand Down
63 changes: 63 additions & 0 deletions 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.

<eslint-code-block :rules="{'vue/custom-event-name-casing': ['error']}">

```vue
<template>
<!-- ✔ GOOD -->
<button @click="$emit('my-event')" />
<!-- ✘ BAD -->
<button @click="$emit('myEvent')" />
</template>
<script>
export default {
methods: {
onClick () {
/* ✔ GOOD */
this.$emit('my-event')
this.$emit('update:myProp', myProp)
/* ✘ BAD */
this.$emit('myEvent')
}
}
}
</script>
```

</eslint-code-block>

## :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)
1 change: 1 addition & 0 deletions lib/configs/essential.js
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/configs/vue3-essential.js
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/index.js
Expand Up @@ -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'),
Expand Down
221 changes: 221 additions & 0 deletions 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)
}
}
}
)
)
}
}

0 comments on commit b89adfa

Please sign in to comment.