diff --git a/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts b/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts index 7aa08d595f5..0f2a47d6b99 100644 --- a/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts @@ -1,5 +1,5 @@ import { Identifier } from '@babel/types' -import { parse } from '../../src' +import { SFCScriptCompileOptions, parse } from '../../src' import { ScriptCompileContext } from '../../src/script/context' import { inferRuntimeType, @@ -410,6 +410,32 @@ describe('resolveType', () => { '/pp.ts' ]) }) + + test('global types', () => { + const files = { + // ambient + '/app.d.ts': + 'declare namespace App { interface User { name: string } }', + // module - should only respect the declare global block + '/global.d.ts': ` + declare type PP = { bar: number } + declare global { + type PP = { bar: string } + } + export {} + ` + } + + const { props, deps } = resolve(`defineProps()`, files, { + globalTypeFiles: Object.keys(files) + }) + + expect(props).toStrictEqual({ + name: ['String'], + bar: ['String'] + }) + expect(deps && [...deps]).toStrictEqual(Object.keys(files)) + }) }) describe('errors', () => { @@ -444,7 +470,11 @@ describe('resolveType', () => { }) }) -function resolve(code: string, files: Record = {}) { +function resolve( + code: string, + files: Record = {}, + options?: Partial +) { const { descriptor } = parse(``, { filename: '/Test.vue' }) @@ -457,7 +487,8 @@ function resolve(code: string, files: Record = {}) { readFile(file) { return files[file] } - } + }, + ...options }) for (const file in files) { diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts index 0d09f02538a..1f525005c4d 100644 --- a/packages/compiler-sfc/src/compileScript.ts +++ b/packages/compiler-sfc/src/compileScript.ts @@ -72,6 +72,11 @@ export interface SFCScriptCompileOptions { * https://babeljs.io/docs/en/babel-parser#plugins */ babelParserPlugins?: ParserPlugin[] + /** + * A list of files to parse for global types to be made available for type + * resolving in SFC macros. The list must be fully resolved file system paths. + */ + globalTypeFiles?: string[] /** * Compile the template and inline the resulting render function * directly inside setup(). diff --git a/packages/compiler-sfc/src/script/context.ts b/packages/compiler-sfc/src/script/context.ts index 641e463741f..d2c5dabd194 100644 --- a/packages/compiler-sfc/src/script/context.ts +++ b/packages/compiler-sfc/src/script/context.ts @@ -24,6 +24,7 @@ export class ScriptCompileContext { // import / type analysis scope?: TypeScope + globalScopes?: TypeScope[] userImports: Record = Object.create(null) // macros presence check @@ -101,7 +102,7 @@ export class ScriptCompileContext { sourceType: 'module' }).program } catch (e: any) { - e.message = `[@vue/compiler-sfc] ${e.message}\n\n${ + e.message = `[vue/compiler-sfc] ${e.message}\n\n${ descriptor.filename }\n${generateCodeFrame( descriptor.source, @@ -113,15 +114,12 @@ export class ScriptCompileContext { } this.scriptAst = - this.descriptor.script && - parse( - this.descriptor.script.content, - this.descriptor.script.loc.start.offset - ) + descriptor.script && + parse(descriptor.script.content, descriptor.script.loc.start.offset) this.scriptSetupAst = - this.descriptor.scriptSetup && - parse(this.descriptor.scriptSetup!.content, this.startOffset!) + descriptor.scriptSetup && + parse(descriptor.scriptSetup!.content, this.startOffset!) } getString(node: Node, scriptSetup = true): string { diff --git a/packages/compiler-sfc/src/script/resolveType.ts b/packages/compiler-sfc/src/script/resolveType.ts index 1e89c5712dc..8efa04579f8 100644 --- a/packages/compiler-sfc/src/script/resolveType.ts +++ b/packages/compiler-sfc/src/script/resolveType.ts @@ -56,7 +56,7 @@ export type SimpleTypeResolveContext = Pick< // required 'source' | 'filename' | 'error' | 'options' > & - Partial> & { + Partial> & { ast: Statement[] } @@ -64,25 +64,18 @@ export type TypeResolveContext = ScriptCompileContext | SimpleTypeResolveContext type Import = Pick +type ScopeTypeNode = Node & { + // scope types always has ownerScope attached + _ownerScope: TypeScope +} + export interface TypeScope { filename: string source: string offset: number imports: Record - types: Record< - string, - Node & { - // scope types always has ownerScope attached - _ownerScope: TypeScope - } - > - exportedTypes: Record< - string, - Node & { - // scope types always has ownerScope attached - _ownerScope: TypeScope - } - > + types: Record + exportedTypes: Record } export interface WithScope { @@ -492,12 +485,12 @@ function resolveBuiltin( function resolveTypeReference( ctx: TypeResolveContext, node: (TSTypeReference | TSExpressionWithTypeArguments) & { - _resolvedReference?: Node + _resolvedReference?: ScopeTypeNode }, scope?: TypeScope, name?: string, onlyExported = false -): (Node & WithScope) | undefined { +): ScopeTypeNode | undefined { if (node._resolvedReference) { return node._resolvedReference } @@ -516,13 +509,26 @@ function innerResolveTypeReference( name: string | string[], node: TSTypeReference | TSExpressionWithTypeArguments, onlyExported: boolean -): Node | undefined { +): ScopeTypeNode | undefined { if (typeof name === 'string') { if (scope.imports[name]) { return resolveTypeFromImport(ctx, node, name, scope) } else { const types = onlyExported ? scope.exportedTypes : scope.types - return types[name] + if (types[name]) { + return types[name] + } else { + // fallback to global + const globalScopes = resolveGlobalScope(ctx) + if (globalScopes) { + for (const s of globalScopes) { + if (s.types[name]) { + ;(ctx.deps || (ctx.deps = new Set())).add(s.filename) + return s.types[name] + } + } + } + } } } else { const ns = innerResolveTypeReference( @@ -539,7 +545,7 @@ function innerResolveTypeReference( childScope, name.length > 2 ? name.slice(1) : name[name.length - 1], node, - true + !ns.declare ) } } @@ -564,6 +570,19 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] { } } +function resolveGlobalScope(ctx: TypeResolveContext): TypeScope[] | undefined { + if (ctx.options.globalTypeFiles) { + const fs: FS = ctx.options.fs || ts?.sys + if (!fs) { + throw new Error('[vue/compiler-sfc] globalTypeFiles requires fs access.') + } + return ctx.options.globalTypeFiles.map(file => + // TODO: differentiate ambient vs non-ambient module + fileToScope(file, fs, ctx.options.babelParserPlugins, true) + ) + } +} + let ts: typeof TS /** @@ -580,7 +599,7 @@ function resolveTypeFromImport( node: TSTypeReference | TSExpressionWithTypeArguments, name: string, scope: TypeScope -): Node | undefined { +): ScopeTypeNode | undefined { const fs: FS = ctx.options.fs || ts?.sys if (!fs) { ctx.error( @@ -629,7 +648,7 @@ function resolveTypeFromImport( return resolveTypeReference( ctx, node, - fileToScope(ctx, resolved, fs), + fileToScope(resolved, fs, ctx.options.babelParserPlugins), imported, true ) @@ -726,10 +745,11 @@ export function invalidateTypeCache(filename: string) { tsConfigCache.delete(filename) } -function fileToScope( - ctx: TypeResolveContext, +export function fileToScope( filename: string, - fs: FS + fs: FS, + parserPlugins: SFCScriptCompileOptions['babelParserPlugins'], + asGlobal = false ): TypeScope { const cached = fileToScopeCache.get(filename) if (cached) { @@ -737,33 +757,30 @@ function fileToScope( } const source = fs.readFile(filename) || '' - const body = parseFile(ctx, filename, source) + const body = parseFile(filename, source, parserPlugins) const scope: TypeScope = { filename, source, offset: 0, + imports: recordImports(body), types: Object.create(null), - exportedTypes: Object.create(null), - imports: recordImports(body) + exportedTypes: Object.create(null) } - recordTypes(body, scope) + recordTypes(body, scope, asGlobal) fileToScopeCache.set(filename, scope) return scope } function parseFile( - ctx: TypeResolveContext, filename: string, - content: string + content: string, + parserPlugins?: SFCScriptCompileOptions['babelParserPlugins'] ): Statement[] { const ext = extname(filename) if (ext === '.ts' || ext === '.tsx') { return babelParse(content, { - plugins: resolveParserPlugins( - ext.slice(1), - ctx.options.babelParserPlugins - ), + plugins: resolveParserPlugins(ext.slice(1), parserPlugins), sourceType: 'module' }).program.body } else if (ext === '.vue') { @@ -792,7 +809,7 @@ function parseFile( } const lang = script?.lang || scriptSetup?.lang return babelParse(scriptContent, { - plugins: resolveParserPlugins(lang!, ctx.options.babelParserPlugins), + plugins: resolveParserPlugins(lang!, parserPlugins), sourceType: 'module' }).program.body } @@ -830,52 +847,71 @@ function ctxToScope(ctx: TypeResolveContext): TypeScope { function moduleDeclToScope( node: TSModuleDeclaration & { _resolvedChildScope?: TypeScope }, - parent: TypeScope + parentScope: TypeScope ): TypeScope { if (node._resolvedChildScope) { return node._resolvedChildScope } const scope: TypeScope = { - ...parent, - types: Object.create(parent.types), - imports: Object.create(parent.imports) + ...parentScope, + types: Object.create(parentScope.types), + imports: Object.create(parentScope.imports) } recordTypes((node.body as TSModuleBlock).body, scope) return (node._resolvedChildScope = scope) } -function recordTypes(body: Statement[], scope: TypeScope) { +const importExportRE = /^Import|^Export/ + +function recordTypes(body: Statement[], scope: TypeScope, asGlobal = false) { const { types, exportedTypes, imports } = scope + const isAmbient = asGlobal + ? !body.some(s => importExportRE.test(s.type)) + : false for (const stmt of body) { - recordType(stmt, types) + if (asGlobal) { + if (isAmbient) { + if ((stmt as any).declare) { + recordType(stmt, types) + } + } else if (stmt.type === 'TSModuleDeclaration' && stmt.global) { + for (const s of (stmt.body as TSModuleBlock).body) { + recordType(s, types) + } + } + } else { + recordType(stmt, types) + } } - for (const stmt of body) { - if (stmt.type === 'ExportNamedDeclaration') { - if (stmt.declaration) { - recordType(stmt.declaration, types) - recordType(stmt.declaration, exportedTypes) - } else { - for (const spec of stmt.specifiers) { - if (spec.type === 'ExportSpecifier') { - const local = spec.local.name - const exported = getId(spec.exported) - if (stmt.source) { - // re-export, register an import + export as a type reference - imports[local] = { - source: stmt.source.value, - imported: local - } - exportedTypes[exported] = { - type: 'TSTypeReference', - typeName: { - type: 'Identifier', - name: local - }, - _ownerScope: scope + if (!asGlobal) { + for (const stmt of body) { + if (stmt.type === 'ExportNamedDeclaration') { + if (stmt.declaration) { + recordType(stmt.declaration, types) + recordType(stmt.declaration, exportedTypes) + } else { + for (const spec of stmt.specifiers) { + if (spec.type === 'ExportSpecifier') { + const local = spec.local.name + const exported = getId(spec.exported) + if (stmt.source) { + // re-export, register an import + export as a type reference + imports[local] = { + source: stmt.source.value, + imported: local + } + exportedTypes[exported] = { + type: 'TSTypeReference', + typeName: { + type: 'Identifier', + name: local + }, + _ownerScope: scope + } + } else if (types[local]) { + // exporting local defined type + exportedTypes[exported] = types[local] } - } else if (types[local]) { - // exporting local defined type - exportedTypes[exported] = types[local] } } }