From a4e10f79f6efba0cfa928dfb3a52718b872b8198 Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Thu, 15 Jul 2021 16:19:20 +0900 Subject: [PATCH] Add `vue/require-expose` rule --- docs/rules/README.md | 1 + docs/rules/require-expose.md | 120 +++++++++ lib/index.js | 1 + lib/rules/require-explicit-emits.js | 47 +--- lib/rules/require-expose.js | 370 +++++++++++++++++++++++++++ lib/rules/require-render-return.js | 2 +- lib/utils/index.js | 43 ++-- tests/lib/rules/require-expose.js | 384 ++++++++++++++++++++++++++++ 8 files changed, 916 insertions(+), 52 deletions(-) create mode 100644 docs/rules/require-expose.md create mode 100644 lib/rules/require-expose.js create mode 100644 tests/lib/rules/require-expose.js 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 b697a0d6b..9faec74c0 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: ` + + ` + } + ] + } + ] + } + ] +})