diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js index 797428254..34b557b4b 100644 --- a/lib/rules/require-explicit-emits.js +++ b/lib/rules/require-explicit-emits.js @@ -7,8 +7,10 @@ /** * @typedef {import('../utils').ComponentArrayEmit} ComponentArrayEmit * @typedef {import('../utils').ComponentObjectEmit} ComponentObjectEmit + * @typedef {import('../utils').ComponentTypeEmit} ComponentTypeEmit * @typedef {import('../utils').ComponentArrayProp} ComponentArrayProp * @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp + * @typedef {import('../utils').ComponentTypeProp} ComponentTypeProp * @typedef {import('../utils').VueObjectData} VueObjectData */ @@ -105,42 +107,43 @@ module.exports = { ], messages: { missing: - 'The "{{name}}" event has been triggered but not declared on `emits` option.', - addOneOption: 'Add the "{{name}}" to `emits` option.', + 'The "{{name}}" event has been triggered but not declared on {{emitsKind}}.', + addOneOption: 'Add the "{{name}}" to {{emitsKind}}.', addArrayEmitsOption: - 'Add the `emits` option with array syntax and define "{{name}}" event.', + 'Add the {{emitsKind}} with array syntax and define "{{name}}" event.', addObjectEmitsOption: - 'Add the `emits` option with object syntax and define "{{name}}" event.' + 'Add the {{emitsKind}} with object syntax and define "{{name}}" event.' } }, /** @param {RuleContext} context */ create(context) { const options = context.options[0] || {} const allowProps = !!options.allowProps - /** @type {Map, emitReferenceIds: Set }>} */ + /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() - /** @type {Map} */ + /** @type {Map} */ const vueEmitsDeclarations = new Map() - /** @type {Map} */ + /** @type {Map} */ const vuePropsDeclarations = new Map() /** - * @typedef {object} VueTemplateObjectData - * @property {'export' | 'mark' | 'definition'} type - * @property {ObjectExpression} object - * @property {(ComponentArrayEmit | ComponentObjectEmit)[]} emits - * @property {(ComponentArrayProp | ComponentObjectProp)[]} props + * @typedef {object} VueTemplateDefineData + * @property {'export' | 'mark' | 'definition' | 'setup'} type + * @property {ObjectExpression | Program} define + * @property {(ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit)[]} emits + * @property {(ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[]} props + * @property {CallExpression} [defineEmits] */ - /** @type {VueTemplateObjectData | null} */ - let vueTemplateObjectData = null + /** @type {VueTemplateDefineData | null} */ + let vueTemplateDefineData = null /** - * @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emits - * @param {(ComponentArrayProp | ComponentObjectProp)[]} props + * @param {(ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit)[]} emits + * @param {(ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[]} props * @param {Literal} nameLiteralNode - * @param {ObjectExpression} vueObjectNode + * @param {ObjectExpression | Program} vueDefineNode */ - function verifyEmit(emits, props, nameLiteralNode, vueObjectNode) { + function verifyEmit(emits, props, nameLiteralNode, vueDefineNode) { const name = `${nameLiteralNode.value}` if (emits.some((e) => e.emitName === name)) { return @@ -155,12 +158,98 @@ module.exports = { node: nameLiteralNode, messageId: 'missing', data: { - name + name, + emitsKind: + vueDefineNode.type === 'ObjectExpression' + ? '`emits` option' + : '`defineEmits`' }, - suggest: buildSuggest(vueObjectNode, emits, nameLiteralNode, context) + suggest: buildSuggest(vueDefineNode, emits, nameLiteralNode, context) }) } + const programNode = context.getSourceCode().ast + if (utils.isScriptSetup(context)) { + // init + vueTemplateDefineData = { + type: 'setup', + define: programNode, + emits: [], + props: [] + } + } + + const callVisitor = { + /** + * @param {CallExpression & { arguments: [Literal, ...Expression] }} node + * @param {VueObjectData} [info] + */ + 'CallExpression[arguments.0.type=Literal]'(node, info) { + const callee = utils.skipChainExpression(node.callee) + const nameLiteralNode = node.arguments[0] + if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') { + // cannot check + return + } + const vueDefineNode = info ? info.node : programNode + const emitsDeclarations = vueEmitsDeclarations.get(vueDefineNode) + if (!emitsDeclarations) { + return + } + + let emit + if (callee.type === 'MemberExpression') { + const name = utils.getStaticPropertyName(callee) + if (name === 'emit' || name === '$emit') { + emit = { name, member: callee } + } + } + + // verify setup context + const setupContext = setupContexts.get(vueDefineNode) + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext + if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { + // verify setup(props,{emit}) {emit()} + verifyEmit( + emitsDeclarations, + vuePropsDeclarations.get(vueDefineNode) || [], + nameLiteralNode, + vueDefineNode + ) + } else if (emit && emit.name === 'emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if ( + memObject.type === 'Identifier' && + contextReferenceIds.has(memObject) + ) { + // verify setup(props,context) {context.emit()} + verifyEmit( + emitsDeclarations, + vuePropsDeclarations.get(vueDefineNode) || [], + nameLiteralNode, + vueDefineNode + ) + } + } + } + + // verify $emit + if (emit && emit.name === '$emit') { + const memObject = utils.skipChainExpression(emit.member.object) + if (utils.isThis(memObject, context)) { + // verify this.$emit() + verifyEmit( + emitsDeclarations, + vuePropsDeclarations.get(vueDefineNode) || [], + nameLiteralNode, + vueDefineNode + ) + } + } + } + } + return utils.defineTemplateBodyVisitor( context, { @@ -172,54 +261,41 @@ module.exports = { // cannot check return } - if (!vueTemplateObjectData) { + if (!vueTemplateDefineData) { return } if (callee.type === 'Identifier' && callee.name === '$emit') { verifyEmit( - vueTemplateObjectData.emits, - vueTemplateObjectData.props, + vueTemplateDefineData.emits, + vueTemplateDefineData.props, nameLiteralNode, - vueTemplateObjectData.object + vueTemplateDefineData.define ) } } }, - utils.defineVueVisitor(context, { - onVueObjectEnter(node) { - vueEmitsDeclarations.set(node, utils.getComponentEmits(node)) - if (allowProps) { - vuePropsDeclarations.set(node, utils.getComponentProps(node)) - } - }, - 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 - } - /** @type {Set} */ - const contextReferenceIds = new Set() - /** @type {Set} */ - const emitReferenceIds = new Set() - if (contextParam.type === 'ObjectPattern') { - const emitProperty = utils.findAssignmentProperty( - contextParam, - 'emit' - ) - if (!emitProperty) { + utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefineEmitsEnter(node, emits) { + vueEmitsDeclarations.set(programNode, emits) + + if ( + vueTemplateDefineData && + vueTemplateDefineData.type === 'setup' + ) { + vueTemplateDefineData.emits = emits + vueTemplateDefineData.defineEmits = node + } + + if ( + !node.parent || + node.parent.type !== 'VariableDeclarator' || + node.parent.init !== node + ) { return } - const emitParam = emitProperty.value - // `setup(props, {emit})` + + const emitParam = node.parent.id const variable = emitParam.type === 'Identifier' ? findVariable(context.getScope(), emitParam) @@ -227,6 +303,8 @@ module.exports = { if (!variable) { return } + /** @type {Set} */ + const emitReferenceIds = new Set() for (const reference of variable.references) { if (!reference.isRead()) { continue @@ -234,150 +312,170 @@ module.exports = { emitReferenceIds.add(reference.identifier) } - } else if (contextParam.type === 'Identifier') { - // `setup(props, context)` - const variable = findVariable(context.getScope(), contextParam) - if (!variable) { + setupContexts.set(programNode, { + contextReferenceIds: new Set(), + emitReferenceIds + }) + }, + onDefinePropsEnter(_node, props) { + if (allowProps) { + vuePropsDeclarations.set(programNode, props) + + if ( + vueTemplateDefineData && + vueTemplateDefineData.type === 'setup' + ) { + vueTemplateDefineData.props = props + } + } + }, + ...callVisitor + }), + utils.defineVueVisitor(context, { + onVueObjectEnter(node) { + vueEmitsDeclarations.set(node, utils.getComponentEmits(node)) + if (allowProps) { + vuePropsDeclarations.set(node, utils.getComponentProps(node)) + } + }, + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = node.params[1] + if (!contextParam) { + // no arguments return } - for (const reference of variable.references) { - if (!reference.isRead()) { - continue - } - - contextReferenceIds.add(reference.identifier) + if (contextParam.type === 'RestElement') { + // cannot check + return } - } - setupContexts.set(vueNode, { - contextReferenceIds, - emitReferenceIds - }) - }, - /** - * @param {CallExpression & { arguments: [Literal, ...Expression] }} node - * @param {VueObjectData} data - */ - 'CallExpression[arguments.0.type=Literal]'(node, { node: vueNode }) { - const callee = utils.skipChainExpression(node.callee) - const nameLiteralNode = node.arguments[0] - if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') { - // cannot check - return - } - const emitsDeclarations = vueEmitsDeclarations.get(vueNode) - if (!emitsDeclarations) { - return - } - - let emit - if (callee.type === 'MemberExpression') { - const name = utils.getStaticPropertyName(callee) - if (name === 'emit' || name === '$emit') { - emit = { name, member: callee } + if (contextParam.type === 'ArrayPattern') { + // cannot check + return } - } - - // verify setup context - const setupContext = setupContexts.get(vueNode) - if (setupContext) { - const { contextReferenceIds, emitReferenceIds } = setupContext - if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { - // verify setup(props,{emit}) {emit()} - verifyEmit( - emitsDeclarations, - vuePropsDeclarations.get(vueNode) || [], - nameLiteralNode, - vueNode + /** @type {Set} */ + const contextReferenceIds = new Set() + /** @type {Set} */ + const emitReferenceIds = new Set() + if (contextParam.type === 'ObjectPattern') { + const emitProperty = utils.findAssignmentProperty( + contextParam, + 'emit' ) - } else if (emit && emit.name === 'emit') { - const memObject = utils.skipChainExpression(emit.member.object) - if ( - memObject.type === 'Identifier' && - contextReferenceIds.has(memObject) - ) { - // verify setup(props,context) {context.emit()} - verifyEmit( - emitsDeclarations, - vuePropsDeclarations.get(vueNode) || [], - nameLiteralNode, - vueNode - ) + if (!emitProperty) { + return } - } - } + const emitParam = emitProperty.value + // `setup(props, {emit})` + const variable = + emitParam.type === 'Identifier' + ? findVariable(context.getScope(), emitParam) + : null + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } - // verify $emit - if (emit && emit.name === '$emit') { - const memObject = utils.skipChainExpression(emit.member.object) - if (utils.isThis(memObject, context)) { - // verify this.$emit() - verifyEmit( - emitsDeclarations, - vuePropsDeclarations.get(vueNode) || [], - nameLiteralNode, - vueNode - ) + emitReferenceIds.add(reference.identifier) + } + } else if (contextParam.type === 'Identifier') { + // `setup(props, context)` + const variable = findVariable(context.getScope(), contextParam) + if (!variable) { + return + } + for (const reference of variable.references) { + if (!reference.isRead()) { + continue + } + + contextReferenceIds.add(reference.identifier) + } } - } - }, - onVueObjectExit(node, { type }) { - const emits = vueEmitsDeclarations.get(node) - if ( - !vueTemplateObjectData || - vueTemplateObjectData.type !== 'export' - ) { + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds + }) + }, + ...callVisitor, + onVueObjectExit(node, { type }) { + const emits = vueEmitsDeclarations.get(node) if ( - emits && - (type === 'mark' || type === 'export' || type === 'definition') + !vueTemplateDefineData || + (vueTemplateDefineData.type !== 'export' && + vueTemplateDefineData.type !== 'setup') ) { - vueTemplateObjectData = { - type, - object: node, - emits, - props: vuePropsDeclarations.get(node) || [] + if ( + emits && + (type === 'mark' || type === 'export' || type === 'definition') + ) { + vueTemplateDefineData = { + type, + define: node, + emits, + props: vuePropsDeclarations.get(node) || [] + } } } + setupContexts.delete(node) + vueEmitsDeclarations.delete(node) + vuePropsDeclarations.delete(node) } - setupContexts.delete(node) - vueEmitsDeclarations.delete(node) - vuePropsDeclarations.delete(node) - } - }) + }) + ) ) } } /** - * @param {ObjectExpression} object - * @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emits + * @param {ObjectExpression|Program} define + * @param {(ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit)[]} emits * @param {Literal} nameNode * @param {RuleContext} context * @returns {Rule.SuggestionReportDescriptor[]} */ -function buildSuggest(object, emits, nameNode, context) { +function buildSuggest(define, emits, nameNode, context) { + const emitsKind = + define.type === 'ObjectExpression' ? '`emits` option' : '`defineEmits`' const certainEmits = emits.filter((e) => e.key) if (certainEmits.length) { const last = certainEmits[certainEmits.length - 1] return [ { messageId: 'addOneOption', - data: { name: `${nameNode.value}` }, + data: { + name: `${nameNode.value}`, + emitsKind + }, fix(fixer) { - if (last.value === null) { + if (last.type === 'array') { // Array return fixer.insertTextAfter(last.node, `, '${nameNode.value}'`) - } else { + } else if (last.type === 'object') { // Object return fixer.insertTextAfter( last.node, `, '${nameNode.value}': null` ) + } else { + // type + // The argument is unknown and cannot be suggested. + return null } } } ] } + if (define.type !== 'ObjectExpression') { + // We don't know where to put defineEmits. + return [] + } + + const object = define + const propertyNodes = object.properties.filter(utils.isProperty) const emitsOption = propertyNodes.find( @@ -393,7 +491,7 @@ function buildSuggest(object, emits, nameNode, context) { return [ { messageId: 'addOneOption', - data: { name: `${nameNode.value}` }, + data: { name: `${nameNode.value}`, emitsKind }, fix(fixer) { return fixer.insertTextAfter( leftBracket, @@ -411,7 +509,7 @@ function buildSuggest(object, emits, nameNode, context) { return [ { messageId: 'addOneOption', - data: { name: `${nameNode.value}` }, + data: { name: `${nameNode.value}`, emitsKind }, fix(fixer) { return fixer.insertTextAfter( leftBrace, @@ -433,7 +531,7 @@ function buildSuggest(object, emits, nameNode, context) { return [ { messageId: 'addArrayEmitsOption', - data: { name: `${nameNode.value}` }, + data: { name: `${nameNode.value}`, emitsKind }, fix(fixer) { if (afterOptionNode) { return fixer.insertTextAfter( @@ -468,7 +566,7 @@ function buildSuggest(object, emits, nameNode, context) { }, { messageId: 'addObjectEmitsOption', - data: { name: `${nameNode.value}` }, + data: { name: `${nameNode.value}`, emitsKind }, fix(fixer) { if (afterOptionNode) { return fixer.insertTextAfter( diff --git a/tests/lib/rules/require-explicit-emits.js b/tests/lib/rules/require-explicit-emits.js index 6cf1cc6eb..1ea1758d2 100644 --- a/tests/lib/rules/require-explicit-emits.js +++ b/tests/lib/rules/require-explicit-emits.js @@ -394,6 +394,69 @@ tester.run('require-explicit-emits', rule, { `, options: [{ allowProps: true }] + }, + + // + ` + }, + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + { + filename: 'test.vue', + code: ` + + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + { + filename: 'test.vue', + code: ` + + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } } ], invalid: [ @@ -1607,6 +1670,145 @@ emits: {'foo': null} messageId: 'missing' } ] + }, + + // + `, + errors: [ + { + message: + 'The "bar" event has been triggered but not declared on `defineEmits`.', + line: 3, + suggestions: [ + { + desc: 'Add the "bar" to `defineEmits`.', + output: ` + + + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: + 'The "bar" event has been triggered but not declared on `defineEmits`.', + line: 3, + suggestions: [ + { + desc: 'Add the "bar" to `defineEmits`.', + output: ` + + + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') }, + errors: [ + { + message: + 'The "bar" event has been triggered but not declared on `defineEmits`.', + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') }, + errors: [ + { + message: + 'The "bar" event has been triggered but not declared on `defineEmits`.', + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + { + message: + 'The "foo" event has been triggered but not declared on `defineEmits`.', + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') }, + errors: [ + { + message: + 'The "bar" event has been triggered but not declared on `defineEmits`.', + line: 5 + } + ] } ] })