You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

588 lines
19 KiB

/**
* @author Yosuke Ota <https://github.com/ota-meshi>
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {import('eslint').ReportDescriptorFix} ReportDescriptorFix
* @typedef {'method' | 'inline' | 'inline-function'} HandlerKind
* @typedef {object} ObjectOption
* @property {boolean} [ignoreIncludesComment]
*/
/**
* @param {RuleContext} context
*/
function parseOptions(context) {
/** @type {[HandlerKind | HandlerKind[] | undefined, ObjectOption | undefined]} */
const options = /** @type {any} */ (context.options)
/** @type {HandlerKind[]} */
const allows = []
if (options[0]) {
if (Array.isArray(options[0])) {
allows.push(...options[0])
} else {
allows.push(options[0])
}
} else {
allows.push('method', 'inline-function')
}
const option = options[1] || {}
const ignoreIncludesComment = !!option.ignoreIncludesComment
return { allows, ignoreIncludesComment }
}
/**
* Check whether the given token is a quote.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a quote.
*/
function isQuote(token) {
return (
token != null &&
token.type === 'Punctuator' &&
(token.value === '"' || token.value === "'")
)
}
/**
* Check whether the given node is an identifier call expression. e.g. `foo()`
* @param {Expression} node The node to check.
* @returns {node is CallExpression & {callee: Identifier}}
*/
function isIdentifierCallExpression(node) {
if (node.type !== 'CallExpression') {
return false
}
if (node.optional) {
// optional chaining
return false
}
const callee = node.callee
return callee.type === 'Identifier'
}
/**
* Returns a call expression node if the given VOnExpression or BlockStatement consists
* of only a single identifier call expression.
* e.g.
* @click="foo()"
* @click="{ foo() }"
* @click="foo();;"
* @param {VOnExpression | BlockStatement} node
* @returns {CallExpression & {callee: Identifier} | null}
*/
function getIdentifierCallExpression(node) {
/** @type {ExpressionStatement} */
let exprStatement
let body = node.body
while (true) {
const statements = body.filter((st) => st.type !== 'EmptyStatement')
if (statements.length !== 1) {
return null
}
const statement = statements[0]
if (statement.type === 'ExpressionStatement') {
exprStatement = statement
break
}
if (statement.type === 'BlockStatement') {
body = statement.body
continue
}
return null
}
const expression = exprStatement.expression
if (!isIdentifierCallExpression(expression)) {
return null
}
return expression
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce writing style for handlers in `v-on` directives',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/v-on-handler-style.html'
},
fixable: 'code',
schema: [
{
oneOf: [
{ enum: ['inline', 'inline-function'] },
{
type: 'array',
items: [
{ const: 'method' },
{ enum: ['inline', 'inline-function'] }
],
uniqueItems: true,
additionalItems: false,
minItems: 2,
maxItems: 2
}
]
},
{
type: 'object',
properties: {
ignoreIncludesComment: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
preferMethodOverInline:
'Prefer method handler over inline handler in v-on.',
preferMethodOverInlineWithoutIdCall:
'Prefer method handler over inline handler in v-on. Note that you may need to create a new method.',
preferMethodOverInlineFunction:
'Prefer method handler over inline function in v-on.',
preferMethodOverInlineFunctionWithoutIdCall:
'Prefer method handler over inline function in v-on. Note that you may need to create a new method.',
preferInlineOverMethod:
'Prefer inline handler over method handler in v-on.',
preferInlineOverInlineFunction:
'Prefer inline handler over inline function in v-on.',
preferInlineOverInlineFunctionWithMultipleParams:
'Prefer inline handler over inline function in v-on. Note that the custom event must be changed to a single payload.',
preferInlineFunctionOverMethod:
'Prefer inline function over method handler in v-on.',
preferInlineFunctionOverInline:
'Prefer inline function over inline handler in v-on.'
}
},
/** @param {RuleContext} context */
create(context) {
const { allows, ignoreIncludesComment } = parseOptions(context)
/** @type {Set<VElement>} */
const upperElements = new Set()
/** @type {Map<string, number>} */
const methodParamCountMap = new Map()
/** @type {Identifier[]} */
const $eventIdentifiers = []
/**
* Verify for inline handler.
* @param {VOnExpression} node
* @param {HandlerKind} kind
* @returns {boolean} Returns `true` if reported.
*/
function verifyForInlineHandler(node, kind) {
switch (kind) {
case 'method': {
return verifyCanUseMethodHandlerForInlineHandler(node)
}
case 'inline-function': {
reportCanUseInlineFunctionForInlineHandler(node)
return true
}
}
return false
}
/**
* Report for method handler.
* @param {Identifier} node
* @param {HandlerKind} kind
* @returns {boolean} Returns `true` if reported.
*/
function reportForMethodHandler(node, kind) {
switch (kind) {
case 'inline':
case 'inline-function': {
context.report({
node,
messageId:
kind === 'inline'
? 'preferInlineOverMethod'
: 'preferInlineFunctionOverMethod'
})
return true
}
}
// This path is currently not taken.
return false
}
/**
* Verify for inline function handler.
* @param {ArrowFunctionExpression | FunctionExpression} node
* @param {HandlerKind} kind
* @returns {boolean} Returns `true` if reported.
*/
function verifyForInlineFunction(node, kind) {
switch (kind) {
case 'method': {
return verifyCanUseMethodHandlerForInlineFunction(node)
}
case 'inline': {
reportCanUseInlineHandlerForInlineFunction(node)
return true
}
}
return false
}
/**
* Get token information for the given VExpressionContainer node.
* @param {VExpressionContainer} node
*/
function getVExpressionContainerTokenInfo(node) {
const sourceCode = context.getSourceCode()
const tokenStore = sourceCode.parserServices.getTemplateBodyTokenStore()
const tokens = tokenStore.getTokens(node, {
includeComments: true
})
const firstToken = tokens[0]
const lastToken = tokens[tokens.length - 1]
const hasQuote = isQuote(firstToken)
/** @type {Range} */
const rangeWithoutQuotes = hasQuote
? [firstToken.range[1], lastToken.range[0]]
: [firstToken.range[0], lastToken.range[1]]
return {
rangeWithoutQuotes,
get hasComment() {
return tokens.some(
(token) => token.type === 'Block' || token.type === 'Line'
)
},
hasQuote
}
}
/**
* Checks whether the given node refers to a variable of the element.
* @param {Expression | VOnExpression} node
*/
function hasReferenceUpperElementVariable(node) {
for (const element of upperElements) {
for (const vv of element.variables) {
for (const reference of vv.references) {
const { range } = reference.id
if (node.range[0] <= range[0] && range[1] <= node.range[1]) {
return true
}
}
}
}
return false
}
/**
* Check if `v-on:click="foo()"` can be converted to `v-on:click="foo"` and report if it can.
* @param {VOnExpression} node
* @returns {boolean} Returns `true` if reported.
*/
function verifyCanUseMethodHandlerForInlineHandler(node) {
const { rangeWithoutQuotes, hasComment } =
getVExpressionContainerTokenInfo(node.parent)
if (ignoreIncludesComment && hasComment) {
return false
}
const idCallExpr = getIdentifierCallExpression(node)
if (
(!idCallExpr || idCallExpr.arguments.length > 0) &&
hasReferenceUpperElementVariable(node)
) {
// It cannot be converted to method because it refers to the variable of the element.
// e.g. <template v-for="e in list"><button @click="foo(e)" /></template>
return false
}
context.report({
node,
messageId: idCallExpr
? 'preferMethodOverInline'
: 'preferMethodOverInlineWithoutIdCall',
fix: (fixer) => {
if (
hasComment /* The statement contains comment and cannot be fixed. */ ||
!idCallExpr /* The statement is not a simple identifier call and cannot be fixed. */ ||
idCallExpr.arguments.length > 0
) {
return null
}
const paramCount = methodParamCountMap.get(idCallExpr.callee.name)
if (paramCount != null && paramCount > 0) {
// The behavior of target method can change given the arguments.
return null
}
return fixer.replaceTextRange(
rangeWithoutQuotes,
context.getSourceCode().getText(idCallExpr.callee)
)
}
})
return true
}
/**
* Check if `v-on:click="() => foo()"` can be converted to `v-on:click="foo"` and report if it can.
* @param {ArrowFunctionExpression | FunctionExpression} node
* @returns {boolean} Returns `true` if reported.
*/
function verifyCanUseMethodHandlerForInlineFunction(node) {
const { rangeWithoutQuotes, hasComment } =
getVExpressionContainerTokenInfo(
/** @type {VExpressionContainer} */ (node.parent)
)
if (ignoreIncludesComment && hasComment) {
return false
}
/** @type {CallExpression & {callee: Identifier} | null} */
let idCallExpr = null
if (node.body.type === 'BlockStatement') {
idCallExpr = getIdentifierCallExpression(node.body)
} else if (isIdentifierCallExpression(node.body)) {
idCallExpr = node.body
}
if (
(!idCallExpr || !isSameParamsAndArgs(idCallExpr)) &&
hasReferenceUpperElementVariable(node)
) {
// It cannot be converted to method because it refers to the variable of the element.
// e.g. <template v-for="e in list"><button @click="() => foo(e)" /></template>
return false
}
context.report({
node,
messageId: idCallExpr
? 'preferMethodOverInlineFunction'
: 'preferMethodOverInlineFunctionWithoutIdCall',
fix: (fixer) => {
if (
hasComment /* The function contains comment and cannot be fixed. */ ||
!idCallExpr /* The function is not a simple identifier call and cannot be fixed. */
) {
return null
}
if (!isSameParamsAndArgs(idCallExpr)) {
// It is not a call with the arguments given as is.
return null
}
const paramCount = methodParamCountMap.get(idCallExpr.callee.name)
if (
paramCount != null &&
paramCount !== idCallExpr.arguments.length
) {
// The behavior of target method can change given the arguments.
return null
}
return fixer.replaceTextRange(
rangeWithoutQuotes,
context.getSourceCode().getText(idCallExpr.callee)
)
}
})
return true
/**
* Checks whether parameters are passed as arguments as-is.
* @param {CallExpression} expression
*/
function isSameParamsAndArgs(expression) {
return (
node.params.length === expression.arguments.length &&
node.params.every((param, index) => {
if (param.type !== 'Identifier') {
return false
}
const arg = expression.arguments[index]
if (!arg || arg.type !== 'Identifier') {
return false
}
return param.name === arg.name
})
)
}
}
/**
* Report `v-on:click="foo()"` can be converted to `v-on:click="()=>foo()"`.
* @param {VOnExpression} node
* @returns {void}
*/
function reportCanUseInlineFunctionForInlineHandler(node) {
context.report({
node,
messageId: 'preferInlineFunctionOverInline',
*fix(fixer) {
const has$Event = $eventIdentifiers.some(
({ range }) =>
node.range[0] <= range[0] && range[1] <= node.range[1]
)
if (has$Event) {
/* The statements contains $event and cannot be fixed. */
return
}
const { rangeWithoutQuotes, hasQuote } =
getVExpressionContainerTokenInfo(node.parent)
if (!hasQuote) {
/* The statements is not enclosed in quotes and cannot be fixed. */
return
}
yield fixer.insertTextBeforeRange(rangeWithoutQuotes, '() => ')
const sourceCode = context.getSourceCode()
const tokenStore =
sourceCode.parserServices.getTemplateBodyTokenStore()
const firstToken = tokenStore.getFirstToken(node)
const lastToken = tokenStore.getLastToken(node)
if (firstToken.value === '{' && lastToken.value === '}') return
if (
lastToken.value !== ';' &&
node.body.length === 1 &&
node.body[0].type === 'ExpressionStatement'
) {
// it is a single expression
return
}
yield fixer.insertTextBefore(firstToken, '{')
yield fixer.insertTextAfter(lastToken, '}')
}
})
}
/**
* Report `v-on:click="() => foo()"` can be converted to `v-on:click="foo()"`.
* @param {ArrowFunctionExpression | FunctionExpression} node
* @returns {void}
*/
function reportCanUseInlineHandlerForInlineFunction(node) {
// If a function has one parameter, you can turn it into an inline handler using $event.
// If a function has two or more parameters, it cannot be easily converted to an inline handler.
// However, users can use inline handlers by changing the payload of the component's custom event.
// So we report it regardless of the number of parameters.
context.report({
node,
messageId:
node.params.length > 1
? 'preferInlineOverInlineFunctionWithMultipleParams'
: 'preferInlineOverInlineFunction',
fix:
node.params.length > 0
? null /* The function has parameters and cannot be fixed. */
: (fixer) => {
let text = context.getSourceCode().getText(node.body)
if (node.body.type === 'BlockStatement') {
text = text.slice(1, -1) // strip braces
}
return fixer.replaceText(node, text)
}
})
}
return utils.defineTemplateBodyVisitor(
context,
{
VElement(node) {
upperElements.add(node)
},
'VElement:exit'(node) {
upperElements.delete(node)
},
/** @param {VExpressionContainer} node */
"VAttribute[directive=true][key.name.name='on'][key.argument!=null] > VExpressionContainer.value:exit"(
node
) {
const expression = node.expression
if (!expression) {
return
}
switch (expression.type) {
case 'VOnExpression': {
// e.g. v-on:click="foo()"
if (allows[0] === 'inline') {
return
}
for (const allow of allows) {
if (verifyForInlineHandler(expression, allow)) {
return
}
}
break
}
case 'Identifier': {
// e.g. v-on:click="foo"
if (allows[0] === 'method') {
return
}
for (const allow of allows) {
if (reportForMethodHandler(expression, allow)) {
return
}
}
break
}
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
// e.g. v-on:click="()=>foo()"
if (allows[0] === 'inline-function') {
return
}
for (const allow of allows) {
if (verifyForInlineFunction(expression, allow)) {
return
}
}
break
}
default: {
return
}
}
},
...(allows.includes('inline-function')
? // Collect $event identifiers to check for side effects
// when converting from `v-on:click="foo($event)"` to `v-on:click="()=>foo($event)"` .
{
'Identifier[name="$event"]'(node) {
$eventIdentifiers.push(node)
}
}
: {})
},
allows.includes('method')
? // Collect method definition with params information to check for side effects.
// when converting from `v-on:click="foo()"` to `v-on:click="foo"`, or
// converting from `v-on:click="() => foo()"` to `v-on:click="foo"`.
utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
for (const method of utils.iterateProperties(
node,
new Set(['methods'])
)) {
if (method.type !== 'object') {
// This branch is usually not passed.
continue
}
const value = method.property.value
if (
value.type === 'FunctionExpression' ||
value.type === 'ArrowFunctionExpression'
) {
methodParamCountMap.set(
method.name,
value.params.some((p) => p.type === 'RestElement')
? Number.POSITIVE_INFINITY
: value.params.length
)
}
}
}
})
: {}
)
}
}