Skip to content

Commit 618f49c

Browse files
authoredNov 27, 2024··
fix(require-explicit-slots): add support for type references (#2617)
1 parent a270df8 commit 618f49c

File tree

8 files changed

+775
-31
lines changed

8 files changed

+775
-31
lines changed
 

‎lib/rules/require-explicit-slots.js

+16-24
Original file line numberDiff line numberDiff line change
@@ -98,30 +98,22 @@ module.exports = {
9898

9999
return utils.compositingVisitors(
100100
utils.defineScriptSetupVisitor(context, {
101-
onDefineSlotsEnter(node) {
102-
const typeArguments =
103-
'typeArguments' in node ? node.typeArguments : node.typeParameters
104-
const param = /** @type {TypeNode|undefined} */ (
105-
typeArguments?.params[0]
106-
)
107-
if (!param) return
108-
109-
if (param.type === 'TSTypeLiteral') {
110-
for (const memberNode of param.members) {
111-
const slotName = getSlotsName(memberNode)
112-
if (!slotName) continue
113-
114-
if (slotsDefined.has(slotName)) {
115-
context.report({
116-
node: memberNode,
117-
messageId: 'alreadyDefinedSlot',
118-
data: {
119-
slotName
120-
}
121-
})
122-
} else {
123-
slotsDefined.add(slotName)
124-
}
101+
onDefineSlotsEnter(_node, slots) {
102+
for (const slot of slots) {
103+
if (!slot.slotName) {
104+
continue
105+
}
106+
107+
if (slotsDefined.has(slot.slotName)) {
108+
context.report({
109+
node: slot.node,
110+
messageId: 'alreadyDefinedSlot',
111+
data: {
112+
slotName: slot.slotName
113+
}
114+
})
115+
} else {
116+
slotsDefined.add(slot.slotName)
125117
}
126118
}
127119
}

‎lib/utils/index.js

+29-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const { getScope } = require('./scope')
2626
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeEmit} ComponentInferTypeEmit
2727
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownEmit} ComponentUnknownEmit
2828
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentEmit} ComponentEmit
29+
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeSlot} ComponentTypeSlot
30+
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeSlot} ComponentInferTypeSlot
31+
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownSlot} ComponentUnknownSlot
32+
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentSlot} ComponentSlot
2933
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentModelName} ComponentModelName
3034
* @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentModel} ComponentModel
3135
*/
@@ -70,6 +74,7 @@ const {
7074
const {
7175
getComponentPropsFromTypeDefine,
7276
getComponentEmitsFromTypeDefine,
77+
getComponentSlotsFromTypeDefine,
7378
isTypeNode
7479
} = require('./ts-utils')
7580

@@ -1435,7 +1440,7 @@ module.exports = {
14351440
'onDefineSlotsEnter',
14361441
'onDefineSlotsExit',
14371442
(candidateMacro, node) => candidateMacro === node,
1438-
() => undefined
1443+
getComponentSlotsFromDefineSlots
14391444
),
14401445
new MacroListener(
14411446
'defineExpose',
@@ -3372,6 +3377,28 @@ function getComponentEmitsFromDefineEmits(context, node) {
33723377
}
33733378
]
33743379
}
3380+
3381+
/**
3382+
* Get all slots from `defineSlots` call expression.
3383+
* @param {RuleContext} context The rule context object.
3384+
* @param {CallExpression} node `defineSlots` call expression
3385+
* @return {ComponentSlot[]} Array of component slots
3386+
*/
3387+
function getComponentSlotsFromDefineSlots(context, node) {
3388+
const typeArguments =
3389+
'typeArguments' in node ? node.typeArguments : node.typeParameters
3390+
if (typeArguments && typeArguments.params.length > 0) {
3391+
return getComponentSlotsFromTypeDefine(context, typeArguments.params[0])
3392+
}
3393+
return [
3394+
{
3395+
type: 'unknown',
3396+
slotName: null,
3397+
node: null
3398+
}
3399+
]
3400+
}
3401+
33753402
/**
33763403
* Get model info from `defineModel` call expression.
33773404
* @param {RuleContext} _context The rule context object.
@@ -3414,6 +3441,7 @@ function getComponentModelFromDefineModel(_context, node) {
34143441
typeNode: null
34153442
}
34163443
}
3444+
34173445
/**
34183446
* Get all props by looking at all component's properties
34193447
* @param {ObjectExpression|ArrayExpression} propsNode Object with props definition

‎lib/utils/ts-utils/index.js

+36-3
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ const {
55
isTSTypeLiteralOrTSFunctionType,
66
extractRuntimeEmits,
77
flattenTypeNodes,
8-
isTSInterfaceBody
8+
isTSInterfaceBody,
9+
extractRuntimeSlots
910
} = require('./ts-ast')
1011
const {
1112
getComponentPropsFromTypeDefineTypes,
12-
getComponentEmitsFromTypeDefineTypes
13+
getComponentEmitsFromTypeDefineTypes,
14+
getComponentSlotsFromTypeDefineTypes
1315
} = require('./ts-types')
1416

1517
/**
@@ -22,12 +24,16 @@ const {
2224
* @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit
2325
* @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit
2426
* @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit
27+
* @typedef {import('../index').ComponentTypeSlot} ComponentTypeSlot
28+
* @typedef {import('../index').ComponentInferTypeSlot} ComponentInferTypeSlot
29+
* @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot
2530
*/
2631

2732
module.exports = {
2833
isTypeNode,
2934
getComponentPropsFromTypeDefine,
30-
getComponentEmitsFromTypeDefine
35+
getComponentEmitsFromTypeDefine,
36+
getComponentSlotsFromTypeDefine
3137
}
3238

3339
/**
@@ -86,3 +92,30 @@ function getComponentEmitsFromTypeDefine(context, emitsNode) {
8692
}
8793
return result
8894
}
95+
96+
/**
97+
* Get all slots by looking at all component's properties
98+
* @param {RuleContext} context The ESLint rule context object.
99+
* @param {TypeNode} slotsNode Type with slots definition
100+
* @return {(ComponentTypeSlot|ComponentInferTypeSlot|ComponentUnknownSlot)[]} Array of component slots
101+
*/
102+
function getComponentSlotsFromTypeDefine(context, slotsNode) {
103+
/** @type {(ComponentTypeSlot|ComponentInferTypeSlot|ComponentUnknownSlot)[]} */
104+
const result = []
105+
for (const defNode of flattenTypeNodes(
106+
context,
107+
/** @type {TSESTreeTypeNode} */ (slotsNode)
108+
)) {
109+
if (isTSInterfaceBody(defNode) || isTSTypeLiteral(defNode)) {
110+
result.push(...extractRuntimeSlots(defNode))
111+
} else {
112+
result.push(
113+
...getComponentSlotsFromTypeDefineTypes(
114+
context,
115+
/** @type {TypeNode} */ (defNode)
116+
)
117+
)
118+
}
119+
}
120+
return result
121+
}

‎lib/utils/ts-utils/ts-ast.js

+36-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const { inferRuntimeTypeFromTypeNode } = require('./ts-types')
1515
* @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp
1616
* @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit
1717
* @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit
18+
* @typedef {import('../index').ComponentTypeSlot} ComponentTypeSlot
19+
* @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot
1820
*/
1921

2022
const noop = Function.prototype
@@ -26,7 +28,8 @@ module.exports = {
2628
isTSTypeLiteral,
2729
isTSTypeLiteralOrTSFunctionType,
2830
extractRuntimeProps,
29-
extractRuntimeEmits
31+
extractRuntimeEmits,
32+
extractRuntimeSlots
3033
}
3134

3235
/**
@@ -209,6 +212,38 @@ function* extractRuntimeEmits(node) {
209212
}
210213
}
211214

215+
/**
216+
* @param {TSESTreeTSTypeLiteral | TSESTreeTSInterfaceBody} node
217+
* @returns {IterableIterator<ComponentTypeSlot | ComponentUnknownSlot>}
218+
*/
219+
function* extractRuntimeSlots(node) {
220+
const members = node.type === 'TSTypeLiteral' ? node.members : node.body
221+
for (const member of members) {
222+
if (
223+
member.type === 'TSPropertySignature' ||
224+
member.type === 'TSMethodSignature'
225+
) {
226+
if (member.key.type !== 'Identifier' && member.key.type !== 'Literal') {
227+
yield {
228+
type: 'unknown',
229+
slotName: null,
230+
node: /** @type {Expression} */ (member.key)
231+
}
232+
continue
233+
}
234+
yield {
235+
type: 'type',
236+
key: /** @type {Identifier | Literal} */ (member.key),
237+
slotName:
238+
member.key.type === 'Identifier'
239+
? member.key.name
240+
: `${member.key.value}`,
241+
node: /** @type {TSPropertySignature | TSMethodSignature} */ (member)
242+
}
243+
}
244+
}
245+
}
246+
212247
/**
213248
* @param {TSESTreeParameter} eventName
214249
* @param {TSCallSignatureDeclaration | TSFunctionType} member

‎lib/utils/ts-utils/ts-types.js

+48
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ const {
2424
* @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp
2525
* @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit
2626
* @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit
27+
* @typedef {import('../index').ComponentInferTypeSlot} ComponentInferTypeSlot
28+
* @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot
2729
*/
2830

2931
module.exports = {
3032
getComponentPropsFromTypeDefineTypes,
3133
getComponentEmitsFromTypeDefineTypes,
34+
getComponentSlotsFromTypeDefineTypes,
3235
inferRuntimeTypeFromTypeNode
3336
}
3437

@@ -122,6 +125,34 @@ function getComponentEmitsFromTypeDefineTypes(context, emitsNode) {
122125
return [...extractRuntimeEmits(type, tsNode, emitsNode, services)]
123126
}
124127

128+
/**
129+
* Get all slots by looking at all component's properties
130+
* @param {RuleContext} context The ESLint rule context object.
131+
* @param {TypeNode} slotsNode Type with slots definition
132+
* @return {(ComponentInferTypeSlot|ComponentUnknownSlot)[]} Array of component slots
133+
*/
134+
function getComponentSlotsFromTypeDefineTypes(context, slotsNode) {
135+
const services = getTSParserServices(context)
136+
const tsNode = services && services.tsNodeMap.get(slotsNode)
137+
const type = tsNode && services.checker.getTypeAtLocation(tsNode)
138+
if (
139+
!type ||
140+
isAny(type) ||
141+
isUnknown(type) ||
142+
isNever(type) ||
143+
isNull(type)
144+
) {
145+
return [
146+
{
147+
type: 'unknown',
148+
slotName: null,
149+
node: slotsNode
150+
}
151+
]
152+
}
153+
return [...extractRuntimeSlots(type, slotsNode)]
154+
}
155+
125156
/**
126157
* @param {RuleContext} context The ESLint rule context object.
127158
* @param {TypeNode|Expression} node
@@ -259,6 +290,23 @@ function* extractRuntimeEmits(type, tsNode, emitsNode, services) {
259290
}
260291
}
261292

293+
/**
294+
* @param {Type} type
295+
* @param {TypeNode} slotsNode Type with slots definition
296+
* @returns {IterableIterator<ComponentInferTypeSlot>}
297+
*/
298+
function* extractRuntimeSlots(type, slotsNode) {
299+
for (const property of type.getProperties()) {
300+
const name = property.getName()
301+
302+
yield {
303+
type: 'infer-type',
304+
slotName: name,
305+
node: slotsNode
306+
}
307+
}
308+
}
309+
262310
/**
263311
* @param {Type} type
264312
* @returns {Iterable<Type>}

‎tests/lib/rules/require-explicit-slots.js

+468
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Test for getComponentSlotsFromTypeDefineTypes
3+
*/
4+
'use strict'
5+
6+
const path = require('path')
7+
const fs = require('fs')
8+
const Linter = require('../../../../eslint-compat').Linter
9+
const parser = require('vue-eslint-parser')
10+
const tsParser = require('@typescript-eslint/parser')
11+
const utils = require('../../../../../lib/utils/index')
12+
const assert = require('assert')
13+
14+
const FIXTURES_ROOT = path.resolve(
15+
__dirname,
16+
'../../../../fixtures/utils/ts-utils'
17+
)
18+
const TSCONFIG_PATH = path.resolve(FIXTURES_ROOT, './tsconfig.json')
19+
const SRC_TS_TEST_PATH = path.join(FIXTURES_ROOT, './src/test.ts')
20+
21+
function extractComponentSlots(code, tsFileCode) {
22+
const linter = new Linter()
23+
const result = []
24+
const config = {
25+
files: ['**/*.vue'],
26+
languageOptions: {
27+
parser,
28+
ecmaVersion: 2020,
29+
parserOptions: {
30+
parser: tsParser,
31+
project: [TSCONFIG_PATH],
32+
extraFileExtensions: ['.vue']
33+
}
34+
},
35+
plugins: {
36+
test: {
37+
rules: {
38+
test: {
39+
create(context) {
40+
return utils.defineScriptSetupVisitor(context, {
41+
onDefineSlotsEnter(_node, slots) {
42+
result.push(
43+
...slots.map((prop) => ({
44+
type: prop.type,
45+
name: prop.slotName
46+
}))
47+
)
48+
}
49+
})
50+
}
51+
}
52+
}
53+
}
54+
},
55+
rules: {
56+
'test/test': 'error'
57+
}
58+
}
59+
fs.writeFileSync(SRC_TS_TEST_PATH, tsFileCode || '', 'utf8')
60+
// clean './src/test.ts' cache
61+
tsParser.clearCaches()
62+
assert.deepStrictEqual(
63+
linter.verify(code, config, path.join(FIXTURES_ROOT, './src/test.vue')),
64+
[]
65+
)
66+
// reset
67+
fs.writeFileSync(SRC_TS_TEST_PATH, '', 'utf8')
68+
return result
69+
}
70+
71+
describe('getComponentSlotsFromTypeDefineTypes', () => {
72+
for (const { scriptCode, tsFileCode, slots: expected } of [
73+
{
74+
scriptCode: `
75+
defineSlots<{
76+
default(props: { msg: string }): any
77+
}>()
78+
`,
79+
slots: [{ type: 'type', name: 'default' }]
80+
},
81+
{
82+
scriptCode: `
83+
interface Slots {
84+
default(props: { msg: string }): any
85+
}
86+
defineSlots<Slots>()
87+
`,
88+
slots: [{ type: 'type', name: 'default' }]
89+
},
90+
{
91+
scriptCode: `
92+
type Slots = {
93+
default(props: { msg: string }): any
94+
}
95+
defineSlots<Slots>()
96+
`,
97+
slots: [{ type: 'type', name: 'default' }]
98+
}
99+
]) {
100+
const code = `
101+
<script setup lang="ts">
102+
${scriptCode}
103+
</script>
104+
`
105+
it(`should return expected slots with :${code}`, () => {
106+
const slots = extractComponentSlots(code, tsFileCode)
107+
108+
assert.deepStrictEqual(
109+
slots,
110+
expected,
111+
`\n${JSON.stringify(slots)}\n === \n${JSON.stringify(expected)}`
112+
)
113+
})
114+
}
115+
})

‎typings/eslint-plugin-vue/util-types/utils.ts

+27-2
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase {
4242
onDefineEmitsExit?(node: CallExpression, emits: ComponentEmit[]): void
4343
onDefineOptionsEnter?(node: CallExpression): void
4444
onDefineOptionsExit?(node: CallExpression): void
45-
onDefineSlotsEnter?(node: CallExpression): void
46-
onDefineSlotsExit?(node: CallExpression): void
45+
onDefineSlotsEnter?(node: CallExpression, slots: ComponentSlot[]): void
46+
onDefineSlotsExit?(node: CallExpression, slots: ComponentSlot[]): void
4747
onDefineExposeEnter?(node: CallExpression): void
4848
onDefineExposeExit?(node: CallExpression): void
4949
onDefineModelEnter?(node: CallExpression, model: ComponentModel): void
@@ -52,6 +52,7 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase {
5252
| ((node: VAST.ParamNode) => void)
5353
| ((node: CallExpression, props: ComponentProp[]) => void)
5454
| ((node: CallExpression, emits: ComponentEmit[]) => void)
55+
| ((node: CallExpression, slots: ComponentSlot[]) => void)
5556
| ((node: CallExpression, model: ComponentModel) => void)
5657
| undefined
5758
}
@@ -191,6 +192,30 @@ export type ComponentEmit =
191192
| ComponentInferTypeEmit
192193
| ComponentUnknownEmit
193194

195+
export type ComponentUnknownSlot = {
196+
type: 'unknown'
197+
slotName: null
198+
node: Expression | SpreadElement | TypeNode | null
199+
}
200+
201+
export type ComponentTypeSlot = {
202+
type: 'type'
203+
key: Identifier | Literal
204+
slotName: string
205+
node: TSPropertySignature | TSMethodSignature
206+
}
207+
208+
export type ComponentInferTypeSlot = {
209+
type: 'infer-type'
210+
slotName: string
211+
node: TypeNode
212+
}
213+
214+
export type ComponentSlot =
215+
| ComponentTypeSlot
216+
| ComponentInferTypeSlot
217+
| ComponentUnknownSlot
218+
194219
export type ComponentModelName = {
195220
modelName: string
196221
node: Literal | null

0 commit comments

Comments
 (0)
Please sign in to comment.