require-explicit-emits.js 16 KB

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