require-explicit-emits.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. /**
  2. * @author Yosuke Ota
  3. * See LICENSE file in root directory for full license.
  4. */
  5. 'use strict'
  6. /**
  7. * @typedef {import('../utils').ComponentEmit} ComponentEmit
  8. * @typedef {import('../utils').ComponentProp} ComponentProp
  9. * @typedef {import('../utils').VueObjectData} VueObjectData
  10. */
  11. const {
  12. findVariable,
  13. isOpeningBraceToken,
  14. isClosingBraceToken,
  15. isOpeningBracketToken
  16. } = require('@eslint-community/eslint-utils')
  17. const utils = require('../utils')
  18. const { capitalize } = require('../utils/casing')
  19. const FIX_EMITS_AFTER_OPTIONS = new Set([
  20. 'setup',
  21. 'data',
  22. 'computed',
  23. 'watch',
  24. 'methods',
  25. 'template',
  26. 'render',
  27. 'renderError',
  28. // lifecycle hooks
  29. 'beforeCreate',
  30. 'created',
  31. 'beforeMount',
  32. 'mounted',
  33. 'beforeUpdate',
  34. 'updated',
  35. 'activated',
  36. 'deactivated',
  37. 'beforeUnmount',
  38. 'unmounted',
  39. 'beforeDestroy',
  40. 'destroyed',
  41. 'renderTracked',
  42. 'renderTriggered',
  43. 'errorCaptured'
  44. ])
  45. /**
  46. * @typedef {object} NameWithLoc
  47. * @property {string} name
  48. * @property {SourceLocation} loc
  49. * @property {Range} range
  50. */
  51. /**
  52. * Get the name param node from the given CallExpression
  53. * @param {CallExpression} node CallExpression
  54. * @returns { NameWithLoc | null }
  55. */
  56. function getNameParamNode(node) {
  57. const nameLiteralNode = node.arguments[0]
  58. if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) {
  59. const name = utils.getStringLiteralValue(nameLiteralNode)
  60. if (name != null) {
  61. return { name, loc: nameLiteralNode.loc, range: nameLiteralNode.range }
  62. }
  63. }
  64. // cannot check
  65. return null
  66. }
  67. module.exports = {
  68. meta: {
  69. type: 'suggestion',
  70. docs: {
  71. description: 'require `emits` option with name triggered by `$emit()`',
  72. categories: ['vue3-strongly-recommended'],
  73. url: 'https://eslint.vuejs.org/rules/require-explicit-emits.html'
  74. },
  75. fixable: null,
  76. hasSuggestions: true,
  77. schema: [
  78. {
  79. type: 'object',
  80. properties: {
  81. allowProps: {
  82. type: 'boolean'
  83. }
  84. },
  85. additionalProperties: false
  86. }
  87. ],
  88. messages: {
  89. missing:
  90. 'The "{{name}}" event has been triggered but not declared on {{emitsKind}}.',
  91. addOneOption: 'Add the "{{name}}" to {{emitsKind}}.',
  92. addArrayEmitsOption:
  93. 'Add the {{emitsKind}} with array syntax and define "{{name}}" event.',
  94. addObjectEmitsOption:
  95. 'Add the {{emitsKind}} with object syntax and define "{{name}}" event.'
  96. }
  97. },
  98. /** @param {RuleContext} context */
  99. create(context) {
  100. const options = context.options[0] || {}
  101. const allowProps = !!options.allowProps
  102. /** @type {Map<ObjectExpression | Program, { contextReferenceIds: Set<Identifier>, emitReferenceIds: Set<Identifier> }>} */
  103. const setupContexts = new Map()
  104. /** @type {Map<ObjectExpression | Program, ComponentEmit[]>} */
  105. const vueEmitsDeclarations = new Map()
  106. /** @type {Map<ObjectExpression | Program, ComponentProp[]>} */
  107. const vuePropsDeclarations = new Map()
  108. let emitParamName = ''
  109. /**
  110. * @typedef {object} VueTemplateDefineData
  111. * @property {'export' | 'mark' | 'definition' | 'setup'} type
  112. * @property {ObjectExpression | Program} define
  113. * @property {ComponentEmit[]} emits
  114. * @property {ComponentProp[]} props
  115. * @property {CallExpression} [defineEmits]
  116. */
  117. /** @type {VueTemplateDefineData | null} */
  118. let vueTemplateDefineData = null
  119. /**
  120. * @param {ComponentEmit[]} emits
  121. * @param {ComponentProp[]} props
  122. * @param {NameWithLoc} nameWithLoc
  123. * @param {ObjectExpression | Program} vueDefineNode
  124. */
  125. function verifyEmit(emits, props, nameWithLoc, vueDefineNode) {
  126. const name = nameWithLoc.name
  127. if (emits.some((e) => e.emitName === name || e.emitName == null)) {
  128. return
  129. }
  130. if (allowProps) {
  131. const key = `on${capitalize(name)}`
  132. if (props.some((e) => e.propName === key || e.propName == null)) {
  133. return
  134. }
  135. }
  136. context.report({
  137. loc: nameWithLoc.loc,
  138. messageId: 'missing',
  139. data: {
  140. name,
  141. emitsKind:
  142. vueDefineNode.type === 'ObjectExpression'
  143. ? '`emits` option'
  144. : '`defineEmits`'
  145. },
  146. suggest: buildSuggest(vueDefineNode, emits, nameWithLoc, context)
  147. })
  148. }
  149. const programNode = context.getSourceCode().ast
  150. if (utils.isScriptSetup(context)) {
  151. // init
  152. vueTemplateDefineData = {
  153. type: 'setup',
  154. define: programNode,
  155. emits: [],
  156. props: []
  157. }
  158. }
  159. const callVisitor = {
  160. /**
  161. * @param {CallExpression} node
  162. * @param {VueObjectData} [info]
  163. */
  164. CallExpression(node, info) {
  165. const callee = utils.skipChainExpression(node.callee)
  166. const nameWithLoc = getNameParamNode(node)
  167. if (!nameWithLoc) {
  168. // cannot check
  169. return
  170. }
  171. const vueDefineNode = info ? info.node : programNode
  172. const emitsDeclarations = vueEmitsDeclarations.get(vueDefineNode)
  173. if (!emitsDeclarations) {
  174. return
  175. }
  176. let emit
  177. if (callee.type === 'MemberExpression') {
  178. const name = utils.getStaticPropertyName(callee)
  179. if (name === 'emit' || name === '$emit') {
  180. emit = { name, member: callee }
  181. }
  182. }
  183. // verify setup context
  184. const setupContext = setupContexts.get(vueDefineNode)
  185. if (setupContext) {
  186. const { contextReferenceIds, emitReferenceIds } = setupContext
  187. if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) {
  188. // verify setup(props,{emit}) {emit()}
  189. verifyEmit(
  190. emitsDeclarations,
  191. vuePropsDeclarations.get(vueDefineNode) || [],
  192. nameWithLoc,
  193. vueDefineNode
  194. )
  195. } else if (emit && emit.name === 'emit') {
  196. const memObject = utils.skipChainExpression(emit.member.object)
  197. if (
  198. memObject.type === 'Identifier' &&
  199. contextReferenceIds.has(memObject)
  200. ) {
  201. // verify setup(props,context) {context.emit()}
  202. verifyEmit(
  203. emitsDeclarations,
  204. vuePropsDeclarations.get(vueDefineNode) || [],
  205. nameWithLoc,
  206. vueDefineNode
  207. )
  208. }
  209. }
  210. }
  211. // verify $emit
  212. if (emit && emit.name === '$emit') {
  213. const memObject = utils.skipChainExpression(emit.member.object)
  214. if (utils.isThis(memObject, context)) {
  215. // verify this.$emit()
  216. verifyEmit(
  217. emitsDeclarations,
  218. vuePropsDeclarations.get(vueDefineNode) || [],
  219. nameWithLoc,
  220. vueDefineNode
  221. )
  222. }
  223. }
  224. }
  225. }
  226. return utils.defineTemplateBodyVisitor(
  227. context,
  228. {
  229. /** @param { CallExpression } node */
  230. CallExpression(node) {
  231. const callee = utils.skipChainExpression(node.callee)
  232. const nameWithLoc = getNameParamNode(node)
  233. if (!nameWithLoc) {
  234. // cannot check
  235. return
  236. }
  237. if (!vueTemplateDefineData) {
  238. return
  239. }
  240. // e.g. $emit() / emit() in template
  241. if (
  242. callee.type === 'Identifier' &&
  243. (callee.name === '$emit' || callee.name === emitParamName)
  244. ) {
  245. verifyEmit(
  246. vueTemplateDefineData.emits,
  247. vueTemplateDefineData.props,
  248. nameWithLoc,
  249. vueTemplateDefineData.define
  250. )
  251. }
  252. }
  253. },
  254. utils.compositingVisitors(
  255. utils.defineScriptSetupVisitor(context, {
  256. onDefineEmitsEnter(node, emits) {
  257. vueEmitsDeclarations.set(programNode, emits)
  258. if (
  259. vueTemplateDefineData &&
  260. vueTemplateDefineData.type === 'setup'
  261. ) {
  262. vueTemplateDefineData.emits = emits
  263. vueTemplateDefineData.defineEmits = node
  264. }
  265. if (
  266. !node.parent ||
  267. node.parent.type !== 'VariableDeclarator' ||
  268. node.parent.init !== node
  269. ) {
  270. return
  271. }
  272. const emitParam = node.parent.id
  273. if (emitParam.type !== 'Identifier') {
  274. return
  275. }
  276. emitParamName = emitParam.name
  277. const variable = findVariable(
  278. utils.getScope(context, emitParam),
  279. emitParam
  280. )
  281. if (!variable) {
  282. return
  283. }
  284. /** @type {Set<Identifier>} */
  285. const emitReferenceIds = new Set()
  286. for (const reference of variable.references) {
  287. if (!reference.isRead()) {
  288. continue
  289. }
  290. emitReferenceIds.add(reference.identifier)
  291. }
  292. setupContexts.set(programNode, {
  293. contextReferenceIds: new Set(),
  294. emitReferenceIds
  295. })
  296. },
  297. onDefinePropsEnter(_node, props) {
  298. if (allowProps) {
  299. vuePropsDeclarations.set(programNode, props)
  300. if (
  301. vueTemplateDefineData &&
  302. vueTemplateDefineData.type === 'setup'
  303. ) {
  304. vueTemplateDefineData.props = props
  305. }
  306. }
  307. },
  308. ...callVisitor
  309. }),
  310. utils.defineVueVisitor(context, {
  311. onVueObjectEnter(node) {
  312. vueEmitsDeclarations.set(
  313. node,
  314. utils.getComponentEmitsFromOptions(node)
  315. )
  316. if (allowProps) {
  317. vuePropsDeclarations.set(
  318. node,
  319. utils.getComponentPropsFromOptions(node)
  320. )
  321. }
  322. },
  323. onSetupFunctionEnter(node, { node: vueNode }) {
  324. const contextParam = node.params[1]
  325. if (!contextParam) {
  326. // no arguments
  327. return
  328. }
  329. if (contextParam.type === 'RestElement') {
  330. // cannot check
  331. return
  332. }
  333. if (contextParam.type === 'ArrayPattern') {
  334. // cannot check
  335. return
  336. }
  337. /** @type {Set<Identifier>} */
  338. const contextReferenceIds = new Set()
  339. /** @type {Set<Identifier>} */
  340. const emitReferenceIds = new Set()
  341. if (contextParam.type === 'ObjectPattern') {
  342. const emitProperty = utils.findAssignmentProperty(
  343. contextParam,
  344. 'emit'
  345. )
  346. if (!emitProperty) {
  347. return
  348. }
  349. const emitParam = emitProperty.value
  350. // `setup(props, {emit})`
  351. const variable =
  352. emitParam.type === 'Identifier'
  353. ? findVariable(utils.getScope(context, emitParam), emitParam)
  354. : null
  355. if (!variable) {
  356. return
  357. }
  358. for (const reference of variable.references) {
  359. if (!reference.isRead()) {
  360. continue
  361. }
  362. emitReferenceIds.add(reference.identifier)
  363. }
  364. } else if (contextParam.type === 'Identifier') {
  365. // `setup(props, context)`
  366. const variable = findVariable(
  367. utils.getScope(context, contextParam),
  368. contextParam
  369. )
  370. if (!variable) {
  371. return
  372. }
  373. for (const reference of variable.references) {
  374. if (!reference.isRead()) {
  375. continue
  376. }
  377. contextReferenceIds.add(reference.identifier)
  378. }
  379. }
  380. setupContexts.set(vueNode, {
  381. contextReferenceIds,
  382. emitReferenceIds
  383. })
  384. },
  385. ...callVisitor,
  386. onVueObjectExit(node, { type }) {
  387. const emits = vueEmitsDeclarations.get(node)
  388. if (
  389. (!vueTemplateDefineData ||
  390. (vueTemplateDefineData.type !== 'export' &&
  391. vueTemplateDefineData.type !== 'setup')) &&
  392. emits &&
  393. (type === 'mark' || type === 'export' || type === 'definition')
  394. ) {
  395. vueTemplateDefineData = {
  396. type,
  397. define: node,
  398. emits,
  399. props: vuePropsDeclarations.get(node) || []
  400. }
  401. }
  402. setupContexts.delete(node)
  403. vueEmitsDeclarations.delete(node)
  404. vuePropsDeclarations.delete(node)
  405. }
  406. })
  407. )
  408. )
  409. }
  410. }
  411. /**
  412. * @param {ObjectExpression|Program} define
  413. * @param {ComponentEmit[]} emits
  414. * @param {NameWithLoc} nameWithLoc
  415. * @param {RuleContext} context
  416. * @returns {Rule.SuggestionReportDescriptor[]}
  417. */
  418. function buildSuggest(define, emits, nameWithLoc, context) {
  419. const emitsKind =
  420. define.type === 'ObjectExpression' ? '`emits` option' : '`defineEmits`'
  421. const certainEmits = emits.filter(
  422. /** @returns {e is ComponentEmit & {type:'array'|'object'}} */
  423. (e) => e.type === 'array' || e.type === 'object'
  424. )
  425. if (certainEmits.length > 0) {
  426. const last = certainEmits[certainEmits.length - 1]
  427. return [
  428. {
  429. messageId: 'addOneOption',
  430. data: {
  431. name: nameWithLoc.name,
  432. emitsKind
  433. },
  434. fix(fixer) {
  435. if (last.type === 'array') {
  436. // Array
  437. return fixer.insertTextAfter(last.node, `, '${nameWithLoc.name}'`)
  438. } else if (last.type === 'object') {
  439. // Object
  440. return fixer.insertTextAfter(
  441. last.node,
  442. `, '${nameWithLoc.name}': null`
  443. )
  444. } else {
  445. // type
  446. // The argument is unknown and cannot be suggested.
  447. return null
  448. }
  449. }
  450. }
  451. ]
  452. }
  453. if (define.type !== 'ObjectExpression') {
  454. // We don't know where to put defineEmits.
  455. return []
  456. }
  457. const object = define
  458. const propertyNodes = object.properties.filter(utils.isProperty)
  459. const emitsOption = propertyNodes.find(
  460. (p) => utils.getStaticPropertyName(p) === 'emits'
  461. )
  462. if (emitsOption) {
  463. const sourceCode = context.getSourceCode()
  464. const emitsOptionValue = emitsOption.value
  465. if (emitsOptionValue.type === 'ArrayExpression') {
  466. const leftBracket = /** @type {Token} */ (
  467. sourceCode.getFirstToken(emitsOptionValue, isOpeningBracketToken)
  468. )
  469. return [
  470. {
  471. messageId: 'addOneOption',
  472. data: { name: `${nameWithLoc.name}`, emitsKind },
  473. fix(fixer) {
  474. return fixer.insertTextAfter(
  475. leftBracket,
  476. `'${nameWithLoc.name}'${
  477. emitsOptionValue.elements.length > 0 ? ',' : ''
  478. }`
  479. )
  480. }
  481. }
  482. ]
  483. } else if (emitsOptionValue.type === 'ObjectExpression') {
  484. const leftBrace = /** @type {Token} */ (
  485. sourceCode.getFirstToken(emitsOptionValue, isOpeningBraceToken)
  486. )
  487. return [
  488. {
  489. messageId: 'addOneOption',
  490. data: { name: `${nameWithLoc.name}`, emitsKind },
  491. fix(fixer) {
  492. return fixer.insertTextAfter(
  493. leftBrace,
  494. `'${nameWithLoc.name}': null${
  495. emitsOptionValue.properties.length > 0 ? ',' : ''
  496. }`
  497. )
  498. }
  499. }
  500. ]
  501. }
  502. return []
  503. }
  504. const sourceCode = context.getSourceCode()
  505. const afterOptionNode = propertyNodes.find((p) =>
  506. FIX_EMITS_AFTER_OPTIONS.has(utils.getStaticPropertyName(p) || '')
  507. )
  508. return [
  509. {
  510. messageId: 'addArrayEmitsOption',
  511. data: { name: `${nameWithLoc.name}`, emitsKind },
  512. fix(fixer) {
  513. if (afterOptionNode) {
  514. return fixer.insertTextAfter(
  515. sourceCode.getTokenBefore(afterOptionNode),
  516. `\nemits: ['${nameWithLoc.name}'],`
  517. )
  518. } else if (object.properties.length > 0) {
  519. const before =
  520. propertyNodes[propertyNodes.length - 1] ||
  521. object.properties[object.properties.length - 1]
  522. return fixer.insertTextAfter(
  523. before,
  524. `,\nemits: ['${nameWithLoc.name}']`
  525. )
  526. } else {
  527. const objectLeftBrace = /** @type {Token} */ (
  528. sourceCode.getFirstToken(object, isOpeningBraceToken)
  529. )
  530. const objectRightBrace = /** @type {Token} */ (
  531. sourceCode.getLastToken(object, isClosingBraceToken)
  532. )
  533. return fixer.insertTextAfter(
  534. objectLeftBrace,
  535. `\nemits: ['${nameWithLoc.name}']${
  536. objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
  537. ? ''
  538. : '\n'
  539. }`
  540. )
  541. }
  542. }
  543. },
  544. {
  545. messageId: 'addObjectEmitsOption',
  546. data: { name: `${nameWithLoc.name}`, emitsKind },
  547. fix(fixer) {
  548. if (afterOptionNode) {
  549. return fixer.insertTextAfter(
  550. sourceCode.getTokenBefore(afterOptionNode),
  551. `\nemits: {'${nameWithLoc.name}': null},`
  552. )
  553. } else if (object.properties.length > 0) {
  554. const before =
  555. propertyNodes[propertyNodes.length - 1] ||
  556. object.properties[object.properties.length - 1]
  557. return fixer.insertTextAfter(
  558. before,
  559. `,\nemits: {'${nameWithLoc.name}': null}`
  560. )
  561. } else {
  562. const objectLeftBrace = /** @type {Token} */ (
  563. sourceCode.getFirstToken(object, isOpeningBraceToken)
  564. )
  565. const objectRightBrace = /** @type {Token} */ (
  566. sourceCode.getLastToken(object, isClosingBraceToken)
  567. )
  568. return fixer.insertTextAfter(
  569. objectLeftBrace,
  570. `\nemits: {'${nameWithLoc.name}': null}${
  571. objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
  572. ? ''
  573. : '\n'
  574. }`
  575. )
  576. }
  577. }
  578. }
  579. ]
  580. }