You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

625 lines
18 KiB

/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
/**
* @typedef {import('../utils').ComponentEmit} ComponentEmit
* @typedef {import('../utils').ComponentProp} ComponentProp
* @typedef {import('../utils').VueObjectData} VueObjectData
*/
const {
findVariable,
isOpeningBraceToken,
isClosingBraceToken,
isOpeningBracketToken
} = require('@eslint-community/eslint-utils')
const utils = require('../utils')
const { capitalize } = require('../utils/casing')
const FIX_EMITS_AFTER_OPTIONS = new Set([
'setup',
'data',
'computed',
'watch',
'methods',
'template',
'render',
'renderError',
// lifecycle hooks
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'activated',
'deactivated',
'beforeUnmount',
'unmounted',
'beforeDestroy',
'destroyed',
'renderTracked',
'renderTriggered',
'errorCaptured'
])
/**
* @typedef {object} NameWithLoc
* @property {string} name
* @property {SourceLocation} loc
* @property {Range} range
*/
/**
* Get the name param node from the given CallExpression
* @param {CallExpression} node CallExpression
* @returns { NameWithLoc | null }
*/
function getNameParamNode(node) {
const nameLiteralNode = node.arguments[0]
if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) {
const name = utils.getStringLiteralValue(nameLiteralNode)
if (name != null) {
return { name, loc: nameLiteralNode.loc, range: nameLiteralNode.range }
}
}
// cannot check
return null
}
/**
* Check if the given name matches defineEmitsNode variable name
* @param {string} name
* @param {CallExpression | undefined} defineEmitsNode
* @returns {boolean}
*/
function isEmitVariableName(name, defineEmitsNode) {
const node = defineEmitsNode?.parent
if (node?.type === 'VariableDeclarator' && node.id.type === 'Identifier') {
return name === node.id.name
}
return false
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'require `emits` option with name triggered by `$emit()`',
categories: ['vue3-strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/require-explicit-emits.html'
},
fixable: null,
hasSuggestions: true,
schema: [
{
type: 'object',
properties: {
allowProps: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
missing:
'The "{{name}}" event has been triggered but not declared on {{emitsKind}}.',
addOneOption: 'Add the "{{name}}" to {{emitsKind}}.',
addArrayEmitsOption:
'Add the {{emitsKind}} with array syntax and define "{{name}}" event.',
addObjectEmitsOption:
'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<ObjectExpression | Program, { contextReferenceIds: Set<Identifier>, emitReferenceIds: Set<Identifier> }>} */
const setupContexts = new Map()
/** @type {Map<ObjectExpression | Program, ComponentEmit[]>} */
const vueEmitsDeclarations = new Map()
/** @type {Map<ObjectExpression | Program, ComponentProp[]>} */
const vuePropsDeclarations = new Map()
/**
* @typedef {object} VueTemplateDefineData
* @property {'export' | 'mark' | 'definition' | 'setup'} type
* @property {ObjectExpression | Program} define
* @property {ComponentEmit[]} emits
* @property {ComponentProp[]} props
* @property {CallExpression} [defineEmits]
*/
/** @type {VueTemplateDefineData | null} */
let vueTemplateDefineData = null
/**
* @param {ComponentEmit[]} emits
* @param {ComponentProp[]} props
* @param {NameWithLoc} nameWithLoc
* @param {ObjectExpression | Program} vueDefineNode
*/
function verifyEmit(emits, props, nameWithLoc, vueDefineNode) {
const name = nameWithLoc.name
if (emits.some((e) => e.emitName === name || e.emitName == null)) {
return
}
if (allowProps) {
const key = `on${capitalize(name)}`
if (props.some((e) => e.propName === key || e.propName == null)) {
return
}
}
context.report({
loc: nameWithLoc.loc,
messageId: 'missing',
data: {
name,
emitsKind:
vueDefineNode.type === 'ObjectExpression'
? '`emits` option'
: '`defineEmits`'
},
suggest: buildSuggest(vueDefineNode, emits, nameWithLoc, context)
})
}
const programNode = context.getSourceCode().ast
if (utils.isScriptSetup(context)) {
// init
vueTemplateDefineData = {
type: 'setup',
define: programNode,
emits: [],
props: []
}
}
const callVisitor = {
/**
* @param {CallExpression} node
* @param {VueObjectData} [info]
*/
CallExpression(node, info) {
const callee = utils.skipChainExpression(node.callee)
const nameWithLoc = getNameParamNode(node)
if (!nameWithLoc) {
// 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) || [],
nameWithLoc,
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) || [],
nameWithLoc,
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) || [],
nameWithLoc,
vueDefineNode
)
}
}
}
}
return utils.defineTemplateBodyVisitor(
context,
{
/** @param { CallExpression } node */
CallExpression(node) {
const callee = utils.skipChainExpression(node.callee)
const nameWithLoc = getNameParamNode(node)
if (!nameWithLoc) {
// cannot check
return
}
if (!vueTemplateDefineData) {
return
}
// e.g. $emit() / emit() in template
if (
callee.type === 'Identifier' &&
(callee.name === '$emit' ||
isEmitVariableName(
callee.name,
vueTemplateDefineData.defineEmits
))
) {
verifyEmit(
vueTemplateDefineData.emits,
vueTemplateDefineData.props,
nameWithLoc,
vueTemplateDefineData.define
)
}
}
},
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 = node.parent.id
const variable =
emitParam.type === 'Identifier'
? findVariable(utils.getScope(context, emitParam), emitParam)
: null
if (!variable) {
return
}
/** @type {Set<Identifier>} */
const emitReferenceIds = new Set()
for (const reference of variable.references) {
if (!reference.isRead()) {
continue
}
emitReferenceIds.add(reference.identifier)
}
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.getComponentEmitsFromOptions(node)
)
if (allowProps) {
vuePropsDeclarations.set(
node,
utils.getComponentPropsFromOptions(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<Identifier>} */
const contextReferenceIds = new Set()
/** @type {Set<Identifier>} */
const emitReferenceIds = new Set()
if (contextParam.type === 'ObjectPattern') {
const emitProperty = utils.findAssignmentProperty(
contextParam,
'emit'
)
if (!emitProperty) {
return
}
const emitParam = emitProperty.value
// `setup(props, {emit})`
const variable =
emitParam.type === 'Identifier'
? findVariable(utils.getScope(context, emitParam), emitParam)
: null
if (!variable) {
return
}
for (const reference of variable.references) {
if (!reference.isRead()) {
continue
}
emitReferenceIds.add(reference.identifier)
}
} else if (contextParam.type === 'Identifier') {
// `setup(props, context)`
const variable = findVariable(
utils.getScope(context, contextParam),
contextParam
)
if (!variable) {
return
}
for (const reference of variable.references) {
if (!reference.isRead()) {
continue
}
contextReferenceIds.add(reference.identifier)
}
}
setupContexts.set(vueNode, {
contextReferenceIds,
emitReferenceIds
})
},
...callVisitor,
onVueObjectExit(node, { type }) {
const emits = vueEmitsDeclarations.get(node)
if (
(!vueTemplateDefineData ||
(vueTemplateDefineData.type !== 'export' &&
vueTemplateDefineData.type !== 'setup')) &&
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)
}
})
)
)
}
}
/**
* @param {ObjectExpression|Program} define
* @param {ComponentEmit[]} emits
* @param {NameWithLoc} nameWithLoc
* @param {RuleContext} context
* @returns {Rule.SuggestionReportDescriptor[]}
*/
function buildSuggest(define, emits, nameWithLoc, context) {
const emitsKind =
define.type === 'ObjectExpression' ? '`emits` option' : '`defineEmits`'
const certainEmits = emits.filter(
/** @returns {e is ComponentEmit & {type:'array'|'object'}} */
(e) => e.type === 'array' || e.type === 'object'
)
if (certainEmits.length > 0) {
const last = certainEmits[certainEmits.length - 1]
return [
{
messageId: 'addOneOption',
data: {
name: nameWithLoc.name,
emitsKind
},
fix(fixer) {
if (last.type === 'array') {
// Array
return fixer.insertTextAfter(last.node, `, '${nameWithLoc.name}'`)
} else if (last.type === 'object') {
// Object
return fixer.insertTextAfter(
last.node,
`, '${nameWithLoc.name}': 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(
(p) => utils.getStaticPropertyName(p) === 'emits'
)
if (emitsOption) {
const sourceCode = context.getSourceCode()
const emitsOptionValue = emitsOption.value
if (emitsOptionValue.type === 'ArrayExpression') {
const leftBracket = /** @type {Token} */ (
sourceCode.getFirstToken(emitsOptionValue, isOpeningBracketToken)
)
return [
{
messageId: 'addOneOption',
data: { name: `${nameWithLoc.name}`, emitsKind },
fix(fixer) {
return fixer.insertTextAfter(
leftBracket,
`'${nameWithLoc.name}'${
emitsOptionValue.elements.length > 0 ? ',' : ''
}`
)
}
}
]
} else if (emitsOptionValue.type === 'ObjectExpression') {
const leftBrace = /** @type {Token} */ (
sourceCode.getFirstToken(emitsOptionValue, isOpeningBraceToken)
)
return [
{
messageId: 'addOneOption',
data: { name: `${nameWithLoc.name}`, emitsKind },
fix(fixer) {
return fixer.insertTextAfter(
leftBrace,
`'${nameWithLoc.name}': null${
emitsOptionValue.properties.length > 0 ? ',' : ''
}`
)
}
}
]
}
return []
}
const sourceCode = context.getSourceCode()
const afterOptionNode = propertyNodes.find((p) =>
FIX_EMITS_AFTER_OPTIONS.has(utils.getStaticPropertyName(p) || '')
)
return [
{
messageId: 'addArrayEmitsOption',
data: { name: `${nameWithLoc.name}`, emitsKind },
fix(fixer) {
if (afterOptionNode) {
return fixer.insertTextAfter(
sourceCode.getTokenBefore(afterOptionNode),
`\nemits: ['${nameWithLoc.name}'],`
)
} else if (object.properties.length > 0) {
const before =
propertyNodes[propertyNodes.length - 1] ||
object.properties[object.properties.length - 1]
return fixer.insertTextAfter(
before,
`,\nemits: ['${nameWithLoc.name}']`
)
} else {
const objectLeftBrace = /** @type {Token} */ (
sourceCode.getFirstToken(object, isOpeningBraceToken)
)
const objectRightBrace = /** @type {Token} */ (
sourceCode.getLastToken(object, isClosingBraceToken)
)
return fixer.insertTextAfter(
objectLeftBrace,
`\nemits: ['${nameWithLoc.name}']${
objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
? ''
: '\n'
}`
)
}
}
},
{
messageId: 'addObjectEmitsOption',
data: { name: `${nameWithLoc.name}`, emitsKind },
fix(fixer) {
if (afterOptionNode) {
return fixer.insertTextAfter(
sourceCode.getTokenBefore(afterOptionNode),
`\nemits: {'${nameWithLoc.name}': null},`
)
} else if (object.properties.length > 0) {
const before =
propertyNodes[propertyNodes.length - 1] ||
object.properties[object.properties.length - 1]
return fixer.insertTextAfter(
before,
`,\nemits: {'${nameWithLoc.name}': null}`
)
} else {
const objectLeftBrace = /** @type {Token} */ (
sourceCode.getFirstToken(object, isOpeningBraceToken)
)
const objectRightBrace = /** @type {Token} */ (
sourceCode.getLastToken(object, isClosingBraceToken)
)
return fixer.insertTextAfter(
objectLeftBrace,
`\nemits: {'${nameWithLoc.name}': null}${
objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
? ''
: '\n'
}`
)
}
}
}
]
}