require-explicit-emits.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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. /**
  109. * @typedef {object} VueTemplateDefineData
  110. * @property {'export' | 'mark' | 'definition' | 'setup'} type
  111. * @property {ObjectExpression | Program} define
  112. * @property {ComponentEmit[]} emits
  113. * @property {ComponentProp[]} props
  114. * @property {CallExpression} [defineEmits]
  115. */
  116. /** @type {VueTemplateDefineData | null} */
  117. let vueTemplateDefineData = null
  118. /**
  119. * @param {ComponentEmit[]} emits
  120. * @param {ComponentProp[]} props
  121. * @param {NameWithLoc} nameWithLoc
  122. * @param {ObjectExpression | Program} vueDefineNode
  123. */
  124. function verifyEmit(emits, props, nameWithLoc, vueDefineNode) {
  125. const name = nameWithLoc.name
  126. if (emits.some((e) => e.emitName === name || e.emitName == null)) {
  127. return
  128. }
  129. if (allowProps) {
  130. const key = `on${capitalize(name)}`
  131. if (props.some((e) => e.propName === key || e.propName == null)) {
  132. return
  133. }
  134. }
  135. context.report({
  136. loc: nameWithLoc.loc,
  137. messageId: 'missing',
  138. data: {
  139. name,
  140. emitsKind:
  141. vueDefineNode.type === 'ObjectExpression'
  142. ? '`emits` option'
  143. : '`defineEmits`'
  144. },
  145. suggest: buildSuggest(vueDefineNode, emits, nameWithLoc, context)
  146. })
  147. }
  148. const programNode = context.getSourceCode().ast
  149. if (utils.isScriptSetup(context)) {
  150. // init
  151. vueTemplateDefineData = {
  152. type: 'setup',
  153. define: programNode,
  154. emits: [],
  155. props: []
  156. }
  157. }
  158. const callVisitor = {
  159. /**
  160. * @param {CallExpression} node
  161. * @param {VueObjectData} [info]
  162. */
  163. CallExpression(node, info) {
  164. const callee = utils.skipChainExpression(node.callee)
  165. const nameWithLoc = getNameParamNode(node)
  166. if (!nameWithLoc) {
  167. // cannot check
  168. return
  169. }
  170. const vueDefineNode = info ? info.node : programNode
  171. const emitsDeclarations = vueEmitsDeclarations.get(vueDefineNode)
  172. if (!emitsDeclarations) {
  173. return
  174. }
  175. let emit
  176. if (callee.type === 'MemberExpression') {
  177. const name = utils.getStaticPropertyName(callee)
  178. if (name === 'emit' || name === '$emit') {
  179. emit = { name, member: callee }
  180. }
  181. }
  182. // verify setup context
  183. const setupContext = setupContexts.get(vueDefineNode)
  184. if (setupContext) {
  185. const { contextReferenceIds, emitReferenceIds } = setupContext
  186. if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) {
  187. // verify setup(props,{emit}) {emit()}
  188. verifyEmit(
  189. emitsDeclarations,
  190. vuePropsDeclarations.get(vueDefineNode) || [],
  191. nameWithLoc,
  192. vueDefineNode
  193. )
  194. } else if (emit && emit.name === 'emit') {
  195. const memObject = utils.skipChainExpression(emit.member.object)
  196. if (
  197. memObject.type === 'Identifier' &&
  198. contextReferenceIds.has(memObject)
  199. ) {
  200. // verify setup(props,context) {context.emit()}
  201. verifyEmit(
  202. emitsDeclarations,
  203. vuePropsDeclarations.get(vueDefineNode) || [],
  204. nameWithLoc,
  205. vueDefineNode
  206. )
  207. }
  208. }
  209. }
  210. // verify $emit
  211. if (emit && emit.name === '$emit') {
  212. const memObject = utils.skipChainExpression(emit.member.object)
  213. if (utils.isThis(memObject, context)) {
  214. // verify this.$emit()
  215. verifyEmit(
  216. emitsDeclarations,
  217. vuePropsDeclarations.get(vueDefineNode) || [],
  218. nameWithLoc,
  219. vueDefineNode
  220. )
  221. }
  222. }
  223. }
  224. }
  225. return utils.defineTemplateBodyVisitor(
  226. context,
  227. {
  228. /** @param { CallExpression } node */
  229. CallExpression(node) {
  230. const callee = utils.skipChainExpression(node.callee)
  231. const nameWithLoc = getNameParamNode(node)
  232. if (!nameWithLoc) {
  233. // cannot check
  234. return
  235. }
  236. if (!vueTemplateDefineData) {
  237. return
  238. }
  239. if (callee.type === 'Identifier' && callee.name === '$emit') {
  240. verifyEmit(
  241. vueTemplateDefineData.emits,
  242. vueTemplateDefineData.props,
  243. nameWithLoc,
  244. vueTemplateDefineData.define
  245. )
  246. }
  247. }
  248. },
  249. utils.compositingVisitors(
  250. utils.defineScriptSetupVisitor(context, {
  251. onDefineEmitsEnter(node, emits) {
  252. vueEmitsDeclarations.set(programNode, emits)
  253. if (
  254. vueTemplateDefineData &&
  255. vueTemplateDefineData.type === 'setup'
  256. ) {
  257. vueTemplateDefineData.emits = emits
  258. vueTemplateDefineData.defineEmits = node
  259. }
  260. if (
  261. !node.parent ||
  262. node.parent.type !== 'VariableDeclarator' ||
  263. node.parent.init !== node
  264. ) {
  265. return
  266. }
  267. const emitParam = node.parent.id
  268. const variable =
  269. emitParam.type === 'Identifier'
  270. ? findVariable(context.getScope(), emitParam)
  271. : null
  272. if (!variable) {
  273. return
  274. }
  275. /** @type {Set<Identifier>} */
  276. const emitReferenceIds = new Set()
  277. for (const reference of variable.references) {
  278. if (!reference.isRead()) {
  279. continue
  280. }
  281. emitReferenceIds.add(reference.identifier)
  282. }
  283. setupContexts.set(programNode, {
  284. contextReferenceIds: new Set(),
  285. emitReferenceIds
  286. })
  287. },
  288. onDefinePropsEnter(_node, props) {
  289. if (allowProps) {
  290. vuePropsDeclarations.set(programNode, props)
  291. if (
  292. vueTemplateDefineData &&
  293. vueTemplateDefineData.type === 'setup'
  294. ) {
  295. vueTemplateDefineData.props = props
  296. }
  297. }
  298. },
  299. ...callVisitor
  300. }),
  301. utils.defineVueVisitor(context, {
  302. onVueObjectEnter(node) {
  303. vueEmitsDeclarations.set(
  304. node,
  305. utils.getComponentEmitsFromOptions(node)
  306. )
  307. if (allowProps) {
  308. vuePropsDeclarations.set(
  309. node,
  310. utils.getComponentPropsFromOptions(node)
  311. )
  312. }
  313. },
  314. onSetupFunctionEnter(node, { node: vueNode }) {
  315. const contextParam = node.params[1]
  316. if (!contextParam) {
  317. // no arguments
  318. return
  319. }
  320. if (contextParam.type === 'RestElement') {
  321. // cannot check
  322. return
  323. }
  324. if (contextParam.type === 'ArrayPattern') {
  325. // cannot check
  326. return
  327. }
  328. /** @type {Set<Identifier>} */
  329. const contextReferenceIds = new Set()
  330. /** @type {Set<Identifier>} */
  331. const emitReferenceIds = new Set()
  332. if (contextParam.type === 'ObjectPattern') {
  333. const emitProperty = utils.findAssignmentProperty(
  334. contextParam,
  335. 'emit'
  336. )
  337. if (!emitProperty) {
  338. return
  339. }
  340. const emitParam = emitProperty.value
  341. // `setup(props, {emit})`
  342. const variable =
  343. emitParam.type === 'Identifier'
  344. ? findVariable(context.getScope(), emitParam)
  345. : null
  346. if (!variable) {
  347. return
  348. }
  349. for (const reference of variable.references) {
  350. if (!reference.isRead()) {
  351. continue
  352. }
  353. emitReferenceIds.add(reference.identifier)
  354. }
  355. } else if (contextParam.type === 'Identifier') {
  356. // `setup(props, context)`
  357. const variable = findVariable(context.getScope(), contextParam)
  358. if (!variable) {
  359. return
  360. }
  361. for (const reference of variable.references) {
  362. if (!reference.isRead()) {
  363. continue
  364. }
  365. contextReferenceIds.add(reference.identifier)
  366. }
  367. }
  368. setupContexts.set(vueNode, {
  369. contextReferenceIds,
  370. emitReferenceIds
  371. })
  372. },
  373. ...callVisitor,
  374. onVueObjectExit(node, { type }) {
  375. const emits = vueEmitsDeclarations.get(node)
  376. if (
  377. (!vueTemplateDefineData ||
  378. (vueTemplateDefineData.type !== 'export' &&
  379. vueTemplateDefineData.type !== 'setup')) &&
  380. emits &&
  381. (type === 'mark' || type === 'export' || type === 'definition')
  382. ) {
  383. vueTemplateDefineData = {
  384. type,
  385. define: node,
  386. emits,
  387. props: vuePropsDeclarations.get(node) || []
  388. }
  389. }
  390. setupContexts.delete(node)
  391. vueEmitsDeclarations.delete(node)
  392. vuePropsDeclarations.delete(node)
  393. }
  394. })
  395. )
  396. )
  397. }
  398. }
  399. /**
  400. * @param {ObjectExpression|Program} define
  401. * @param {ComponentEmit[]} emits
  402. * @param {NameWithLoc} nameWithLoc
  403. * @param {RuleContext} context
  404. * @returns {Rule.SuggestionReportDescriptor[]}
  405. */
  406. function buildSuggest(define, emits, nameWithLoc, context) {
  407. const emitsKind =
  408. define.type === 'ObjectExpression' ? '`emits` option' : '`defineEmits`'
  409. const certainEmits = emits.filter(
  410. /** @returns {e is ComponentEmit & {type:'array'|'object'}} */
  411. (e) => e.type === 'array' || e.type === 'object'
  412. )
  413. if (certainEmits.length > 0) {
  414. const last = certainEmits[certainEmits.length - 1]
  415. return [
  416. {
  417. messageId: 'addOneOption',
  418. data: {
  419. name: nameWithLoc.name,
  420. emitsKind
  421. },
  422. fix(fixer) {
  423. if (last.type === 'array') {
  424. // Array
  425. return fixer.insertTextAfter(last.node, `, '${nameWithLoc.name}'`)
  426. } else if (last.type === 'object') {
  427. // Object
  428. return fixer.insertTextAfter(
  429. last.node,
  430. `, '${nameWithLoc.name}': null`
  431. )
  432. } else {
  433. // type
  434. // The argument is unknown and cannot be suggested.
  435. return null
  436. }
  437. }
  438. }
  439. ]
  440. }
  441. if (define.type !== 'ObjectExpression') {
  442. // We don't know where to put defineEmits.
  443. return []
  444. }
  445. const object = define
  446. const propertyNodes = object.properties.filter(utils.isProperty)
  447. const emitsOption = propertyNodes.find(
  448. (p) => utils.getStaticPropertyName(p) === 'emits'
  449. )
  450. if (emitsOption) {
  451. const sourceCode = context.getSourceCode()
  452. const emitsOptionValue = emitsOption.value
  453. if (emitsOptionValue.type === 'ArrayExpression') {
  454. const leftBracket = /** @type {Token} */ (
  455. sourceCode.getFirstToken(emitsOptionValue, isOpeningBracketToken)
  456. )
  457. return [
  458. {
  459. messageId: 'addOneOption',
  460. data: { name: `${nameWithLoc.name}`, emitsKind },
  461. fix(fixer) {
  462. return fixer.insertTextAfter(
  463. leftBracket,
  464. `'${nameWithLoc.name}'${
  465. emitsOptionValue.elements.length > 0 ? ',' : ''
  466. }`
  467. )
  468. }
  469. }
  470. ]
  471. } else if (emitsOptionValue.type === 'ObjectExpression') {
  472. const leftBrace = /** @type {Token} */ (
  473. sourceCode.getFirstToken(emitsOptionValue, isOpeningBraceToken)
  474. )
  475. return [
  476. {
  477. messageId: 'addOneOption',
  478. data: { name: `${nameWithLoc.name}`, emitsKind },
  479. fix(fixer) {
  480. return fixer.insertTextAfter(
  481. leftBrace,
  482. `'${nameWithLoc.name}': null${
  483. emitsOptionValue.properties.length > 0 ? ',' : ''
  484. }`
  485. )
  486. }
  487. }
  488. ]
  489. }
  490. return []
  491. }
  492. const sourceCode = context.getSourceCode()
  493. const afterOptionNode = propertyNodes.find((p) =>
  494. FIX_EMITS_AFTER_OPTIONS.has(utils.getStaticPropertyName(p) || '')
  495. )
  496. return [
  497. {
  498. messageId: 'addArrayEmitsOption',
  499. data: { name: `${nameWithLoc.name}`, emitsKind },
  500. fix(fixer) {
  501. if (afterOptionNode) {
  502. return fixer.insertTextAfter(
  503. sourceCode.getTokenBefore(afterOptionNode),
  504. `\nemits: ['${nameWithLoc.name}'],`
  505. )
  506. } else if (object.properties.length > 0) {
  507. const before =
  508. propertyNodes[propertyNodes.length - 1] ||
  509. object.properties[object.properties.length - 1]
  510. return fixer.insertTextAfter(
  511. before,
  512. `,\nemits: ['${nameWithLoc.name}']`
  513. )
  514. } else {
  515. const objectLeftBrace = /** @type {Token} */ (
  516. sourceCode.getFirstToken(object, isOpeningBraceToken)
  517. )
  518. const objectRightBrace = /** @type {Token} */ (
  519. sourceCode.getLastToken(object, isClosingBraceToken)
  520. )
  521. return fixer.insertTextAfter(
  522. objectLeftBrace,
  523. `\nemits: ['${nameWithLoc.name}']${
  524. objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
  525. ? ''
  526. : '\n'
  527. }`
  528. )
  529. }
  530. }
  531. },
  532. {
  533. messageId: 'addObjectEmitsOption',
  534. data: { name: `${nameWithLoc.name}`, emitsKind },
  535. fix(fixer) {
  536. if (afterOptionNode) {
  537. return fixer.insertTextAfter(
  538. sourceCode.getTokenBefore(afterOptionNode),
  539. `\nemits: {'${nameWithLoc.name}': null},`
  540. )
  541. } else if (object.properties.length > 0) {
  542. const before =
  543. propertyNodes[propertyNodes.length - 1] ||
  544. object.properties[object.properties.length - 1]
  545. return fixer.insertTextAfter(
  546. before,
  547. `,\nemits: {'${nameWithLoc.name}': null}`
  548. )
  549. } else {
  550. const objectLeftBrace = /** @type {Token} */ (
  551. sourceCode.getFirstToken(object, isOpeningBraceToken)
  552. )
  553. const objectRightBrace = /** @type {Token} */ (
  554. sourceCode.getLastToken(object, isClosingBraceToken)
  555. )
  556. return fixer.insertTextAfter(
  557. objectLeftBrace,
  558. `\nemits: {'${nameWithLoc.name}': null}${
  559. objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
  560. ? ''
  561. : '\n'
  562. }`
  563. )
  564. }
  565. }
  566. }
  567. ]
  568. }