feat(compiler-sfc): support ExtractPropTypes when resolving types

close #8104
This commit is contained in:
Evan You 2023-04-20 21:02:50 +08:00
parent 5c6989557d
commit 50c0bbe522
2 changed files with 303 additions and 39 deletions

View File

@ -372,6 +372,56 @@ describe('resolveType', () => {
}) })
}) })
test('typeof', () => {
expect(
resolve(`
declare const a: string
defineProps<{ foo: typeof a }>()
`).props
).toStrictEqual({
foo: ['String']
})
})
test('ExtractPropTypes (element-plus)', () => {
const { props, raw } = resolve(
`
import { ExtractPropTypes } from 'vue'
declare const props: {
foo: StringConstructor,
bar: {
type: import('foo').EpPropFinalized<BooleanConstructor>,
required: true
}
}
type Props = ExtractPropTypes<typeof props>
defineProps<Props>()
`
)
expect(props).toStrictEqual({
foo: ['String'],
bar: ['Boolean']
})
expect(raw.props.bar.optional).toBe(false)
})
test('ExtractPropTypes (antd)', () => {
const { props } = resolve(
`
declare const props: () => {
foo: StringConstructor,
bar: { type: PropType<boolean> }
}
type Props = Partial<import('vue').ExtractPropTypes<ReturnType<typeof props>>>
defineProps<Props>()
`
)
expect(props).toStrictEqual({
foo: ['String'],
bar: ['Boolean']
})
})
describe('external type imports', () => { describe('external type imports', () => {
const files = { const files = {
'/foo.ts': 'export type P = { foo: number }', '/foo.ts': 'export type P = { foo: number }',
@ -659,6 +709,7 @@ function resolve(
return { return {
props, props,
calls: raw.calls, calls: raw.calls,
deps: ctx.deps deps: ctx.deps,
raw
} }
} }

View File

@ -19,6 +19,8 @@ import {
TSType, TSType,
TSTypeAnnotation, TSTypeAnnotation,
TSTypeElement, TSTypeElement,
TSTypeLiteral,
TSTypeQuery,
TSTypeReference, TSTypeReference,
TemplateLiteral TemplateLiteral
} from '@babel/types' } from '@babel/types'
@ -81,6 +83,8 @@ export interface TypeScope {
imports: Record<string, Import> imports: Record<string, Import>
types: Record<string, ScopeTypeNode> types: Record<string, ScopeTypeNode>
exportedTypes: Record<string, ScopeTypeNode> exportedTypes: Record<string, ScopeTypeNode>
declares: Record<string, ScopeTypeNode>
exportedDeclares: Record<string, ScopeTypeNode>
} }
export interface MaybeWithScope { export interface MaybeWithScope {
@ -150,17 +154,38 @@ function innerResolveTypeElements(
} }
case 'TSExpressionWithTypeArguments': // referenced by interface extends case 'TSExpressionWithTypeArguments': // referenced by interface extends
case 'TSTypeReference': { case 'TSTypeReference': {
const typeName = getReferenceName(node)
if (
typeName === 'ExtractPropTypes' &&
node.typeParameters &&
scope.imports[typeName]?.source === 'vue'
) {
return resolveExtractPropTypes(
resolveTypeElements(ctx, node.typeParameters.params[0], scope),
scope
)
}
const resolved = resolveTypeReference(ctx, node, scope) const resolved = resolveTypeReference(ctx, node, scope)
if (resolved) { if (resolved) {
return resolveTypeElements(ctx, resolved, resolved._ownerScope) return resolveTypeElements(ctx, resolved, resolved._ownerScope)
} else { } else {
const typeName = getReferenceName(node) if (typeof typeName === 'string') {
if ( if (
typeof typeName === 'string' && // @ts-ignore
// @ts-ignore SupportedBuiltinsSet.has(typeName)
SupportedBuiltinsSet.has(typeName) ) {
) { return resolveBuiltin(ctx, node, typeName as any, scope)
return resolveBuiltin(ctx, node, typeName as any, scope) } else if (typeName === 'ReturnType' && node.typeParameters) {
// limited support, only reference types
const ret = resolveReturnType(
ctx,
node.typeParameters.params[0],
scope
)
if (ret) {
return resolveTypeElements(ctx, ret, scope)
}
}
} }
return ctx.error( return ctx.error(
`Unresolvable type reference or unsupported built-in utility type`, `Unresolvable type reference or unsupported built-in utility type`,
@ -169,7 +194,18 @@ function innerResolveTypeElements(
) )
} }
} }
case 'TSImportType': case 'TSImportType': {
if (
getId(node.argument) === 'vue' &&
node.qualifier?.type === 'Identifier' &&
node.qualifier.name === 'ExtractPropTypes' &&
node.typeParameters
) {
return resolveExtractPropTypes(
resolveTypeElements(ctx, node.typeParameters.params[0], scope),
scope
)
}
const sourceScope = importSourceToScope( const sourceScope = importSourceToScope(
ctx, ctx,
node.argument, node.argument,
@ -180,6 +216,13 @@ function innerResolveTypeElements(
if (resolved) { if (resolved) {
return resolveTypeElements(ctx, resolved, resolved._ownerScope) return resolveTypeElements(ctx, resolved, resolved._ownerScope)
} }
}
case 'TSTypeQuery': {
const resolved = resolveTypeReference(ctx, node, scope)
if (resolved) {
return resolveTypeElements(ctx, resolved, resolved._ownerScope)
}
}
} }
return ctx.error(`Unresolvable type: ${node.type}`, node, scope) return ctx.error(`Unresolvable type: ${node.type}`, node, scope)
} }
@ -233,7 +276,8 @@ function mergeElements(
// @ts-ignore // @ts-ignore
types: [baseProps[key], props[key]] types: [baseProps[key], props[key]]
}, },
baseProps[key]._ownerScope baseProps[key]._ownerScope,
baseProps[key].optional || props[key].optional
) )
} }
} }
@ -247,12 +291,14 @@ function mergeElements(
function createProperty( function createProperty(
key: Expression, key: Expression,
typeAnnotation: TSType, typeAnnotation: TSType,
scope: TypeScope scope: TypeScope,
): TSPropertySignature & { _ownerScope: TypeScope } { optional: boolean
): TSPropertySignature & WithScope {
return { return {
type: 'TSPropertySignature', type: 'TSPropertySignature',
key, key,
kind: 'get', kind: 'get',
optional,
typeAnnotation: { typeAnnotation: {
type: 'TSTypeAnnotation', type: 'TSTypeAnnotation',
typeAnnotation typeAnnotation
@ -294,7 +340,8 @@ function resolveMappedType(
name: key name: key
}, },
node.typeAnnotation!, node.typeAnnotation!,
scope scope,
!!node.optional
) )
} }
return res return res
@ -502,6 +549,7 @@ type ReferenceTypes =
| TSTypeReference | TSTypeReference
| TSExpressionWithTypeArguments | TSExpressionWithTypeArguments
| TSImportType | TSImportType
| TSTypeQuery
function resolveTypeReference( function resolveTypeReference(
ctx: TypeResolveContext, ctx: TypeResolveContext,
@ -535,17 +583,25 @@ function innerResolveTypeReference(
if (scope.imports[name]) { if (scope.imports[name]) {
return resolveTypeFromImport(ctx, node, name, scope) return resolveTypeFromImport(ctx, node, name, scope)
} else { } else {
const types = onlyExported ? scope.exportedTypes : scope.types const lookupSource =
if (types[name]) { node.type === 'TSTypeQuery'
return types[name] ? onlyExported
? scope.exportedDeclares
: scope.declares
: onlyExported
? scope.exportedTypes
: scope.types
if (lookupSource[name]) {
return lookupSource[name]
} else { } else {
// fallback to global // fallback to global
const globalScopes = resolveGlobalScope(ctx) const globalScopes = resolveGlobalScope(ctx)
if (globalScopes) { if (globalScopes) {
for (const s of globalScopes) { for (const s of globalScopes) {
if (s.types[name]) { const src = node.type === 'TSTypeQuery' ? s.declares : s.types
if (src[name]) {
;(ctx.deps || (ctx.deps = new Set())).add(s.filename) ;(ctx.deps || (ctx.deps = new Set())).add(s.filename)
return s.types[name] return src[name]
} }
} }
} }
@ -578,13 +634,15 @@ function getReferenceName(node: ReferenceTypes): string | string[] {
? node.typeName ? node.typeName
: node.type === 'TSExpressionWithTypeArguments' : node.type === 'TSExpressionWithTypeArguments'
? node.expression ? node.expression
: node.qualifier : node.type === 'TSImportType'
if (!ref) { ? node.qualifier
return 'default' : node.exprName
} else if (ref.type === 'Identifier') { if (ref?.type === 'Identifier') {
return ref.name return ref.name
} else { } else if (ref?.type === 'TSQualifiedName') {
return qualifiedNameToPath(ref) return qualifiedNameToPath(ref)
} else {
return 'default'
} }
} }
@ -786,7 +844,9 @@ export function fileToScope(
offset: 0, offset: 0,
imports: recordImports(body), imports: recordImports(body),
types: Object.create(null), types: Object.create(null),
exportedTypes: Object.create(null) exportedTypes: Object.create(null),
declares: Object.create(null),
exportedDeclares: Object.create(null)
} }
recordTypes(ctx, body, scope, asGlobal) recordTypes(ctx, body, scope, asGlobal)
fileToScopeCache.set(filename, scope) fileToScopeCache.set(filename, scope)
@ -858,7 +918,9 @@ function ctxToScope(ctx: TypeResolveContext): TypeScope {
? Object.create(ctx.userImports) ? Object.create(ctx.userImports)
: recordImports(body), : recordImports(body),
types: Object.create(null), types: Object.create(null),
exportedTypes: Object.create(null) exportedTypes: Object.create(null),
declares: Object.create(null),
exportedDeclares: Object.create(null)
} }
recordTypes(ctx, body, scope) recordTypes(ctx, body, scope)
@ -877,9 +939,10 @@ function moduleDeclToScope(
const scope: TypeScope = { const scope: TypeScope = {
...parentScope, ...parentScope,
imports: Object.create(parentScope.imports), imports: Object.create(parentScope.imports),
// TODO this seems wrong
types: Object.create(parentScope.types), types: Object.create(parentScope.types),
exportedTypes: Object.create(null) declares: Object.create(parentScope.declares),
exportedTypes: Object.create(null),
exportedDeclares: Object.create(null)
} }
if (node.body.type === 'TSModuleDeclaration') { if (node.body.type === 'TSModuleDeclaration') {
@ -902,7 +965,7 @@ function recordTypes(
scope: TypeScope, scope: TypeScope,
asGlobal = false asGlobal = false
) { ) {
const { types, exportedTypes, imports } = scope const { types, declares, exportedTypes, exportedDeclares, imports } = scope
const isAmbient = asGlobal const isAmbient = asGlobal
? !body.some(s => importExportRE.test(s.type)) ? !body.some(s => importExportRE.test(s.type))
: false : false
@ -910,23 +973,23 @@ function recordTypes(
if (asGlobal) { if (asGlobal) {
if (isAmbient) { if (isAmbient) {
if ((stmt as any).declare) { if ((stmt as any).declare) {
recordType(stmt, types) recordType(stmt, types, declares)
} }
} else if (stmt.type === 'TSModuleDeclaration' && stmt.global) { } else if (stmt.type === 'TSModuleDeclaration' && stmt.global) {
for (const s of (stmt.body as TSModuleBlock).body) { for (const s of (stmt.body as TSModuleBlock).body) {
recordType(s, types) recordType(s, types, declares)
} }
} }
} else { } else {
recordType(stmt, types) recordType(stmt, types, declares)
} }
} }
if (!asGlobal) { if (!asGlobal) {
for (const stmt of body) { for (const stmt of body) {
if (stmt.type === 'ExportNamedDeclaration') { if (stmt.type === 'ExportNamedDeclaration') {
if (stmt.declaration) { if (stmt.declaration) {
recordType(stmt.declaration, types) recordType(stmt.declaration, types, declares)
recordType(stmt.declaration, exportedTypes) recordType(stmt.declaration, exportedTypes, exportedDeclares)
} else { } else {
for (const spec of stmt.specifiers) { for (const spec of stmt.specifiers) {
if (spec.type === 'ExportSpecifier') { if (spec.type === 'ExportSpecifier') {
@ -969,9 +1032,16 @@ function recordTypes(
node._ownerScope = scope node._ownerScope = scope
if (node._ns) node._ns._ownerScope = scope if (node._ns) node._ns._ownerScope = scope
} }
for (const key of Object.keys(declares)) {
declares[key]._ownerScope = scope
}
} }
function recordType(node: Node, types: Record<string, Node>) { function recordType(
node: Node,
types: Record<string, Node>,
declares: Record<string, Node>
) {
switch (node.type) { switch (node.type) {
case 'TSInterfaceDeclaration': case 'TSInterfaceDeclaration':
case 'TSEnumDeclaration': case 'TSEnumDeclaration':
@ -1014,11 +1084,14 @@ function recordType(node: Node, types: Record<string, Node>) {
case 'TSTypeAliasDeclaration': case 'TSTypeAliasDeclaration':
types[node.id.name] = node.typeAnnotation types[node.id.name] = node.typeAnnotation
break break
case 'TSDeclareFunction':
if (node.id) declares[node.id.name] = node
break
case 'VariableDeclaration': { case 'VariableDeclaration': {
if (node.declare) { if (node.declare) {
for (const decl of node.declarations) { for (const decl of node.declarations) {
if (decl.id.type === 'Identifier' && decl.id.typeAnnotation) { if (decl.id.type === 'Identifier' && decl.id.typeAnnotation) {
types[decl.id.name] = ( declares[decl.id.name] = (
decl.id.typeAnnotation as TSTypeAnnotation decl.id.typeAnnotation as TSTypeAnnotation
).typeAnnotation ).typeAnnotation
} }
@ -1211,7 +1284,7 @@ export function inferRuntimeType(
} }
} }
// cannot infer, fallback to UNKNOWN: ThisParameterType // cannot infer, fallback to UNKNOWN: ThisParameterType
return [UNKNOWN_TYPE] break
} }
case 'TSParenthesizedType': case 'TSParenthesizedType':
@ -1235,7 +1308,9 @@ export function inferRuntimeType(
try { try {
const types = resolveIndexType(ctx, node, scope) const types = resolveIndexType(ctx, node, scope)
return flattenTypes(ctx, types, scope) return flattenTypes(ctx, types, scope)
} catch (e) {} } catch (e) {
break
}
} }
case 'ClassDeclaration': case 'ClassDeclaration':
@ -1254,11 +1329,22 @@ export function inferRuntimeType(
return inferRuntimeType(ctx, resolved, resolved._ownerScope) return inferRuntimeType(ctx, resolved, resolved._ownerScope)
} }
} catch (e) {} } catch (e) {}
break
} }
default: case 'TSTypeQuery': {
return [UNKNOWN_TYPE] // no runtime check const id = node.exprName
if (id.type === 'Identifier') {
// typeof only support identifier in local scope
const matched = scope.declares[id.name]
if (matched) {
return inferRuntimeType(ctx, matched, matched._ownerScope)
}
}
break
}
} }
return [UNKNOWN_TYPE] // no runtime check
} }
function flattenTypes( function flattenTypes(
@ -1294,3 +1380,130 @@ function inferEnumType(node: TSEnumDeclaration): string[] {
} }
return types.size ? [...types] : ['Number'] return types.size ? [...types] : ['Number']
} }
/**
* support for the `ExtractPropTypes` helper - it's non-exhaustive, mostly
* tailored towards popular component libs like element-plus and antd-vue.
*/
function resolveExtractPropTypes(
{ props }: ResolvedElements,
scope: TypeScope
): ResolvedElements {
const res: ResolvedElements = { props: {} }
for (const key in props) {
const raw = props[key]
res.props[key] = reverseInferType(
raw.key,
raw.typeAnnotation!.typeAnnotation,
scope
)
}
return res
}
function reverseInferType(
key: Expression,
node: TSType,
scope: TypeScope,
optional = true,
checkObjectSyntax = true
): TSPropertySignature & WithScope {
if (checkObjectSyntax && node.type === 'TSTypeLiteral') {
// check { type: xxx }
const typeType = findStaticPropertyType(node, 'type')
if (typeType) {
const requiredType = findStaticPropertyType(node, 'required')
const optional =
requiredType &&
requiredType.type === 'TSLiteralType' &&
requiredType.literal.type === 'BooleanLiteral'
? !requiredType.literal.value
: true
return reverseInferType(key, typeType, scope, optional, false)
}
} else if (
node.type === 'TSTypeReference' &&
node.typeName.type === 'Identifier'
) {
if (node.typeName.name.endsWith('Constructor')) {
return createProperty(
key,
ctorToType(node.typeName.name),
scope,
optional
)
} else if (node.typeName.name === 'PropType' && node.typeParameters) {
// PropType<{}>
return createProperty(key, node.typeParameters.params[0], scope, optional)
}
}
if (
(node.type === 'TSTypeReference' || node.type === 'TSImportType') &&
node.typeParameters
) {
// try if we can catch Foo.Bar<XXXConstructor>
for (const t of node.typeParameters.params) {
const inferred = reverseInferType(key, t, scope, optional)
if (inferred) return inferred
}
}
return createProperty(key, { type: `TSNullKeyword` }, scope, optional)
}
function ctorToType(ctorType: string): TSType {
const ctor = ctorType.slice(0, -11)
switch (ctor) {
case 'String':
case 'Number':
case 'Boolean':
return { type: `TS${ctor}Keyword` }
case 'Array':
case 'Function':
case 'Object':
case 'Set':
case 'Map':
case 'WeakSet':
case 'WeakMap':
case 'Date':
case 'Promise':
return {
type: 'TSTypeReference',
typeName: { type: 'Identifier', name: ctor }
}
}
// fallback to null
return { type: `TSNullKeyword` }
}
function findStaticPropertyType(node: TSTypeLiteral, key: string) {
const prop = node.members.find(
m =>
m.type === 'TSPropertySignature' &&
!m.computed &&
getId(m.key) === key &&
m.typeAnnotation
)
return prop && prop.typeAnnotation!.typeAnnotation
}
function resolveReturnType(
ctx: TypeResolveContext,
arg: Node,
scope: TypeScope
) {
let resolved: Node | undefined = arg
if (
arg.type === 'TSTypeReference' ||
arg.type === 'TSTypeQuery' ||
arg.type === 'TSImportType'
) {
resolved = resolveTypeReference(ctx, arg, scope)
}
if (!resolved) return
if (resolved.type === 'TSFunctionType') {
return resolved.typeAnnotation?.typeAnnotation
}
if (resolved.type === 'TSDeclareFunction') {
return resolved.returnType
}
}