From 3c75d8e0065fe825d4aeb14c9d8cdf3f17b52971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Wed, 10 May 2023 15:29:53 +0800 Subject: [PATCH 1/8] refactor: better define --- packages/api/src/ts.ts | 546 ------------------ packages/api/src/ts/create.ts | 33 ++ packages/api/src/ts/exports.ts | 91 +++ packages/api/src/ts/impl.ts | 34 ++ packages/api/src/ts/index.ts | 8 + packages/api/src/ts/is.ts | 20 + packages/api/src/ts/property.ts | 218 +++++++ packages/api/src/ts/resolve-reference.ts | 135 +++++ packages/api/src/ts/resolve.ts | 316 ++++++++++ packages/api/src/ts/scope.ts | 41 ++ packages/api/src/vue/props.ts | 334 +++++++---- .../tests/__snapshots__/fixtures.test.ts.snap | 298 +++++++++- .../better-define/tests/fixtures/built-in.vue | 12 + .../tests/fixtures/namespace.vue | 28 + .../tests/fixtures/playground.vue | 6 + .../tests/fixtures/template-keys.vue | 8 + .../tests/fixtures/ts-indexed-access-type.vue | 31 + .../tests/fixtures/undefined.vue | 5 + .../tests/fixtures/vue-core-7553.vue | 2 +- 19 files changed, 1497 insertions(+), 669 deletions(-) delete mode 100644 packages/api/src/ts.ts create mode 100644 packages/api/src/ts/create.ts create mode 100644 packages/api/src/ts/exports.ts create mode 100644 packages/api/src/ts/impl.ts create mode 100644 packages/api/src/ts/index.ts create mode 100644 packages/api/src/ts/is.ts create mode 100644 packages/api/src/ts/property.ts create mode 100644 packages/api/src/ts/resolve-reference.ts create mode 100644 packages/api/src/ts/resolve.ts create mode 100644 packages/api/src/ts/scope.ts create mode 100644 packages/better-define/tests/fixtures/built-in.vue create mode 100644 packages/better-define/tests/fixtures/namespace.vue create mode 100644 packages/better-define/tests/fixtures/playground.vue create mode 100644 packages/better-define/tests/fixtures/template-keys.vue create mode 100644 packages/better-define/tests/fixtures/ts-indexed-access-type.vue create mode 100644 packages/better-define/tests/fixtures/undefined.vue diff --git a/packages/api/src/ts.ts b/packages/api/src/ts.ts deleted file mode 100644 index 5c68fc3d3..000000000 --- a/packages/api/src/ts.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import path from 'node:path' -import { - babelParse, - getFileCodeAndLang, - isStaticExpression, - resolveLiteral, - resolveObjectKey, -} from '@vue-macros/common' -import { - type Identifier, - type ImportNamespaceSpecifier, - type ImportSpecifier, - type Statement, - type TSCallSignatureDeclaration, - type TSConstructSignatureDeclaration, - type TSDeclareFunction, - type TSEntityName, - type TSEnumDeclaration, - type TSFunctionType, - type TSInterfaceBody, - type TSInterfaceDeclaration, - type TSIntersectionType, - type TSMappedType, - type TSMethodSignature, - type TSModuleBlock, - type TSModuleDeclaration, - type TSParenthesizedType, - type TSPropertySignature, - type TSType, - type TSTypeAliasDeclaration, - type TSTypeElement, - type TSTypeLiteral, - type UnaryExpression, - isDeclaration, -} from '@babel/types' - -export type TSDeclaration = - /* TypeScript & Declaration */ - | TSDeclareFunction - | TSInterfaceDeclaration - | TSTypeAliasDeclaration - | TSEnumDeclaration - | TSModuleDeclaration - -export interface TSFile { - filePath: string - content: string - ast: Statement[] -} - -export type TSScope = TSFile | TSResolvedType - -export interface TSProperties { - callSignatures: Array< - TSResolvedType - > - constructSignatures: Array> - methods: Record>> - properties: Record< - string | number, - { - value: TSResolvedType | null - optional: boolean - signature: TSResolvedType - } - > -} - -export const tsFileCache: Record = {} -export async function getTSFile(filePath: string): Promise { - if (tsFileCache[filePath]) return tsFileCache[filePath] - const content = await readFile(filePath, 'utf-8') - const { code, lang } = getFileCodeAndLang(content, filePath) - const program = babelParse(code, lang) - return (tsFileCache[filePath] = { - filePath, - content, - ast: program.body, - }) -} - -export function isTSDeclaration(node: any): node is TSDeclaration { - return isDeclaration(node) && node.type.startsWith('TS') -} - -export function mergeTSProperties( - a: TSProperties, - b: TSProperties -): TSProperties { - return { - callSignatures: [...a.callSignatures, ...b.callSignatures], - constructSignatures: [...a.constructSignatures, ...b.constructSignatures], - methods: { ...a.methods, ...b.methods }, - properties: { ...a.properties, ...b.properties }, - } -} - -/** - * get properties of `interface` or `type` declaration - * - * @limitation don't support index signature - */ -export async function resolveTSProperties({ - type, - scope, -}: TSResolvedType< - | TSInterfaceDeclaration - | TSInterfaceBody - | TSTypeLiteral - | TSIntersectionType - | TSMappedType - | TSFunctionType ->): Promise { - switch (type.type) { - case 'TSInterfaceBody': - return resolveTypeElements(scope, type.body) - case 'TSTypeLiteral': - return resolveTypeElements(scope, type.members) - case 'TSInterfaceDeclaration': { - let properties = resolveTypeElements(scope, type.body.body) - if (type.extends) { - const resolvedExtends = ( - await Promise.all( - type.extends.map((node) => - node.expression.type === 'Identifier' - ? resolveTSReferencedType({ - scope, - type: node.expression, - }) - : undefined - ) - ) - ) - // eslint-disable-next-line unicorn/no-array-callback-reference - .filter(filterValidExtends) - - if (resolvedExtends.length > 0) { - const ext = ( - await Promise.all( - resolvedExtends.map((resolved) => resolveTSProperties(resolved)) - ) - ).reduceRight((acc, curr) => mergeTSProperties(acc, curr)) - properties = mergeTSProperties(ext, properties) - } - } - return properties - } - case 'TSIntersectionType': { - let properties: TSProperties = { - callSignatures: [], - constructSignatures: [], - methods: {}, - properties: {}, - } - for (const subType of type.types) { - const resolved = await resolveTSReferencedType({ - scope, - type: subType, - }) - if (!filterValidExtends(resolved)) continue - properties = mergeTSProperties( - properties, - await resolveTSProperties(resolved) - ) - } - return properties - } - case 'TSMappedType': { - const properties: TSProperties = { - callSignatures: [], - constructSignatures: [], - methods: {}, - properties: {}, - } - if (!type.typeParameter.constraint) return properties - - const constraint = await resolveTSReferencedType({ - type: type.typeParameter.constraint, - scope, - }) - if (!constraint?.type) return properties - - const types = - constraint.type.type === 'TSUnionType' - ? constraint.type.types - : [constraint.type] - - for (const subType of types) { - if (subType.type !== 'TSLiteralType') continue - const literal = subType.literal - if (!isStaticExpression(literal)) continue - const key = resolveLiteral( - literal as Exclude - ) - if (!key) continue - properties.properties[String(key)] = { - value: type.typeAnnotation - ? { scope, type: type.typeAnnotation } - : null, - optional: type.optional === '+' || type.optional === true, - signature: { type, scope }, - } - } - - return properties - } - case 'TSFunctionType': { - const properties: TSProperties = { - callSignatures: [{ type, scope }], - constructSignatures: [], - methods: {}, - properties: {}, - } - return properties - } - default: - // @ts-expect-error type is never - throw new Error(`unknown node: ${type?.type}`) - } - - function filterValidExtends( - node: TSResolvedType | TSExports | undefined - ): node is TSResolvedType< - TSInterfaceDeclaration | TSTypeLiteral | TSIntersectionType - > { - return ( - !isTSExports(node) && - [ - 'TSInterfaceDeclaration', - 'TSTypeLiteral', - 'TSIntersectionType', - ].includes(node?.type.type as any) - ) - } -} - -/** - * @limitation don't support index signature - */ -export function resolveTypeElements( - scope: TSScope, - elements: Array -) { - const properties: TSProperties = { - callSignatures: [], - constructSignatures: [], - methods: {}, - properties: {}, - } - - const tryGetKey = (element: TSMethodSignature | TSPropertySignature) => { - try { - return resolveObjectKey(element.key, element.computed, false) - } catch {} - } - - for (const element of elements) { - switch (element.type) { - case 'TSCallSignatureDeclaration': - properties.callSignatures.push({ scope, type: element }) - break - case 'TSConstructSignatureDeclaration': - properties.constructSignatures.push({ scope, type: element }) - break - case 'TSMethodSignature': { - const key = tryGetKey(element) - if (!key) continue - - // cannot overwrite if already exists - if (properties.properties[key]) continue - - if (!properties.methods[key]) properties.methods[key] = [] - if (element.typeAnnotation) { - properties.methods[key].push({ scope, type: element }) - } - break - } - case 'TSPropertySignature': { - const key = tryGetKey(element) - if (!key) continue - - if (!properties.properties[key] && !properties.methods[key]) { - // cannot be override - const type = element.typeAnnotation?.typeAnnotation - properties.properties[key] = { - value: type ? { type, scope } : null, - optional: !!element.optional, - signature: { type: element, scope }, - } - } - break - } - case 'TSIndexSignature': - // TODO: unsupported - break - } - } - - return properties -} - -export interface TSResolvedType< - T = - | Exclude - | Exclude -> { - scope: TSScope - type: T -} - -/** - * Resolve a reference to a type. - * - * Supports `type` and `interface` only. - * - * @limitation don't support non-TS declaration (e.g. class, function...) - */ -export async function resolveTSReferencedType( - ref: TSResolvedType, - stacks: TSResolvedType[] = [] -): Promise { - const { scope, type } = ref - if (stacks.some((stack) => stack.scope === scope && stack.type === type)) { - return ref as any - } - stacks.push(ref) - - if ( - type.type === 'TSTypeAliasDeclaration' || - type.type === 'TSParenthesizedType' - ) { - return resolveTSReferencedType({ scope, type: type.typeAnnotation }, stacks) - } - - let refNames: string[] - if (type.type === 'Identifier') { - refNames = [type.name] - } else if (type.type === 'TSTypeReference') { - if (type.typeName.type === 'Identifier') { - refNames = [type.typeName.name] - } else { - refNames = resolveTSEntityName(type.typeName).map((id) => id.name) - } - } else if ( - type.type === 'TSModuleDeclaration' && - type.body.type === 'TSModuleBlock' - ) { - return resolveTSExports({ type: type.body, scope }) - } else { - return { scope, type } - } - const [refName, ...restNames] = refNames - - const { body, file } = resolveTSScope(scope) - for (let node of body) { - if (node.type === 'ImportDeclaration') { - const specifier = node.specifiers.find( - (specifier): specifier is ImportSpecifier | ImportNamespaceSpecifier => - (specifier.type === 'ImportSpecifier' && - specifier.imported.type === 'Identifier' && - specifier.imported.name === refName) || - (specifier.type === 'ImportNamespaceSpecifier' && - specifier.local.name === refName) - ) - if (!specifier) continue - - const resolved = await resolveTSFileId(node.source.value, file.filePath) - if (!resolved) continue - const exports = await resolveTSExports(await getTSFile(resolved)) - - let type: any = exports - for (const name of specifier.type === 'ImportSpecifier' - ? refNames - : restNames) { - type = type?.[name] - } - return type - } - - if (node.type === 'ExportNamedDeclaration' && node.declaration) - node = node.declaration - - if (isTSDeclaration(node)) { - if (node.id?.type !== 'Identifier') continue - if (node.id.name !== refName) continue - const resolved = await resolveTSReferencedType( - { scope, type: node }, - stacks - ) - if (!resolved) return - - if (restNames.length === 0) { - return resolved - } else { - let exports: any = resolved - for (const name of restNames) { - exports = exports[name] - } - return exports as TSResolvedType - } - } - } - - if (type.type === 'TSTypeReference') return { scope, type } -} - -export function resolveTSScope(scope: TSScope): { - isFile: boolean - file: TSFile - body: Statement[] -} { - const isFile = 'ast' in scope - const file = isFile ? scope : resolveTSScope(scope.scope).file - const body = isFile ? scope.ast : scope.type.body - - return { - isFile, - file, - body, - } -} - -export function resolveTSEntityName(node: TSEntityName): Identifier[] { - if (node.type === 'Identifier') return [node] - else { - return [...resolveTSEntityName(node.left), node.right] - } -} - -export const exportsSymbol = Symbol('exports') -export type TSExports = { - [K in string]: TSResolvedType | TSExports | undefined -} & { [exportsSymbol]: true } -export const tsFileExportsCache: Map = new Map() - -export function isTSExports(val: unknown): val is TSExports { - return !!val && typeof val === 'object' && exportsSymbol in val -} - -/** - * Get exports of the TS file. - * - * @limitation don't support non-TS declaration (e.g. class, function...) - * @limitation don't support `export default`, since TS don't support it currently. - * @limitation don't support `export * as xxx from '...'` (aka namespace). - */ -export async function resolveTSExports(scope: TSScope): Promise { - if (tsFileExportsCache.has(scope)) return tsFileExportsCache.get(scope)! - - const exports: TSExports = { - [exportsSymbol]: true, - } - tsFileExportsCache.set(scope, exports) - - const { body, file } = resolveTSScope(scope) - for (const stmt of body) { - if (stmt.type === 'ExportDefaultDeclaration') { - // TS don't support it. - } else if (stmt.type === 'ExportAllDeclaration') { - const resolved = await resolveTSFileId(stmt.source.value, file.filePath) - if (!resolved) continue - const sourceExports = await resolveTSExports(await getTSFile(resolved)) - Object.assign(exports, sourceExports) - } else if (stmt.type === 'ExportNamedDeclaration') { - let sourceExports: Awaited> - if (stmt.source) { - const resolved = await resolveTSFileId(stmt.source.value, file.filePath) - if (!resolved) continue - sourceExports = await resolveTSExports(await getTSFile(resolved)) - } - - for (const specifier of stmt.specifiers) { - if (specifier.type === 'ExportDefaultSpecifier') { - // default export: TS don't support it. - continue - } - - if (specifier.type === 'ExportNamespaceSpecifier') { - exports[specifier.exported.name] = sourceExports! - } else { - const exportedName = - specifier.exported.type === 'Identifier' - ? specifier.exported.name - : specifier.exported.value - - if (stmt.source) { - exports[exportedName] = sourceExports![specifier.local.name] - } else { - exports[exportedName] = await resolveTSReferencedType({ - scope, - type: specifier.local, - }) - } - } - } - - if (isTSDeclaration(stmt.declaration)) { - const decl = stmt.declaration - - if (decl.id?.type === 'Identifier') { - const exportedName = decl.id.name - exports[exportedName] = await resolveTSReferencedType({ - scope, - type: decl, - }) - } - } - } - } - - return exports -} - -export type ResolveTSFileIdImpl = ( - id: string, - importer: string -) => Promise | string | undefined -let resolveTSFileIdImpl: ResolveTSFileIdImpl = resolveTSFileIdNode -export function resolveTSFileId(id: string, importer: string) { - return resolveTSFileIdImpl(id, importer) -} - -/** - * @limitation don't node_modules and JavaScript file - */ -export function resolveTSFileIdNode(id: string, importer: string) { - function tryResolve(id: string, importer: string) { - const filePath = path.resolve(importer, '..', id) - if (!existsSync(filePath)) return - return filePath - } - return ( - tryResolve(id, importer) || - tryResolve(`${id}.ts`, importer) || - tryResolve(`${id}.d.ts`, importer) || - tryResolve(`${id}/index`, importer) || - tryResolve(`${id}/index.ts`, importer) || - tryResolve(`${id}/index.d.ts`, importer) - ) -} - -export function setResolveTSFileIdImpl(impl: ResolveTSFileIdImpl) { - resolveTSFileIdImpl = impl -} diff --git a/packages/api/src/ts/create.ts b/packages/api/src/ts/create.ts new file mode 100644 index 000000000..b38b5d535 --- /dev/null +++ b/packages/api/src/ts/create.ts @@ -0,0 +1,33 @@ +import { + type StringLiteral, + type TSLiteralType, + type TSType, + type TSUnionType, +} from '@babel/types' + +export function createUnionType(types: TSType[]): TSUnionType { + return { + type: 'TSUnionType', + types, + } +} + +export function createStringLiteral(value: string): StringLiteral { + return { + type: 'StringLiteral', + value, + extra: { + rawValue: value, + raw: JSON.stringify(value), + }, + } +} + +export function createTSLiteralType( + literal: TSLiteralType['literal'] +): TSLiteralType { + return { + type: 'TSLiteralType', + literal, + } +} diff --git a/packages/api/src/ts/exports.ts b/packages/api/src/ts/exports.ts new file mode 100644 index 000000000..28a118164 --- /dev/null +++ b/packages/api/src/ts/exports.ts @@ -0,0 +1,91 @@ +import { + type TSResolvedType, + resolveTSReferencedType, +} from './resolve-reference' +import { type TSScope, getTSFile, resolveTSScope } from './scope' +import { isTSDeclaration } from './is' +import { resolveTSFileId } from './impl' + +export const exportsSymbol = Symbol('exports') +export type TSExports = { + [K in string]: TSResolvedType | TSExports | undefined +} & { [exportsSymbol]: true } +export const tsFileExportsCache: Map = new Map() + +export function isTSExports(val: unknown): val is TSExports { + return !!val && typeof val === 'object' && exportsSymbol in val +} + +/** + * Get exports of the TS file. + * + * @limitation don't support non-TS declaration (e.g. class, function...) + * @limitation don't support `export default`, since TS don't support it currently. + * @limitation don't support `export * as xxx from '...'` (aka namespace). + */ +export async function resolveTSExports(scope: TSScope): Promise { + if (tsFileExportsCache.has(scope)) return tsFileExportsCache.get(scope)! + + const exports: TSExports = { + [exportsSymbol]: true, + } + tsFileExportsCache.set(scope, exports) + + const { body, file } = resolveTSScope(scope) + for (const stmt of body) { + if (stmt.type === 'ExportDefaultDeclaration') { + // TS don't support it. + } else if (stmt.type === 'ExportAllDeclaration') { + const resolved = await resolveTSFileId(stmt.source.value, file.filePath) + if (!resolved) continue + const sourceExports = await resolveTSExports(await getTSFile(resolved)) + Object.assign(exports, sourceExports) + } else if (stmt.type === 'ExportNamedDeclaration') { + let sourceExports: Awaited> + if (stmt.source) { + const resolved = await resolveTSFileId(stmt.source.value, file.filePath) + if (!resolved) continue + sourceExports = await resolveTSExports(await getTSFile(resolved)) + } + + for (const specifier of stmt.specifiers) { + if (specifier.type === 'ExportDefaultSpecifier') { + // default export: TS don't support it. + continue + } + + if (specifier.type === 'ExportNamespaceSpecifier') { + exports[specifier.exported.name] = sourceExports! + } else { + const exportedName = + specifier.exported.type === 'Identifier' + ? specifier.exported.name + : specifier.exported.value + + if (stmt.source) { + exports[exportedName] = sourceExports![specifier.local.name] + } else { + exports[exportedName] = await resolveTSReferencedType({ + scope, + type: specifier.local, + }) + } + } + } + + if (isTSDeclaration(stmt.declaration)) { + const decl = stmt.declaration + + if (decl.id?.type === 'Identifier') { + const exportedName = decl.id.name + exports[exportedName] = await resolveTSReferencedType({ + scope, + type: decl, + }) + } + } + } + } + + return exports +} diff --git a/packages/api/src/ts/impl.ts b/packages/api/src/ts/impl.ts new file mode 100644 index 000000000..56ab904ba --- /dev/null +++ b/packages/api/src/ts/impl.ts @@ -0,0 +1,34 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +export type ResolveTSFileIdImpl = ( + id: string, + importer: string +) => Promise | string | undefined +let resolveTSFileIdImpl: ResolveTSFileIdImpl = resolveTSFileIdNode +export function resolveTSFileId(id: string, importer: string) { + return resolveTSFileIdImpl(id, importer) +} + +/** + * @limitation don't node_modules and JavaScript file + */ +export function resolveTSFileIdNode(id: string, importer: string) { + function tryResolve(id: string, importer: string) { + const filePath = path.resolve(importer, '..', id) + if (!existsSync(filePath)) return + return filePath + } + return ( + tryResolve(id, importer) || + tryResolve(`${id}.ts`, importer) || + tryResolve(`${id}.d.ts`, importer) || + tryResolve(`${id}/index`, importer) || + tryResolve(`${id}/index.ts`, importer) || + tryResolve(`${id}/index.d.ts`, importer) + ) +} + +export function setResolveTSFileIdImpl(impl: ResolveTSFileIdImpl) { + resolveTSFileIdImpl = impl +} diff --git a/packages/api/src/ts/index.ts b/packages/api/src/ts/index.ts new file mode 100644 index 000000000..3b8890123 --- /dev/null +++ b/packages/api/src/ts/index.ts @@ -0,0 +1,8 @@ +export * from './create' +export * from './exports' +export * from './impl' +export * from './is' +export * from './property' +export * from './resolve-reference' +export * from './resolve' +export * from './scope' diff --git a/packages/api/src/ts/is.ts b/packages/api/src/ts/is.ts new file mode 100644 index 000000000..a9cd18af7 --- /dev/null +++ b/packages/api/src/ts/is.ts @@ -0,0 +1,20 @@ +import { + type TSDeclareFunction, + type TSEnumDeclaration, + type TSInterfaceDeclaration, + type TSModuleDeclaration, + type TSTypeAliasDeclaration, + isDeclaration, +} from '@babel/types' + +export type TSDeclaration = + /* TypeScript & Declaration */ + | TSDeclareFunction + | TSInterfaceDeclaration + | TSTypeAliasDeclaration + | TSEnumDeclaration + | TSModuleDeclaration + +export function isTSDeclaration(node: any): node is TSDeclaration { + return isDeclaration(node) && node.type.startsWith('TS') +} diff --git a/packages/api/src/ts/property.ts b/packages/api/src/ts/property.ts new file mode 100644 index 000000000..5910c4c49 --- /dev/null +++ b/packages/api/src/ts/property.ts @@ -0,0 +1,218 @@ +import { + type Node, + type TSCallSignatureDeclaration, + type TSConstructSignatureDeclaration, + type TSFunctionType, + type TSInterfaceBody, + type TSInterfaceDeclaration, + type TSIntersectionType, + type TSMappedType, + type TSMethodSignature, + type TSPropertySignature, + type TSType, + type TSTypeLiteral, +} from '@babel/types' +import { resolveLiteral } from '@vue-macros/common' +import { + type TSResolvedType, + resolveTSReferencedType, +} from './resolve-reference' +import { type TSExports, isTSExports } from './exports' +import { + resolveMaybeTSUnion, + resolveTSLiteralType, + resolveTypeElements, +} from './resolve' + +export interface TSProperties { + callSignatures: Array< + TSResolvedType + > + constructSignatures: Array> + methods: Record>> + properties: Record< + string | number, + { + value: TSResolvedType | null + optional: boolean + signature: TSResolvedType + } + > +} + +export function mergeTSProperties( + a: TSProperties, + b: TSProperties +): TSProperties { + return { + callSignatures: [...a.callSignatures, ...b.callSignatures], + constructSignatures: [...a.constructSignatures, ...b.constructSignatures], + methods: { ...a.methods, ...b.methods }, + properties: { ...a.properties, ...b.properties }, + } +} + +export function checkForTSProperties( + node?: Node +): node is + | TSInterfaceDeclaration + | TSInterfaceBody + | TSTypeLiteral + | TSIntersectionType + | TSMappedType + | TSFunctionType { + return ( + !!node && + [ + 'TSInterfaceDeclaration', + 'TSInterfaceBody', + 'TSTypeLiteral', + 'TSIntersectionType', + 'TSMappedType', + 'TSFunctionType', + ].includes(node.type) + ) +} + +/** + * get properties of `interface` or `type` declaration + * + * @limitation don't support index signature + */ +export async function resolveTSProperties({ + type, + scope, +}: TSResolvedType< + | TSInterfaceDeclaration + | TSInterfaceBody + | TSTypeLiteral + | TSIntersectionType + | TSMappedType + | TSFunctionType +>): Promise { + switch (type.type) { + case 'TSInterfaceBody': + return resolveTypeElements(scope, type.body) + case 'TSTypeLiteral': + return resolveTypeElements(scope, type.members) + case 'TSInterfaceDeclaration': { + let properties = resolveTypeElements(scope, type.body.body) + if (type.extends) { + const resolvedExtends = ( + await Promise.all( + type.extends.map((node) => + node.expression.type === 'Identifier' + ? resolveTSReferencedType({ + scope, + type: node.expression, + }) + : undefined + ) + ) + ) + // eslint-disable-next-line unicorn/no-array-callback-reference + .filter(filterValidExtends) + + if (resolvedExtends.length > 0) { + const ext = ( + await Promise.all( + resolvedExtends.map((resolved) => resolveTSProperties(resolved)) + ) + ).reduceRight((acc, curr) => mergeTSProperties(acc, curr)) + properties = mergeTSProperties(ext, properties) + } + } + return properties + } + case 'TSIntersectionType': { + let properties: TSProperties = { + callSignatures: [], + constructSignatures: [], + methods: {}, + properties: {}, + } + for (const subType of type.types) { + const resolved = await resolveTSReferencedType({ + scope, + type: subType, + }) + if (!filterValidExtends(resolved)) continue + properties = mergeTSProperties( + properties, + await resolveTSProperties(resolved) + ) + } + return properties + } + case 'TSMappedType': { + const properties: TSProperties = { + callSignatures: [], + constructSignatures: [], + methods: {}, + properties: {}, + } + if (!type.typeParameter.constraint) return properties + + const constraint = await resolveTSReferencedType({ + type: type.typeParameter.constraint, + scope, + }) + if (!constraint || isTSExports(constraint)) return properties + + const types = resolveMaybeTSUnion(constraint.type) + for (const subType of types) { + if (subType.type !== 'TSLiteralType') continue + + const literal = await resolveTSLiteralType({ + type: subType, + scope: constraint.scope, + }) + if (!literal) continue + + const keys = resolveMaybeTSUnion(literal).map((literal) => + String(resolveLiteral(literal)) + ) + for (const key of keys) { + properties.properties[String(key)] = { + value: type.typeAnnotation + ? { scope, type: type.typeAnnotation } + : null, + optional: type.optional === '+' || type.optional === true, + signature: { type, scope }, + } + } + } + + return properties + } + case 'TSFunctionType': { + const properties: TSProperties = { + callSignatures: [{ type, scope }], + constructSignatures: [], + methods: {}, + properties: {}, + } + return properties + } + default: + // @ts-expect-error type is never + throw new Error(`unknown node: ${type?.type}`) + } + + function filterValidExtends( + node: TSResolvedType | TSExports | undefined + ): node is TSResolvedType< + TSInterfaceDeclaration | TSTypeLiteral | TSIntersectionType + > { + return !isTSExports(node) && checkForTSProperties(node?.type) + } +} + +export function getTSPropertiesKeys(properties: TSProperties) { + return [ + ...new Set([ + ...Object.keys(properties.properties), + ...Object.keys(properties.methods), + ]), + ] +} diff --git a/packages/api/src/ts/resolve-reference.ts b/packages/api/src/ts/resolve-reference.ts new file mode 100644 index 000000000..88024532b --- /dev/null +++ b/packages/api/src/ts/resolve-reference.ts @@ -0,0 +1,135 @@ +import { + type Identifier, + type ImportNamespaceSpecifier, + type ImportSpecifier, + type Node, + type TSParenthesizedType, + type TSType, + type TSTypeAliasDeclaration, + isTSType, +} from '@babel/types' +import { type TSExports, resolveTSExports } from './exports' +import { resolveTSFileId } from './impl' +import { parseTSEntityName, resolveTSIndexedAccessType } from './resolve' +import { type TSScope, getTSFile, resolveTSScope } from './scope' +import { type TSDeclaration, isTSDeclaration } from './is' + +export interface TSResolvedType< + T = + | Exclude + | Exclude +> { + scope: TSScope + type: T +} + +type TSReferencedType = TSType | Identifier | TSDeclaration + +export function isSupportedForTSReferencedType( + node: Node +): node is TSReferencedType { + return isTSType(node) || node.type === 'Identifier' || isTSDeclaration(node) +} + +/** + * Resolve a reference to a type. + * + * Supports `type` and `interface` only. + * + * @limitation don't support non-TS declaration (e.g. class, function...) + */ +export async function resolveTSReferencedType( + ref: TSResolvedType, + stacks: TSResolvedType[] = [] +): Promise { + const { scope, type } = ref + if (stacks.some((stack) => stack.scope === scope && stack.type === type)) { + return ref as any + } + stacks.push(ref) + + switch (type.type) { + case 'TSTypeAliasDeclaration': + case 'TSParenthesizedType': + return resolveTSReferencedType( + { scope, type: type.typeAnnotation }, + stacks + ) + case 'TSIndexedAccessType': + return resolveTSIndexedAccessType({ type, scope }, stacks) + + case 'TSModuleDeclaration': { + if (type.body.type === 'TSModuleBlock') { + return resolveTSExports({ type: type.body, scope }) + } + return undefined + } + } + + let refNames: string[] + if (type.type === 'Identifier') { + refNames = [type.name] + } else if (type.type === 'TSTypeReference') { + if (type.typeName.type === 'Identifier') { + refNames = [type.typeName.name] + } else { + refNames = parseTSEntityName(type.typeName).map((id) => id.name) + } + } else { + return { scope, type } + } + + const [refName, ...restNames] = refNames + + const { body, file } = resolveTSScope(scope) + for (let node of body) { + if (node.type === 'ImportDeclaration') { + const specifier = node.specifiers.find( + (specifier): specifier is ImportSpecifier | ImportNamespaceSpecifier => + (specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === refName) || + (specifier.type === 'ImportNamespaceSpecifier' && + specifier.local.name === refName) + ) + if (!specifier) continue + + const resolved = await resolveTSFileId(node.source.value, file.filePath) + if (!resolved) continue + const exports = await resolveTSExports(await getTSFile(resolved)) + + let type: any = exports + for (const name of specifier.type === 'ImportSpecifier' + ? refNames + : restNames) { + type = type?.[name] + } + return type + } + + if (node.type === 'ExportNamedDeclaration' && node.declaration) + node = node.declaration + + if (isTSDeclaration(node)) { + if (node.id?.type !== 'Identifier') continue + if (node.id.name !== refName) continue + const resolved = await resolveTSReferencedType( + { scope, type: node }, + stacks + ) + if (!resolved) return + + if (restNames.length === 0) { + return resolved + } else { + let exports: any = resolved + for (const name of restNames) { + exports = exports[name] + } + return exports + } + } + } + + if (type.type === 'TSTypeReference') return { scope, type } +} diff --git a/packages/api/src/ts/resolve.ts b/packages/api/src/ts/resolve.ts new file mode 100644 index 000000000..95e6c4167 --- /dev/null +++ b/packages/api/src/ts/resolve.ts @@ -0,0 +1,316 @@ +import { resolveLiteral, resolveObjectKey } from '@vue-macros/common' +import { + type BigIntLiteral, + type BooleanLiteral, + type Expression, + type Identifier, + type Node, + type NumericLiteral, + type StringLiteral, + type TSEntityName, + type TSFunctionType, + type TSIndexedAccessType, + type TSLiteralType, + type TSMethodSignature, + type TSPropertySignature, + type TSType, + type TSTypeElement, + type TSTypeOperator, + type TemplateElement, + type TemplateLiteral, +} from '@babel/types' +import { createStringLiteral, createUnionType } from './create' +import { isTSExports } from './exports' +import { + type TSProperties, + checkForTSProperties, + getTSPropertiesKeys, + resolveTSProperties, +} from './property' +import { + type TSResolvedType, + isSupportedForTSReferencedType, + resolveTSReferencedType, +} from './resolve-reference' +import { type TSScope } from './scope' + +export function parseTSEntityName(node: TSEntityName): Identifier[] { + if (node.type === 'Identifier') return [node] + else { + return [...parseTSEntityName(node.left), node.right] + } +} + +export async function resolveTSTemplateLiteral({ + type, + scope, +}: TSResolvedType): Promise { + const types = (await resolveKeys('', type.quasis, type.expressions)).map( + (k) => createStringLiteral(k) + ) + return types + + async function resolveKeys( + prefix: string, + quasis: TemplateElement[], + expressions: Array + ): Promise { + if (expressions.length === 0) { + return [prefix + (quasis[0]?.value.cooked ?? '')] + } + + const [expr, ...restExpr] = expressions + const [quasi, ...restQuasis] = quasis + const subTypes = resolveMaybeTSUnion(expr) + + const keys: string[] = [] + for (const type of subTypes) { + if (!isSupportedForTSReferencedType(type)) continue + + const resolved = await resolveTSReferencedType({ + type, + scope, + }) + if (!resolved || isTSExports(resolved)) continue + + const types = resolveMaybeTSUnion(resolved.type) + for (const type of types) { + if (type.type !== 'TSLiteralType') continue + + const literal = await resolveTSLiteralType({ type, scope }) + if (!literal) continue + + const subKeys = resolveMaybeTSUnion(literal).map((literal) => + String(resolveLiteral(literal)) + ) + for (const key of subKeys) { + const newPrefix = prefix + quasi.value.cooked + String(key) + keys.push(...(await resolveKeys(newPrefix, restQuasis, restExpr))) + } + } + } + return keys + } +} + +export async function resolveTSLiteralType({ + type, + scope, +}: TSResolvedType): Promise< + | StringLiteral[] + | NumericLiteral + | StringLiteral + | BooleanLiteral + | BigIntLiteral + | undefined +> { + if (type.literal.type === 'UnaryExpression') return + if (type.literal.type === 'TemplateLiteral') { + const types = await resolveTSTemplateLiteral({ type: type.literal, scope }) + return types + } + return type.literal +} + +/** + * @limitation don't support index signature + */ +export function resolveTypeElements( + scope: TSScope, + elements: Array +) { + const properties: TSProperties = { + callSignatures: [], + constructSignatures: [], + methods: {}, + properties: {}, + } + + const tryGetKey = (element: TSMethodSignature | TSPropertySignature) => { + try { + return resolveObjectKey(element.key, element.computed, false) + } catch {} + } + + for (const element of elements) { + switch (element.type) { + case 'TSCallSignatureDeclaration': + properties.callSignatures.push({ scope, type: element }) + break + case 'TSConstructSignatureDeclaration': + properties.constructSignatures.push({ scope, type: element }) + break + case 'TSMethodSignature': { + const key = tryGetKey(element) + if (!key) continue + + // cannot overwrite if already exists + if (properties.properties[key]) continue + + if (!properties.methods[key]) properties.methods[key] = [] + if (element.typeAnnotation) { + properties.methods[key].push({ scope, type: element }) + } + break + } + case 'TSPropertySignature': { + const key = tryGetKey(element) + if (!key) continue + + if (!properties.properties[key] && !properties.methods[key]) { + // cannot be override + const type = element.typeAnnotation?.typeAnnotation + properties.properties[key] = { + value: type ? { type, scope } : null, + optional: !!element.optional, + signature: { type: element, scope }, + } + } + break + } + case 'TSIndexSignature': + // TODO: unsupported + break + } + } + + return properties +} + +export async function resolveTSIndexedAccessType( + { scope, type }: TSResolvedType, + stacks: TSResolvedType[] = [] +) { + const object = await resolveTSReferencedType( + { type: type.objectType, scope }, + stacks + ) + if (!object || isTSExports(object)) return undefined + + const objectType = object.type + if (type.indexType.type === 'TSNumberKeyword') { + let types: TSType[] + + if (objectType.type === 'TSArrayType') { + types = [objectType.elementType] + } else if (objectType.type === 'TSTupleType') { + types = objectType.elementTypes.map((t) => + t.type === 'TSNamedTupleMember' ? t.elementType : t + ) + } else if ( + objectType.type === 'TSTypeReference' && + objectType.typeName.type === 'Identifier' && + objectType.typeName.name === 'Array' && + objectType.typeParameters + ) { + types = objectType.typeParameters.params + } else { + return undefined + } + + return { type: createUnionType(types), scope } + } else if ( + objectType.type !== 'TSInterfaceDeclaration' && + objectType.type !== 'TSTypeLiteral' && + objectType.type !== 'TSIntersectionType' && + objectType.type !== 'TSMappedType' && + objectType.type !== 'TSFunctionType' + ) + return undefined + + const properties = await resolveTSProperties({ + type: objectType, + scope: object.scope, + }) + + const indexTypes = resolveMaybeTSUnion(type.indexType) + const indexes: TSType[] = [] + let optional = false + + for (const index of indexTypes) { + let keys: string[] + + if (index.type === 'TSLiteralType') { + const literal = await resolveTSLiteralType({ + type: index, + scope: object.scope, + }) + if (!literal) continue + keys = resolveMaybeTSUnion(literal).map((literal) => + String(resolveLiteral(literal)) + ) + } else if (index.type === 'TSTypeOperator') { + const keysStrings = await resolveTSTypeOperator({ + type: index, + scope: object.scope, + }) + if (!keysStrings) continue + keys = resolveMaybeTSUnion(keysStrings).map((literal) => + String(resolveLiteral(literal)) + ) + } else continue + + for (const key of keys) { + const property = properties.properties[key] + if (property) { + optional ||= property.optional + const propertyType = properties.properties[key].value + if (propertyType) indexes.push(propertyType.type) + } + + const methods = properties.methods[key] + if (methods) { + optional ||= methods.some((m) => !!m.type.optional) + indexes.push( + ...methods.map( + ({ type }): TSFunctionType => ({ + ...type, + type: 'TSFunctionType', + }) + ) + ) + } + } + } + + if (indexes.length === 0) return undefined + if (optional) indexes.push({ type: 'TSUndefinedKeyword' }) + + return { type: createUnionType(indexes), scope } +} + +export async function resolveTSTypeOperator( + { scope, type }: TSResolvedType, + stacks: TSResolvedType[] = [] +) { + if (type.operator !== 'keyof') return undefined + + const resolved = await resolveTSReferencedType( + { + type: type.typeAnnotation, + scope, + }, + stacks + ) + if (!resolved || isTSExports(resolved)) return undefined + const { type: resolvedType, scope: resolvedScope } = resolved + if (!checkForTSProperties(resolvedType)) return undefined + + const properties = await resolveTSProperties({ + type: resolvedType, + scope: resolvedScope, + }) + + return getTSPropertiesKeys(properties).map((k) => createStringLiteral(k)) +} + +export function resolveMaybeTSUnion(node: T | T[]): T[] +export function resolveMaybeTSUnion(node: T): (T | TSType)[] +export function resolveMaybeTSUnion( + node: T | T[] +): (T | TSType)[] { + if (Array.isArray(node)) return node + if (node.type === 'TSUnionType') + return node.types.flatMap((t) => resolveMaybeTSUnion(t)) + return [node] +} diff --git a/packages/api/src/ts/scope.ts b/packages/api/src/ts/scope.ts new file mode 100644 index 000000000..3da2a575c --- /dev/null +++ b/packages/api/src/ts/scope.ts @@ -0,0 +1,41 @@ +import { readFile } from 'node:fs/promises' +import { type Statement, type TSModuleBlock } from '@babel/types' +import { babelParse, getFileCodeAndLang } from '@vue-macros/common' +import { type TSResolvedType } from './resolve-reference' + +export interface TSFile { + filePath: string + content: string + ast: Statement[] +} + +export type TSScope = TSFile | TSResolvedType + +export const tsFileCache: Record = {} +export async function getTSFile(filePath: string): Promise { + if (tsFileCache[filePath]) return tsFileCache[filePath] + const content = await readFile(filePath, 'utf-8') + const { code, lang } = getFileCodeAndLang(content, filePath) + const program = babelParse(code, lang) + return (tsFileCache[filePath] = { + filePath, + content, + ast: program.body, + }) +} + +export function resolveTSScope(scope: TSScope): { + isFile: boolean + file: TSFile + body: Statement[] +} { + const isFile = 'ast' in scope + const file = isFile ? scope : resolveTSScope(scope.scope).file + const body = isFile ? scope.ast : scope.type.body + + return { + isFile, + file, + body, + } +} diff --git a/packages/api/src/vue/props.ts b/packages/api/src/vue/props.ts index 32062c0bb..2d7c7f4b8 100644 --- a/packages/api/src/vue/props.ts +++ b/packages/api/src/vue/props.ts @@ -21,13 +21,17 @@ import { type TSPropertySignature, type TSType, type TSTypeLiteral, + type TSTypeReference, type TSUnionType, type VariableDeclaration, } from '@babel/types' import { type TSFile, + type TSProperties, type TSResolvedType, + type TSScope, isTSExports, + resolveMaybeTSUnion, resolveTSProperties, resolveTSReferencedType, resolveTSScope, @@ -36,6 +40,44 @@ import { keyToString } from '../utils' import { type ASTDefinition, DefinitionKind } from './types' import { attachNodeLoc, inferRuntimeType } from './utils' +type BuiltInTypesHandler = Record< + string, + { + handleType(resolved: TSTypeReference): TSType | undefined + handleTSProperties?(properties: TSProperties): TSProperties + } +> +const builtInTypesHandlers: BuiltInTypesHandler = { + Partial: { + handleType(resolved) { + return resolved.typeParameters?.params[0] + }, + handleTSProperties(properties) { + for (const prop of Object.values(properties.properties)) { + prop.optional = true + } + return properties + }, + }, + Required: { + handleType(resolved) { + return resolved.typeParameters?.params[0] + }, + handleTSProperties(properties) { + for (const prop of Object.values(properties.properties)) { + prop.optional = false + } + return properties + }, + }, + Readonly: { + handleType(resolved) { + return resolved.typeParameters?.params[0] + }, + }, + // TODO: pick, omit +} + export async function handleTSPropsDefinition({ s, file, @@ -193,12 +235,18 @@ export async function handleTSPropsDefinition({ } else { const resolvedType = def.value if (resolvedType) { + const optional = + def.optional || + resolveMaybeTSUnion(resolvedType.ast).some( + (t) => t.type === 'TSUndefinedKeyword' + ) + prop = { type: await inferRuntimeType({ scope: resolvedType.scope || file, type: resolvedType.ast, }), - required: !def.optional, + required: !optional, } } else { prop = { type: ['null'], required: false } @@ -243,145 +291,128 @@ export async function handleTSPropsDefinition({ withDefaultsAst, } - async function resolveDefinitions( - typeDeclRaw: TSResolvedType - ): Promise<{ - definitions: TSProps['definitions'] - definitionsAst: TSProps['definitionsAst'] - }> { - const resolved = await resolveTSReferencedType(typeDeclRaw) - if (!resolved || isTSExports(resolved)) - throw new SyntaxError(`Cannot resolve TS definition.`) + async function resolveUnion(definitionsAst: TSUnionType, scope: TSScope) { + const unionDefs: TSProps['definitions'][] = [] + const keys = new Set() + for (const type of definitionsAst.types) { + const defs = await resolveDefinitions({ type, scope }).then( + ({ definitions }) => definitions + ) + Object.keys(defs).map((key) => keys.add(key)) + unionDefs.push(defs) + } - const { type: definitionsAst, scope } = resolved - if (definitionsAst.type === 'TSIntersectionType') { - const results: TSProps['definitions'] = {} - for (const type of definitionsAst.types) { - const defMap = await resolveDefinitions({ type, scope }).then( - ({ definitions }) => definitions - ) - for (const [key, def] of Object.entries(defMap)) { - const result = results[key] - if (!result) { - results[key] = def - continue - } + const results: TSProps['definitions'] = {} + for (const key of keys) { + let optional = false + let result: TSPropsMethod | TSPropsProperty | undefined - if (result.type === 'method' && def.type === 'method') { - result.methods.push(...def.methods) - } else { - results[key] = def - } + for (const defMap of unionDefs) { + const def = defMap[key] + if (!def) { + optional = true + continue } - } + optional ||= def.optional - return { - definitions: results, - definitionsAst: buildDefinition({ scope, type: definitionsAst }), - } - } else if (definitionsAst.type === 'TSUnionType') { - const unionDefs: TSProps['definitions'][] = [] - const keys = new Set() - for (const type of definitionsAst.types) { - const defs = await resolveDefinitions({ type, scope }).then( - ({ definitions }) => definitions - ) - Object.keys(defs).map((key) => keys.add(key)) - unionDefs.push(defs) - } - - const results: TSProps['definitions'] = {} - for (const key of keys) { - let optional = false - let result: TSPropsMethod | TSPropsProperty | undefined + if (!result) { + result = def + continue + } - for (const defMap of unionDefs) { - const def = defMap[key] - if (!def) { - optional = true + if (result.type === 'method' && def.type === 'method') { + result.methods.push(...def.methods) + } else if (result.type === 'property' && def.type === 'property') { + if (!def.value) { continue - } - optional ||= def.optional - - if (!result) { + } else if (!result.value) { result = def continue } - if (result.type === 'method' && def.type === 'method') { - result.methods.push(...def.methods) - } else if (result.type === 'property' && def.type === 'property') { - if (!def.value) { - continue - } else if (!result.value) { - result = def - continue - } - - if ( - def.value.ast.type === 'TSImportType' || - def.value.ast.type === 'TSDeclareFunction' || - def.value.ast.type === 'TSEnumDeclaration' || - def.value.ast.type === 'TSInterfaceDeclaration' || - def.value.ast.type === 'TSModuleDeclaration' || - result.value.ast.type === 'TSImportType' || - result.value.ast.type === 'TSDeclareFunction' || - result.value.ast.type === 'TSEnumDeclaration' || - result.value.ast.type === 'TSInterfaceDeclaration' || - result.value.ast.type === 'TSModuleDeclaration' - ) { - // no way! - continue - } + if ( + def.value.ast.type === 'TSImportType' || + def.value.ast.type === 'TSDeclareFunction' || + def.value.ast.type === 'TSEnumDeclaration' || + def.value.ast.type === 'TSInterfaceDeclaration' || + def.value.ast.type === 'TSModuleDeclaration' || + result.value.ast.type === 'TSImportType' || + result.value.ast.type === 'TSDeclareFunction' || + result.value.ast.type === 'TSEnumDeclaration' || + result.value.ast.type === 'TSInterfaceDeclaration' || + result.value.ast.type === 'TSModuleDeclaration' + ) { + // no way! + continue + } - if (result.value.ast.type === 'TSUnionType') { - result.value.ast.types.push(def.value.ast) - } else { - // overwrite original to union type - result = { - type: 'property', - value: buildDefinition({ - scope, - type: { - type: 'TSUnionType', - types: [result.value.ast, def.value.ast], - } satisfies TSUnionType, - }), - signature: null as any, - optional, - addByAPI: false, - } - } + if (result.value.ast.type === 'TSUnionType') { + result.value.ast.types.push(def.value.ast) } else { - throw new SyntaxError( - `Cannot resolve TS definition. Union type contains different types of results.` - ) + // overwrite original to union type + result = { + type: 'property', + value: buildDefinition({ + scope, + type: { + type: 'TSUnionType', + types: [result.value.ast, def.value.ast], + } satisfies TSUnionType, + }), + signature: null as any, + optional, + addByAPI: false, + } } - } - - if (result) { - results[key] = { ...result, optional } + } else { + throw new SyntaxError( + `Cannot resolve TS definition. Union type contains different types of results.` + ) } } - return { - definitions: results, - definitionsAst: buildDefinition({ scope, type: definitionsAst }), + if (result) { + results[key] = { ...result, optional } } - } else if ( - definitionsAst.type !== 'TSInterfaceDeclaration' && - definitionsAst.type !== 'TSTypeLiteral' && - definitionsAst.type !== 'TSMappedType' - ) - throw new SyntaxError( - `Cannot resolve TS definition: ${definitionsAst.type}.` + } + + return { + definitions: results, + definitionsAst: buildDefinition({ scope, type: definitionsAst }), + } + } + + async function resolveIntersection( + definitionsAst: TSIntersectionType, + scope: TSScope + ) { + const results: TSProps['definitions'] = {} + for (const type of definitionsAst.types) { + const defMap = await resolveDefinitions({ type, scope }).then( + ({ definitions }) => definitions ) + for (const [key, def] of Object.entries(defMap)) { + const result = results[key] + if (!result) { + results[key] = def + continue + } - const properties = await resolveTSProperties({ - scope, - type: definitionsAst, - }) + if (result.type === 'method' && def.type === 'method') { + result.methods.push(...def.methods) + } else { + results[key] = def + } + } + } + return { + definitions: results, + definitionsAst: buildDefinition({ scope, type: definitionsAst }), + } + } + + async function resolveNormal(properties: TSProperties) { const definitions: TSProps['definitions'] = {} for (const [key, sign] of Object.entries(properties.methods)) { const methods = sign.map((sign) => buildDefinition(sign)) @@ -408,8 +439,68 @@ export async function handleTSPropsDefinition({ } } + return definitions + } + + async function resolveDefinitions( + typeDeclRaw: TSResolvedType + ): Promise<{ + definitions: TSProps['definitions'] + definitionsAst: TSProps['definitionsAst'] + }> { + let resolved = await resolveTSReferencedType(typeDeclRaw) + + let builtInTypesHandler: BuiltInTypesHandler[string] | undefined + + if ( + resolved && + !isTSExports(resolved) && + resolved.type.type === 'TSTypeReference' && + resolved.type.typeName.type === 'Identifier' + ) { + const typeName = resolved.type.typeName.name + + let type: TSType | undefined + if (typeName in builtInTypesHandlers) { + builtInTypesHandler = builtInTypesHandlers[typeName] + type = builtInTypesHandler.handleType(resolved.type) + } + + if (type) + resolved = await resolveTSReferencedType({ + type, + scope: resolved.scope, + }) + } + + if (!resolved || isTSExports(resolved)) { + throw new SyntaxError(`Cannot resolve TS definition.`) + } + + const { type: definitionsAst, scope } = resolved + if (definitionsAst.type === 'TSIntersectionType') { + return resolveIntersection(definitionsAst, scope) + } else if (definitionsAst.type === 'TSUnionType') { + return resolveUnion(definitionsAst, scope) + } else if ( + definitionsAst.type !== 'TSInterfaceDeclaration' && + definitionsAst.type !== 'TSTypeLiteral' && + definitionsAst.type !== 'TSMappedType' + ) + throw new SyntaxError( + `Cannot resolve TS definition: ${definitionsAst.type}.` + ) + + let properties = await resolveTSProperties({ + scope, + type: definitionsAst, + }) + + if (builtInTypesHandler?.handleTSProperties) + properties = builtInTypesHandler.handleTSProperties(properties) + return { - definitions, + definitions: await resolveNormal(properties), definitionsAst: buildDefinition({ scope, type: definitionsAst }), } } @@ -531,6 +622,7 @@ export interface TSProps extends PropsBase { | TSIntersectionType | TSUnionType | TSMappedType + | TSTypeReference > /** diff --git a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap index 0f9274e89..a820cd70f 100644 --- a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap +++ b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap @@ -56,6 +56,52 @@ export { basic as default }; " `; +exports[`fixtures > tests/fixtures/built-in.vue > isProduction = false 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"built-in\\", + props: { + \\"optional\\": { type: Number, required: false }, + \\"required\\": { type: Boolean, required: true }, + \\"readonly\\": { type: String, required: true } + }, + setup(__props) { + return () => { + }; + } +}); + +var builtIn = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { builtIn as default }; +" +`; + +exports[`fixtures > tests/fixtures/built-in.vue > isProduction = true 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"built-in\\", + props: { + \\"optional\\": null, + \\"required\\": { type: Boolean }, + \\"readonly\\": null + }, + setup(__props) { + return () => { + }; + } +}); + +var builtIn = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { builtIn as default }; +" +`; + exports[`fixtures > tests/fixtures/defaults-dynamic.vue > isProduction = false 1`] = ` "import { defineComponent, mergeDefaults, openBlock, createElementBlock } from 'vue'; import _export_sfc from '/plugin-vue/export-helper'; @@ -522,6 +568,52 @@ export { issue362 as default }; " `; +exports[`fixtures > tests/fixtures/namespace.vue > isProduction = false 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"namespace\\", + props: { + \\"x\\": { type: String, required: true }, + \\"y\\": { type: String, required: true }, + \\"z\\": { type: String, required: true } + }, + setup(__props) { + return () => { + }; + } +}); + +var namespace = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { namespace as default }; +" +`; + +exports[`fixtures > tests/fixtures/namespace.vue > isProduction = true 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"namespace\\", + props: { + \\"x\\": null, + \\"y\\": null, + \\"z\\": null + }, + setup(__props) { + return () => { + }; + } +}); + +var namespace = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { namespace as default }; +" +`; + exports[`fixtures > tests/fixtures/optional-method.vue > isProduction = false 1`] = ` "import { defineComponent } from 'vue'; import _export_sfc from '/plugin-vue/export-helper'; @@ -566,6 +658,48 @@ export { optionalMethod as default }; " `; +exports[`fixtures > tests/fixtures/playground.vue > isProduction = false 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"playground\\", + props: { + \\"foo\\": { required: true } + }, + setup(__props) { + return () => { + }; + } +}); + +var playground = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { playground as default }; +" +`; + +exports[`fixtures > tests/fixtures/playground.vue > isProduction = true 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"playground\\", + props: { + \\"foo\\": null + }, + setup(__props) { + return () => { + }; + } +}); + +var playground = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { playground as default }; +" +`; + exports[`fixtures > tests/fixtures/resolve-failed.vue > isProduction = false 1`] = ` "import { defineComponent } from 'vue'; import _export_sfc from '/plugin-vue/export-helper'; @@ -658,6 +792,126 @@ export { specialKey as default }; " `; +exports[`fixtures > tests/fixtures/template-keys.vue > isProduction = false 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"template-keys\\", + props: { + \\"TfooSxZ0strstatic_value'\\": { type: String, required: true }, + \\"TfooSxZ1strstatic_value'\\": { type: String, required: true }, + \\"TfooSyZ0strstatic_value'\\": { type: String, required: true }, + \\"TfooSyZ1strstatic_value'\\": { type: String, required: true }, + \\"TbarSxZ0strstatic_value'\\": { type: String, required: true }, + \\"TbarSxZ1strstatic_value'\\": { type: String, required: true }, + \\"TbarSyZ0strstatic_value'\\": { type: String, required: true }, + \\"TbarSyZ1strstatic_value'\\": { type: String, required: true }, + \\"extra\\": { type: String, required: true } + }, + setup(__props) { + return () => { + }; + } +}); + +var templateKeys = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { templateKeys as default }; +" +`; + +exports[`fixtures > tests/fixtures/template-keys.vue > isProduction = true 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"template-keys\\", + props: { + \\"TfooSxZ0strstatic_value'\\": null, + \\"TfooSxZ1strstatic_value'\\": null, + \\"TfooSyZ0strstatic_value'\\": null, + \\"TfooSyZ1strstatic_value'\\": null, + \\"TbarSxZ0strstatic_value'\\": null, + \\"TbarSxZ1strstatic_value'\\": null, + \\"TbarSyZ0strstatic_value'\\": null, + \\"TbarSyZ1strstatic_value'\\": null, + \\"extra\\": null + }, + setup(__props) { + return () => { + }; + } +}); + +var templateKeys = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { templateKeys as default }; +" +`; + +exports[`fixtures > tests/fixtures/ts-indexed-access-type.vue > isProduction = false 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"ts-indexed-access-type\\", + props: { + \\"foo\\": { type: String, required: true }, + \\"bar\\": { required: false }, + \\"baz\\": { required: false }, + \\"qux\\": { required: false }, + \\"all\\": { required: false }, + \\"arr\\": { type: [String, Number], required: true }, + \\"arr2\\": { type: [String, Number], required: true }, + \\"tuple\\": { type: [String, Boolean], required: true }, + \\"methodFoo\\": { type: Function, required: true }, + \\"methodBar\\": { type: Function, required: true }, + \\"methodOpt\\": { type: Function, skipCheck: true, required: false } + }, + setup(__props) { + return () => { + }; + } +}); + +var tsIndexedAccessType = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { tsIndexedAccessType as default }; +" +`; + +exports[`fixtures > tests/fixtures/ts-indexed-access-type.vue > isProduction = true 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"ts-indexed-access-type\\", + props: { + \\"foo\\": null, + \\"bar\\": null, + \\"baz\\": null, + \\"qux\\": null, + \\"all\\": null, + \\"arr\\": null, + \\"arr2\\": null, + \\"tuple\\": { type: [String, Boolean] }, + \\"methodFoo\\": { type: Function }, + \\"methodBar\\": { type: Function }, + \\"methodOpt\\": { type: Function } + }, + setup(__props) { + return () => { + }; + } +}); + +var tsIndexedAccessType = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { tsIndexedAccessType as default }; +" +`; + exports[`fixtures > tests/fixtures/ts-utility-types.vue > isProduction = false 1`] = ` "import { defineComponent } from 'vue'; import _export_sfc from '/plugin-vue/export-helper'; @@ -702,6 +956,48 @@ export { tsUtilityTypes as default }; " `; +exports[`fixtures > tests/fixtures/undefined.vue > isProduction = false 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"undefined\\", + props: { + \\"foo\\": { required: false } + }, + setup(__props) { + return () => { + }; + } +}); + +var _undefined = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { _undefined as default }; +" +`; + +exports[`fixtures > tests/fixtures/undefined.vue > isProduction = true 1`] = ` +"import { defineComponent } from 'vue'; +import _export_sfc from '/plugin-vue/export-helper'; + +var _sfc_main = /* @__PURE__ */ defineComponent({ + __name: \\"undefined\\", + props: { + \\"foo\\": null + }, + setup(__props) { + return () => { + }; + } +}); + +var _undefined = /* @__PURE__ */ _export_sfc(_sfc_main, [__FILE__]); + +export { _undefined as default }; +" +`; + exports[`fixtures > tests/fixtures/union.vue > isProduction = false 1`] = ` "import { defineComponent } from 'vue'; import _export_sfc from '/plugin-vue/export-helper'; @@ -884,7 +1180,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"vue-core-7553\\", props: { \\"size\\": { type: String, required: false }, - \\"color\\": { type: String, required: true }, + \\"color\\": { type: [String, Number], required: true }, \\"appearance\\": { type: String, required: true }, \\"note\\": { type: String, required: false } }, diff --git a/packages/better-define/tests/fixtures/built-in.vue b/packages/better-define/tests/fixtures/built-in.vue new file mode 100644 index 000000000..a4f27ee2c --- /dev/null +++ b/packages/better-define/tests/fixtures/built-in.vue @@ -0,0 +1,12 @@ + diff --git a/packages/better-define/tests/fixtures/namespace.vue b/packages/better-define/tests/fixtures/namespace.vue new file mode 100644 index 000000000..362a7c347 --- /dev/null +++ b/packages/better-define/tests/fixtures/namespace.vue @@ -0,0 +1,28 @@ + diff --git a/packages/better-define/tests/fixtures/playground.vue b/packages/better-define/tests/fixtures/playground.vue new file mode 100644 index 000000000..00ad26141 --- /dev/null +++ b/packages/better-define/tests/fixtures/playground.vue @@ -0,0 +1,6 @@ + diff --git a/packages/better-define/tests/fixtures/template-keys.vue b/packages/better-define/tests/fixtures/template-keys.vue new file mode 100644 index 000000000..1b85bdfdc --- /dev/null +++ b/packages/better-define/tests/fixtures/template-keys.vue @@ -0,0 +1,8 @@ + diff --git a/packages/better-define/tests/fixtures/ts-indexed-access-type.vue b/packages/better-define/tests/fixtures/ts-indexed-access-type.vue new file mode 100644 index 000000000..453fdeeb8 --- /dev/null +++ b/packages/better-define/tests/fixtures/ts-indexed-access-type.vue @@ -0,0 +1,31 @@ + diff --git a/packages/better-define/tests/fixtures/undefined.vue b/packages/better-define/tests/fixtures/undefined.vue new file mode 100644 index 000000000..60df95a37 --- /dev/null +++ b/packages/better-define/tests/fixtures/undefined.vue @@ -0,0 +1,5 @@ + diff --git a/packages/better-define/tests/fixtures/vue-core-7553.vue b/packages/better-define/tests/fixtures/vue-core-7553.vue index 983ff6ae7..e079d1b1e 100644 --- a/packages/better-define/tests/fixtures/vue-core-7553.vue +++ b/packages/better-define/tests/fixtures/vue-core-7553.vue @@ -9,7 +9,7 @@ type ConditionalProps = appearance: 'normal' | 'outline' | 'text' } | { - color: 'white' + color: number appearance: 'outline' note: string } From d8c31df2d6f66430df38426cbfe51daa4dd66579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 17:49:34 +0800 Subject: [PATCH 2/8] fix: namespace --- packages/api/src/ts/exports.ts | 124 +++++++++++++----- packages/api/src/ts/is.ts | 18 +-- packages/api/src/ts/resolve-reference.ts | 86 +++--------- packages/api/src/ts/resolve.ts | 11 +- packages/api/src/ts/scope.ts | 42 ++++-- packages/api/src/vue/analyze.ts | 1 + packages/api/src/vue/props.ts | 6 +- packages/api/src/vue/types.ts | 6 +- .../better-define/tests/fixtures/built-in.vue | 4 +- .../tests/fixtures/namespace.vue | 34 ++--- 10 files changed, 172 insertions(+), 160 deletions(-) diff --git a/packages/api/src/ts/exports.ts b/packages/api/src/ts/exports.ts index 28a118164..39d6e4e63 100644 --- a/packages/api/src/ts/exports.ts +++ b/packages/api/src/ts/exports.ts @@ -10,7 +10,6 @@ export const exportsSymbol = Symbol('exports') export type TSExports = { [K in string]: TSResolvedType | TSExports | undefined } & { [exportsSymbol]: true } -export const tsFileExportsCache: Map = new Map() export function isTSExports(val: unknown): val is TSExports { return !!val && typeof val === 'object' && exportsSymbol in val @@ -23,69 +22,124 @@ export function isTSExports(val: unknown): val is TSExports { * @limitation don't support `export default`, since TS don't support it currently. * @limitation don't support `export * as xxx from '...'` (aka namespace). */ -export async function resolveTSExports(scope: TSScope): Promise { - if (tsFileExportsCache.has(scope)) return tsFileExportsCache.get(scope)! +export async function resolveTSExports(scope: TSScope): Promise { + if (scope.exports) return const exports: TSExports = { [exportsSymbol]: true, } - tsFileExportsCache.set(scope, exports) + scope.exports = exports + + const declarations: TSExports = { + [exportsSymbol]: true, + ...scope.declarations, + } + scope.declarations = declarations const { body, file } = resolveTSScope(scope) for (const stmt of body) { - if (stmt.type === 'ExportDefaultDeclaration') { - // TS don't support it. + if ( + stmt.type === 'ExportDefaultDeclaration' && + isTSDeclaration(stmt.declaration) + ) { + exports['default'] = await resolveTSReferencedType({ + scope, + type: stmt.declaration, + }) } else if (stmt.type === 'ExportAllDeclaration') { const resolved = await resolveTSFileId(stmt.source.value, file.filePath) if (!resolved) continue - const sourceExports = await resolveTSExports(await getTSFile(resolved)) - Object.assign(exports, sourceExports) + + const sourceScope = await getTSFile(resolved) + await resolveTSExports(sourceScope) + + Object.assign(exports, sourceScope.exports!) } else if (stmt.type === 'ExportNamedDeclaration') { - let sourceExports: Awaited> + let sourceExports: TSExports + if (stmt.source) { const resolved = await resolveTSFileId(stmt.source.value, file.filePath) if (!resolved) continue - sourceExports = await resolveTSExports(await getTSFile(resolved)) + + const scope = await getTSFile(resolved) + await resolveTSExports(scope) + sourceExports = scope.exports! + } else { + sourceExports = declarations } for (const specifier of stmt.specifiers) { + let exported: TSExports[string] if (specifier.type === 'ExportDefaultSpecifier') { - // default export: TS don't support it. - continue - } - - if (specifier.type === 'ExportNamespaceSpecifier') { - exports[specifier.exported.name] = sourceExports! + // export x from 'xxx' + exported = sourceExports['default'] + } else if (specifier.type === 'ExportNamespaceSpecifier') { + // export * as x from 'xxx' + exported = sourceExports + } else if (specifier.type === 'ExportSpecifier') { + // export { x } from 'xxx' + exported = sourceExports![specifier.local.name] } else { - const exportedName = - specifier.exported.type === 'Identifier' - ? specifier.exported.name - : specifier.exported.value - - if (stmt.source) { - exports[exportedName] = sourceExports![specifier.local.name] - } else { - exports[exportedName] = await resolveTSReferencedType({ - scope, - type: specifier.local, - }) - } + throw new Error(`Unknown export type: ${(specifier as any).type}`) } + + const name = + specifier.exported.type === 'Identifier' + ? specifier.exported.name + : specifier.exported.value + exports[name] = exported } + // export interface A {} if (isTSDeclaration(stmt.declaration)) { const decl = stmt.declaration if (decl.id?.type === 'Identifier') { const exportedName = decl.id.name - exports[exportedName] = await resolveTSReferencedType({ - scope, - type: decl, - }) + declarations[exportedName] = exports[exportedName] = + await resolveTSReferencedType({ + scope, + type: decl, + }) } } } - } - return exports + // declarations + else if (isTSDeclaration(stmt)) { + if (stmt.id?.type !== 'Identifier') continue + + declarations[stmt.id.name] = await resolveTSReferencedType({ + scope, + type: stmt, + }) + } else if (stmt.type === 'ImportDeclaration') { + const resolved = await resolveTSFileId(stmt.source.value, file.filePath) + if (!resolved) continue + + const importScope = await getTSFile(resolved) + await resolveTSExports(importScope) + const exports = importScope.exports! + + for (const specifier of stmt.specifiers) { + const local = specifier.local.name + + let imported: TSExports[string] + if (specifier.type === 'ImportDefaultSpecifier') { + imported = exports['default'] + } else if (specifier.type === 'ImportNamespaceSpecifier') { + imported = exports + } else if (specifier.type === 'ImportSpecifier') { + const name = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value + imported = exports[name] + } else { + throw new Error(`Unknown import type: ${(specifier as any).type}`) + } + declarations[local] = imported + } + } + } } diff --git a/packages/api/src/ts/is.ts b/packages/api/src/ts/is.ts index a9cd18af7..45058de89 100644 --- a/packages/api/src/ts/is.ts +++ b/packages/api/src/ts/is.ts @@ -1,20 +1,6 @@ -import { - type TSDeclareFunction, - type TSEnumDeclaration, - type TSInterfaceDeclaration, - type TSModuleDeclaration, - type TSTypeAliasDeclaration, - isDeclaration, -} from '@babel/types' - -export type TSDeclaration = - /* TypeScript & Declaration */ - | TSDeclareFunction - | TSInterfaceDeclaration - | TSTypeAliasDeclaration - | TSEnumDeclaration - | TSModuleDeclaration +import { type Declaration, type TypeScript, isDeclaration } from '@babel/types' +export type TSDeclaration = TypeScript & Declaration export function isTSDeclaration(node: any): node is TSDeclaration { return isDeclaration(node) && node.type.startsWith('TS') } diff --git a/packages/api/src/ts/resolve-reference.ts b/packages/api/src/ts/resolve-reference.ts index 88024532b..8d09caeb5 100644 --- a/packages/api/src/ts/resolve-reference.ts +++ b/packages/api/src/ts/resolve-reference.ts @@ -1,17 +1,14 @@ import { type Identifier, - type ImportNamespaceSpecifier, - type ImportSpecifier, type Node, type TSParenthesizedType, type TSType, type TSTypeAliasDeclaration, isTSType, } from '@babel/types' -import { type TSExports, resolveTSExports } from './exports' -import { resolveTSFileId } from './impl' -import { parseTSEntityName, resolveTSIndexedAccessType } from './resolve' -import { type TSScope, getTSFile, resolveTSScope } from './scope' +import { type TSExports, isTSExports, resolveTSExports } from './exports' +import { resolveReferenceName, resolveTSIndexedAccessType } from './resolve' +import { type TSScope, resolveTSScope } from './scope' import { type TSDeclaration, isTSDeclaration } from './is' export interface TSResolvedType< @@ -60,76 +57,31 @@ export async function resolveTSReferencedType( case 'TSModuleDeclaration': { if (type.body.type === 'TSModuleBlock') { - return resolveTSExports({ type: type.body, scope }) + const newScope: TSScope = { + kind: 'module', + ast: type.body, + scope, + } + await resolveTSExports(newScope) + return newScope.exports } return undefined } } - let refNames: string[] - if (type.type === 'Identifier') { - refNames = [type.name] - } else if (type.type === 'TSTypeReference') { - if (type.typeName.type === 'Identifier') { - refNames = [type.typeName.name] - } else { - refNames = parseTSEntityName(type.typeName).map((id) => id.name) - } - } else { + if (type.type !== 'Identifier' && type.type !== 'TSTypeReference') return { scope, type } - } - - const [refName, ...restNames] = refNames - const { body, file } = resolveTSScope(scope) - for (let node of body) { - if (node.type === 'ImportDeclaration') { - const specifier = node.specifiers.find( - (specifier): specifier is ImportSpecifier | ImportNamespaceSpecifier => - (specifier.type === 'ImportSpecifier' && - specifier.imported.type === 'Identifier' && - specifier.imported.name === refName) || - (specifier.type === 'ImportNamespaceSpecifier' && - specifier.local.name === refName) - ) - if (!specifier) continue - - const resolved = await resolveTSFileId(node.source.value, file.filePath) - if (!resolved) continue - const exports = await resolveTSExports(await getTSFile(resolved)) + await resolveTSExports(scope) + const refNames = resolveReferenceName(type).map((id) => id.name) - let type: any = exports - for (const name of specifier.type === 'ImportSpecifier' - ? refNames - : restNames) { - type = type?.[name] - } - return type - } + let resolved: TSResolvedType | TSExports | undefined = + resolveTSScope(scope).declarations! - if (node.type === 'ExportNamedDeclaration' && node.declaration) - node = node.declaration - - if (isTSDeclaration(node)) { - if (node.id?.type !== 'Identifier') continue - if (node.id.name !== refName) continue - const resolved = await resolveTSReferencedType( - { scope, type: node }, - stacks - ) - if (!resolved) return - - if (restNames.length === 0) { - return resolved - } else { - let exports: any = resolved - for (const name of restNames) { - exports = exports[name] - } - return exports - } - } + for (const name of refNames) { + if (isTSExports(resolved)) resolved = resolved[name] + else return } - if (type.type === 'TSTypeReference') return { scope, type } + return resolved } diff --git a/packages/api/src/ts/resolve.ts b/packages/api/src/ts/resolve.ts index 95e6c4167..8b177df51 100644 --- a/packages/api/src/ts/resolve.ts +++ b/packages/api/src/ts/resolve.ts @@ -16,6 +16,7 @@ import { type TSType, type TSTypeElement, type TSTypeOperator, + type TSTypeReference, type TemplateElement, type TemplateLiteral, } from '@babel/types' @@ -34,10 +35,14 @@ import { } from './resolve-reference' import { type TSScope } from './scope' -export function parseTSEntityName(node: TSEntityName): Identifier[] { - if (node.type === 'Identifier') return [node] +export function resolveReferenceName( + node: TSTypeReference | Identifier | TSEntityName +): Identifier[] { + if (node.type === 'TSTypeReference') { + return resolveReferenceName(node.typeName) + } else if (node.type === 'Identifier') return [node] else { - return [...parseTSEntityName(node.left), node.right] + return [...resolveReferenceName(node.left), node.right] } } diff --git a/packages/api/src/ts/scope.ts b/packages/api/src/ts/scope.ts index 3da2a575c..02f25de03 100644 --- a/packages/api/src/ts/scope.ts +++ b/packages/api/src/ts/scope.ts @@ -1,15 +1,27 @@ import { readFile } from 'node:fs/promises' import { type Statement, type TSModuleBlock } from '@babel/types' import { babelParse, getFileCodeAndLang } from '@vue-macros/common' -import { type TSResolvedType } from './resolve-reference' +import { type TSExports } from './exports' -export interface TSFile { +export interface TSScopeBase { + exports?: TSExports + declarations?: TSExports +} + +export interface TSFile extends TSScopeBase { + kind: 'file' filePath: string content: string ast: Statement[] } -export type TSScope = TSFile | TSResolvedType +export interface TSModule extends TSScopeBase { + kind: 'module' + ast: TSModuleBlock + scope: TSScope +} + +export type TSScope = TSFile | TSModule export const tsFileCache: Record = {} export async function getTSFile(filePath: string): Promise { @@ -18,24 +30,38 @@ export async function getTSFile(filePath: string): Promise { const { code, lang } = getFileCodeAndLang(content, filePath) const program = babelParse(code, lang) return (tsFileCache[filePath] = { + kind: 'file', filePath, content, ast: program.body, }) } -export function resolveTSScope(scope: TSScope): { +interface ResolvedTSScope { isFile: boolean file: TSFile body: Statement[] -} { - const isFile = 'ast' in scope - const file = isFile ? scope : resolveTSScope(scope.scope).file - const body = isFile ? scope.ast : scope.type.body + exports?: TSExports + declarations?: TSExports +} +export function resolveTSScope(scope: TSScope): ResolvedTSScope { + const isFile = scope.kind === 'file' + + let parentScope: ResolvedTSScope | undefined + if (!isFile) parentScope = resolveTSScope(scope.scope) + + const file = isFile ? scope : parentScope!.file + const body = isFile ? scope.ast : scope.ast.body + const exports = scope.exports + const declarations: TSExports | undefined = isFile + ? scope.declarations + : { ...resolveTSScope(scope.scope).declarations!, ...scope.declarations } return { isFile, file, body, + declarations, + exports, } } diff --git a/packages/api/src/vue/analyze.ts b/packages/api/src/vue/analyze.ts index f26a262a4..f97d892f6 100644 --- a/packages/api/src/vue/analyze.ts +++ b/packages/api/src/vue/analyze.ts @@ -40,6 +40,7 @@ export async function analyzeSFC( const offset = scriptSetup.loc.start.offset const file: TSFile = { + kind: 'file', filePath: sfc.filename, content: scriptSetup.content, ast: body, diff --git a/packages/api/src/vue/props.ts b/packages/api/src/vue/props.ts index 2d7c7f4b8..024291afe 100644 --- a/packages/api/src/vue/props.ts +++ b/packages/api/src/vue/props.ts @@ -235,11 +235,7 @@ export async function handleTSPropsDefinition({ } else { const resolvedType = def.value if (resolvedType) { - const optional = - def.optional || - resolveMaybeTSUnion(resolvedType.ast).some( - (t) => t.type === 'TSUndefinedKeyword' - ) + const optional = def.optional prop = { type: await inferRuntimeType({ diff --git a/packages/api/src/vue/types.ts b/packages/api/src/vue/types.ts index 168dc8c7f..aa5e34a74 100644 --- a/packages/api/src/vue/types.ts +++ b/packages/api/src/vue/types.ts @@ -1,5 +1,5 @@ -import { type Node, type TSModuleBlock } from '@babel/types' -import { type TSFile, type TSResolvedType } from '../ts' +import { type Node } from '@babel/types' +import { type TSFile, type TSModule } from '../ts' export enum DefinitionKind { /** @@ -24,6 +24,6 @@ export enum DefinitionKind { export interface ASTDefinition { code: string - scope: TSFile | TSResolvedType | undefined + scope: TSFile | TSModule | undefined ast: T } diff --git a/packages/better-define/tests/fixtures/built-in.vue b/packages/better-define/tests/fixtures/built-in.vue index a4f27ee2c..3f14cd451 100644 --- a/packages/better-define/tests/fixtures/built-in.vue +++ b/packages/better-define/tests/fixtures/built-in.vue @@ -7,6 +7,8 @@ defineProps< Required<{ required?: boolean }> & - Readonly<{ readonly: string }> + Readonly<{ + readonly: string + }> >() diff --git a/packages/better-define/tests/fixtures/namespace.vue b/packages/better-define/tests/fixtures/namespace.vue index 362a7c347..52cdaf97a 100644 --- a/packages/better-define/tests/fixtures/namespace.vue +++ b/packages/better-define/tests/fixtures/namespace.vue @@ -1,28 +1,18 @@ From 63fd8c1351b8d5c08b97e8bcf359a67d773485ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 18:01:36 +0800 Subject: [PATCH 3/8] refactor: rename namespace --- packages/api/src/ts/index.ts | 2 +- .../api/src/ts/{exports.ts => namespace.ts} | 36 ++++++------ packages/api/src/ts/property.ts | 8 +-- packages/api/src/ts/resolve-reference.ts | 16 +++-- packages/api/src/ts/resolve.ts | 8 +-- packages/api/src/ts/scope.ts | 12 ++-- packages/api/src/vue/emits.ts | 6 +- packages/api/src/vue/props.ts | 9 ++- packages/api/src/vue/utils.ts | 6 +- .../tests/__snapshots__/fixtures.test.ts.snap | 58 +++++++++---------- 10 files changed, 81 insertions(+), 80 deletions(-) rename packages/api/src/ts/{exports.ts => namespace.ts} (81%) diff --git a/packages/api/src/ts/index.ts b/packages/api/src/ts/index.ts index 3b8890123..e46b22382 100644 --- a/packages/api/src/ts/index.ts +++ b/packages/api/src/ts/index.ts @@ -1,7 +1,7 @@ export * from './create' -export * from './exports' export * from './impl' export * from './is' +export * from './namespace' export * from './property' export * from './resolve-reference' export * from './resolve' diff --git a/packages/api/src/ts/exports.ts b/packages/api/src/ts/namespace.ts similarity index 81% rename from packages/api/src/ts/exports.ts rename to packages/api/src/ts/namespace.ts index 39d6e4e63..4cc0dbbbc 100644 --- a/packages/api/src/ts/exports.ts +++ b/packages/api/src/ts/namespace.ts @@ -6,32 +6,30 @@ import { type TSScope, getTSFile, resolveTSScope } from './scope' import { isTSDeclaration } from './is' import { resolveTSFileId } from './impl' -export const exportsSymbol = Symbol('exports') -export type TSExports = { - [K in string]: TSResolvedType | TSExports | undefined -} & { [exportsSymbol]: true } +export const namespaceSymbol = Symbol('namespace') +export type TSNamespace = { + [K in string]: TSResolvedType | TSNamespace | undefined +} & { [namespaceSymbol]: true } -export function isTSExports(val: unknown): val is TSExports { - return !!val && typeof val === 'object' && exportsSymbol in val +export function isTSNamespace(val: unknown): val is TSNamespace { + return !!val && typeof val === 'object' && namespaceSymbol in val } /** * Get exports of the TS file. * * @limitation don't support non-TS declaration (e.g. class, function...) - * @limitation don't support `export default`, since TS don't support it currently. - * @limitation don't support `export * as xxx from '...'` (aka namespace). */ -export async function resolveTSExports(scope: TSScope): Promise { +export async function resolveTSNamespace(scope: TSScope): Promise { if (scope.exports) return - const exports: TSExports = { - [exportsSymbol]: true, + const exports: TSNamespace = { + [namespaceSymbol]: true, } scope.exports = exports - const declarations: TSExports = { - [exportsSymbol]: true, + const declarations: TSNamespace = { + [namespaceSymbol]: true, ...scope.declarations, } scope.declarations = declarations @@ -51,25 +49,25 @@ export async function resolveTSExports(scope: TSScope): Promise { if (!resolved) continue const sourceScope = await getTSFile(resolved) - await resolveTSExports(sourceScope) + await resolveTSNamespace(sourceScope) Object.assign(exports, sourceScope.exports!) } else if (stmt.type === 'ExportNamedDeclaration') { - let sourceExports: TSExports + let sourceExports: TSNamespace if (stmt.source) { const resolved = await resolveTSFileId(stmt.source.value, file.filePath) if (!resolved) continue const scope = await getTSFile(resolved) - await resolveTSExports(scope) + await resolveTSNamespace(scope) sourceExports = scope.exports! } else { sourceExports = declarations } for (const specifier of stmt.specifiers) { - let exported: TSExports[string] + let exported: TSNamespace[string] if (specifier.type === 'ExportDefaultSpecifier') { // export x from 'xxx' exported = sourceExports['default'] @@ -118,13 +116,13 @@ export async function resolveTSExports(scope: TSScope): Promise { if (!resolved) continue const importScope = await getTSFile(resolved) - await resolveTSExports(importScope) + await resolveTSNamespace(importScope) const exports = importScope.exports! for (const specifier of stmt.specifiers) { const local = specifier.local.name - let imported: TSExports[string] + let imported: TSNamespace[string] if (specifier.type === 'ImportDefaultSpecifier') { imported = exports['default'] } else if (specifier.type === 'ImportNamespaceSpecifier') { diff --git a/packages/api/src/ts/property.ts b/packages/api/src/ts/property.ts index 5910c4c49..ea7bec24e 100644 --- a/packages/api/src/ts/property.ts +++ b/packages/api/src/ts/property.ts @@ -17,7 +17,7 @@ import { type TSResolvedType, resolveTSReferencedType, } from './resolve-reference' -import { type TSExports, isTSExports } from './exports' +import { type TSNamespace, isTSNamespace } from './namespace' import { resolveMaybeTSUnion, resolveTSLiteralType, @@ -157,7 +157,7 @@ export async function resolveTSProperties({ type: type.typeParameter.constraint, scope, }) - if (!constraint || isTSExports(constraint)) return properties + if (!constraint || isTSNamespace(constraint)) return properties const types = resolveMaybeTSUnion(constraint.type) for (const subType of types) { @@ -200,11 +200,11 @@ export async function resolveTSProperties({ } function filterValidExtends( - node: TSResolvedType | TSExports | undefined + node: TSResolvedType | TSNamespace | undefined ): node is TSResolvedType< TSInterfaceDeclaration | TSTypeLiteral | TSIntersectionType > { - return !isTSExports(node) && checkForTSProperties(node?.type) + return !isTSNamespace(node) && checkForTSProperties(node?.type) } } diff --git a/packages/api/src/ts/resolve-reference.ts b/packages/api/src/ts/resolve-reference.ts index 8d09caeb5..e2a62efdb 100644 --- a/packages/api/src/ts/resolve-reference.ts +++ b/packages/api/src/ts/resolve-reference.ts @@ -6,7 +6,11 @@ import { type TSTypeAliasDeclaration, isTSType, } from '@babel/types' -import { type TSExports, isTSExports, resolveTSExports } from './exports' +import { + type TSNamespace, + isTSNamespace, + resolveTSNamespace, +} from './namespace' import { resolveReferenceName, resolveTSIndexedAccessType } from './resolve' import { type TSScope, resolveTSScope } from './scope' import { type TSDeclaration, isTSDeclaration } from './is' @@ -38,7 +42,7 @@ export function isSupportedForTSReferencedType( export async function resolveTSReferencedType( ref: TSResolvedType, stacks: TSResolvedType[] = [] -): Promise { +): Promise { const { scope, type } = ref if (stacks.some((stack) => stack.scope === scope && stack.type === type)) { return ref as any @@ -62,7 +66,7 @@ export async function resolveTSReferencedType( ast: type.body, scope, } - await resolveTSExports(newScope) + await resolveTSNamespace(newScope) return newScope.exports } return undefined @@ -72,14 +76,14 @@ export async function resolveTSReferencedType( if (type.type !== 'Identifier' && type.type !== 'TSTypeReference') return { scope, type } - await resolveTSExports(scope) + await resolveTSNamespace(scope) const refNames = resolveReferenceName(type).map((id) => id.name) - let resolved: TSResolvedType | TSExports | undefined = + let resolved: TSResolvedType | TSNamespace | undefined = resolveTSScope(scope).declarations! for (const name of refNames) { - if (isTSExports(resolved)) resolved = resolved[name] + if (isTSNamespace(resolved)) resolved = resolved[name] else return } diff --git a/packages/api/src/ts/resolve.ts b/packages/api/src/ts/resolve.ts index 8b177df51..912633ff1 100644 --- a/packages/api/src/ts/resolve.ts +++ b/packages/api/src/ts/resolve.ts @@ -21,7 +21,7 @@ import { type TemplateLiteral, } from '@babel/types' import { createStringLiteral, createUnionType } from './create' -import { isTSExports } from './exports' +import { isTSNamespace } from './namespace' import { type TSProperties, checkForTSProperties, @@ -76,7 +76,7 @@ export async function resolveTSTemplateLiteral({ type, scope, }) - if (!resolved || isTSExports(resolved)) continue + if (!resolved || isTSNamespace(resolved)) continue const types = resolveMaybeTSUnion(resolved.type) for (const type of types) { @@ -190,7 +190,7 @@ export async function resolveTSIndexedAccessType( { type: type.objectType, scope }, stacks ) - if (!object || isTSExports(object)) return undefined + if (!object || isTSNamespace(object)) return undefined const objectType = object.type if (type.indexType.type === 'TSNumberKeyword') { @@ -297,7 +297,7 @@ export async function resolveTSTypeOperator( }, stacks ) - if (!resolved || isTSExports(resolved)) return undefined + if (!resolved || isTSNamespace(resolved)) return undefined const { type: resolvedType, scope: resolvedScope } = resolved if (!checkForTSProperties(resolvedType)) return undefined diff --git a/packages/api/src/ts/scope.ts b/packages/api/src/ts/scope.ts index 02f25de03..1df6c6856 100644 --- a/packages/api/src/ts/scope.ts +++ b/packages/api/src/ts/scope.ts @@ -1,11 +1,11 @@ import { readFile } from 'node:fs/promises' import { type Statement, type TSModuleBlock } from '@babel/types' import { babelParse, getFileCodeAndLang } from '@vue-macros/common' -import { type TSExports } from './exports' +import { type TSNamespace } from './namespace' export interface TSScopeBase { - exports?: TSExports - declarations?: TSExports + exports?: TSNamespace + declarations?: TSNamespace } export interface TSFile extends TSScopeBase { @@ -41,8 +41,8 @@ interface ResolvedTSScope { isFile: boolean file: TSFile body: Statement[] - exports?: TSExports - declarations?: TSExports + exports?: TSNamespace + declarations?: TSNamespace } export function resolveTSScope(scope: TSScope): ResolvedTSScope { const isFile = scope.kind === 'file' @@ -53,7 +53,7 @@ export function resolveTSScope(scope: TSScope): ResolvedTSScope { const file = isFile ? scope : parentScope!.file const body = isFile ? scope.ast : scope.ast.body const exports = scope.exports - const declarations: TSExports | undefined = isFile + const declarations: TSNamespace | undefined = isFile ? scope.declarations : { ...resolveTSScope(scope.scope).declarations!, ...scope.declarations } diff --git a/packages/api/src/vue/emits.ts b/packages/api/src/vue/emits.ts index 775a8f666..e281f6a39 100644 --- a/packages/api/src/vue/emits.ts +++ b/packages/api/src/vue/emits.ts @@ -23,7 +23,7 @@ import { import { type TSFile, type TSResolvedType, - isTSExports, + isTSNamespace, resolveTSProperties, resolveTSReferencedType, resolveTSScope, @@ -126,7 +126,7 @@ export async function handleTSEmitsDefinition({ async function resolveDefinitions(typeDeclRaw: TSResolvedType) { const resolved = await resolveTSReferencedType(typeDeclRaw) - if (!resolved || isTSExports(resolved)) + if (!resolved || isTSNamespace(resolved)) throw new SyntaxError(`Cannot resolve TS definition.`) const { type: definitionsAst, scope } = resolved @@ -160,7 +160,7 @@ export async function handleTSEmitsDefinition({ scope: signature.scope, }) - if (isTSExports(evtType) || !evtType?.type) continue + if (isTSNamespace(evtType) || !evtType?.type) continue const types = evtType.type.type === 'TSUnionType' diff --git a/packages/api/src/vue/props.ts b/packages/api/src/vue/props.ts index 024291afe..36090f4b3 100644 --- a/packages/api/src/vue/props.ts +++ b/packages/api/src/vue/props.ts @@ -30,8 +30,7 @@ import { type TSProperties, type TSResolvedType, type TSScope, - isTSExports, - resolveMaybeTSUnion, + isTSNamespace, resolveTSProperties, resolveTSReferencedType, resolveTSScope, @@ -427,7 +426,7 @@ export async function handleTSPropsDefinition({ type: 'property', addByAPI: false, value: - referenced && !isTSExports(referenced) + referenced && !isTSNamespace(referenced) ? buildDefinition(referenced) : undefined, optional: value.optional, @@ -450,7 +449,7 @@ export async function handleTSPropsDefinition({ if ( resolved && - !isTSExports(resolved) && + !isTSNamespace(resolved) && resolved.type.type === 'TSTypeReference' && resolved.type.typeName.type === 'Identifier' ) { @@ -469,7 +468,7 @@ export async function handleTSPropsDefinition({ }) } - if (!resolved || isTSExports(resolved)) { + if (!resolved || isTSNamespace(resolved)) { throw new SyntaxError(`Cannot resolve TS definition.`) } diff --git a/packages/api/src/vue/utils.ts b/packages/api/src/vue/utils.ts index 8a8a535cc..7a60ac7ba 100644 --- a/packages/api/src/vue/utils.ts +++ b/packages/api/src/vue/utils.ts @@ -2,7 +2,7 @@ import { type Node } from '@babel/types' import { type TSExports, type TSResolvedType, - isTSExports, + isTSNamespace, resolveTSReferencedType, } from '../ts' @@ -11,7 +11,7 @@ export const UNKNOWN_TYPE = 'Unknown' export async function inferRuntimeType( node: TSResolvedType | TSExports ): Promise { - if (isTSExports(node)) return ['Object'] + if (isTSNamespace(node)) return ['Object'] switch (node.type.type) { case 'TSStringKeyword': @@ -114,7 +114,7 @@ export async function inferRuntimeType( scope: node.scope, type: subType, }) - return resolved && !isTSExports(resolved) + return resolved && !isTSNamespace(resolved) ? inferRuntimeType(resolved) : undefined }) diff --git a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap index a820cd70f..fa1ea7dd8 100644 --- a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap +++ b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap @@ -10,7 +10,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"base\\": { type: String, required: true }, \\"str\\": { type: String, required: true }, \\"num\\": { type: Number, required: true }, - \\"map\\": { type: Map, required: true }, + \\"map\\": { type: null, required: false }, \\"arr\\": { type: Array, required: true }, \\"union\\": { type: [String, Number], required: true } }, @@ -63,9 +63,9 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"built-in\\", props: { - \\"optional\\": { type: Number, required: false }, - \\"required\\": { type: Boolean, required: true }, - \\"readonly\\": { type: String, required: true } + optional: { type: Number, required: false }, + required: { type: Boolean, required: true }, + readonly: { type: String, required: true } }, setup(__props) { return () => { @@ -86,9 +86,9 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"built-in\\", props: { - \\"optional\\": null, - \\"required\\": { type: Boolean }, - \\"readonly\\": null + optional: { type: Number, required: false }, + required: { type: Boolean, required: true }, + readonly: { type: String, required: true } }, setup(__props) { return () => { @@ -161,7 +161,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"bar\\": { type: Number, required: false, get default() { return 10; } }, - \\"baz\\": { type: Promise, required: false, async default() { + \\"baz\\": { type: null, required: false, async default() { return 10; } }, \\"qux\\": { type: Function, required: false, default: () => { @@ -268,7 +268,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"setupBase\\": { type: String, required: true }, \\"num\\": { type: Number, required: true }, \\"bool\\": { type: Boolean, required: true }, - \\"map\\": { type: Map, required: true }, + \\"map\\": { type: null, required: false }, \\"arr\\": { type: Array, required: true }, \\"union\\": { type: [String, Number], required: true }, \\"normalBase\\": { type: String, required: true }, @@ -330,7 +330,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"base\\": { type: String, required: true }, \\"str\\": { type: String, required: true }, \\"num\\": { type: Number, required: true }, - \\"map\\": { type: Map, required: true }, + \\"map\\": { type: null, required: false }, \\"arr\\": { type: Array, required: true }, \\"union\\": { type: [String, Number], required: true } }, @@ -575,8 +575,8 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"namespace\\", props: { - \\"x\\": { type: String, required: true }, - \\"y\\": { type: String, required: true }, + \\"x\\": { type: Boolean, required: true }, + \\"y\\": { type: Number, required: true }, \\"z\\": { type: String, required: true } }, setup(__props) { @@ -598,7 +598,7 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"namespace\\", props: { - \\"x\\": null, + \\"x\\": { type: Boolean }, \\"y\\": null, \\"z\\": null }, @@ -707,11 +707,11 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"resolve-failed\\", props: { - \\"foo\\": { required: true }, - \\"bar\\": { type: Object, required: true }, + \\"foo\\": { type: null, required: false }, + \\"bar\\": { type: null, required: false }, \\"bool\\": { type: Boolean, skipCheck: true, required: true }, - \\"fun\\": { type: Function, skipCheck: true, required: true }, - \\"boolAndFun\\": { type: [Boolean, Function], skipCheck: true, required: true } + \\"fun\\": { required: true }, + \\"boolAndFun\\": { type: Boolean, skipCheck: true, required: true } }, setup(__props) { return () => { @@ -735,8 +735,8 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"foo\\": null, \\"bar\\": null, \\"bool\\": { type: Boolean }, - \\"fun\\": { type: Function }, - \\"boolAndFun\\": { type: [Boolean, Function] } + \\"fun\\": null, + \\"boolAndFun\\": { type: Boolean } }, setup(__props) { return () => { @@ -858,16 +858,16 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"ts-indexed-access-type\\", props: { \\"foo\\": { type: String, required: true }, - \\"bar\\": { required: false }, - \\"baz\\": { required: false }, - \\"qux\\": { required: false }, - \\"all\\": { required: false }, + \\"bar\\": { required: true }, + \\"baz\\": { required: true }, + \\"qux\\": { required: true }, + \\"all\\": { required: true }, \\"arr\\": { type: [String, Number], required: true }, - \\"arr2\\": { type: [String, Number], required: true }, + \\"arr2\\": { type: null, required: false }, \\"tuple\\": { type: [String, Boolean], required: true }, \\"methodFoo\\": { type: Function, required: true }, \\"methodBar\\": { type: Function, required: true }, - \\"methodOpt\\": { type: Function, skipCheck: true, required: false } + \\"methodOpt\\": { type: Function, skipCheck: true, required: true } }, setup(__props) { return () => { @@ -919,8 +919,8 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"ts-utility-types\\", props: { - \\"foo\\": { type: String, required: true }, - \\"bar\\": { type: String, required: true } + \\"foo\\": { type: null, required: false }, + \\"bar\\": { type: null, required: false } }, setup(__props) { return () => { @@ -963,7 +963,7 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"undefined\\", props: { - \\"foo\\": { required: false } + \\"foo\\": { required: true } }, setup(__props) { return () => { @@ -1137,7 +1137,7 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"unresolved\\", props: { - \\"foo\\": { required: true } + \\"foo\\": { type: null, required: false } }, setup(__props) { return () => { From c0276ac8a719b96ce0ae03f24e09dde888040a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 18:15:05 +0800 Subject: [PATCH 4/8] fix: built-in types --- packages/api/src/vue/props.ts | 7 ++++++- .../tests/__snapshots__/fixtures.test.ts.snap | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/api/src/vue/props.ts b/packages/api/src/vue/props.ts index 36090f4b3..ed776a404 100644 --- a/packages/api/src/vue/props.ts +++ b/packages/api/src/vue/props.ts @@ -27,6 +27,7 @@ import { } from '@babel/types' import { type TSFile, + type TSNamespace, type TSProperties, type TSResolvedType, type TSScope, @@ -443,7 +444,11 @@ export async function handleTSPropsDefinition({ definitions: TSProps['definitions'] definitionsAst: TSProps['definitionsAst'] }> { - let resolved = await resolveTSReferencedType(typeDeclRaw) + let resolved: + | TSResolvedType + | TSResolvedType + | TSNamespace + | undefined = (await resolveTSReferencedType(typeDeclRaw)) || typeDeclRaw let builtInTypesHandler: BuiltInTypesHandler[string] | undefined diff --git a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap index fa1ea7dd8..32498afa1 100644 --- a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap +++ b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap @@ -63,9 +63,9 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"built-in\\", props: { - optional: { type: Number, required: false }, - required: { type: Boolean, required: true }, - readonly: { type: String, required: true } + \\"optional\\": { type: Number, required: false }, + \\"required\\": { type: Boolean, required: true }, + \\"readonly\\": { type: String, required: true } }, setup(__props) { return () => { @@ -86,9 +86,9 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"built-in\\", props: { - optional: { type: Number, required: false }, - required: { type: Boolean, required: true }, - readonly: { type: String, required: true } + \\"optional\\": null, + \\"required\\": { type: Boolean }, + \\"readonly\\": null }, setup(__props) { return () => { From 4bef01e4b3ff4b13b0a81bbda4b3cbb0ac79a590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 18:30:05 +0800 Subject: [PATCH 5/8] fix: built-in types --- packages/api/src/ts/resolve-reference.ts | 7 +++-- packages/api/src/vue/utils.ts | 4 +-- .../tests/__snapshots__/fixtures.test.ts.snap | 28 +++++++++---------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/api/src/ts/resolve-reference.ts b/packages/api/src/ts/resolve-reference.ts index e2a62efdb..19e577b14 100644 --- a/packages/api/src/ts/resolve-reference.ts +++ b/packages/api/src/ts/resolve-reference.ts @@ -83,8 +83,11 @@ export async function resolveTSReferencedType( resolveTSScope(scope).declarations! for (const name of refNames) { - if (isTSNamespace(resolved)) resolved = resolved[name] - else return + if (isTSNamespace(resolved) && resolved[name]) { + resolved = resolved[name] + } else if (type.type === 'TSTypeReference') { + return { type, scope } + } } return resolved diff --git a/packages/api/src/vue/utils.ts b/packages/api/src/vue/utils.ts index 7a60ac7ba..235150137 100644 --- a/packages/api/src/vue/utils.ts +++ b/packages/api/src/vue/utils.ts @@ -1,6 +1,6 @@ import { type Node } from '@babel/types' import { - type TSExports, + type TSNamespace, type TSResolvedType, isTSNamespace, resolveTSReferencedType, @@ -9,7 +9,7 @@ import { export const UNKNOWN_TYPE = 'Unknown' export async function inferRuntimeType( - node: TSResolvedType | TSExports + node: TSResolvedType | TSNamespace ): Promise { if (isTSNamespace(node)) return ['Object'] diff --git a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap index 32498afa1..43f416704 100644 --- a/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap +++ b/packages/better-define/tests/__snapshots__/fixtures.test.ts.snap @@ -10,7 +10,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"base\\": { type: String, required: true }, \\"str\\": { type: String, required: true }, \\"num\\": { type: Number, required: true }, - \\"map\\": { type: null, required: false }, + \\"map\\": { type: Map, required: true }, \\"arr\\": { type: Array, required: true }, \\"union\\": { type: [String, Number], required: true } }, @@ -161,7 +161,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"bar\\": { type: Number, required: false, get default() { return 10; } }, - \\"baz\\": { type: null, required: false, async default() { + \\"baz\\": { type: Promise, required: false, async default() { return 10; } }, \\"qux\\": { type: Function, required: false, default: () => { @@ -268,7 +268,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"setupBase\\": { type: String, required: true }, \\"num\\": { type: Number, required: true }, \\"bool\\": { type: Boolean, required: true }, - \\"map\\": { type: null, required: false }, + \\"map\\": { type: Map, required: true }, \\"arr\\": { type: Array, required: true }, \\"union\\": { type: [String, Number], required: true }, \\"normalBase\\": { type: String, required: true }, @@ -330,7 +330,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"base\\": { type: String, required: true }, \\"str\\": { type: String, required: true }, \\"num\\": { type: Number, required: true }, - \\"map\\": { type: null, required: false }, + \\"map\\": { type: Map, required: true }, \\"arr\\": { type: Array, required: true }, \\"union\\": { type: [String, Number], required: true } }, @@ -707,11 +707,11 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"resolve-failed\\", props: { - \\"foo\\": { type: null, required: false }, - \\"bar\\": { type: null, required: false }, + \\"foo\\": { required: true }, + \\"bar\\": { type: Object, required: true }, \\"bool\\": { type: Boolean, skipCheck: true, required: true }, - \\"fun\\": { required: true }, - \\"boolAndFun\\": { type: Boolean, skipCheck: true, required: true } + \\"fun\\": { type: Function, skipCheck: true, required: true }, + \\"boolAndFun\\": { type: [Boolean, Function], skipCheck: true, required: true } }, setup(__props) { return () => { @@ -735,8 +735,8 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"foo\\": null, \\"bar\\": null, \\"bool\\": { type: Boolean }, - \\"fun\\": null, - \\"boolAndFun\\": { type: Boolean } + \\"fun\\": { type: Function }, + \\"boolAndFun\\": { type: [Boolean, Function] } }, setup(__props) { return () => { @@ -863,7 +863,7 @@ var _sfc_main = /* @__PURE__ */ defineComponent({ \\"qux\\": { required: true }, \\"all\\": { required: true }, \\"arr\\": { type: [String, Number], required: true }, - \\"arr2\\": { type: null, required: false }, + \\"arr2\\": { type: [String, Number], required: true }, \\"tuple\\": { type: [String, Boolean], required: true }, \\"methodFoo\\": { type: Function, required: true }, \\"methodBar\\": { type: Function, required: true }, @@ -919,8 +919,8 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"ts-utility-types\\", props: { - \\"foo\\": { type: null, required: false }, - \\"bar\\": { type: null, required: false } + \\"foo\\": { type: String, required: true }, + \\"bar\\": { type: String, required: true } }, setup(__props) { return () => { @@ -1137,7 +1137,7 @@ import _export_sfc from '/plugin-vue/export-helper'; var _sfc_main = /* @__PURE__ */ defineComponent({ __name: \\"unresolved\\", props: { - \\"foo\\": { type: null, required: false } + \\"foo\\": { required: true } }, setup(__props) { return () => { From 86a5c40b093e6162292126cb8f49bc14eddfc453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 18:42:04 +0800 Subject: [PATCH 6/8] fix: tests --- packages/api/tests/ts.test.ts | 19 ++++--- .../tests/__snapshots__/fixtures.test.ts.snap | 57 ------------------- 2 files changed, 10 insertions(+), 66 deletions(-) diff --git a/packages/api/tests/ts.test.ts b/packages/api/tests/ts.test.ts index b6102f26d..30af137c4 100644 --- a/packages/api/tests/ts.test.ts +++ b/packages/api/tests/ts.test.ts @@ -9,7 +9,7 @@ import { import { type TSFile, getTSFile, - resolveTSExports, + resolveTSNamespace, resolveTSProperties, resolveTSReferencedType, } from '../src' @@ -19,6 +19,7 @@ const fixtures = path.resolve(__dirname, 'fixtures') function mockTSFile(content: string): TSFile { return { + kind: 'file', filePath: '/foo.ts', content, ast: babelParse(content, 'ts').body, @@ -76,9 +77,6 @@ type Base2 = { { "type": "TSCallSignatureDeclaration...", }, - { - "type": "TSCallSignatureDeclaration...", - }, ], "constructSignatures": [ { @@ -214,7 +212,8 @@ type Foo = AliasString` describe('resolveTSFileExports', () => { test('basic', async () => { const file = await getTSFile(path.resolve(fixtures, 'basic/index.ts')) - const exports = await resolveTSExports(file) + await resolveTSNamespace(file) + const exports = file.exports! expect(hideAstLocation(exports)).toMatchInlineSnapshot(` { "ExportAll": { @@ -296,14 +295,15 @@ type Foo = AliasString` const file = await getTSFile( path.resolve(fixtures, 'circular-referencing/foo.ts') ) - const exports = await resolveTSExports(file) + await resolveTSNamespace(file) + const exports = file.exports! expect(hideAstLocation(exports)).toMatchInlineSnapshot(` { "A": { - "type": "TSTypeAliasDeclaration...", + "type": "TSTypeReference...", }, "B": { - "type": "TSTypeAliasDeclaration...", + "type": "TSTypeReference...", }, "Bar": { "type": "TSLiteralType...", @@ -317,7 +317,8 @@ type Foo = AliasString` test('namespace', async () => { const file = await getTSFile(path.resolve(fixtures, 'namespace/index.ts')) - const exports = await resolveTSExports(file) + await resolveTSNamespace(file) + const exports = file.exports! expect(hideAstLocation(exports)).toMatchInlineSnapshot(` { "BarStr": { diff --git a/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap b/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap index 47a9a2487..44a52f8e8 100644 --- a/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap +++ b/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap @@ -39,60 +39,3 @@ const unknownType = __MACROS_toRef(__MACROS_props, \\"unknownType\\") export { basic as default }; " `; - -exports[`fixtures > tests/fixtures/kevin-edition/basic.vue 1`] = ` -"var basic = \` -\`; - -export { basic as default }; -" -`; From 45792645a729e605962d11cd26b53c5ca4125e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 18:43:19 +0800 Subject: [PATCH 7/8] chore: add changesets --- .changeset/spotty-swans-tickle.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/spotty-swans-tickle.md diff --git a/.changeset/spotty-swans-tickle.md b/.changeset/spotty-swans-tickle.md new file mode 100644 index 000000000..e09dc0caf --- /dev/null +++ b/.changeset/spotty-swans-tickle.md @@ -0,0 +1,7 @@ +--- +'@vue-macros/better-define': minor +'@vue-macros/api': minor +'@vue-macros/define-prop': patch +--- + +rewrite TypeScript resolver From abce65f6ac6553e3251a821679cbf7f654774c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Thu, 18 May 2023 18:45:37 +0800 Subject: [PATCH 8/8] fix: ts type --- packages/api/src/ts/resolve.ts | 3 +- packages/define-prop/src/core/index.ts | 1 + .../tests/__snapshots__/fixtures.test.ts.snap | 57 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/api/src/ts/resolve.ts b/packages/api/src/ts/resolve.ts index 912633ff1..545b4db3f 100644 --- a/packages/api/src/ts/resolve.ts +++ b/packages/api/src/ts/resolve.ts @@ -17,6 +17,7 @@ import { type TSTypeElement, type TSTypeOperator, type TSTypeReference, + type TSUnionType, type TemplateElement, type TemplateLiteral, } from '@babel/types' @@ -185,7 +186,7 @@ export function resolveTypeElements( export async function resolveTSIndexedAccessType( { scope, type }: TSResolvedType, stacks: TSResolvedType[] = [] -) { +): Promise<{ type: TSUnionType; scope: TSScope } | undefined> { const object = await resolveTSReferencedType( { type: type.objectType, scope }, stacks diff --git a/packages/define-prop/src/core/index.ts b/packages/define-prop/src/core/index.ts index a242f928a..50790fd59 100644 --- a/packages/define-prop/src/core/index.ts +++ b/packages/define-prop/src/core/index.ts @@ -80,6 +80,7 @@ export async function transformDefineProp( async function resolveTSType(type: TSType) { const resolved = await resolveTSReferencedType({ scope: { + kind: 'file', filePath: id, content: scriptSetup!.content, ast: setupAst.body, diff --git a/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap b/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap index 44a52f8e8..47a9a2487 100644 --- a/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap +++ b/packages/define-prop/tests/__snapshots__/fixtures.test.ts.snap @@ -39,3 +39,60 @@ const unknownType = __MACROS_toRef(__MACROS_props, \\"unknownType\\") export { basic as default }; " `; + +exports[`fixtures > tests/fixtures/kevin-edition/basic.vue 1`] = ` +"var basic = \` +\`; + +export { basic as default }; +" +`;