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.
528 lines
14 KiB
528 lines
14 KiB
/**
|
|
* @fileoverview disallow mutation component props
|
|
* @author 2018 Armano
|
|
*/
|
|
'use strict'
|
|
|
|
/**
|
|
* @typedef {{name?: string, set: Set<string>}} PropsInfo
|
|
*/
|
|
|
|
const utils = require('../utils')
|
|
const { findVariable } = require('@eslint-community/eslint-utils')
|
|
|
|
// https://github.com/vuejs/vue-next/blob/7c11c58faf8840ab97b6449c98da0296a60dddd8/packages/shared/src/globalsWhitelist.ts
|
|
const GLOBALS_WHITE_LISTED = new Set([
|
|
'Infinity',
|
|
'undefined',
|
|
'NaN',
|
|
'isFinite',
|
|
'isNaN',
|
|
'parseFloat',
|
|
'parseInt',
|
|
'decodeURI',
|
|
'decodeURIComponent',
|
|
'encodeURI',
|
|
'encodeURIComponent',
|
|
'Math',
|
|
'Number',
|
|
'Date',
|
|
'Array',
|
|
'Object',
|
|
'Boolean',
|
|
'String',
|
|
'RegExp',
|
|
'Map',
|
|
'Set',
|
|
'JSON',
|
|
'Intl',
|
|
'BigInt'
|
|
])
|
|
|
|
/**
|
|
* @param {ASTNode} node
|
|
* @returns {VExpressionContainer}
|
|
*/
|
|
function getVExpressionContainer(node) {
|
|
let n = node
|
|
while (n.type !== 'VExpressionContainer') {
|
|
n = /** @type {ASTNode} */ (n.parent)
|
|
}
|
|
return n
|
|
}
|
|
|
|
/**
|
|
* @param {ASTNode} node
|
|
* @returns {node is Identifier}
|
|
*/
|
|
function isVmReference(node) {
|
|
if (node.type !== 'Identifier') {
|
|
return false
|
|
}
|
|
const parent = node.parent
|
|
if (parent.type === 'MemberExpression') {
|
|
if (parent.property === node) {
|
|
// foo.id
|
|
return false
|
|
}
|
|
} else if (
|
|
parent.type === 'Property' &&
|
|
parent.key === node &&
|
|
!parent.computed
|
|
) {
|
|
// {id: foo}
|
|
return false
|
|
}
|
|
|
|
const exprContainer = getVExpressionContainer(node)
|
|
|
|
for (const reference of exprContainer.references) {
|
|
if (reference.variable != null) {
|
|
// Not vm reference
|
|
continue
|
|
}
|
|
if (reference.id === node) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param { object } options
|
|
* @param { boolean } options.shallowOnly Enables mutating the value of a prop but leaving the reference the same
|
|
*/
|
|
function parseOptions(options) {
|
|
return Object.assign(
|
|
{
|
|
shallowOnly: false
|
|
},
|
|
options
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'disallow mutation of component props',
|
|
categories: ['vue3-essential', 'vue2-essential'],
|
|
url: 'https://eslint.vuejs.org/rules/no-mutating-props.html'
|
|
},
|
|
fixable: null,
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
shallowOnly: {
|
|
type: 'boolean'
|
|
}
|
|
},
|
|
additionalProperties: false
|
|
}
|
|
],
|
|
messages: {
|
|
unexpectedMutation: 'Unexpected mutation of "{{key}}" prop.'
|
|
}
|
|
},
|
|
/** @param {RuleContext} context */
|
|
create(context) {
|
|
const { shallowOnly } = parseOptions(context.options[0])
|
|
/** @type {Map<ObjectExpression|CallExpression, PropsInfo>} */
|
|
const propsMap = new Map()
|
|
/** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression } | { type: 'setup', object: CallExpression } | null } */
|
|
let vueObjectData = null
|
|
|
|
/**
|
|
* @param {ASTNode} node
|
|
* @param {string} name
|
|
*/
|
|
function report(node, name) {
|
|
context.report({
|
|
node,
|
|
messageId: 'unexpectedMutation',
|
|
data: {
|
|
key: name
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {MemberExpression|AssignmentProperty} node
|
|
* @returns {string}
|
|
*/
|
|
function getPropertyNameText(node) {
|
|
const name = utils.getStaticPropertyName(node)
|
|
if (name) {
|
|
return name
|
|
}
|
|
if (node.computed) {
|
|
const expr = node.type === 'Property' ? node.key : node.property
|
|
const str = context.getSourceCode().getText(expr)
|
|
return `[${str}]`
|
|
}
|
|
return '?unknown?'
|
|
}
|
|
|
|
/**
|
|
* @param {MemberExpression|Identifier} props
|
|
* @param {string} name
|
|
* @param {boolean} isRootProps
|
|
*/
|
|
function verifyMutating(props, name, isRootProps = false) {
|
|
const invalid = utils.findMutating(props)
|
|
if (invalid && isShallowOnlyInvalid(invalid, isRootProps)) {
|
|
report(invalid.node, name)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Pattern} param
|
|
* @param {string[]} path
|
|
* @returns {Generator<{ node: Identifier, path: string[] }>}
|
|
*/
|
|
function* iteratePatternProperties(param, path) {
|
|
if (!param) {
|
|
return
|
|
}
|
|
switch (param.type) {
|
|
case 'Identifier': {
|
|
yield { node: param, path }
|
|
break
|
|
}
|
|
case 'RestElement': {
|
|
yield* iteratePatternProperties(param.argument, path)
|
|
break
|
|
}
|
|
case 'AssignmentPattern': {
|
|
yield* iteratePatternProperties(param.left, path)
|
|
break
|
|
}
|
|
case 'ObjectPattern': {
|
|
for (const prop of param.properties) {
|
|
if (prop.type === 'Property') {
|
|
const name = getPropertyNameText(prop)
|
|
yield* iteratePatternProperties(prop.value, [...path, name])
|
|
} else if (prop.type === 'RestElement') {
|
|
yield* iteratePatternProperties(prop.argument, path)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'ArrayPattern': {
|
|
for (let index = 0; index < param.elements.length; index++) {
|
|
const element = param.elements[index]
|
|
if (element)
|
|
yield* iteratePatternProperties(element, [...path, `${index}`])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Identifier} prop
|
|
* @param {string[]} path
|
|
*/
|
|
function verifyPropVariable(prop, path) {
|
|
const variable = findVariable(utils.getScope(context, prop), prop)
|
|
if (!variable) {
|
|
return
|
|
}
|
|
|
|
for (const reference of variable.references) {
|
|
if (!reference.isRead()) {
|
|
continue
|
|
}
|
|
const id = reference.identifier
|
|
|
|
const invalid = utils.findMutating(id)
|
|
if (!invalid) {
|
|
continue
|
|
}
|
|
let name
|
|
if (!isShallowOnlyInvalid(invalid, path.length === 0)) {
|
|
continue
|
|
}
|
|
if (path.length === 0) {
|
|
if (invalid.pathNodes.length === 0) {
|
|
continue
|
|
}
|
|
const mem = invalid.pathNodes[0]
|
|
name = getPropertyNameText(mem)
|
|
} else {
|
|
if (invalid.pathNodes.length === 0 && invalid.kind !== 'call') {
|
|
continue
|
|
}
|
|
name = path[0]
|
|
}
|
|
|
|
report(invalid.node, name)
|
|
}
|
|
}
|
|
|
|
function* extractDefineVariableNames() {
|
|
const globalScope = context.getSourceCode().scopeManager.globalScope
|
|
if (globalScope) {
|
|
for (const variable of globalScope.variables) {
|
|
if (variable.defs.length > 0) {
|
|
yield variable.name
|
|
}
|
|
}
|
|
const moduleScope = globalScope.childScopes.find(
|
|
(scope) => scope.type === 'module'
|
|
)
|
|
for (const variable of (moduleScope && moduleScope.variables) || []) {
|
|
if (variable.defs.length > 0) {
|
|
yield variable.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is shallowOnly false or the prop reassigned
|
|
* @param {Exclude<ReturnType<typeof utils.findMutating>, null>} invalid
|
|
* @param {boolean} isRootProps
|
|
* @return {boolean}
|
|
*/
|
|
function isShallowOnlyInvalid(invalid, isRootProps) {
|
|
return (
|
|
!shallowOnly ||
|
|
(invalid.pathNodes.length === (isRootProps ? 1 : 0) &&
|
|
['assignment', 'update'].includes(invalid.kind))
|
|
)
|
|
}
|
|
|
|
return utils.compositingVisitors(
|
|
{},
|
|
utils.defineScriptSetupVisitor(context, {
|
|
onDefinePropsEnter(node, props) {
|
|
const defineVariableNames = new Set(extractDefineVariableNames())
|
|
|
|
const propsInfo = {
|
|
name: '',
|
|
set: new Set(
|
|
props
|
|
.map((p) => p.propName)
|
|
.filter(
|
|
/**
|
|
* @returns {propName is string}
|
|
*/
|
|
(propName) =>
|
|
utils.isDef(propName) &&
|
|
!GLOBALS_WHITE_LISTED.has(propName) &&
|
|
!defineVariableNames.has(propName)
|
|
)
|
|
)
|
|
}
|
|
propsMap.set(node, propsInfo)
|
|
vueObjectData = {
|
|
type: 'setup',
|
|
object: node
|
|
}
|
|
|
|
let target = node
|
|
if (
|
|
target.parent &&
|
|
target.parent.type === 'CallExpression' &&
|
|
target.parent.arguments[0] === target &&
|
|
target.parent.callee.type === 'Identifier' &&
|
|
target.parent.callee.name === 'withDefaults'
|
|
) {
|
|
target = target.parent
|
|
}
|
|
|
|
if (
|
|
!target.parent ||
|
|
target.parent.type !== 'VariableDeclarator' ||
|
|
target.parent.init !== target
|
|
) {
|
|
return
|
|
}
|
|
|
|
for (const { node: prop, path } of iteratePatternProperties(
|
|
target.parent.id,
|
|
[]
|
|
)) {
|
|
if (path.length === 0) {
|
|
propsInfo.name = prop.name
|
|
} else {
|
|
propsInfo.set.add(prop.name)
|
|
}
|
|
verifyPropVariable(prop, path)
|
|
}
|
|
}
|
|
}),
|
|
utils.defineVueVisitor(context, {
|
|
onVueObjectEnter(node) {
|
|
propsMap.set(node, {
|
|
set: new Set(
|
|
utils
|
|
.getComponentPropsFromOptions(node)
|
|
.map((p) => p.propName)
|
|
.filter(utils.isDef)
|
|
)
|
|
})
|
|
},
|
|
onVueObjectExit(node, { type }) {
|
|
if (
|
|
(!vueObjectData ||
|
|
(vueObjectData.type !== 'export' &&
|
|
vueObjectData.type !== 'setup')) &&
|
|
type !== 'instance'
|
|
) {
|
|
vueObjectData = {
|
|
type,
|
|
object: node
|
|
}
|
|
}
|
|
},
|
|
onSetupFunctionEnter(node) {
|
|
const propsParam = node.params[0]
|
|
if (!propsParam) {
|
|
// no arguments
|
|
return
|
|
}
|
|
if (
|
|
propsParam.type === 'RestElement' ||
|
|
propsParam.type === 'ArrayPattern'
|
|
) {
|
|
// cannot check
|
|
return
|
|
}
|
|
for (const { node: prop, path } of iteratePatternProperties(
|
|
propsParam,
|
|
[]
|
|
)) {
|
|
verifyPropVariable(prop, path)
|
|
}
|
|
},
|
|
/** @param {(Identifier | ThisExpression) & { parent: MemberExpression } } node */
|
|
'MemberExpression > :matches(Identifier, ThisExpression)'(
|
|
node,
|
|
{ node: vueNode }
|
|
) {
|
|
if (!utils.isThis(node, context)) {
|
|
return
|
|
}
|
|
const mem = node.parent
|
|
if (mem.object !== node) {
|
|
return
|
|
}
|
|
const name = utils.getStaticPropertyName(mem)
|
|
if (
|
|
name &&
|
|
/** @type {PropsInfo} */ (propsMap.get(vueNode)).set.has(name)
|
|
) {
|
|
verifyMutating(mem, name)
|
|
}
|
|
}
|
|
}),
|
|
utils.defineTemplateBodyVisitor(context, {
|
|
/** @param {ThisExpression & { parent: MemberExpression } } node */
|
|
'VExpressionContainer MemberExpression > ThisExpression'(node) {
|
|
if (!vueObjectData) {
|
|
return
|
|
}
|
|
const mem = node.parent
|
|
if (mem.object !== node) {
|
|
return
|
|
}
|
|
const name = utils.getStaticPropertyName(mem)
|
|
if (
|
|
name &&
|
|
/** @type {PropsInfo} */ (
|
|
propsMap.get(vueObjectData.object)
|
|
).set.has(name)
|
|
) {
|
|
verifyMutating(mem, name)
|
|
}
|
|
},
|
|
/** @param {Identifier } node */
|
|
'VExpressionContainer Identifier'(node) {
|
|
if (!vueObjectData) {
|
|
return
|
|
}
|
|
if (!isVmReference(node)) {
|
|
return
|
|
}
|
|
const propsInfo = /** @type {PropsInfo} */ (
|
|
propsMap.get(vueObjectData.object)
|
|
)
|
|
const isRootProps = !!node.name && propsInfo.name === node.name
|
|
const parent = node.parent
|
|
const name =
|
|
(isRootProps &&
|
|
parent.type === 'MemberExpression' &&
|
|
utils.getStaticPropertyName(parent)) ||
|
|
node.name
|
|
if (name && (propsInfo.set.has(name) || isRootProps)) {
|
|
verifyMutating(node, name, isRootProps)
|
|
}
|
|
},
|
|
/** @param {ESNode} node */
|
|
"VAttribute[directive=true]:matches([key.name.name='model'], [key.name.name='bind']) VExpressionContainer > *"(
|
|
node
|
|
) {
|
|
if (!vueObjectData) {
|
|
return
|
|
}
|
|
let attr = node.parent
|
|
while (attr && attr.type !== 'VAttribute') {
|
|
attr = attr.parent
|
|
}
|
|
if (
|
|
attr &&
|
|
attr.directive &&
|
|
attr.key.name.name === 'bind' &&
|
|
!attr.key.modifiers.some((mod) => mod.name === 'sync')
|
|
) {
|
|
return
|
|
}
|
|
|
|
const propsInfo = /** @type {PropsInfo} */ (
|
|
propsMap.get(vueObjectData.object)
|
|
)
|
|
|
|
const nodes = utils.getMemberChaining(node)
|
|
const first = nodes[0]
|
|
let name
|
|
if (isVmReference(first)) {
|
|
if (first.name === propsInfo.name) {
|
|
// props variable
|
|
if (shallowOnly && nodes.length > 2) {
|
|
return
|
|
}
|
|
name = (nodes[1] && getPropertyNameText(nodes[1])) || first.name
|
|
} else {
|
|
if (shallowOnly && nodes.length > 1) {
|
|
return
|
|
}
|
|
name = first.name
|
|
if (!name || !propsInfo.set.has(name)) {
|
|
return
|
|
}
|
|
}
|
|
} else if (first.type === 'ThisExpression') {
|
|
if (shallowOnly && nodes.length > 2) {
|
|
return
|
|
}
|
|
const mem = nodes[1]
|
|
if (!mem) {
|
|
return
|
|
}
|
|
name = utils.getStaticPropertyName(mem)
|
|
if (!name || !propsInfo.set.has(name)) {
|
|
return
|
|
}
|
|
} else {
|
|
return
|
|
}
|
|
report(node, name)
|
|
}
|
|
})
|
|
)
|
|
}
|
|
}
|