v-on-handler-style.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. /**
  2. * @author Yosuke Ota <https://github.com/ota-meshi>
  3. * See LICENSE file in root directory for full license.
  4. */
  5. 'use strict'
  6. const utils = require('../utils')
  7. /**
  8. * @typedef {import('eslint').ReportDescriptorFix} ReportDescriptorFix
  9. * @typedef {'method' | 'inline' | 'inline-function'} HandlerKind
  10. * @typedef {object} ObjectOption
  11. * @property {boolean} [ignoreIncludesComment]
  12. */
  13. /**
  14. * @param {RuleContext} context
  15. */
  16. function parseOptions(context) {
  17. /** @type {[HandlerKind | HandlerKind[] | undefined, ObjectOption | undefined]} */
  18. const options = /** @type {any} */ (context.options)
  19. /** @type {HandlerKind[]} */
  20. const allows = []
  21. if (options[0]) {
  22. if (Array.isArray(options[0])) {
  23. allows.push(...options[0])
  24. } else {
  25. allows.push(options[0])
  26. }
  27. } else {
  28. allows.push('method', 'inline-function')
  29. }
  30. const option = options[1] || {}
  31. const ignoreIncludesComment = !!option.ignoreIncludesComment
  32. return { allows, ignoreIncludesComment }
  33. }
  34. /**
  35. * Check whether the given token is a quote.
  36. * @param {Token} token The token to check.
  37. * @returns {boolean} `true` if the token is a quote.
  38. */
  39. function isQuote(token) {
  40. return (
  41. token != null &&
  42. token.type === 'Punctuator' &&
  43. (token.value === '"' || token.value === "'")
  44. )
  45. }
  46. /**
  47. * Check whether the given node is an identifier call expression. e.g. `foo()`
  48. * @param {Expression} node The node to check.
  49. * @returns {node is CallExpression & {callee: Identifier}}
  50. */
  51. function isIdentifierCallExpression(node) {
  52. if (node.type !== 'CallExpression') {
  53. return false
  54. }
  55. if (node.optional) {
  56. // optional chaining
  57. return false
  58. }
  59. const callee = node.callee
  60. return callee.type === 'Identifier'
  61. }
  62. /**
  63. * Returns a call expression node if the given VOnExpression or BlockStatement consists
  64. * of only a single identifier call expression.
  65. * e.g.
  66. * @click="foo()"
  67. * @click="{ foo() }"
  68. * @click="foo();;"
  69. * @param {VOnExpression | BlockStatement} node
  70. * @returns {CallExpression & {callee: Identifier} | null}
  71. */
  72. function getIdentifierCallExpression(node) {
  73. /** @type {ExpressionStatement} */
  74. let exprStatement
  75. let body = node.body
  76. while (true) {
  77. const statements = body.filter((st) => st.type !== 'EmptyStatement')
  78. if (statements.length !== 1) {
  79. return null
  80. }
  81. const statement = statements[0]
  82. if (statement.type === 'ExpressionStatement') {
  83. exprStatement = statement
  84. break
  85. }
  86. if (statement.type === 'BlockStatement') {
  87. body = statement.body
  88. continue
  89. }
  90. return null
  91. }
  92. const expression = exprStatement.expression
  93. if (!isIdentifierCallExpression(expression)) {
  94. return null
  95. }
  96. return expression
  97. }
  98. module.exports = {
  99. meta: {
  100. type: 'suggestion',
  101. docs: {
  102. description: 'enforce writing style for handlers in `v-on` directives',
  103. categories: undefined,
  104. url: 'https://eslint.vuejs.org/rules/v-on-handler-style.html'
  105. },
  106. fixable: 'code',
  107. schema: [
  108. {
  109. oneOf: [
  110. { enum: ['inline', 'inline-function'] },
  111. {
  112. type: 'array',
  113. items: [
  114. { const: 'method' },
  115. { enum: ['inline', 'inline-function'] }
  116. ],
  117. uniqueItems: true,
  118. additionalItems: false,
  119. minItems: 2,
  120. maxItems: 2
  121. }
  122. ]
  123. },
  124. {
  125. type: 'object',
  126. properties: {
  127. ignoreIncludesComment: {
  128. type: 'boolean'
  129. }
  130. },
  131. additionalProperties: false
  132. }
  133. ],
  134. messages: {
  135. preferMethodOverInline:
  136. 'Prefer method handler over inline handler in v-on.',
  137. preferMethodOverInlineWithoutIdCall:
  138. 'Prefer method handler over inline handler in v-on. Note that you may need to create a new method.',
  139. preferMethodOverInlineFunction:
  140. 'Prefer method handler over inline function in v-on.',
  141. preferMethodOverInlineFunctionWithoutIdCall:
  142. 'Prefer method handler over inline function in v-on. Note that you may need to create a new method.',
  143. preferInlineOverMethod:
  144. 'Prefer inline handler over method handler in v-on.',
  145. preferInlineOverInlineFunction:
  146. 'Prefer inline handler over inline function in v-on.',
  147. preferInlineOverInlineFunctionWithMultipleParams:
  148. 'Prefer inline handler over inline function in v-on. Note that the custom event must be changed to a single payload.',
  149. preferInlineFunctionOverMethod:
  150. 'Prefer inline function over method handler in v-on.',
  151. preferInlineFunctionOverInline:
  152. 'Prefer inline function over inline handler in v-on.'
  153. }
  154. },
  155. /** @param {RuleContext} context */
  156. create(context) {
  157. const { allows, ignoreIncludesComment } = parseOptions(context)
  158. /** @type {Set<VElement>} */
  159. const upperElements = new Set()
  160. /** @type {Map<string, number>} */
  161. const methodParamCountMap = new Map()
  162. /** @type {Identifier[]} */
  163. const $eventIdentifiers = []
  164. /**
  165. * Verify for inline handler.
  166. * @param {VOnExpression} node
  167. * @param {HandlerKind} kind
  168. * @returns {boolean} Returns `true` if reported.
  169. */
  170. function verifyForInlineHandler(node, kind) {
  171. switch (kind) {
  172. case 'method': {
  173. return verifyCanUseMethodHandlerForInlineHandler(node)
  174. }
  175. case 'inline-function': {
  176. reportCanUseInlineFunctionForInlineHandler(node)
  177. return true
  178. }
  179. }
  180. return false
  181. }
  182. /**
  183. * Report for method handler.
  184. * @param {Identifier} node
  185. * @param {HandlerKind} kind
  186. * @returns {boolean} Returns `true` if reported.
  187. */
  188. function reportForMethodHandler(node, kind) {
  189. switch (kind) {
  190. case 'inline':
  191. case 'inline-function': {
  192. context.report({
  193. node,
  194. messageId:
  195. kind === 'inline'
  196. ? 'preferInlineOverMethod'
  197. : 'preferInlineFunctionOverMethod'
  198. })
  199. return true
  200. }
  201. }
  202. // This path is currently not taken.
  203. return false
  204. }
  205. /**
  206. * Verify for inline function handler.
  207. * @param {ArrowFunctionExpression | FunctionExpression} node
  208. * @param {HandlerKind} kind
  209. * @returns {boolean} Returns `true` if reported.
  210. */
  211. function verifyForInlineFunction(node, kind) {
  212. switch (kind) {
  213. case 'method': {
  214. return verifyCanUseMethodHandlerForInlineFunction(node)
  215. }
  216. case 'inline': {
  217. reportCanUseInlineHandlerForInlineFunction(node)
  218. return true
  219. }
  220. }
  221. return false
  222. }
  223. /**
  224. * Get token information for the given VExpressionContainer node.
  225. * @param {VExpressionContainer} node
  226. */
  227. function getVExpressionContainerTokenInfo(node) {
  228. const sourceCode = context.getSourceCode()
  229. const tokenStore = sourceCode.parserServices.getTemplateBodyTokenStore()
  230. const tokens = tokenStore.getTokens(node, {
  231. includeComments: true
  232. })
  233. const firstToken = tokens[0]
  234. const lastToken = tokens[tokens.length - 1]
  235. const hasQuote = isQuote(firstToken)
  236. /** @type {Range} */
  237. const rangeWithoutQuotes = hasQuote
  238. ? [firstToken.range[1], lastToken.range[0]]
  239. : [firstToken.range[0], lastToken.range[1]]
  240. return {
  241. rangeWithoutQuotes,
  242. get hasComment() {
  243. return tokens.some(
  244. (token) => token.type === 'Block' || token.type === 'Line'
  245. )
  246. },
  247. hasQuote
  248. }
  249. }
  250. /**
  251. * Checks whether the given node refers to a variable of the element.
  252. * @param {Expression | VOnExpression} node
  253. */
  254. function hasReferenceUpperElementVariable(node) {
  255. for (const element of upperElements) {
  256. for (const vv of element.variables) {
  257. for (const reference of vv.references) {
  258. const { range } = reference.id
  259. if (node.range[0] <= range[0] && range[1] <= node.range[1]) {
  260. return true
  261. }
  262. }
  263. }
  264. }
  265. return false
  266. }
  267. /**
  268. * Check if `v-on:click="foo()"` can be converted to `v-on:click="foo"` and report if it can.
  269. * @param {VOnExpression} node
  270. * @returns {boolean} Returns `true` if reported.
  271. */
  272. function verifyCanUseMethodHandlerForInlineHandler(node) {
  273. const { rangeWithoutQuotes, hasComment } =
  274. getVExpressionContainerTokenInfo(node.parent)
  275. if (ignoreIncludesComment && hasComment) {
  276. return false
  277. }
  278. const idCallExpr = getIdentifierCallExpression(node)
  279. if (
  280. (!idCallExpr || idCallExpr.arguments.length > 0) &&
  281. hasReferenceUpperElementVariable(node)
  282. ) {
  283. // It cannot be converted to method because it refers to the variable of the element.
  284. // e.g. <template v-for="e in list"><button @click="foo(e)" /></template>
  285. return false
  286. }
  287. context.report({
  288. node,
  289. messageId: idCallExpr
  290. ? 'preferMethodOverInline'
  291. : 'preferMethodOverInlineWithoutIdCall',
  292. fix: (fixer) => {
  293. if (
  294. hasComment /* The statement contains comment and cannot be fixed. */ ||
  295. !idCallExpr /* The statement is not a simple identifier call and cannot be fixed. */ ||
  296. idCallExpr.arguments.length > 0
  297. ) {
  298. return null
  299. }
  300. const paramCount = methodParamCountMap.get(idCallExpr.callee.name)
  301. if (paramCount != null && paramCount > 0) {
  302. // The behavior of target method can change given the arguments.
  303. return null
  304. }
  305. return fixer.replaceTextRange(
  306. rangeWithoutQuotes,
  307. context.getSourceCode().getText(idCallExpr.callee)
  308. )
  309. }
  310. })
  311. return true
  312. }
  313. /**
  314. * Check if `v-on:click="() => foo()"` can be converted to `v-on:click="foo"` and report if it can.
  315. * @param {ArrowFunctionExpression | FunctionExpression} node
  316. * @returns {boolean} Returns `true` if reported.
  317. */
  318. function verifyCanUseMethodHandlerForInlineFunction(node) {
  319. const { rangeWithoutQuotes, hasComment } =
  320. getVExpressionContainerTokenInfo(
  321. /** @type {VExpressionContainer} */ (node.parent)
  322. )
  323. if (ignoreIncludesComment && hasComment) {
  324. return false
  325. }
  326. /** @type {CallExpression & {callee: Identifier} | null} */
  327. let idCallExpr = null
  328. if (node.body.type === 'BlockStatement') {
  329. idCallExpr = getIdentifierCallExpression(node.body)
  330. } else if (isIdentifierCallExpression(node.body)) {
  331. idCallExpr = node.body
  332. }
  333. if (
  334. (!idCallExpr || !isSameParamsAndArgs(idCallExpr)) &&
  335. hasReferenceUpperElementVariable(node)
  336. ) {
  337. // It cannot be converted to method because it refers to the variable of the element.
  338. // e.g. <template v-for="e in list"><button @click="() => foo(e)" /></template>
  339. return false
  340. }
  341. context.report({
  342. node,
  343. messageId: idCallExpr
  344. ? 'preferMethodOverInlineFunction'
  345. : 'preferMethodOverInlineFunctionWithoutIdCall',
  346. fix: (fixer) => {
  347. if (
  348. hasComment /* The function contains comment and cannot be fixed. */ ||
  349. !idCallExpr /* The function is not a simple identifier call and cannot be fixed. */
  350. ) {
  351. return null
  352. }
  353. if (!isSameParamsAndArgs(idCallExpr)) {
  354. // It is not a call with the arguments given as is.
  355. return null
  356. }
  357. const paramCount = methodParamCountMap.get(idCallExpr.callee.name)
  358. if (
  359. paramCount != null &&
  360. paramCount !== idCallExpr.arguments.length
  361. ) {
  362. // The behavior of target method can change given the arguments.
  363. return null
  364. }
  365. return fixer.replaceTextRange(
  366. rangeWithoutQuotes,
  367. context.getSourceCode().getText(idCallExpr.callee)
  368. )
  369. }
  370. })
  371. return true
  372. /**
  373. * Checks whether parameters are passed as arguments as-is.
  374. * @param {CallExpression} expression
  375. */
  376. function isSameParamsAndArgs(expression) {
  377. return (
  378. node.params.length === expression.arguments.length &&
  379. node.params.every((param, index) => {
  380. if (param.type !== 'Identifier') {
  381. return false
  382. }
  383. const arg = expression.arguments[index]
  384. if (!arg || arg.type !== 'Identifier') {
  385. return false
  386. }
  387. return param.name === arg.name
  388. })
  389. )
  390. }
  391. }
  392. /**
  393. * Report `v-on:click="foo()"` can be converted to `v-on:click="()=>foo()"`.
  394. * @param {VOnExpression} node
  395. * @returns {void}
  396. */
  397. function reportCanUseInlineFunctionForInlineHandler(node) {
  398. context.report({
  399. node,
  400. messageId: 'preferInlineFunctionOverInline',
  401. *fix(fixer) {
  402. const has$Event = $eventIdentifiers.some(
  403. ({ range }) =>
  404. node.range[0] <= range[0] && range[1] <= node.range[1]
  405. )
  406. if (has$Event) {
  407. /* The statements contains $event and cannot be fixed. */
  408. return
  409. }
  410. const { rangeWithoutQuotes, hasQuote } =
  411. getVExpressionContainerTokenInfo(node.parent)
  412. if (!hasQuote) {
  413. /* The statements is not enclosed in quotes and cannot be fixed. */
  414. return
  415. }
  416. yield fixer.insertTextBeforeRange(rangeWithoutQuotes, '() => ')
  417. const sourceCode = context.getSourceCode()
  418. const tokenStore =
  419. sourceCode.parserServices.getTemplateBodyTokenStore()
  420. const firstToken = tokenStore.getFirstToken(node)
  421. const lastToken = tokenStore.getLastToken(node)
  422. if (firstToken.value === '{' && lastToken.value === '}') return
  423. if (
  424. lastToken.value !== ';' &&
  425. node.body.length === 1 &&
  426. node.body[0].type === 'ExpressionStatement'
  427. ) {
  428. // it is a single expression
  429. return
  430. }
  431. yield fixer.insertTextBefore(firstToken, '{')
  432. yield fixer.insertTextAfter(lastToken, '}')
  433. }
  434. })
  435. }
  436. /**
  437. * Report `v-on:click="() => foo()"` can be converted to `v-on:click="foo()"`.
  438. * @param {ArrowFunctionExpression | FunctionExpression} node
  439. * @returns {void}
  440. */
  441. function reportCanUseInlineHandlerForInlineFunction(node) {
  442. // If a function has one parameter, you can turn it into an inline handler using $event.
  443. // If a function has two or more parameters, it cannot be easily converted to an inline handler.
  444. // However, users can use inline handlers by changing the payload of the component's custom event.
  445. // So we report it regardless of the number of parameters.
  446. context.report({
  447. node,
  448. messageId:
  449. node.params.length > 1
  450. ? 'preferInlineOverInlineFunctionWithMultipleParams'
  451. : 'preferInlineOverInlineFunction',
  452. fix:
  453. node.params.length > 0
  454. ? null /* The function has parameters and cannot be fixed. */
  455. : (fixer) => {
  456. let text = context.getSourceCode().getText(node.body)
  457. if (node.body.type === 'BlockStatement') {
  458. text = text.slice(1, -1) // strip braces
  459. }
  460. return fixer.replaceText(node, text)
  461. }
  462. })
  463. }
  464. return utils.defineTemplateBodyVisitor(
  465. context,
  466. {
  467. VElement(node) {
  468. upperElements.add(node)
  469. },
  470. 'VElement:exit'(node) {
  471. upperElements.delete(node)
  472. },
  473. /** @param {VExpressionContainer} node */
  474. "VAttribute[directive=true][key.name.name='on'][key.argument!=null] > VExpressionContainer.value:exit"(
  475. node
  476. ) {
  477. const expression = node.expression
  478. if (!expression) {
  479. return
  480. }
  481. switch (expression.type) {
  482. case 'VOnExpression': {
  483. // e.g. v-on:click="foo()"
  484. if (allows[0] === 'inline') {
  485. return
  486. }
  487. for (const allow of allows) {
  488. if (verifyForInlineHandler(expression, allow)) {
  489. return
  490. }
  491. }
  492. break
  493. }
  494. case 'Identifier': {
  495. // e.g. v-on:click="foo"
  496. if (allows[0] === 'method') {
  497. return
  498. }
  499. for (const allow of allows) {
  500. if (reportForMethodHandler(expression, allow)) {
  501. return
  502. }
  503. }
  504. break
  505. }
  506. case 'ArrowFunctionExpression':
  507. case 'FunctionExpression': {
  508. // e.g. v-on:click="()=>foo()"
  509. if (allows[0] === 'inline-function') {
  510. return
  511. }
  512. for (const allow of allows) {
  513. if (verifyForInlineFunction(expression, allow)) {
  514. return
  515. }
  516. }
  517. break
  518. }
  519. default: {
  520. return
  521. }
  522. }
  523. },
  524. ...(allows.includes('inline-function')
  525. ? // Collect $event identifiers to check for side effects
  526. // when converting from `v-on:click="foo($event)"` to `v-on:click="()=>foo($event)"` .
  527. {
  528. 'Identifier[name="$event"]'(node) {
  529. $eventIdentifiers.push(node)
  530. }
  531. }
  532. : {})
  533. },
  534. allows.includes('method')
  535. ? // Collect method definition with params information to check for side effects.
  536. // when converting from `v-on:click="foo()"` to `v-on:click="foo"`, or
  537. // converting from `v-on:click="() => foo()"` to `v-on:click="foo"`.
  538. utils.defineVueVisitor(context, {
  539. onVueObjectEnter(node) {
  540. for (const method of utils.iterateProperties(
  541. node,
  542. new Set(['methods'])
  543. )) {
  544. if (method.type !== 'object') {
  545. // This branch is usually not passed.
  546. continue
  547. }
  548. const value = method.property.value
  549. if (
  550. value.type === 'FunctionExpression' ||
  551. value.type === 'ArrowFunctionExpression'
  552. ) {
  553. methodParamCountMap.set(
  554. method.name,
  555. value.params.some((p) => p.type === 'RestElement')
  556. ? Number.POSITIVE_INFINITY
  557. : value.params.length
  558. )
  559. }
  560. }
  561. }
  562. })
  563. : {}
  564. )
  565. }
  566. }