Repository: vuejs/eslint-plugin-vue
Pull request #1561 (2021-07-05 14:54:51)

Add `vue/valid-define-emits` rule
This PR adds `vue/valid-define-props` rule.

`vue/valid-define-emits` rule reports `defineEmits` compiler macros in the following cases:

- `defineEmits` are referencing locally declared variables.
- `defineEmits` has both a literal type and an argument. e.g. `defineEmits<(e: 'foo')=>void>(['bar'])`
- `defineEmits` has been called multiple times.
- Custom events are defined in both `defineEmits` and `export default {}`.
- Custom events are not defined in either `defineEmits` or `export default {}`.

Related to #1248


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index ae82c5d0..3577668f 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -301,6 +301,7 @@ For example:
 | [vue/no-deprecated-v-is](./no-deprecated-v-is.md) | disallow deprecated `v-is` directive (in Vue.js 3.1.0+) | :wrench: |
 | [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` |  |
 | [vue/no-empty-component-block](./no-empty-component-block.md) | disallow the `<template>` `<script>` `<style>` block to be empty |  |
+| [vue/no-export-in-script-setup](./no-export-in-script-setup.md) | disallow `export` in `<script setup>` |  |
 | [vue/no-invalid-model-keys](./no-invalid-model-keys.md) | require valid keys in model option |  |
 | [vue/no-multiple-objects-in-class](./no-multiple-objects-in-class.md) | disallow to pass multiple objects into array to class |  |
 | [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property |  |
@@ -331,6 +332,7 @@ For example:
 | [vue/v-for-delimiter-style](./v-for-delimiter-style.md) | enforce `v-for` directive's delimiter style | :wrench: |
 | [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md) | enforce v-on event naming style on custom components in template | :wrench: |
 | [vue/v-on-function-call](./v-on-function-call.md) | enforce or forbid parentheses after method calls without arguments in `v-on` directives | :wrench: |
+| [vue/valid-define-emits](./valid-define-emits.md) | enforce valid `defineEmits` compiler macro |  |
 | [vue/valid-define-props](./valid-define-props.md) | enforce valid `defineProps` compiler macro |  |
 | [vue/valid-next-tick](./valid-next-tick.md) | enforce valid `nextTick` function calls | :wrench: |
 
diff --git a/docs/rules/no-export-in-script-setup.md b/docs/rules/no-export-in-script-setup.md
new file mode 100644
index 00000000..4ab2a31c
--- /dev/null
+++ b/docs/rules/no-export-in-script-setup.md
@@ -0,0 +1,55 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-export-in-script-setup
+description: disallow `export` in `<script setup>`
+---
+# vue/no-export-in-script-setup
+
+> disallow `export` in `<script setup>`
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule warns ES module exports in `<script setup>`.
+
+The previous version of `<script setup>` RFC used `export` to define variables used in templates, but the new `<script setup>` RFC has been updated to define without using `export`.
+See [Vue RFCs - 0040-script-setup] for more details.
+
+<eslint-code-block :rules="{'vue/no-export-in-script-setup': ['error']}">
+
+```vue
+<script setup>
+/* ✓ GOOD */
+let msg = 'Hello!'
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-export-in-script-setup': ['error']}">
+
+```vue
+<script setup>
+/* ✗ BAD */
+export let msg = 'Hello!'
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [Vue RFCs - 0040-script-setup]
+
+[Vue RFCs - 0040-script-setup]: https://github.com/vuejs/rfcs/blob/master/active-rfcs/0040-script-setup.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-export-in-script-setup.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-export-in-script-setup.js)
diff --git a/docs/rules/valid-define-emits.md b/docs/rules/valid-define-emits.md
new file mode 100644
index 00000000..cf6ea6c1
--- /dev/null
+++ b/docs/rules/valid-define-emits.md
@@ -0,0 +1,133 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/valid-define-emits
+description: enforce valid `defineEmits` compiler macro
+---
+# vue/valid-define-emits
+
+> enforce valid `defineEmits` compiler macro
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+This rule checks whether `defineEmits` compiler macro is valid.
+
+## :book: Rule Details
+
+This rule reports `defineEmits` compiler macros in the following cases:
+
+- `defineEmits` are referencing locally declared variables.
+- `defineEmits` has both a literal type and an argument. e.g. `defineEmits<(e: 'foo')=>void>(['bar'])`
+- `defineEmits` has been called multiple times.
+- Custom events are defined in both `defineEmits` and `export default {}`.
+- Custom events are not defined in either `defineEmits` or `export default {}`.
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script setup>
+  /* ✓ GOOD */
+  defineEmits({ notify: null })
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script setup>
+  /* ✓ GOOD */
+  defineEmits(['notify'])
+</script>
+```
+
+</eslint-code-block>
+
+```vue
+<script setup lang="ts">
+  /* ✓ GOOD */
+  defineEmits<(e: 'notify')=>void>()
+</script>
+```
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script>
+  const def = { notify: null }
+</script>
+<script setup>
+  /* ✓ GOOD */
+  defineEmits(def)
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script setup>
+  /* ✗ BAD */
+  const def = { notify: null }
+  defineEmits(def)
+</script>
+```
+
+</eslint-code-block>
+
+```vue
+<script setup lang="ts">
+  /* ✗ BAD */
+  defineEmits<(e: 'notify')=>void>({ submit: null })
+</script>
+```
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script setup>
+  /* ✗ BAD */
+  defineEmits({ notify: null })
+  defineEmits({ submit: null })
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script>
+  export default {
+    emits: { notify: null }
+  }
+</script>
+<script setup>
+  /* ✗ BAD */
+  defineEmits({ submit: null })
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-emits': ['error']}">
+
+```vue
+<script setup>
+  /* ✗ BAD */
+  defineEmits()
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/valid-define-emits.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/valid-define-emits.js)
diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md
index 45a3b72a..0b3c3d82 100644
--- a/docs/user-guide/README.md
+++ b/docs/user-guide/README.md
@@ -103,6 +103,8 @@ If you want to use custom parsers such as [babel-eslint](https://www.npmjs.com/p
   }
 ```
 
+The `parserOptions.parser` option can also specify an object to specify multiple parsers. See [vue-eslint-parser README](https://github.com/vuejs/vue-eslint-parser#readme) for more details.
+
 ### How does ESLint detect components?
 
 All component-related rules are applied to code that passes any of the following checks:
diff --git a/lib/index.js b/lib/index.js
index 0fcef5d9..8bf6cad4 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -81,6 +81,7 @@ module.exports = {
     'no-duplicate-attributes': require('./rules/no-duplicate-attributes'),
     'no-empty-component-block': require('./rules/no-empty-component-block'),
     'no-empty-pattern': require('./rules/no-empty-pattern'),
+    'no-export-in-script-setup': require('./rules/no-export-in-script-setup'),
     'no-extra-parens': require('./rules/no-extra-parens'),
     'no-invalid-model-keys': require('./rules/no-invalid-model-keys'),
     'no-irregular-whitespace': require('./rules/no-irregular-whitespace'),
@@ -171,6 +172,7 @@ module.exports = {
     'v-on-function-call': require('./rules/v-on-function-call'),
     'v-on-style': require('./rules/v-on-style'),
     'v-slot-style': require('./rules/v-slot-style'),
+    'valid-define-emits': require('./rules/valid-define-emits'),
     'valid-define-props': require('./rules/valid-define-props'),
     'valid-next-tick': require('./rules/valid-next-tick'),
     'valid-template-root': require('./rules/valid-template-root'),
diff --git a/lib/rules/no-export-in-script-setup.js b/lib/rules/no-export-in-script-setup.js
new file mode 100644
index 00000000..fcd7f316
--- /dev/null
+++ b/lib/rules/no-export-in-script-setup.js
@@ -0,0 +1,41 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'disallow `export` in `<script setup>`',
+      // TODO Switch in the major version.
+      // categories: ['vue3-essential'],
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-export-in-script-setup.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      forbidden: '`<script setup>` cannot contain ES module exports.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @param {ExportAllDeclaration | ExportDefaultDeclaration | ExportNamedDeclaration} node */
+    function report(node) {
+      context.report({
+        node,
+        messageId: 'forbidden'
+      })
+    }
+
+    return utils.defineScriptSetupVisitor(context, {
+      ExportAllDeclaration: report,
+      ExportDefaultDeclaration: report,
+      ExportNamedDeclaration: report
+    })
+  }
+}
diff --git a/lib/rules/valid-define-emits.js b/lib/rules/valid-define-emits.js
new file mode 100644
index 00000000..6b79e83c
--- /dev/null
+++ b/lib/rules/valid-define-emits.js
@@ -0,0 +1,144 @@
+/**
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'enforce valid `defineEmits` compiler macro',
+      // TODO Switch in the major version.
+      // categories: ['vue3-essential'],
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/valid-define-emits.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      hasTypeAndArg: '`defineEmits` has both a type-only emit and an argument.',
+      referencingLocally:
+        '`defineEmits` are referencing locally declared variables.',
+      multiple: '`defineEmits` has been called multiple times.',
+      notDefined: 'Custom events are not defined.',
+      definedInBoth:
+        'Custom events are defined in both `defineEmits` and `export default {}`.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const scriptSetup = utils.getScriptSetupElement(context)
+    if (!scriptSetup) {
+      return {}
+    }
+
+    /** @type {Set<Expression | SpreadElement>} */
+    const emitsDefExpressions = new Set()
+    let hasDefaultExport = false
+    /** @type {CallExpression[]} */
+    const defineEmitsNodes = []
+    /** @type {CallExpression | null} */
+    let emptyDefineEmits = null
+
+    return utils.compositingVisitors(
+      utils.defineScriptSetupVisitor(context, {
+        onDefineEmitsEnter(node) {
+          defineEmitsNodes.push(node)
+
+          if (node.arguments.length >= 1) {
+            if (node.typeParameters && node.typeParameters.params.length >= 1) {
+              // `defineEmits` has both a literal type and an argument.
+              context.report({
+                node,
+                messageId: 'hasTypeAndArg'
+              })
+              return
+            }
+
+            emitsDefExpressions.add(node.arguments[0])
+          } else {
+            if (
+              !node.typeParameters ||
+              node.typeParameters.params.length === 0
+            ) {
+              emptyDefineEmits = node
+            }
+          }
+        },
+        Identifier(node) {
+          for (const def of emitsDefExpressions) {
+            if (utils.inRange(def.range, node)) {
+              const variable = findVariable(context.getScope(), node)
+              if (
+                variable &&
+                variable.references.some((ref) => ref.identifier === node)
+              ) {
+                if (
+                  variable.defs.length &&
+                  variable.defs.every((def) =>
+                    utils.inRange(scriptSetup.range, def.name)
+                  )
+                ) {
+                  //`defineEmits` are referencing locally declared variables.
+                  context.report({
+                    node,
+                    messageId: 'referencingLocally'
+                  })
+                }
+              }
+            }
+          }
+        }
+      }),
+      utils.defineVueVisitor(context, {
+        onVueObjectEnter(node, { type }) {
+          if (type !== 'export' || utils.inRange(scriptSetup.range, node)) {
+            return
+          }
+
+          hasDefaultExport = Boolean(utils.findProperty(node, 'emits'))
+        }
+      }),
+      {
+        'Program:exit'() {
+          if (!defineEmitsNodes.length) {
+            return
+          }
+          if (defineEmitsNodes.length > 1) {
+            // `defineEmits` has been called multiple times.
+            for (const node of defineEmitsNodes) {
+              context.report({
+                node,
+                messageId: 'multiple'
+              })
+            }
+            return
+          }
+          if (emptyDefineEmits) {
+            if (!hasDefaultExport) {
+              // Custom events are not defined.
+              context.report({
+                node: emptyDefineEmits,
+                messageId: 'notDefined'
+              })
+            }
+          } else {
+            if (hasDefaultExport) {
+              // Custom events are defined in both `defineEmits` and `export default {}`.
+              for (const node of defineEmitsNodes) {
+                context.report({
+                  node,
+                  messageId: 'definedInBoth'
+                })
+              }
+            }
+          }
+        }
+      }
+    )
+  }
+}
diff --git a/package.json b/package.json
index df2f8fbf..ab778581 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
     "eslint-utils": "^2.1.0",
     "natural-compare": "^1.4.0",
     "semver": "^7.3.2",
-    "vue-eslint-parser": "^7.7.2"
+    "vue-eslint-parser": "^7.8.0"
   },
   "devDependencies": {
     "@types/eslint": "^7.2.0",


Test patch:
diff --git a/tests/lib/rules/no-export-in-script-setup.js b/tests/lib/rules/no-export-in-script-setup.js
new file mode 100644
index 00000000..5bc759f4
--- /dev/null
+++ b/tests/lib/rules/no-export-in-script-setup.js
@@ -0,0 +1,107 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const eslint = require('eslint')
+const rule = require('../../../lib/rules/no-export-in-script-setup')
+
+const RuleTester = eslint.RuleTester
+const ruleTester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 6,
+    sourceType: 'module'
+  }
+})
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+ruleTester.run('no-export-in-script-setup', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export * from 'foo'
+      export default {}
+      export class A {}
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export * from 'foo'
+      export default {}
+      export class A {}
+      </script>
+      <script setup>
+      let foo;
+      </script>
+      `
+    }
+  ],
+
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      export * from 'foo'
+      export default {}
+      export class A {}
+      </script>
+      `,
+      errors: [
+        {
+          message: '`<script setup>` cannot contain ES module exports.',
+          line: 3
+        },
+        {
+          message: '`<script setup>` cannot contain ES module exports.',
+          line: 4
+        },
+        {
+          message: '`<script setup>` cannot contain ES module exports.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      let foo;
+      </script>
+      <script setup>
+      export * from 'foo'
+      export default {}
+      export class A {}
+      </script>
+      `,
+      errors: [
+        {
+          message: '`<script setup>` cannot contain ES module exports.',
+          line: 6
+        },
+        {
+          message: '`<script setup>` cannot contain ES module exports.',
+          line: 7
+        },
+        {
+          message: '`<script setup>` cannot contain ES module exports.',
+          line: 8
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/valid-define-emits.js b/tests/lib/rules/valid-define-emits.js
new file mode 100644
index 00000000..309375e9
--- /dev/null
+++ b/tests/lib/rules/valid-define-emits.js
@@ -0,0 +1,156 @@
+/**
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/valid-define-emits')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2015, sourceType: 'module' }
+})
+
+tester.run('valid-define-emits', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✓ GOOD */
+        defineEmits({ notify: null })
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✓ GOOD */
+        defineEmits(['notify'])
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+        /* ✓ GOOD */
+        defineEmits<(e: 'notify')=>void>()
+      </script>
+      `,
+      parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        const def = { notify: null }
+      </script>
+      <script setup>
+        /* ✓ GOOD */
+        defineEmits(def)
+      </script>
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✗ BAD */
+        const def = { notify: null }
+        defineEmits(def)
+      </script>
+      `,
+      errors: [
+        {
+          message: '`defineEmits` are referencing locally declared variables.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+        /* ✗ BAD */
+        defineEmits<(e: 'notify')=>void>({ submit: null })
+      </script>
+      `,
+      parserOptions: { parser: require.resolve('@typescript-eslint/parser') },
+      errors: [
+        {
+          message: '`defineEmits` has both a type-only emit and an argument.',
+          line: 4
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✗ BAD */
+        defineEmits({ notify: null })
+        defineEmits({ submit: null })
+      </script>
+      `,
+      errors: [
+        {
+          message: '`defineEmits` has been called multiple times.',
+          line: 4
+        },
+        {
+          message: '`defineEmits` has been called multiple times.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        emits: ['notify']
+      }
+      </script>
+      <script setup>
+        /* ✗ BAD */
+        defineEmits({ submit: null })
+      </script>
+      `,
+      errors: [
+        {
+          message:
+            'Custom events are defined in both `defineEmits` and `export default {}`.',
+          line: 9
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✗ BAD */
+        defineEmits()
+      </script>
+      `,
+      errors: [
+        {
+          message: 'Custom events are not defined.',
+          line: 4
+        }
+      ]
+    }
+  ]
+})