Repository: vuejs/eslint-plugin-vue
Pull request #1377 (2020-12-16 02:29:25)

Add vue/no-restricted-custom-event rule
This PR adds `vue/no-restricted-custom-event` rule.

`vue/no-restricted-custom-event` rule that allows you to specify custom event that you don't want to use in your application.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index a242514b..68677d25 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -302,6 +302,7 @@ For example:
 | [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property |  |
 | [vue/no-reserved-component-names](./no-reserved-component-names.md) | disallow the use of reserved names in component definitions |  |
 | [vue/no-restricted-component-options](./no-restricted-component-options.md) | disallow specific component option |  |
+| [vue/no-restricted-custom-event](./no-restricted-custom-event.md) | disallow specific custom event |  |
 | [vue/no-restricted-props](./no-restricted-props.md) | disallow specific props |  |
 | [vue/no-restricted-static-attribute](./no-restricted-static-attribute.md) | disallow specific attribute |  |
 | [vue/no-restricted-v-bind](./no-restricted-v-bind.md) | disallow specific argument in `v-bind` |  |
diff --git a/docs/rules/no-restricted-custom-event.md b/docs/rules/no-restricted-custom-event.md
new file mode 100644
index 00000000..73cf0967
--- /dev/null
+++ b/docs/rules/no-restricted-custom-event.md
@@ -0,0 +1,97 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-restricted-custom-event
+description: disallow specific custom event
+---
+# vue/no-restricted-custom-event
+> disallow specific custom event
+
+## :book: Rule Details
+
+This rule allows you to specify custom event that you don't want to use in your application.
+
+## :wrench: Options
+
+This rule takes a list of strings, where each string is a custom event name or pattern to be restricted:
+
+```json
+{
+  "vue/no-restricted-custom-event": ["error", "input", "/^forbidden/"]
+}
+```
+
+<eslint-code-block :rules="{'vue/no-restricted-custom-event': ['error', 'input', '/^forbidden/']}">
+
+```vue
+<template>
+  <!-- ✗ BAD -->
+  <input @input="$emit('input', $event.target.value)">
+  <!-- ✗ GOOD -->
+  <input @input="$emit('update:value', $event.target.value)">
+</template>
+<script>
+export default {
+  methods: {
+    handleChangeValue(newValue) {
+      /* ✗ BAD */
+      this.$emit('input', newValue)
+      this.$emit('forbiddenEvent')
+
+      /* ✓ GOOD */
+      this.$emit('foo')
+      this.$emit('bar')
+    }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+
+Alternatively, the rule also accepts objects.
+
+```json
+{
+  "vue/no-restricted-custom-event": ["error",
+    {
+      "event": "input",
+      "message": "If you intend a prop for v-model, it should be 'update:modelValue' in Vue 3.",
+      "suggest": "update:modelValue"
+    },
+  ]
+}
+```
+
+The following properties can be specified for the object.
+
+- `event` ... Specify the event name or pattern.
+- `message` ... Specify an optional custom message.
+- `suggest` ... Specify an optional name to suggest changes.
+
+<eslint-code-block :rules="{'vue/no-restricted-custom-event': ['error', { event: 'input', message: 'If you intend a prop for v-model, it should be \'update:modelValue\' in Vue 3.', suggest: 'update:modelValue'}]}">
+
+```vue
+<template>
+  <!-- ✗ BAD -->
+  <input @input="$emit('input', $event.target.value)">
+</template>
+<script>
+export default {
+  methods: {
+    handleChangeValue(newValue) {
+      /* ✗ BAD */
+      this.$emit('input', newValue)
+    }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-restricted-custom-event.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-restricted-custom-event.js)
diff --git a/lib/index.js b/lib/index.js
index 9c396cb6..95ffd1dd 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -92,6 +92,7 @@ module.exports = {
     'no-reserved-component-names': require('./rules/no-reserved-component-names'),
     'no-reserved-keys': require('./rules/no-reserved-keys'),
     'no-restricted-component-options': require('./rules/no-restricted-component-options'),
+    'no-restricted-custom-event': require('./rules/no-restricted-custom-event'),
     'no-restricted-props': require('./rules/no-restricted-props'),
     'no-restricted-static-attribute': require('./rules/no-restricted-static-attribute'),
     'no-restricted-syntax': require('./rules/no-restricted-syntax'),
diff --git a/lib/rules/no-restricted-custom-event.js b/lib/rules/no-restricted-custom-event.js
new file mode 100644
index 00000000..72835ddd
--- /dev/null
+++ b/lib/rules/no-restricted-custom-event.js
@@ -0,0 +1,293 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+const regexp = require('../utils/regexp')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * @typedef {object} ParsedOption
+ * @property { (name: string) => boolean } test
+ * @property {string} [message]
+ * @property {string} [suggest]
+ */
+
+/**
+ * @param {string} str
+ * @returns {(str: string) => boolean}
+ */
+function buildMatcher(str) {
+  if (regexp.isRegExp(str)) {
+    const re = regexp.toRegExp(str)
+    return (s) => {
+      re.lastIndex = 0
+      return re.test(s)
+    }
+  }
+  return (s) => s === str
+}
+/**
+ * @param {string|{event: string, message?: string, suggest?: string}} option
+ * @returns {ParsedOption}
+ */
+function parseOption(option) {
+  if (typeof option === 'string') {
+    const matcher = buildMatcher(option)
+    return {
+      test(name) {
+        return matcher(name)
+      }
+    }
+  }
+  const parsed = parseOption(option.event)
+  parsed.message = option.message
+  parsed.suggest = option.suggest
+  return parsed
+}
+
+/**
+ * Get the name param node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ * @returns { Literal & { value: string } | null }
+ */
+function getNameParamNode(node) {
+  const nameLiteralNode = node.arguments[0]
+  if (
+    !nameLiteralNode ||
+    nameLiteralNode.type !== 'Literal' ||
+    typeof nameLiteralNode.value !== 'string'
+  ) {
+    // cannot check
+    return null
+  }
+
+  return /** @type {Literal & { value: string }} */ (nameLiteralNode)
+}
+/**
+ * Get the callee member node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ */
+function getCalleeMemberNode(node) {
+  const callee = utils.skipChainExpression(node.callee)
+
+  if (callee.type === 'MemberExpression') {
+    const name = utils.getStaticPropertyName(callee)
+    if (name) {
+      return { name, member: callee }
+    }
+  }
+  return null
+}
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'disallow specific custom event',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-restricted-custom-event.html'
+    },
+    fixable: null,
+    schema: {
+      type: 'array',
+      items: {
+        oneOf: [
+          { type: ['string'] },
+          {
+            type: 'object',
+            properties: {
+              event: { type: 'string' },
+              message: { type: 'string', minLength: 1 },
+              suggest: { type: 'string' }
+            },
+            required: ['event'],
+            additionalProperties: false
+          }
+        ]
+      },
+      uniqueItems: true,
+      minItems: 0
+    },
+
+    messages: {
+      // eslint-disable-next-line eslint-plugin/report-message-format
+      restrictedEvent: '{{message}}',
+      instead: 'Instead, change to `{{suggest}}`.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @type {Map<ObjectExpression, {contextReferenceIds:Set<Identifier>,emitReferenceIds:Set<Identifier>}>} */
+    const setupContexts = new Map()
+    /** @type {ParsedOption[]} */
+    const options = context.options.map(parseOption)
+
+    /**
+     * @param { Literal & { value: string } } nameLiteralNode
+     */
+    function verify(nameLiteralNode) {
+      const name = nameLiteralNode.value
+
+      for (const option of options) {
+        if (option.test(name)) {
+          const message =
+            option.message || `Using \`${name}\` event is not allowed.`
+          context.report({
+            node: nameLiteralNode,
+            messageId: 'restrictedEvent',
+            data: { message },
+            suggest: option.suggest
+              ? [
+                  {
+                    fix(fixer) {
+                      const sourceCode = context.getSourceCode()
+                      return fixer.replaceText(
+                        nameLiteralNode,
+                        `${
+                          sourceCode.text[nameLiteralNode.range[0]]
+                        }${JSON.stringify(option.suggest)
+                          .slice(1, -1)
+                          .replace(/'/gu, "\\'")}${
+                          sourceCode.text[nameLiteralNode.range[1] - 1]
+                        }`
+                      )
+                    },
+                    messageId: 'instead',
+                    data: { suggest: option.suggest }
+                  }
+                ]
+              : []
+          })
+          break
+        }
+      }
+    }
+
+    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 = utils.skipDefaultParamValue(node.params[1])
+            if (!contextParam) {
+              // no arguments
+              return
+            }
+            if (
+              contextParam.type === 'RestElement' ||
+              contextParam.type === 'ArrayPattern'
+            ) {
+              // cannot check
+              return
+            }
+            const contextReferenceIds = new Set()
+            const emitReferenceIds = new Set()
+            if (contextParam.type === 'ObjectPattern') {
+              const emitProperty = utils.findAssignmentProperty(
+                contextParam,
+                'emit'
+              )
+              if (!emitProperty || emitProperty.value.type !== 'Identifier') {
+                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 (
+                node.callee.type === 'Identifier' &&
+                emitReferenceIds.has(node.callee)
+              ) {
+                // verify setup(props,{emit}) {emit()}
+                verify(nameLiteralNode)
+              } else {
+                const emit = getCalleeMemberNode(node)
+                if (
+                  emit &&
+                  emit.name === 'emit' &&
+                  emit.member.object.type === 'Identifier' &&
+                  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)
+            }
+          }
+        }
+      )
+    )
+  }
+}


Test patch:
diff --git a/tests/lib/rules/no-restricted-custom-event.js b/tests/lib/rules/no-restricted-custom-event.js
new file mode 100644
index 00000000..447f8122
--- /dev/null
+++ b/tests/lib/rules/no-restricted-custom-event.js
@@ -0,0 +1,296 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-restricted-custom-event')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
+})
+
+tester.run('no-restricted-custom-event', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="$emit('click')"></button>
+      </template>
+      <script>
+      export default {
+        methods: {
+          handleClick() {
+            this.$emit('foo')
+            this.$emit('bar')
+          }
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="$emit('click')"></button>
+      </template>
+      <script>
+      export default {
+        methods: {
+          handleClick() {
+            this.$emit('good')
+          }
+        }
+      }
+      </script>
+      `,
+      options: ['bad']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="$emit(0)"></button>
+        <button @click="emit('ignore')"></button>
+      </template>
+      <script>
+      export default {
+        setup(props, {emit}) {
+          emit(ignore)
+        }
+      }
+      </script>
+      `,
+      options: ['ignore', '0']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(props, emit) {
+          emit('ignore')
+        }
+      }
+      </script>
+      `,
+      options: ['ignore', '0']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(props, ...emit) {
+          emit('ignore')
+        }
+      }
+      </script>
+      `,
+      options: ['ignore', '0']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(props) {
+          emit('ignore')
+        }
+      }
+      </script>
+      `,
+      options: ['ignore', '0']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(props, {a: emit}) {
+          emit('ignore')
+        }
+      }
+      </script>
+      `,
+      options: ['ignore', '0']
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        methods: {
+          handleClick() {
+            /* ✗ BAD */
+            this.$emit('bad')
+
+            /* ✓ GOOD */
+            this.$emit('good')
+          }
+        }
+      }
+      </script>
+      `,
+      options: ['bad'],
+      errors: [
+        {
+          message: 'Using `bad` event is not allowed.',
+          line: 7,
+          column: 24
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        methods: {
+          handleClick() {
+            this.$emit('foo')
+            this.$emit('bar', 123)
+          }
+        }
+      }
+      </script>
+      `,
+      options: [
+        { event: 'foo' },
+        { event: 'bar', message: 'Use Bar instead', suggest: 'Bar' }
+      ],
+      errors: [
+        {
+          message: 'Using `foo` event is not allowed.',
+          line: 6
+        },
+        {
+          message: 'Use Bar instead',
+          line: 7,
+          suggestions: [
+            {
+              desc: 'Instead, change to `Bar`.',
+              output: `
+      <script>
+      export default {
+        methods: {
+          handleClick() {
+            this.$emit('foo')
+            this.$emit('Bar', 123)
+          }
+        }
+      }
+      </script>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        methods: {
+          handleClick() {
+            this.$emit('regexp1')
+            this.$emit('regexp2')
+            this.$emit('ignore-regexp')
+          }
+        }
+      }
+      </script>
+      `,
+      options: ['/^regexp/'],
+      errors: [
+        {
+          message: 'Using `regexp1` event is not allowed.',
+          line: 6
+        },
+        {
+          message: 'Using `regexp2` event is not allowed.',
+          line: 7
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="$emit('bad')"></button>
+      </template>
+      `,
+      options: [{ event: 'bad', suggest: "foo'" }],
+      errors: [
+        {
+          message: 'Using `bad` event is not allowed.',
+          line: 3,
+          suggestions: [
+            {
+              desc: "Instead, change to `foo'`.",
+              output: `
+      <template>
+        <button @click="$emit('foo\\'')"></button>
+      </template>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(props, {emit}) {
+          emit('foo')
+          emit('bar')
+        }
+      }
+      </script>
+      `,
+      options: ['foo'],
+      errors: [
+        {
+          message: 'Using `foo` event is not allowed.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(props, ctx) {
+          ctx.emit('foo')
+          ctx.emit('bar')
+        }
+      }
+      </script>
+      `,
+      options: ['foo'],
+      errors: [
+        {
+          message: 'Using `foo` event is not allowed.',
+          line: 5
+        }
+      ]
+    }
+  ]
+})