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.
372 lines
9.9 KiB
372 lines
9.9 KiB
/**
|
|
* @fileoverview Require `expose` in Vue components
|
|
* @author Yosuke Ota <https://github.com/ota-meshi>
|
|
*/
|
|
'use strict'
|
|
|
|
const {
|
|
findVariable,
|
|
isOpeningBraceToken,
|
|
isClosingBraceToken
|
|
} = require('@eslint-community/eslint-utils')
|
|
const utils = require('../utils')
|
|
const { getVueComponentDefinitionType } = require('../utils')
|
|
|
|
const FIX_EXPOSE_BEFORE_OPTIONS = new Set([
|
|
'name',
|
|
'components',
|
|
'directives',
|
|
'extends',
|
|
'mixins',
|
|
'provide',
|
|
'inject',
|
|
'inheritAttrs',
|
|
'props',
|
|
'emits'
|
|
])
|
|
|
|
/**
|
|
* @param {Property | SpreadElement} node
|
|
* @returns {node is ObjectExpressionProperty}
|
|
*/
|
|
function isExposeProperty(node) {
|
|
return (
|
|
node.type === 'Property' &&
|
|
utils.getStaticPropertyName(node) === 'expose' &&
|
|
!node.computed
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Get the callee member node from the given CallExpression
|
|
* @param {CallExpression} node CallExpression
|
|
*/
|
|
function getCalleeMemberNode(node) {
|
|
const callee = utils.skipChainExpression(node.callee)
|
|
|
|
if (callee.type === 'MemberExpression') {
|
|
const name = utils.getStaticPropertyName(callee)
|
|
if (name) {
|
|
return { name, member: callee }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'require declare public properties using `expose`',
|
|
categories: undefined,
|
|
url: 'https://eslint.vuejs.org/rules/require-expose.html'
|
|
},
|
|
fixable: null,
|
|
hasSuggestions: true,
|
|
schema: [],
|
|
messages: {
|
|
requireExpose:
|
|
'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
|
|
addExposeOptionForEmpty:
|
|
'Add the `expose` option to give an empty array.',
|
|
addExposeOptionForAll:
|
|
'Add the `expose` option to declare all properties.'
|
|
}
|
|
},
|
|
/** @param {RuleContext} context */
|
|
create(context) {
|
|
if (utils.isScriptSetup(context)) {
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} SetupContext
|
|
* @property {Set<Identifier>} exposeReferenceIds
|
|
* @property {Set<Identifier>} contextReferenceIds
|
|
*/
|
|
|
|
/** @type {Map<ObjectExpression, SetupContext>} */
|
|
const setupContexts = new Map()
|
|
/** @type {Set<ObjectExpression>} */
|
|
const calledExpose = new Set()
|
|
|
|
/**
|
|
* @typedef {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} FunctionNode
|
|
*/
|
|
/**
|
|
* @typedef {object} ScopeStack
|
|
* @property {ScopeStack | null} upper
|
|
* @property {FunctionNode} functionNode
|
|
* @property {boolean} returnFunction
|
|
*/
|
|
/**
|
|
* @type {ScopeStack | null}
|
|
*/
|
|
let scopeStack = null
|
|
/** @type {Map<FunctionNode, ObjectExpression>} */
|
|
const setupFunctions = new Map()
|
|
/** @type {Set<ObjectExpression>} */
|
|
const setupRender = new Set()
|
|
|
|
/**
|
|
* @param {Expression} node
|
|
* @returns {boolean}
|
|
*/
|
|
function isFunction(node) {
|
|
if (
|
|
node.type === 'ArrowFunctionExpression' ||
|
|
node.type === 'FunctionExpression'
|
|
) {
|
|
return true
|
|
}
|
|
if (node.type === 'Identifier') {
|
|
const variable = findVariable(utils.getScope(context, node), node)
|
|
if (variable) {
|
|
for (const def of variable.defs) {
|
|
if (def.type === 'FunctionName') {
|
|
return true
|
|
}
|
|
if (def.type === 'Variable' && def.node.init) {
|
|
return isFunction(def.node.init)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return utils.defineVueVisitor(context, {
|
|
onSetupFunctionEnter(node, { node: vueNode }) {
|
|
setupFunctions.set(node, vueNode)
|
|
const contextParam = node.params[1]
|
|
if (!contextParam) {
|
|
// no arguments
|
|
return
|
|
}
|
|
if (contextParam.type === 'RestElement') {
|
|
// cannot check
|
|
return
|
|
}
|
|
if (contextParam.type === 'ArrayPattern') {
|
|
// cannot check
|
|
return
|
|
}
|
|
/** @type {Set<Identifier>} */
|
|
const contextReferenceIds = new Set()
|
|
/** @type {Set<Identifier>} */
|
|
const exposeReferenceIds = new Set()
|
|
if (contextParam.type === 'ObjectPattern') {
|
|
const exposeProperty = utils.findAssignmentProperty(
|
|
contextParam,
|
|
'expose'
|
|
)
|
|
if (!exposeProperty) {
|
|
return
|
|
}
|
|
const exposeParam = exposeProperty.value
|
|
// `setup(props, {emit})`
|
|
const variable =
|
|
exposeParam.type === 'Identifier'
|
|
? findVariable(utils.getScope(context, exposeParam), exposeParam)
|
|
: null
|
|
if (!variable) {
|
|
return
|
|
}
|
|
for (const reference of variable.references) {
|
|
if (!reference.isRead()) {
|
|
continue
|
|
}
|
|
exposeReferenceIds.add(reference.identifier)
|
|
}
|
|
} else if (contextParam.type === 'Identifier') {
|
|
// `setup(props, context)`
|
|
const variable = findVariable(
|
|
utils.getScope(context, contextParam),
|
|
contextParam
|
|
)
|
|
if (!variable) {
|
|
return
|
|
}
|
|
for (const reference of variable.references) {
|
|
if (!reference.isRead()) {
|
|
continue
|
|
}
|
|
contextReferenceIds.add(reference.identifier)
|
|
}
|
|
}
|
|
setupContexts.set(vueNode, {
|
|
contextReferenceIds,
|
|
exposeReferenceIds
|
|
})
|
|
},
|
|
CallExpression(node, { node: vueNode }) {
|
|
if (calledExpose.has(vueNode)) {
|
|
// already called
|
|
return
|
|
}
|
|
// find setup context
|
|
const setupContext = setupContexts.get(vueNode)
|
|
if (setupContext) {
|
|
const { contextReferenceIds, exposeReferenceIds } = setupContext
|
|
if (
|
|
node.callee.type === 'Identifier' &&
|
|
exposeReferenceIds.has(node.callee)
|
|
) {
|
|
// setup(props,{expose}) {expose()}
|
|
calledExpose.add(vueNode)
|
|
} else {
|
|
const expose = getCalleeMemberNode(node)
|
|
if (
|
|
expose &&
|
|
expose.name === 'expose' &&
|
|
expose.member.object.type === 'Identifier' &&
|
|
contextReferenceIds.has(expose.member.object)
|
|
) {
|
|
// setup(props,context) {context.emit()}
|
|
calledExpose.add(vueNode)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
/** @param {FunctionNode} node */
|
|
':function'(node) {
|
|
scopeStack = {
|
|
upper: scopeStack,
|
|
functionNode: node,
|
|
returnFunction: false
|
|
}
|
|
|
|
if (
|
|
node.type === 'ArrowFunctionExpression' &&
|
|
node.expression &&
|
|
isFunction(node.body)
|
|
) {
|
|
scopeStack.returnFunction = true
|
|
}
|
|
},
|
|
ReturnStatement(node) {
|
|
if (!scopeStack) {
|
|
return
|
|
}
|
|
if (
|
|
!scopeStack.returnFunction &&
|
|
node.argument &&
|
|
isFunction(node.argument)
|
|
) {
|
|
scopeStack.returnFunction = true
|
|
}
|
|
},
|
|
':function:exit'(node) {
|
|
if (scopeStack && scopeStack.returnFunction) {
|
|
const vueNode = setupFunctions.get(node)
|
|
if (vueNode) {
|
|
setupRender.add(vueNode)
|
|
}
|
|
}
|
|
scopeStack = scopeStack && scopeStack.upper
|
|
},
|
|
onVueObjectExit(component, { type }) {
|
|
if (calledExpose.has(component)) {
|
|
// `expose` function is called
|
|
return
|
|
}
|
|
if (setupRender.has(component)) {
|
|
// `setup` function is render function
|
|
return
|
|
}
|
|
if (type === 'definition') {
|
|
const defType = getVueComponentDefinitionType(component)
|
|
if (defType === 'mixin') {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (component.properties.some(isExposeProperty)) {
|
|
// has `expose`
|
|
return
|
|
}
|
|
|
|
context.report({
|
|
node: component,
|
|
messageId: 'requireExpose',
|
|
suggest: buildSuggest(component, context)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {ObjectExpression} object
|
|
* @param {RuleContext} context
|
|
* @returns {Rule.SuggestionReportDescriptor[]}
|
|
*/
|
|
function buildSuggest(object, context) {
|
|
const propertyNodes = object.properties.filter(utils.isProperty)
|
|
|
|
const sourceCode = context.getSourceCode()
|
|
const beforeOptionNode = propertyNodes.find((p) =>
|
|
FIX_EXPOSE_BEFORE_OPTIONS.has(utils.getStaticPropertyName(p) || '')
|
|
)
|
|
const allProps = [
|
|
...new Set(
|
|
utils.iterateProperties(
|
|
object,
|
|
new Set(['props', 'data', 'computed', 'setup', 'methods', 'watch'])
|
|
)
|
|
)
|
|
]
|
|
return [
|
|
{
|
|
messageId: 'addExposeOptionForEmpty',
|
|
fix: buildFix('expose: []')
|
|
},
|
|
...(allProps.length > 0
|
|
? [
|
|
{
|
|
messageId: 'addExposeOptionForAll',
|
|
fix: buildFix(
|
|
`expose: [${allProps
|
|
.map((p) => JSON.stringify(p.name))
|
|
.join(', ')}]`
|
|
)
|
|
}
|
|
]
|
|
: [])
|
|
]
|
|
|
|
/**
|
|
* @param {string} text
|
|
*/
|
|
function buildFix(text) {
|
|
/**
|
|
* @param {RuleFixer} fixer
|
|
*/
|
|
return (fixer) => {
|
|
if (beforeOptionNode) {
|
|
return fixer.insertTextAfter(beforeOptionNode, `,\n${text}`)
|
|
} else if (object.properties.length > 0) {
|
|
const after = propertyNodes[0] || object.properties[0]
|
|
return fixer.insertTextAfter(
|
|
sourceCode.getTokenBefore(after),
|
|
`\n${text},`
|
|
)
|
|
} else {
|
|
const objectLeftBrace = /** @type {Token} */ (
|
|
sourceCode.getFirstToken(object, isOpeningBraceToken)
|
|
)
|
|
const objectRightBrace = /** @type {Token} */ (
|
|
sourceCode.getLastToken(object, isClosingBraceToken)
|
|
)
|
|
return fixer.insertTextAfter(
|
|
objectLeftBrace,
|
|
`\n${text}${
|
|
objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
|
|
? ''
|
|
: '\n'
|
|
}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|