v-on-handler-style.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  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. case 'inline-function':
  175. reportCanUseInlineFunctionForInlineHandler(node)
  176. return true
  177. }
  178. return false
  179. }
  180. /**
  181. * Report for method handler.
  182. * @param {Identifier} node
  183. * @param {HandlerKind} kind
  184. * @returns {boolean} Returns `true` if reported.
  185. */
  186. function reportForMethodHandler(node, kind) {
  187. switch (kind) {
  188. case 'inline':
  189. case 'inline-function':
  190. context.report({
  191. node,
  192. messageId:
  193. kind === 'inline'
  194. ? 'preferInlineOverMethod'
  195. : 'preferInlineFunctionOverMethod'
  196. })
  197. return true
  198. }
  199. // This path is currently not taken.
  200. return false
  201. }
  202. /**
  203. * Verify for inline function handler.
  204. * @param {ArrowFunctionExpression | FunctionExpression} node
  205. * @param {HandlerKind} kind
  206. * @returns {boolean} Returns `true` if reported.
  207. */
  208. function verifyForInlineFunction(node, kind) {
  209. switch (kind) {
  210. case 'method':
  211. return verifyCanUseMethodHandlerForInlineFunction(node)
  212. case 'inline':
  213. reportCanUseInlineHandlerForInlineFunction(node)
  214. return true
  215. }
  216. return false
  217. }
  218. /**
  219. * Get token information for the given VExpressionContainer node.
  220. * @param {VExpressionContainer} node
  221. */
  222. function getVExpressionContainerTokenInfo(node) {
  223. const tokenStore = context.parserServices.getTemplateBodyTokenStore()
  224. const tokens = tokenStore.getTokens(node, {
  225. includeComments: true
  226. })
  227. const firstToken = tokens[0]
  228. const lastToken = tokens[tokens.length - 1]
  229. const hasQuote = isQuote(firstToken)
  230. /** @type {Range} */
  231. const rangeWithoutQuotes = hasQuote
  232. ? [firstToken.range[1], lastToken.range[0]]
  233. : [firstToken.range[0], lastToken.range[1]]
  234. return {
  235. rangeWithoutQuotes,
  236. get hasComment() {
  237. return tokens.some(
  238. (token) => token.type === 'Block' || token.type === 'Line'
  239. )
  240. },
  241. hasQuote
  242. }
  243. }
  244. /**
  245. * Checks whether the given node refers to a variable of the element.
  246. * @param {Expression | VOnExpression} node
  247. */
  248. function hasReferenceUpperElementVariable(node) {
  249. for (const element of upperElements) {
  250. for (const vv of element.variables) {
  251. for (const reference of vv.references) {
  252. const { range } = reference.id
  253. if (node.range[0] <= range[0] && range[1] <= node.range[1]) {
  254. return true
  255. }
  256. }
  257. }
  258. }
  259. return false
  260. }
  261. /**
  262. * Check if `v-on:click="foo()"` can be converted to `v-on:click="foo"` and report if it can.
  263. * @param {VOnExpression} node
  264. * @returns {boolean} Returns `true` if reported.
  265. */
  266. function verifyCanUseMethodHandlerForInlineHandler(node) {
  267. const { rangeWithoutQuotes, hasComment } =
  268. getVExpressionContainerTokenInfo(node.parent)
  269. if (ignoreIncludesComment && hasComment) {
  270. return false
  271. }
  272. const idCallExpr = getIdentifierCallExpression(node)
  273. if (
  274. (!idCallExpr || idCallExpr.arguments.length > 0) &&
  275. hasReferenceUpperElementVariable(node)
  276. ) {
  277. // It cannot be converted to method because it refers to the variable of the element.
  278. // e.g. <template v-for="e in list"><button @click="foo(e)" /></template>
  279. return false
  280. }
  281. context.report({
  282. node,
  283. messageId: idCallExpr
  284. ? 'preferMethodOverInline'
  285. : 'preferMethodOverInlineWithoutIdCall',
  286. fix: (fixer) => {
  287. if (
  288. hasComment /* The statement contains comment and cannot be fixed. */ ||
  289. !idCallExpr /* The statement is not a simple identifier call and cannot be fixed. */ ||
  290. idCallExpr.arguments.length > 0
  291. ) {
  292. return null
  293. }
  294. const paramCount = methodParamCountMap.get(idCallExpr.callee.name)
  295. if (paramCount != null && paramCount > 0) {
  296. // The behavior of target method can change given the arguments.
  297. return null
  298. }
  299. return fixer.replaceTextRange(
  300. rangeWithoutQuotes,
  301. context.getSourceCode().getText(idCallExpr.callee)
  302. )
  303. }
  304. })
  305. return true
  306. }
  307. /**
  308. * Check if `v-on:click="() => foo()"` can be converted to `v-on:click="foo"` and report if it can.
  309. * @param {ArrowFunctionExpression | FunctionExpression} node
  310. * @returns {boolean} Returns `true` if reported.
  311. */
  312. function verifyCanUseMethodHandlerForInlineFunction(node) {
  313. const { rangeWithoutQuotes, hasComment } =
  314. getVExpressionContainerTokenInfo(
  315. /** @type {VExpressionContainer} */ (node.parent)
  316. )
  317. if (ignoreIncludesComment && hasComment) {
  318. return false
  319. }
  320. /** @type {CallExpression & {callee: Identifier} | null} */
  321. let idCallExpr = null
  322. if (node.body.type === 'BlockStatement') {
  323. idCallExpr = getIdentifierCallExpression(node.body)
  324. } else if (isIdentifierCallExpression(node.body)) {
  325. idCallExpr = node.body
  326. }
  327. if (
  328. (!idCallExpr || !isSameParamsAndArgs(idCallExpr)) &&
  329. hasReferenceUpperElementVariable(node)
  330. ) {
  331. // It cannot be converted to method because it refers to the variable of the element.
  332. // e.g. <template v-for="e in list"><button @click="() => foo(e)" /></template>
  333. return false
  334. }
  335. context.report({
  336. node,
  337. messageId: idCallExpr
  338. ? 'preferMethodOverInlineFunction'
  339. : 'preferMethodOverInlineFunctionWithoutIdCall',
  340. fix: (fixer) => {
  341. if (
  342. hasComment /* The function contains comment and cannot be fixed. */ ||
  343. !idCallExpr /* The function is not a simple identifier call and cannot be fixed. */
  344. ) {
  345. return null
  346. }
  347. if (!isSameParamsAndArgs(idCallExpr)) {
  348. // It is not a call with the arguments given as is.
  349. return null
  350. }
  351. const paramCount = methodParamCountMap.get(idCallExpr.callee.name)
  352. if (
  353. paramCount != null &&
  354. paramCount !== idCallExpr.arguments.length
  355. ) {
  356. // The behavior of target method can change given the arguments.
  357. return null
  358. }
  359. return fixer.replaceTextRange(
  360. rangeWithoutQuotes,
  361. context.getSourceCode().getText(idCallExpr.callee)
  362. )
  363. }
  364. })
  365. return true
  366. /**
  367. * Checks whether parameters are passed as arguments as-is.
  368. * @param {CallExpression} expression
  369. */
  370. function isSameParamsAndArgs(expression) {
  371. return (
  372. node.params.length === expression.arguments.length &&
  373. node.params.every((param, index) => {
  374. if (param.type !== 'Identifier') {
  375. return false
  376. }
  377. const arg = expression.arguments[index]
  378. if (!arg || arg.type !== 'Identifier') {
  379. return false
  380. }
  381. return param.name === arg.name
  382. })
  383. )
  384. }
  385. }
  386. /**
  387. * Report `v-on:click="foo()"` can be converted to `v-on:click="()=>foo()"`.
  388. * @param {VOnExpression} node
  389. * @returns {void}
  390. */
  391. function reportCanUseInlineFunctionForInlineHandler(node) {
  392. context.report({
  393. node,
  394. messageId: 'preferInlineFunctionOverInline',
  395. *fix(fixer) {
  396. const has$Event = $eventIdentifiers.some(
  397. ({ range }) =>
  398. node.range[0] <= range[0] && range[1] <= node.range[1]
  399. )
  400. if (has$Event) {
  401. /* The statements contains $event and cannot be fixed. */
  402. return
  403. }
  404. const { rangeWithoutQuotes, hasQuote } =
  405. getVExpressionContainerTokenInfo(node.parent)
  406. if (!hasQuote) {
  407. /* The statements is not enclosed in quotes and cannot be fixed. */
  408. return
  409. }
  410. yield fixer.insertTextBeforeRange(rangeWithoutQuotes, '() => ')
  411. const tokenStore = context.parserServices.getTemplateBodyTokenStore()
  412. const firstToken = tokenStore.getFirstToken(node)
  413. const lastToken = tokenStore.getLastToken(node)
  414. if (firstToken.value === '{' && lastToken.value === '}') return
  415. if (
  416. lastToken.value !== ';' &&
  417. node.body.length === 1 &&
  418. node.body[0].type === 'ExpressionStatement'
  419. ) {
  420. // it is a single expression
  421. return
  422. }
  423. yield fixer.insertTextBefore(firstToken, '{')
  424. yield fixer.insertTextAfter(lastToken, '}')
  425. }
  426. })
  427. }
  428. /**
  429. * Report `v-on:click="() => foo()"` can be converted to `v-on:click="foo()"`.
  430. * @param {ArrowFunctionExpression | FunctionExpression} node
  431. * @returns {void}
  432. */
  433. function reportCanUseInlineHandlerForInlineFunction(node) {
  434. // If a function has one parameter, you can turn it into an inline handler using $event.
  435. // If a function has two or more parameters, it cannot be easily converted to an inline handler.
  436. // However, users can use inline handlers by changing the payload of the component's custom event.
  437. // So we report it regardless of the number of parameters.
  438. context.report({
  439. node,
  440. messageId:
  441. node.params.length > 1
  442. ? 'preferInlineOverInlineFunctionWithMultipleParams'
  443. : 'preferInlineOverInlineFunction',
  444. fix:
  445. node.params.length > 0
  446. ? null /* The function has parameters and cannot be fixed. */
  447. : (fixer) => {
  448. let text = context.getSourceCode().getText(node.body)
  449. if (node.body.type === 'BlockStatement') {
  450. text = text.slice(1, -1) // strip braces
  451. }
  452. return fixer.replaceText(node, text)
  453. }
  454. })
  455. }
  456. return utils.defineTemplateBodyVisitor(
  457. context,
  458. {
  459. VElement(node) {
  460. upperElements.add(node)
  461. },
  462. 'VElement:exit'(node) {
  463. upperElements.delete(node)
  464. },
  465. /** @param {VExpressionContainer} node */
  466. "VAttribute[directive=true][key.name.name='on'][key.argument!=null] > VExpressionContainer.value:exit"(
  467. node
  468. ) {
  469. const expression = node.expression
  470. if (!expression) {
  471. return
  472. }
  473. switch (expression.type) {
  474. case 'VOnExpression': {
  475. // e.g. v-on:click="foo()"
  476. if (allows[0] === 'inline') {
  477. return
  478. }
  479. for (const allow of allows) {
  480. if (verifyForInlineHandler(expression, allow)) {
  481. return
  482. }
  483. }
  484. break
  485. }
  486. case 'Identifier': {
  487. // e.g. v-on:click="foo"
  488. if (allows[0] === 'method') {
  489. return
  490. }
  491. for (const allow of allows) {
  492. if (reportForMethodHandler(expression, allow)) {
  493. return
  494. }
  495. }
  496. break
  497. }
  498. case 'ArrowFunctionExpression':
  499. case 'FunctionExpression': {
  500. // e.g. v-on:click="()=>foo()"
  501. if (allows[0] === 'inline-function') {
  502. return
  503. }
  504. for (const allow of allows) {
  505. if (verifyForInlineFunction(expression, allow)) {
  506. return
  507. }
  508. }
  509. break
  510. }
  511. default:
  512. return
  513. }
  514. },
  515. ...(allows.includes('inline-function')
  516. ? // Collect $event identifiers to check for side effects
  517. // when converting from `v-on:click="foo($event)"` to `v-on:click="()=>foo($event)"` .
  518. {
  519. 'Identifier[name="$event"]'(node) {
  520. $eventIdentifiers.push(node)
  521. }
  522. }
  523. : {})
  524. },
  525. allows.includes('method')
  526. ? // Collect method definition with params information to check for side effects.
  527. // when converting from `v-on:click="foo()"` to `v-on:click="foo"`, or
  528. // converting from `v-on:click="() => foo()"` to `v-on:click="foo"`.
  529. utils.defineVueVisitor(context, {
  530. onVueObjectEnter(node) {
  531. for (const method of utils.iterateProperties(
  532. node,
  533. new Set(['methods'])
  534. )) {
  535. if (method.type !== 'object') {
  536. // This branch is usually not passed.
  537. continue
  538. }
  539. const value = method.property.value
  540. if (
  541. value.type === 'FunctionExpression' ||
  542. value.type === 'ArrowFunctionExpression'
  543. ) {
  544. methodParamCountMap.set(
  545. method.name,
  546. value.params.some((p) => p.type === 'RestElement')
  547. ? Number.POSITIVE_INFINITY
  548. : value.params.length
  549. )
  550. }
  551. }
  552. }
  553. })
  554. : {}
  555. )
  556. }
  557. }