diff --git a/docs/rules/README.md b/docs/rules/README.md
index 3577668f4..59e013e24 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -325,6 +325,7 @@ For example:
| [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
| [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | |
| [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | |
+| [vue/require-expose](./require-expose.md) | require declare public properties using `expose` | |
| [vue/require-name-property](./require-name-property.md) | require a name property in Vue components | |
| [vue/script-indent](./script-indent.md) | enforce consistent indentation in `
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [Vue RFCs - 0042-expose-api](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0042-expose-api.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-expose.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-expose.js)
diff --git a/lib/index.js b/lib/index.js
index 8bf6cad45..752a52ddb 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -145,6 +145,7 @@ module.exports = {
'require-direct-export': require('./rules/require-direct-export'),
'require-emit-validator': require('./rules/require-emit-validator'),
'require-explicit-emits': require('./rules/require-explicit-emits'),
+ 'require-expose': require('./rules/require-expose'),
'require-name-property': require('./rules/require-name-property'),
'require-prop-type-constructor': require('./rules/require-prop-type-constructor'),
'require-prop-types': require('./rules/require-prop-types'),
diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js
index 34b557b4b..30371946e 100644
--- a/lib/rules/require-explicit-emits.js
+++ b/lib/rules/require-explicit-emits.js
@@ -18,7 +18,12 @@
// Requirements
// ------------------------------------------------------------------------------
-const { findVariable } = require('eslint-utils')
+const {
+ findVariable,
+ isOpeningBraceToken,
+ isClosingBraceToken,
+ isOpeningBracketToken
+} = require('eslint-utils')
const utils = require('../utils')
const { capitalize } = require('../utils/casing')
@@ -53,34 +58,6 @@ const FIX_EMITS_AFTER_OPTIONS = [
'renderTriggered',
'errorCaptured'
]
-
-/**
- * Check whether the given token is a left brace.
- * @param {Token} token The token to check.
- * @returns {boolean} `true` if the token is a left brace.
- */
-function isLeftBrace(token) {
- return token != null && token.type === 'Punctuator' && token.value === '{'
-}
-
-/**
- * Check whether the given token is a right brace.
- * @param {Token} token The token to check.
- * @returns {boolean} `true` if the token is a right brace.
- */
-function isRightBrace(token) {
- return token != null && token.type === 'Punctuator' && token.value === '}'
-}
-
-/**
- * Check whether the given token is a left bracket.
- * @param {Token} token The token to check.
- * @returns {boolean} `true` if the token is a left bracket.
- */
-function isLeftBracket(token) {
- return token != null && token.type === 'Punctuator' && token.value === '['
-}
-
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
@@ -486,7 +463,7 @@ function buildSuggest(define, emits, nameNode, context) {
const emitsOptionValue = emitsOption.value
if (emitsOptionValue.type === 'ArrayExpression') {
const leftBracket = /** @type {Token} */ (
- sourceCode.getFirstToken(emitsOptionValue, isLeftBracket)
+ sourceCode.getFirstToken(emitsOptionValue, isOpeningBracketToken)
)
return [
{
@@ -504,7 +481,7 @@ function buildSuggest(define, emits, nameNode, context) {
]
} else if (emitsOptionValue.type === 'ObjectExpression') {
const leftBrace = /** @type {Token} */ (
- sourceCode.getFirstToken(emitsOptionValue, isLeftBrace)
+ sourceCode.getFirstToken(emitsOptionValue, isOpeningBraceToken)
)
return [
{
@@ -548,10 +525,10 @@ function buildSuggest(define, emits, nameNode, context) {
)
} else {
const objectLeftBrace = /** @type {Token} */ (
- sourceCode.getFirstToken(object, isLeftBrace)
+ sourceCode.getFirstToken(object, isOpeningBraceToken)
)
const objectRightBrace = /** @type {Token} */ (
- sourceCode.getLastToken(object, isRightBrace)
+ sourceCode.getLastToken(object, isClosingBraceToken)
)
return fixer.insertTextAfter(
objectLeftBrace,
@@ -583,10 +560,10 @@ function buildSuggest(define, emits, nameNode, context) {
)
} else {
const objectLeftBrace = /** @type {Token} */ (
- sourceCode.getFirstToken(object, isLeftBrace)
+ sourceCode.getFirstToken(object, isOpeningBraceToken)
)
const objectRightBrace = /** @type {Token} */ (
- sourceCode.getLastToken(object, isRightBrace)
+ sourceCode.getLastToken(object, isClosingBraceToken)
)
return fixer.insertTextAfter(
objectLeftBrace,
diff --git a/lib/rules/require-expose.js b/lib/rules/require-expose.js
new file mode 100644
index 000000000..02c3a4d0b
--- /dev/null
+++ b/lib/rules/require-expose.js
@@ -0,0 +1,370 @@
+/**
+ * @fileoverview Require `expose` in Vue components
+ * @author Yosuke Ota
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const {
+ findVariable,
+ isOpeningBraceToken,
+ isClosingBraceToken
+} = require('eslint-utils')
+const utils = require('../utils')
+const { getVueComponentDefinitionType } = require('../utils')
+
+const FIX_EXPOSE_BEFORE_OPTIONS = [
+ '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,
+ 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} exposeReferenceIds
+ * @property {Set} contextReferenceIds
+ */
+
+ /** @type {Map} */
+ const setupContexts = new Map()
+ /** @type {Set} */
+ 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} */
+ const setupFunctions = new Map()
+ /** @type {Set} */
+ 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(context.getScope(), node)
+ if (variable) {
+ for (const def of variable.defs) {
+ if (def.type === 'FunctionName') {
+ return true
+ }
+ if (def.type === 'Variable') {
+ if (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} */
+ const contextReferenceIds = new Set()
+ /** @type {Set} */
+ 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(context.getScope(), 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(context.getScope(), 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) {
+ if (isFunction(node.body)) {
+ scopeStack.returnFunction = true
+ }
+ }
+ },
+ ReturnStatement(node) {
+ if (!scopeStack) {
+ return
+ }
+ if (!scopeStack.returnFunction && node.argument) {
+ if (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.includes(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
+ ? [
+ {
+ 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) {
+ 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'
+ }`
+ )
+ }
+ }
+ }
+}
diff --git a/lib/rules/require-render-return.js b/lib/rules/require-render-return.js
index 8d3b2096e..8350f9209 100644
--- a/lib/rules/require-render-return.js
+++ b/lib/rules/require-render-return.js
@@ -33,7 +33,7 @@ module.exports = {
return utils.compositingVisitors(
utils.defineVueVisitor(context, {
onRenderFunctionEnter(node) {
- renderFunctions.set(node.parent.value, node.parent.key)
+ renderFunctions.set(node, node.parent.key)
}
}),
utils.executeOnFunctionsWithoutReturn(true, (node) => {
diff --git a/lib/utils/index.js b/lib/utils/index.js
index 7ad21ec4c..b0c189592 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -1414,21 +1414,21 @@ module.exports = {
/**
* Find all functions which do not always return values
* @param {boolean} treatUndefinedAsUnspecified
- * @param { (node: ESNode) => void } cb Callback function
+ * @param { (node: ArrowFunctionExpression | FunctionExpression | FunctionDeclaration) => void } cb Callback function
* @returns {RuleListener}
*/
executeOnFunctionsWithoutReturn(treatUndefinedAsUnspecified, cb) {
/**
* @typedef {object} FuncInfo
- * @property {FuncInfo} funcInfo
+ * @property {FuncInfo | null} funcInfo
* @property {CodePath} codePath
* @property {boolean} hasReturn
* @property {boolean} hasReturnValue
- * @property {ESNode} node
+ * @property {ArrowFunctionExpression | FunctionExpression | FunctionDeclaration} node
*/
- /** @type {FuncInfo} */
- let funcInfo
+ /** @type {FuncInfo | null} */
+ let funcInfo = null
/** @param {CodePathSegment} segment */
function isReachable(segment) {
@@ -1436,6 +1436,9 @@ module.exports = {
}
function isValidReturn() {
+ if (!funcInfo) {
+ return true
+ }
if (
funcInfo.codePath &&
funcInfo.codePath.currentSegments.some(isReachable)
@@ -1451,30 +1454,38 @@ module.exports = {
* @param {ESNode} node
*/
onCodePathStart(codePath, node) {
- funcInfo = {
- codePath,
- funcInfo,
- hasReturn: false,
- hasReturnValue: false,
- node
+ if (
+ node.type === 'ArrowFunctionExpression' ||
+ node.type === 'FunctionExpression' ||
+ node.type === 'FunctionDeclaration'
+ ) {
+ funcInfo = {
+ codePath,
+ funcInfo,
+ hasReturn: false,
+ hasReturnValue: false,
+ node
+ }
}
},
onCodePathEnd() {
- funcInfo = funcInfo.funcInfo
+ funcInfo = funcInfo && funcInfo.funcInfo
},
/** @param {ReturnStatement} node */
ReturnStatement(node) {
- funcInfo.hasReturn = true
- funcInfo.hasReturnValue = Boolean(node.argument)
+ if (funcInfo) {
+ funcInfo.hasReturn = true
+ funcInfo.hasReturnValue = Boolean(node.argument)
+ }
},
/** @param {ArrowFunctionExpression} node */
'ArrowFunctionExpression:exit'(node) {
- if (!isValidReturn() && !node.expression) {
+ if (funcInfo && !isValidReturn() && !node.expression) {
cb(funcInfo.node)
}
},
'FunctionExpression:exit'() {
- if (!isValidReturn()) {
+ if (funcInfo && !isValidReturn()) {
cb(funcInfo.node)
}
}
diff --git a/tests/lib/rules/require-expose.js b/tests/lib/rules/require-expose.js
new file mode 100644
index 000000000..ca78e55b8
--- /dev/null
+++ b/tests/lib/rules/require-expose.js
@@ -0,0 +1,384 @@
+/**
+ * @fileoverview Require `expose` in Vue components
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const rule = require('../../../lib/rules/require-expose')
+const RuleTester = require('eslint').RuleTester
+
+const tester = new RuleTester({
+ parser: require.resolve('vue-eslint-parser'),
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module'
+ }
+})
+
+tester.run('require-expose', rule, {
+ valid: [
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ code: `
+ Vue.mixin({
+ methods: {
+ foo () {}
+ }
+ })
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `
+ }
+ ],
+
+ invalid: [
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ message:
+ 'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
+ suggestions: [
+ {
+ desc: 'Add the `expose` option to give an empty array.',
+ output: `
+
+ `
+ },
+ {
+ desc: 'Add the `expose` option to declare all properties.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ message:
+ 'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
+ suggestions: [
+ {
+ desc: 'Add the `expose` option to give an empty array.',
+ output: `
+
+ `
+ },
+ {
+ desc: 'Add the `expose` option to declare all properties.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ suggestions: [
+ {
+ desc: 'Add the `expose` option to give an empty array.',
+ output: `
+
+ `
+ },
+ {
+ desc: 'Add the `expose` option to declare all properties.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ suggestions: [
+ {
+ desc: 'Add the `expose` option to give an empty array.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'ValidComponent.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ suggestions: [
+ {
+ desc: 'Add the `expose` option to give an empty array.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ }
+ ]
+})