From 1c0be5c7444966fa444460e87633cf44ec60292a Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 25 Apr 2023 16:30:11 +0800 Subject: [PATCH] feat(compiler-sfc): support project references when resolving types close #8140 --- .../compileScript/resolveType.spec.ts | 38 +++ packages/compiler-sfc/package.json | 1 + .../compiler-sfc/src/script/resolveType.ts | 241 +++++++++++------- pnpm-lock.yaml | 9 + 4 files changed, 194 insertions(+), 95 deletions(-) diff --git a/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts b/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts index dd8f82e1db0..12a414cc1f2 100644 --- a/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts @@ -607,6 +607,44 @@ describe('resolveType', () => { ]) }) + test('ts module resolve w/ project reference & extends', () => { + const files = { + '/tsconfig.json': JSON.stringify({ + references: [ + { + path: './tsconfig.app.json' + } + ] + }), + '/tsconfig.app.json': JSON.stringify({ + include: ['**/*.ts', '**/*.vue'], + extends: './tsconfig.web.json' + }), + '/tsconfig.web.json': JSON.stringify({ + compilerOptions: { + composite: true, + paths: { + bar: ['./user.ts'] + } + } + }), + '/user.ts': 'export type User = { bar: string }' + } + + const { props, deps } = resolve( + ` + import { User } from 'bar' + defineProps() + `, + files + ) + + expect(props).toStrictEqual({ + bar: ['String'] + }) + expect(deps && [...deps]).toStrictEqual(['/user.ts']) + }) + test('global types', () => { const files = { // ambient diff --git a/packages/compiler-sfc/package.json b/packages/compiler-sfc/package.json index 8b0e8adfb06..aa3a34d9c50 100644 --- a/packages/compiler-sfc/package.json +++ b/packages/compiler-sfc/package.json @@ -51,6 +51,7 @@ "hash-sum": "^2.0.0", "lru-cache": "^5.1.1", "merge-source-map": "^1.1.0", + "minimatch": "^9.0.0", "postcss-modules": "^4.0.0", "postcss-selector-parser": "^6.0.4", "pug": "^3.0.1", diff --git a/packages/compiler-sfc/src/script/resolveType.ts b/packages/compiler-sfc/src/script/resolveType.ts index d0fd9c5896c..bf29ae089bd 100644 --- a/packages/compiler-sfc/src/script/resolveType.ts +++ b/packages/compiler-sfc/src/script/resolveType.ts @@ -40,6 +40,7 @@ import { parse } from '../parse' import { createCache } from '../cache' import type TS from 'typescript' import { extname, dirname } from 'path' +import { minimatch as isMatch } from 'minimatch' /** * TypeResolveContext is compatible with ScriptCompileContext @@ -77,15 +78,19 @@ interface WithScope { type ScopeTypeNode = Node & WithScope & { _ns?: TSModuleDeclaration & WithScope } -export interface TypeScope { - filename: string - source: string - offset: number - imports: Record - types: Record - exportedTypes: Record - declares: Record - exportedDeclares: Record +export class TypeScope { + constructor( + public filename: string, + public source: string, + public offset: number = 0, + public imports: Record = Object.create(null), + public types: Record = Object.create(null), + public declares: Record = Object.create(null) + ) {} + + resolvedImportSources: Record = Object.create(null) + exportedTypes: Record = Object.create(null) + exportedDeclares: Record = Object.create(null) } export interface MaybeWithScope { @@ -716,33 +721,38 @@ function importSourceToScope( scope ) } - let resolved - if (source.startsWith('.')) { - // relative import - fast path - const filename = joinPaths(scope.filename, '..', source) - resolved = resolveExt(filename, fs) - } else { - // module or aliased import - use full TS resolution, only supported in Node - if (!__NODE_JS__) { - ctx.error( - `Type import from non-relative sources is not supported in the browser build.`, - node, - scope - ) + + let resolved: string | undefined = scope.resolvedImportSources[source] + if (!resolved) { + if (source.startsWith('.')) { + // relative import - fast path + const filename = joinPaths(scope.filename, '..', source) + resolved = resolveExt(filename, fs) + } else { + // module or aliased import - use full TS resolution, only supported in Node + if (!__NODE_JS__) { + ctx.error( + `Type import from non-relative sources is not supported in the browser build.`, + node, + scope + ) + } + if (!ts) { + ctx.error( + `Failed to resolve import source ${JSON.stringify(source)}. ` + + `typescript is required as a peer dep for vue in order ` + + `to support resolving types from module imports.`, + node, + scope + ) + } + resolved = resolveWithTS(scope.filename, source, fs) } - if (!ts) { - ctx.error( - `Failed to resolve import source ${JSON.stringify(source)}. ` + - `typescript is required as a peer dep for vue in order ` + - `to support resolving types from module imports.`, - node, - scope - ) + if (resolved) { + resolved = scope.resolvedImportSources[source] = normalizePath(resolved) } - resolved = resolveWithTS(scope.filename, source, fs) } if (resolved) { - resolved = normalizePath(resolved) // (hmr) register dependency file on ctx ;(ctx.deps || (ctx.deps = new Set())).add(resolved) return fileToScope(ctx, resolved) @@ -768,10 +778,13 @@ function resolveExt(filename: string, fs: FS) { ) } -const tsConfigCache = createCache<{ - options: TS.CompilerOptions - cache: TS.ModuleResolutionCache -}>() +interface CachedConfig { + config: TS.ParsedCommandLine + cache?: TS.ModuleResolutionCache +} + +const tsConfigCache = createCache() +const tsConfigRefMap = new Map() function resolveWithTS( containingFile: string, @@ -783,51 +796,102 @@ function resolveWithTS( // 1. resolve tsconfig.json const configPath = ts.findConfigFile(containingFile, fs.fileExists) // 2. load tsconfig.json - let options: TS.CompilerOptions - let cache: TS.ModuleResolutionCache | undefined + let tsCompilerOptions: TS.CompilerOptions + let tsResolveCache: TS.ModuleResolutionCache | undefined if (configPath) { + let configs: CachedConfig[] const normalizedConfigPath = normalizePath(configPath) const cached = tsConfigCache.get(normalizedConfigPath) if (!cached) { - // The only case where `fs` is NOT `ts.sys` is during tests. - // parse config host requires an extra `readDirectory` method - // during tests, which is stubbed. - const parseConfigHost = __TEST__ - ? { - ...fs, - useCaseSensitiveFileNames: true, - readDirectory: () => [] + configs = loadTSConfig(configPath, fs).map(config => ({ config })) + tsConfigCache.set(normalizedConfigPath, configs) + } else { + configs = cached + } + let matchedConfig: CachedConfig | undefined + if (configs.length === 1) { + matchedConfig = configs[0] + } else { + // resolve which config matches the current file + for (const c of configs) { + const base = normalizePath( + (c.config.options.pathsBasePath as string) || + dirname(c.config.options.configFilePath as string) + ) + const included: string[] = c.config.raw?.include + const excluded: string[] = c.config.raw?.exclude + if ( + (!included && (!base || containingFile.startsWith(base))) || + included.some(p => isMatch(containingFile, joinPaths(base, p))) + ) { + if ( + excluded && + excluded.some(p => isMatch(containingFile, joinPaths(base, p))) + ) { + continue } - : ts.sys - const parsed = ts.parseJsonConfigFileContent( - ts.readConfigFile(configPath, fs.readFile).config, - parseConfigHost, - dirname(configPath), - undefined, - configPath - ) - options = parsed.options - cache = ts.createModuleResolutionCache( + matchedConfig = c + break + } + } + if (!matchedConfig) { + matchedConfig = configs[configs.length - 1] + } + } + tsCompilerOptions = matchedConfig.config.options + tsResolveCache = + matchedConfig.cache || + (matchedConfig.cache = ts.createModuleResolutionCache( process.cwd(), createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames), - options - ) - tsConfigCache.set(normalizedConfigPath, { options, cache }) - } else { - ;({ options, cache } = cached) - } + tsCompilerOptions + )) } else { - options = {} + tsCompilerOptions = {} } // 3. resolve - const res = ts.resolveModuleName(source, containingFile, options, fs, cache) + const res = ts.resolveModuleName( + source, + containingFile, + tsCompilerOptions, + fs, + tsResolveCache + ) if (res.resolvedModule) { return res.resolvedModule.resolvedFileName } } +function loadTSConfig(configPath: string, fs: FS): TS.ParsedCommandLine[] { + // The only case where `fs` is NOT `ts.sys` is during tests. + // parse config host requires an extra `readDirectory` method + // during tests, which is stubbed. + const parseConfigHost = __TEST__ + ? { + ...fs, + useCaseSensitiveFileNames: true, + readDirectory: () => [] + } + : ts.sys + const config = ts.parseJsonConfigFileContent( + ts.readConfigFile(configPath, fs.readFile).config, + parseConfigHost, + dirname(configPath), + undefined, + configPath + ) + const res = [config] + if (config.projectReferences) { + for (const ref of config.projectReferences) { + tsConfigRefMap.set(ref.path, configPath) + res.unshift(...loadTSConfig(ref.path, fs)) + } + } + return res +} + const fileToScopeCache = createCache() /** @@ -837,6 +901,8 @@ export function invalidateTypeCache(filename: string) { filename = normalizePath(filename) fileToScopeCache.delete(filename) tsConfigCache.delete(filename) + const affectedConfig = tsConfigRefMap.get(filename) + if (affectedConfig) tsConfigCache.delete(affectedConfig) } export function fileToScope( @@ -852,16 +918,7 @@ export function fileToScope( const fs = ctx.options.fs || ts?.sys const source = fs.readFile(filename) || '' const body = parseFile(filename, source, ctx.options.babelParserPlugins) - const scope: TypeScope = { - filename, - source, - offset: 0, - imports: recordImports(body), - types: Object.create(null), - exportedTypes: Object.create(null), - declares: Object.create(null), - exportedDeclares: Object.create(null) - } + const scope = new TypeScope(filename, source, 0, recordImports(body)) recordTypes(ctx, body, scope, asGlobal) fileToScopeCache.set(filename, scope) return scope @@ -923,19 +980,12 @@ function ctxToScope(ctx: TypeResolveContext): TypeScope { ? [...ctx.scriptAst.body, ...ctx.scriptSetupAst!.body] : ctx.scriptSetupAst!.body - const scope: TypeScope = { - filename: ctx.filename, - source: ctx.source, - offset: 'startOffset' in ctx ? ctx.startOffset! : 0, - imports: - 'userImports' in ctx - ? Object.create(ctx.userImports) - : recordImports(body), - types: Object.create(null), - exportedTypes: Object.create(null), - declares: Object.create(null), - exportedDeclares: Object.create(null) - } + const scope = new TypeScope( + ctx.filename, + ctx.source, + 'startOffset' in ctx ? ctx.startOffset! : 0, + 'userImports' in ctx ? Object.create(ctx.userImports) : recordImports(body) + ) recordTypes(ctx, body, scope) @@ -950,14 +1000,15 @@ function moduleDeclToScope( if (node._resolvedChildScope) { return node._resolvedChildScope } - const scope: TypeScope = { - ...parentScope, - imports: Object.create(parentScope.imports), - types: Object.create(parentScope.types), - declares: Object.create(parentScope.declares), - exportedTypes: Object.create(null), - exportedDeclares: Object.create(null) - } + + const scope = new TypeScope( + parentScope.filename, + parentScope.source, + parentScope.offset, + Object.create(parentScope.imports), + Object.create(parentScope.types), + Object.create(parentScope.declares) + ) if (node.body.type === 'TSModuleDeclaration') { const decl = node.body as TSModuleDeclaration & WithScope diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 817ac5a0999..8320181887e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,7 @@ importers: lru-cache: ^5.1.1 magic-string: ^0.30.0 merge-source-map: ^1.1.0 + minimatch: ^9.0.0 postcss: ^8.1.10 postcss-modules: ^4.0.0 postcss-selector-parser: ^6.0.4 @@ -161,6 +162,7 @@ importers: hash-sum: 2.0.0 lru-cache: 5.1.1 merge-source-map: 1.1.0 + minimatch: 9.0.0 postcss-modules: 4.3.1_postcss@8.4.21 postcss-selector-parser: 6.0.11 pug: 3.0.2 @@ -3759,6 +3761,13 @@ packages: brace-expansion: 2.0.1 dev: true + /minimatch/9.0.0: + resolution: {integrity: sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options/4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'}