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
588 lines
19 KiB
1 month ago
|
/**
|
||
|
* @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
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
: {}
|
||
|
)
|
||
|
}
|
||
|
}
|