From 2017a6d89bcdc7428d9d240d7fe35298eab5cb71 Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Mon, 5 Jul 2021 23:36:48 +0900 Subject: [PATCH] Add `vue/valid-define-emits` rule --- docs/rules/README.md | 1 + docs/rules/valid-define-emits.md | 133 ++++++++++++++++++++++ lib/index.js | 1 + lib/rules/valid-define-emits.js | 144 ++++++++++++++++++++++++ lib/utils/index.js | 50 +++++++-- tests/lib/rules/valid-define-emits.js | 156 ++++++++++++++++++++++++++ 6 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 docs/rules/valid-define-emits.md create mode 100644 lib/rules/valid-define-emits.js create mode 100644 tests/lib/rules/valid-define-emits.js diff --git a/docs/rules/README.md b/docs/rules/README.md index 1a0a5fe35..39ef50cba 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -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 diff --git a/docs/rules/valid-define-emits.md b/docs/rules/valid-define-emits.md new file mode 100644 index 000000000..cf6ea6c15 --- /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: ***This rule has not been released yet.*** + +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 {}`. + + + +```vue + +``` + + + + + +```vue + +``` + + + +```vue + +``` + + + +```vue + + +``` + + + + + +```vue + +``` + + + +```vue + +``` + + + +```vue + +``` + + + + + +```vue + + +``` + + + + + +```vue + +``` + + + +## :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/lib/index.js b/lib/index.js index f07aea55f..3f5288020 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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'), diff --git a/lib/rules/valid-define-emits.js b/lib/rules/valid-define-emits.js new file mode 100644 index 000000000..6b79e83cc --- /dev/null +++ b/lib/rules/valid-define-emits.js @@ -0,0 +1,144 @@ +/** + * @author Yosuke Ota + * 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} */ + 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/lib/utils/index.js b/lib/utils/index.js index dc7f3d280..52bf22b8f 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -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() @@ -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) { @@ -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) { @@ -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 diff --git a/tests/lib/rules/valid-define-emits.js b/tests/lib/rules/valid-define-emits.js new file mode 100644 index 000000000..309375e94 --- /dev/null +++ b/tests/lib/rules/valid-define-emits.js @@ -0,0 +1,156 @@ +/** + * @author Yosuke Ota + * 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: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + { + filename: 'test.vue', + code: ` + + + ` + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: '`defineEmits` are referencing locally declared variables.', + line: 5 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + 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: ` + + `, + errors: [ + { + message: '`defineEmits` has been called multiple times.', + line: 4 + }, + { + message: '`defineEmits` has been called multiple times.', + line: 5 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: + 'Custom events are defined in both `defineEmits` and `export default {}`.', + line: 9 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Custom events are not defined.', + line: 4 + } + ] + } + ] +})