Skip to content

Commit

Permalink
Add vue/valid-define-emits rule
Browse files Browse the repository at this point in the history
  • Loading branch information
ota-meshi committed Jul 5, 2021
1 parent 9c8f293 commit 2017a6d
Show file tree
Hide file tree
Showing 6 changed files with 474 additions and 11 deletions.
1 change: 1 addition & 0 deletions docs/rules/README.md
Expand Up @@ -331,6 +331,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-next-tick](./valid-next-tick.md) | enforce valid `nextTick` function calls | :wrench: |

### Extension Rules
Expand Down
133 changes: 133 additions & 0 deletions 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)
1 change: 1 addition & 0 deletions lib/index.js
Expand Up @@ -171,6 +171,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-next-tick': require('./rules/valid-next-tick'),
'valid-template-root': require('./rules/valid-template-root'),
'valid-v-bind-sync': require('./rules/valid-v-bind-sync'),
Expand Down
144 changes: 144 additions & 0 deletions 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'
})
}
}
}
}
}
)
}
}
50 changes: 39 additions & 11 deletions lib/utils/index.js
Expand Up @@ -1058,16 +1058,26 @@ module.exports = {
const hasEmitsEvent =
visitor.onDefineEmitsEnter || visitor.onDefineEmitsExit
if (hasPropsEvent || hasEmitsEvent) {
/** @type {ESNode | null} */
let nested = null
scriptSetupVisitor[':function, BlockStatement'] = (node) => {
if (!nested) {
nested = node
/** @type {Expression | null} */
let candidateMacro = null
/** @param {VariableDeclarator|ExpressionStatement} node */
scriptSetupVisitor[
'Program > VariableDeclaration > VariableDeclarator, Program > ExpressionStatement'
] = (node) => {
if (!candidateMacro) {
candidateMacro =
node.type === 'VariableDeclarator' ? node.init : node.expression
}
}
scriptSetupVisitor[':function, BlockStatement:exit'] = (node) => {
if (nested === node) {
nested = null
/** @param {VariableDeclarator|ExpressionStatement} node */
scriptSetupVisitor[
'Program > VariableDeclaration > VariableDeclarator, Program > ExpressionStatement:exit'
] = (node) => {
if (
candidateMacro ===
(node.type === 'VariableDeclarator' ? node.init : node.expression)
) {
candidateMacro = null
}
}
const definePropsMap = new Map()
Expand All @@ -1077,11 +1087,16 @@ module.exports = {
*/
scriptSetupVisitor.CallExpression = (node) => {
if (
!nested &&
candidateMacro &&
inScriptSetup(node) &&
node.callee.type === 'Identifier'
) {
if (hasPropsEvent && node.callee.name === 'defineProps') {
if (
hasPropsEvent &&
(candidateMacro === node ||
candidateMacro === getWithDefaults(node)) &&
node.callee.name === 'defineProps'
) {
/** @type {(ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[]} */
let props = []
if (node.arguments.length >= 1) {
Expand All @@ -1100,7 +1115,11 @@ module.exports = {
}
callVisitor('onDefinePropsEnter', node, props)
definePropsMap.set(node, props)
} else if (hasEmitsEvent && node.callee.name === 'defineEmits') {
} else if (
hasEmitsEvent &&
candidateMacro === node &&
node.callee.name === 'defineEmits'
) {
/** @type {(ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit)[]} */
let emits = []
if (node.arguments.length >= 1) {
Expand Down Expand Up @@ -2400,6 +2419,15 @@ function hasWithDefaults(node) {
)
}

/**
* Get the withDefaults call node from given defineProps call node.
* @param {CallExpression} node The node of defineProps
* @returns {CallExpression | null}
*/
function getWithDefaults(node) {
return hasWithDefaults(node) ? node.parent : null
}

/**
* Get all props by looking at all component's properties
* @param {ObjectExpression|ArrayExpression} propsNode Object with props definition
Expand Down

0 comments on commit 2017a6d

Please sign in to comment.