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.
538 lines
16 KiB
538 lines
16 KiB
/** |
|
* @fileoverview Disallow undefined properties. |
|
* @author Yosuke Ota |
|
*/ |
|
'use strict' |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Requirements |
|
// ------------------------------------------------------------------------------ |
|
|
|
const utils = require('../utils') |
|
const reserved = require('../utils/vue-reserved.json') |
|
const { toRegExp } = require('../utils/regexp') |
|
const { getStyleVariablesContext } = require('../utils/style-variables') |
|
const { |
|
definePropertyReferenceExtractor |
|
} = require('../utils/property-references') |
|
|
|
/** |
|
* @typedef {import('../utils').VueObjectData} VueObjectData |
|
* @typedef {import('../utils/property-references').IPropertyReferences} IPropertyReferences |
|
*/ |
|
/** |
|
* @typedef {object} PropertyData |
|
* @property {boolean} [hasNestProperty] |
|
* @property { (name: string) => PropertyData | null } [get] |
|
* @property {boolean} [isProps] |
|
*/ |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Helpers |
|
// ------------------------------------------------------------------------------ |
|
|
|
const GROUP_PROPERTY = 'props' |
|
const GROUP_ASYNC_DATA = 'asyncData' // Nuxt.js |
|
const GROUP_DATA = 'data' |
|
const GROUP_COMPUTED_PROPERTY = 'computed' |
|
const GROUP_METHODS = 'methods' |
|
const GROUP_SETUP = 'setup' |
|
const GROUP_WATCHER = 'watch' |
|
const GROUP_EXPOSE = 'expose' |
|
const GROUP_INJECT = 'inject' |
|
|
|
/** |
|
* @param {ObjectExpression} object |
|
* @returns {Map<string, Property> | null} |
|
*/ |
|
function getObjectPropertyMap(object) { |
|
/** @type {Map<string, Property>} */ |
|
const props = new Map() |
|
for (const p of object.properties) { |
|
if (p.type !== 'Property') { |
|
return null |
|
} |
|
const name = utils.getStaticPropertyName(p) |
|
if (name == null) { |
|
return null |
|
} |
|
props.set(name, p) |
|
} |
|
return props |
|
} |
|
|
|
/** |
|
* @param {Property | undefined} property |
|
* @returns {PropertyData | null} |
|
*/ |
|
function getPropertyDataFromObjectProperty(property) { |
|
if (property == null) { |
|
return null |
|
} |
|
const propertyMap = |
|
property.value.type === 'ObjectExpression' |
|
? getObjectPropertyMap(property.value) |
|
: null |
|
return { |
|
hasNestProperty: Boolean(propertyMap), |
|
get(name) { |
|
if (!propertyMap) { |
|
return null |
|
} |
|
return getPropertyDataFromObjectProperty(propertyMap.get(name)) |
|
} |
|
} |
|
} |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Rule Definition |
|
// ------------------------------------------------------------------------------ |
|
|
|
module.exports = { |
|
meta: { |
|
type: 'suggestion', |
|
docs: { |
|
description: 'disallow undefined properties', |
|
categories: undefined, |
|
url: 'https://eslint.vuejs.org/rules/no-undef-properties.html' |
|
}, |
|
fixable: null, |
|
schema: [ |
|
{ |
|
type: 'object', |
|
properties: { |
|
ignores: { |
|
type: 'array', |
|
items: { type: 'string' }, |
|
uniqueItems: true |
|
} |
|
}, |
|
additionalProperties: false |
|
} |
|
], |
|
messages: { |
|
undef: "'{{name}}' is not defined.", |
|
undefProps: "'{{name}}' is not defined in props." |
|
} |
|
}, |
|
/** @param {RuleContext} context */ |
|
create(context) { |
|
const options = context.options[0] || {} |
|
const ignores = /** @type {string[]} */ (options.ignores || ['/^\\$/']).map( |
|
toRegExp |
|
) |
|
const propertyReferenceExtractor = definePropertyReferenceExtractor(context) |
|
const programNode = context.getSourceCode().ast |
|
|
|
/** Vue component context */ |
|
class VueComponentContext { |
|
constructor() { |
|
/** @type { Map<string, PropertyData> } */ |
|
this.defineProperties = new Map() |
|
|
|
/** @type { Set<string | ASTNode> } */ |
|
this.reported = new Set() |
|
} |
|
/** |
|
* Report |
|
* @param {IPropertyReferences} references |
|
* @param {object} [options] |
|
* @param {boolean} [options.props] |
|
*/ |
|
verifyReferences(references, options) { |
|
const that = this |
|
verifyUndefProperties(this.defineProperties, references, null) |
|
|
|
/** |
|
* @param { { get?: (name: string) => PropertyData | null | undefined } } defineProperties |
|
* @param {IPropertyReferences|null} references |
|
* @param {string|null} pathName |
|
*/ |
|
function verifyUndefProperties(defineProperties, references, pathName) { |
|
if (!references) { |
|
return |
|
} |
|
for (const [refName, { nodes }] of references.allProperties()) { |
|
const referencePathName = pathName |
|
? `${pathName}.${refName}` |
|
: refName |
|
|
|
const prop = defineProperties.get && defineProperties.get(refName) |
|
if (prop) { |
|
if (options && options.props) { |
|
if (!prop.isProps) { |
|
that.report(nodes[0], referencePathName, 'undefProps') |
|
continue |
|
} |
|
} |
|
} else { |
|
that.report(nodes[0], referencePathName, 'undef') |
|
continue |
|
} |
|
|
|
if (prop.hasNestProperty) { |
|
verifyUndefProperties( |
|
prop, |
|
references.getNest(refName), |
|
referencePathName |
|
) |
|
} |
|
} |
|
} |
|
} |
|
/** |
|
* Report |
|
* @param {ASTNode} node |
|
* @param {string} name |
|
* @param {'undef' | 'undefProps'} messageId |
|
*/ |
|
report(node, name, messageId = 'undef') { |
|
if ( |
|
reserved.includes(name) || |
|
ignores.some((ignore) => ignore.test(name)) |
|
) { |
|
return |
|
} |
|
if ( |
|
// Prevents reporting to the same node. |
|
this.reported.has(node) || |
|
// Prevents reports with the same name. |
|
// This is so that intentional undefined properties can be resolved with |
|
// a single warning suppression comment (`// eslint-disable-line`). |
|
this.reported.has(name) |
|
) { |
|
return |
|
} |
|
this.reported.add(node) |
|
this.reported.add(name) |
|
context.report({ |
|
node, |
|
messageId, |
|
data: { |
|
name |
|
} |
|
}) |
|
} |
|
} |
|
|
|
/** @type {Map<ASTNode, VueComponentContext>} */ |
|
const vueComponentContextMap = new Map() |
|
|
|
/** |
|
* @param {ASTNode} node |
|
* @returns {VueComponentContext} |
|
*/ |
|
function getVueComponentContext(node) { |
|
let ctx = vueComponentContextMap.get(node) |
|
if (!ctx) { |
|
ctx = new VueComponentContext() |
|
vueComponentContextMap.set(node, ctx) |
|
} |
|
return ctx |
|
} |
|
/** |
|
* @returns {VueComponentContext|void} |
|
*/ |
|
function getVueComponentContextForTemplate() { |
|
const keys = [...vueComponentContextMap.keys()] |
|
const exported = |
|
keys.find(isScriptSetupProgram) || keys.find(utils.isInExportDefault) |
|
return exported && vueComponentContextMap.get(exported) |
|
|
|
/** |
|
* @param {ASTNode} node |
|
*/ |
|
function isScriptSetupProgram(node) { |
|
return node === programNode |
|
} |
|
} |
|
|
|
/** |
|
* @param {Expression} node |
|
* @returns {Property|null} |
|
*/ |
|
function getParentProperty(node) { |
|
if ( |
|
!node.parent || |
|
node.parent.type !== 'Property' || |
|
node.parent.value !== node |
|
) { |
|
return null |
|
} |
|
const property = node.parent |
|
if (!utils.isProperty(property)) { |
|
return null |
|
} |
|
return property |
|
} |
|
|
|
const scriptVisitor = utils.compositingVisitors( |
|
{ |
|
/** @param {Program} node */ |
|
Program() { |
|
if (!utils.isScriptSetup(context)) { |
|
return |
|
} |
|
|
|
const ctx = getVueComponentContext(programNode) |
|
const globalScope = context.getSourceCode().scopeManager.globalScope |
|
if (globalScope) { |
|
for (const variable of globalScope.variables) { |
|
ctx.defineProperties.set(variable.name, {}) |
|
} |
|
const moduleScope = globalScope.childScopes.find( |
|
(scope) => scope.type === 'module' |
|
) |
|
for (const variable of (moduleScope && moduleScope.variables) || |
|
[]) { |
|
ctx.defineProperties.set(variable.name, {}) |
|
} |
|
} |
|
} |
|
}, |
|
utils.defineScriptSetupVisitor(context, { |
|
onDefinePropsEnter(node, props) { |
|
const ctx = getVueComponentContext(programNode) |
|
|
|
for (const prop of props) { |
|
if (!prop.propName) { |
|
continue |
|
} |
|
ctx.defineProperties.set(prop.propName, { |
|
isProps: true |
|
}) |
|
} |
|
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 |
|
} |
|
|
|
const pattern = target.parent.id |
|
const propertyReferences = |
|
propertyReferenceExtractor.extractFromPattern(pattern) |
|
ctx.verifyReferences(propertyReferences) |
|
} |
|
}), |
|
utils.defineVueVisitor(context, { |
|
onVueObjectEnter(node) { |
|
const ctx = getVueComponentContext(node) |
|
|
|
for (const prop of utils.iterateProperties( |
|
node, |
|
new Set([ |
|
GROUP_PROPERTY, |
|
GROUP_ASYNC_DATA, |
|
GROUP_DATA, |
|
GROUP_COMPUTED_PROPERTY, |
|
GROUP_SETUP, |
|
GROUP_METHODS, |
|
GROUP_INJECT |
|
]) |
|
)) { |
|
const propertyMap = |
|
(prop.groupName === GROUP_DATA || |
|
prop.groupName === GROUP_ASYNC_DATA) && |
|
prop.type === 'object' && |
|
prop.property.value.type === 'ObjectExpression' |
|
? getObjectPropertyMap(prop.property.value) |
|
: null |
|
ctx.defineProperties.set(prop.name, { |
|
hasNestProperty: Boolean(propertyMap), |
|
isProps: prop.groupName === GROUP_PROPERTY, |
|
get(name) { |
|
if (!propertyMap) { |
|
return null |
|
} |
|
return getPropertyDataFromObjectProperty(propertyMap.get(name)) |
|
} |
|
}) |
|
} |
|
|
|
for (const watcherOrExpose of utils.iterateProperties( |
|
node, |
|
new Set([GROUP_WATCHER, GROUP_EXPOSE]) |
|
)) { |
|
if (watcherOrExpose.groupName === GROUP_WATCHER) { |
|
const watcher = watcherOrExpose |
|
// Process `watch: { foo /* <- this */ () {} }` |
|
ctx.verifyReferences( |
|
propertyReferenceExtractor.extractFromPath( |
|
watcher.name, |
|
watcher.node |
|
) |
|
) |
|
// Process `watch: { x: 'foo' /* <- this */ }` |
|
if (watcher.type === 'object') { |
|
const property = watcher.property |
|
if (property.kind === 'init') { |
|
for (const handlerValueNode of utils.iterateWatchHandlerValues( |
|
property |
|
)) { |
|
ctx.verifyReferences( |
|
propertyReferenceExtractor.extractFromNameLiteral( |
|
handlerValueNode |
|
) |
|
) |
|
} |
|
} |
|
} |
|
} else if (watcherOrExpose.groupName === GROUP_EXPOSE) { |
|
const expose = watcherOrExpose |
|
ctx.verifyReferences( |
|
propertyReferenceExtractor.extractFromName( |
|
expose.name, |
|
expose.node |
|
) |
|
) |
|
} |
|
} |
|
}, |
|
/** @param { (FunctionExpression | ArrowFunctionExpression) & { parent: Property }} node */ |
|
'ObjectExpression > Property > :function[params.length>0]'( |
|
node, |
|
vueData |
|
) { |
|
let props = false |
|
const property = getParentProperty(node) |
|
if (!property) { |
|
return |
|
} |
|
if (property.parent === vueData.node) { |
|
if (utils.getStaticPropertyName(property) !== 'data') { |
|
return |
|
} |
|
// check { data: (vm) => vm.prop } |
|
props = true |
|
} else { |
|
const parentProperty = getParentProperty(property.parent) |
|
if (!parentProperty) { |
|
return |
|
} |
|
if (parentProperty.parent === vueData.node) { |
|
if (utils.getStaticPropertyName(parentProperty) !== 'computed') { |
|
return |
|
} |
|
// check { computed: { foo: (vm) => vm.prop } } |
|
} else { |
|
const parentParentProperty = getParentProperty( |
|
parentProperty.parent |
|
) |
|
if (!parentParentProperty) { |
|
return |
|
} |
|
if (parentParentProperty.parent === vueData.node) { |
|
if ( |
|
utils.getStaticPropertyName(parentParentProperty) !== |
|
'computed' || |
|
utils.getStaticPropertyName(property) !== 'get' |
|
) { |
|
return |
|
} |
|
// check { computed: { foo: { get: (vm) => vm.prop } } } |
|
} else { |
|
return |
|
} |
|
} |
|
} |
|
|
|
const propertyReferences = |
|
propertyReferenceExtractor.extractFromFunctionParam(node, 0) |
|
const ctx = getVueComponentContext(vueData.node) |
|
ctx.verifyReferences(propertyReferences, { props }) |
|
}, |
|
onSetupFunctionEnter(node, vueData) { |
|
const propertyReferences = |
|
propertyReferenceExtractor.extractFromFunctionParam(node, 0) |
|
const ctx = getVueComponentContext(vueData.node) |
|
ctx.verifyReferences(propertyReferences, { |
|
props: true |
|
}) |
|
}, |
|
onRenderFunctionEnter(node, vueData) { |
|
const ctx = getVueComponentContext(vueData.node) |
|
|
|
// Check for Vue 3.x render |
|
const propertyReferences = |
|
propertyReferenceExtractor.extractFromFunctionParam(node, 0) |
|
ctx.verifyReferences(propertyReferences) |
|
|
|
if (vueData.functional) { |
|
// Check for Vue 2.x render & functional |
|
const propertyReferencesForV2 = |
|
propertyReferenceExtractor.extractFromFunctionParam(node, 1) |
|
|
|
ctx.verifyReferences(propertyReferencesForV2.getNest('props'), { |
|
props: true |
|
}) |
|
} |
|
}, |
|
/** |
|
* @param {ThisExpression | Identifier} node |
|
* @param {VueObjectData} vueData |
|
*/ |
|
'ThisExpression, Identifier'(node, vueData) { |
|
if (!utils.isThis(node, context)) { |
|
return |
|
} |
|
const ctx = getVueComponentContext(vueData.node) |
|
const propertyReferences = |
|
propertyReferenceExtractor.extractFromExpression(node, false) |
|
ctx.verifyReferences(propertyReferences) |
|
} |
|
}), |
|
{ |
|
'Program:exit'() { |
|
const ctx = getVueComponentContextForTemplate() |
|
if (!ctx) { |
|
return |
|
} |
|
const styleVars = getStyleVariablesContext(context) |
|
if (styleVars) { |
|
ctx.verifyReferences( |
|
propertyReferenceExtractor.extractFromStyleVariablesContext( |
|
styleVars |
|
) |
|
) |
|
} |
|
} |
|
} |
|
) |
|
|
|
const templateVisitor = { |
|
/** |
|
* @param {VExpressionContainer} node |
|
*/ |
|
VExpressionContainer(node) { |
|
const ctx = getVueComponentContextForTemplate() |
|
if (!ctx) { |
|
return |
|
} |
|
ctx.verifyReferences( |
|
propertyReferenceExtractor.extractFromVExpressionContainer(node, { |
|
ignoreGlobals: true |
|
}) |
|
) |
|
} |
|
} |
|
|
|
return utils.defineTemplateBodyVisitor( |
|
context, |
|
templateVisitor, |
|
scriptVisitor |
|
) |
|
} |
|
}
|
|
|