From 618f49c147490f813a3460c86db79b6ccdc19e9c Mon Sep 17 00:00:00 2001 From: Joe Ellis Date: Wed, 27 Nov 2024 10:43:27 +0000 Subject: [PATCH] fix(require-explicit-slots): add support for type references (#2617) --- lib/rules/require-explicit-slots.js | 40 +- lib/utils/index.js | 30 +- lib/utils/ts-utils/index.js | 39 +- lib/utils/ts-utils/ts-ast.js | 37 +- lib/utils/ts-utils/ts-types.js | 48 ++ tests/lib/rules/require-explicit-slots.js | 468 ++++++++++++++++++ .../ts-utils/index/get-component-slots.js | 115 +++++ typings/eslint-plugin-vue/util-types/utils.ts | 29 +- 8 files changed, 775 insertions(+), 31 deletions(-) create mode 100644 tests/lib/utils/ts-utils/index/get-component-slots.js diff --git a/lib/rules/require-explicit-slots.js b/lib/rules/require-explicit-slots.js index f87503bb7..5298e598c 100644 --- a/lib/rules/require-explicit-slots.js +++ b/lib/rules/require-explicit-slots.js @@ -98,30 +98,22 @@ module.exports = { return utils.compositingVisitors( utils.defineScriptSetupVisitor(context, { - onDefineSlotsEnter(node) { - const typeArguments = - 'typeArguments' in node ? node.typeArguments : node.typeParameters - const param = /** @type {TypeNode|undefined} */ ( - typeArguments?.params[0] - ) - if (!param) return - - if (param.type === 'TSTypeLiteral') { - for (const memberNode of param.members) { - const slotName = getSlotsName(memberNode) - if (!slotName) continue - - if (slotsDefined.has(slotName)) { - context.report({ - node: memberNode, - messageId: 'alreadyDefinedSlot', - data: { - slotName - } - }) - } else { - slotsDefined.add(slotName) - } + onDefineSlotsEnter(_node, slots) { + for (const slot of slots) { + if (!slot.slotName) { + continue + } + + if (slotsDefined.has(slot.slotName)) { + context.report({ + node: slot.node, + messageId: 'alreadyDefinedSlot', + data: { + slotName: slot.slotName + } + }) + } else { + slotsDefined.add(slot.slotName) } } } diff --git a/lib/utils/index.js b/lib/utils/index.js index eb84c1279..167edf208 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -26,6 +26,10 @@ const { getScope } = require('./scope') * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownEmit} ComponentUnknownEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentEmit} ComponentEmit + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeSlot} ComponentTypeSlot + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeSlot} ComponentInferTypeSlot + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownSlot} ComponentUnknownSlot + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentSlot} ComponentSlot * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentModelName} ComponentModelName * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentModel} ComponentModel */ @@ -70,6 +74,7 @@ const { const { getComponentPropsFromTypeDefine, getComponentEmitsFromTypeDefine, + getComponentSlotsFromTypeDefine, isTypeNode } = require('./ts-utils') @@ -1435,7 +1440,7 @@ module.exports = { 'onDefineSlotsEnter', 'onDefineSlotsExit', (candidateMacro, node) => candidateMacro === node, - () => undefined + getComponentSlotsFromDefineSlots ), new MacroListener( 'defineExpose', @@ -3372,6 +3377,28 @@ function getComponentEmitsFromDefineEmits(context, node) { } ] } + +/** + * Get all slots from `defineSlots` call expression. + * @param {RuleContext} context The rule context object. + * @param {CallExpression} node `defineSlots` call expression + * @return {ComponentSlot[]} Array of component slots + */ +function getComponentSlotsFromDefineSlots(context, node) { + const typeArguments = + 'typeArguments' in node ? node.typeArguments : node.typeParameters + if (typeArguments && typeArguments.params.length > 0) { + return getComponentSlotsFromTypeDefine(context, typeArguments.params[0]) + } + return [ + { + type: 'unknown', + slotName: null, + node: null + } + ] +} + /** * Get model info from `defineModel` call expression. * @param {RuleContext} _context The rule context object. @@ -3414,6 +3441,7 @@ function getComponentModelFromDefineModel(_context, node) { typeNode: null } } + /** * Get all props by looking at all component's properties * @param {ObjectExpression|ArrayExpression} propsNode Object with props definition diff --git a/lib/utils/ts-utils/index.js b/lib/utils/ts-utils/index.js index 8b6c53b26..3db610d1c 100644 --- a/lib/utils/ts-utils/index.js +++ b/lib/utils/ts-utils/index.js @@ -5,11 +5,13 @@ const { isTSTypeLiteralOrTSFunctionType, extractRuntimeEmits, flattenTypeNodes, - isTSInterfaceBody + isTSInterfaceBody, + extractRuntimeSlots } = require('./ts-ast') const { getComponentPropsFromTypeDefineTypes, - getComponentEmitsFromTypeDefineTypes + getComponentEmitsFromTypeDefineTypes, + getComponentSlotsFromTypeDefineTypes } = require('./ts-types') /** @@ -22,12 +24,16 @@ const { * @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit * @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + * @typedef {import('../index').ComponentTypeSlot} ComponentTypeSlot + * @typedef {import('../index').ComponentInferTypeSlot} ComponentInferTypeSlot + * @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot */ module.exports = { isTypeNode, getComponentPropsFromTypeDefine, - getComponentEmitsFromTypeDefine + getComponentEmitsFromTypeDefine, + getComponentSlotsFromTypeDefine } /** @@ -86,3 +92,30 @@ function getComponentEmitsFromTypeDefine(context, emitsNode) { } return result } + +/** + * Get all slots by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} slotsNode Type with slots definition + * @return {(ComponentTypeSlot|ComponentInferTypeSlot|ComponentUnknownSlot)[]} Array of component slots + */ +function getComponentSlotsFromTypeDefine(context, slotsNode) { + /** @type {(ComponentTypeSlot|ComponentInferTypeSlot|ComponentUnknownSlot)[]} */ + const result = [] + for (const defNode of flattenTypeNodes( + context, + /** @type {TSESTreeTypeNode} */ (slotsNode) + )) { + if (isTSInterfaceBody(defNode) || isTSTypeLiteral(defNode)) { + result.push(...extractRuntimeSlots(defNode)) + } else { + result.push( + ...getComponentSlotsFromTypeDefineTypes( + context, + /** @type {TypeNode} */ (defNode) + ) + ) + } + } + return result +} diff --git a/lib/utils/ts-utils/ts-ast.js b/lib/utils/ts-utils/ts-ast.js index ddbb9de05..1021b4baf 100644 --- a/lib/utils/ts-utils/ts-ast.js +++ b/lib/utils/ts-utils/ts-ast.js @@ -15,6 +15,8 @@ const { inferRuntimeTypeFromTypeNode } = require('./ts-types') * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp * @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + * @typedef {import('../index').ComponentTypeSlot} ComponentTypeSlot + * @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot */ const noop = Function.prototype @@ -26,7 +28,8 @@ module.exports = { isTSTypeLiteral, isTSTypeLiteralOrTSFunctionType, extractRuntimeProps, - extractRuntimeEmits + extractRuntimeEmits, + extractRuntimeSlots } /** @@ -209,6 +212,38 @@ function* extractRuntimeEmits(node) { } } +/** + * @param {TSESTreeTSTypeLiteral | TSESTreeTSInterfaceBody} node + * @returns {IterableIterator} + */ +function* extractRuntimeSlots(node) { + const members = node.type === 'TSTypeLiteral' ? node.members : node.body + for (const member of members) { + if ( + member.type === 'TSPropertySignature' || + member.type === 'TSMethodSignature' + ) { + if (member.key.type !== 'Identifier' && member.key.type !== 'Literal') { + yield { + type: 'unknown', + slotName: null, + node: /** @type {Expression} */ (member.key) + } + continue + } + yield { + type: 'type', + key: /** @type {Identifier | Literal} */ (member.key), + slotName: + member.key.type === 'Identifier' + ? member.key.name + : `${member.key.value}`, + node: /** @type {TSPropertySignature | TSMethodSignature} */ (member) + } + } + } +} + /** * @param {TSESTreeParameter} eventName * @param {TSCallSignatureDeclaration | TSFunctionType} member diff --git a/lib/utils/ts-utils/ts-types.js b/lib/utils/ts-utils/ts-types.js index abb303862..2fe354c2c 100644 --- a/lib/utils/ts-utils/ts-types.js +++ b/lib/utils/ts-utils/ts-types.js @@ -24,11 +24,14 @@ const { * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp * @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + * @typedef {import('../index').ComponentInferTypeSlot} ComponentInferTypeSlot + * @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot */ module.exports = { getComponentPropsFromTypeDefineTypes, getComponentEmitsFromTypeDefineTypes, + getComponentSlotsFromTypeDefineTypes, inferRuntimeTypeFromTypeNode } @@ -122,6 +125,34 @@ function getComponentEmitsFromTypeDefineTypes(context, emitsNode) { return [...extractRuntimeEmits(type, tsNode, emitsNode, services)] } +/** + * Get all slots by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} slotsNode Type with slots definition + * @return {(ComponentInferTypeSlot|ComponentUnknownSlot)[]} Array of component slots + */ +function getComponentSlotsFromTypeDefineTypes(context, slotsNode) { + const services = getTSParserServices(context) + const tsNode = services && services.tsNodeMap.get(slotsNode) + const type = tsNode && services.checker.getTypeAtLocation(tsNode) + if ( + !type || + isAny(type) || + isUnknown(type) || + isNever(type) || + isNull(type) + ) { + return [ + { + type: 'unknown', + slotName: null, + node: slotsNode + } + ] + } + return [...extractRuntimeSlots(type, slotsNode)] +} + /** * @param {RuleContext} context The ESLint rule context object. * @param {TypeNode|Expression} node @@ -259,6 +290,23 @@ function* extractRuntimeEmits(type, tsNode, emitsNode, services) { } } +/** + * @param {Type} type + * @param {TypeNode} slotsNode Type with slots definition + * @returns {IterableIterator} + */ +function* extractRuntimeSlots(type, slotsNode) { + for (const property of type.getProperties()) { + const name = property.getName() + + yield { + type: 'infer-type', + slotName: name, + node: slotsNode + } + } +} + /** * @param {Type} type * @returns {Iterable} diff --git a/tests/lib/rules/require-explicit-slots.js b/tests/lib/rules/require-explicit-slots.js index 92d1a1334..f99614119 100644 --- a/tests/lib/rules/require-explicit-slots.js +++ b/tests/lib/rules/require-explicit-slots.js @@ -34,6 +34,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -48,6 +78,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -62,6 +122,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -76,6 +166,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -90,6 +210,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -178,6 +328,40 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -191,6 +375,36 @@ tester.run('require-explicit-slots', rule, { default(props: { msg: string }): any }>() ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` } ], invalid: [ @@ -261,6 +475,46 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -280,6 +534,46 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -299,6 +593,46 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -342,6 +676,48 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + // ignore attribute binding except string literal + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + // ignore attribute binding except string literal + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -362,6 +738,48 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -384,6 +802,56 @@ tester.run('require-explicit-slots', rule, { message: 'Slot foo is already defined.' } ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] } ] }) diff --git a/tests/lib/utils/ts-utils/index/get-component-slots.js b/tests/lib/utils/ts-utils/index/get-component-slots.js new file mode 100644 index 000000000..410021b93 --- /dev/null +++ b/tests/lib/utils/ts-utils/index/get-component-slots.js @@ -0,0 +1,115 @@ +/** + * Test for getComponentSlotsFromTypeDefineTypes + */ +'use strict' + +const path = require('path') +const fs = require('fs') +const Linter = require('../../../../eslint-compat').Linter +const parser = require('vue-eslint-parser') +const tsParser = require('@typescript-eslint/parser') +const utils = require('../../../../../lib/utils/index') +const assert = require('assert') + +const FIXTURES_ROOT = path.resolve( + __dirname, + '../../../../fixtures/utils/ts-utils' +) +const TSCONFIG_PATH = path.resolve(FIXTURES_ROOT, './tsconfig.json') +const SRC_TS_TEST_PATH = path.join(FIXTURES_ROOT, './src/test.ts') + +function extractComponentSlots(code, tsFileCode) { + const linter = new Linter() + const result = [] + const config = { + files: ['**/*.vue'], + languageOptions: { + parser, + ecmaVersion: 2020, + parserOptions: { + parser: tsParser, + project: [TSCONFIG_PATH], + extraFileExtensions: ['.vue'] + } + }, + plugins: { + test: { + rules: { + test: { + create(context) { + return utils.defineScriptSetupVisitor(context, { + onDefineSlotsEnter(_node, slots) { + result.push( + ...slots.map((prop) => ({ + type: prop.type, + name: prop.slotName + })) + ) + } + }) + } + } + } + } + }, + rules: { + 'test/test': 'error' + } + } + fs.writeFileSync(SRC_TS_TEST_PATH, tsFileCode || '', 'utf8') + // clean './src/test.ts' cache + tsParser.clearCaches() + assert.deepStrictEqual( + linter.verify(code, config, path.join(FIXTURES_ROOT, './src/test.vue')), + [] + ) + // reset + fs.writeFileSync(SRC_TS_TEST_PATH, '', 'utf8') + return result +} + +describe('getComponentSlotsFromTypeDefineTypes', () => { + for (const { scriptCode, tsFileCode, slots: expected } of [ + { + scriptCode: ` + defineSlots<{ + default(props: { msg: string }): any + }>() + `, + slots: [{ type: 'type', name: 'default' }] + }, + { + scriptCode: ` + interface Slots { + default(props: { msg: string }): any + } + defineSlots() + `, + slots: [{ type: 'type', name: 'default' }] + }, + { + scriptCode: ` + type Slots = { + default(props: { msg: string }): any + } + defineSlots() + `, + slots: [{ type: 'type', name: 'default' }] + } + ]) { + const code = ` + + ` + it(`should return expected slots with :${code}`, () => { + const slots = extractComponentSlots(code, tsFileCode) + + assert.deepStrictEqual( + slots, + expected, + `\n${JSON.stringify(slots)}\n === \n${JSON.stringify(expected)}` + ) + }) + } +}) diff --git a/typings/eslint-plugin-vue/util-types/utils.ts b/typings/eslint-plugin-vue/util-types/utils.ts index 3e9184262..ebe9933d3 100644 --- a/typings/eslint-plugin-vue/util-types/utils.ts +++ b/typings/eslint-plugin-vue/util-types/utils.ts @@ -42,8 +42,8 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase { onDefineEmitsExit?(node: CallExpression, emits: ComponentEmit[]): void onDefineOptionsEnter?(node: CallExpression): void onDefineOptionsExit?(node: CallExpression): void - onDefineSlotsEnter?(node: CallExpression): void - onDefineSlotsExit?(node: CallExpression): void + onDefineSlotsEnter?(node: CallExpression, slots: ComponentSlot[]): void + onDefineSlotsExit?(node: CallExpression, slots: ComponentSlot[]): void onDefineExposeEnter?(node: CallExpression): void onDefineExposeExit?(node: CallExpression): void onDefineModelEnter?(node: CallExpression, model: ComponentModel): void @@ -52,6 +52,7 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase { | ((node: VAST.ParamNode) => void) | ((node: CallExpression, props: ComponentProp[]) => void) | ((node: CallExpression, emits: ComponentEmit[]) => void) + | ((node: CallExpression, slots: ComponentSlot[]) => void) | ((node: CallExpression, model: ComponentModel) => void) | undefined } @@ -191,6 +192,30 @@ export type ComponentEmit = | ComponentInferTypeEmit | ComponentUnknownEmit +export type ComponentUnknownSlot = { + type: 'unknown' + slotName: null + node: Expression | SpreadElement | TypeNode | null +} + +export type ComponentTypeSlot = { + type: 'type' + key: Identifier | Literal + slotName: string + node: TSPropertySignature | TSMethodSignature +} + +export type ComponentInferTypeSlot = { + type: 'infer-type' + slotName: string + node: TypeNode +} + +export type ComponentSlot = + | ComponentTypeSlot + | ComponentInferTypeSlot + | ComponentUnknownSlot + export type ComponentModelName = { modelName: string node: Literal | null