v-on-handler-style.js 19 KB

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