]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(compiler-sfc): support project references when resolving types
authorEvan You <yyx990803@gmail.com>
Tue, 25 Apr 2023 08:30:11 +0000 (16:30 +0800)
committerEvan You <yyx990803@gmail.com>
Tue, 25 Apr 2023 08:30:11 +0000 (16:30 +0800)
close #8140

packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts
packages/compiler-sfc/package.json
packages/compiler-sfc/src/script/resolveType.ts
pnpm-lock.yaml

index dd8f82e1db013372985c08d1f6e9c76d30020270..12a414cc1f275472434ce73cb9d409f2d9ca59fc 100644 (file)
@@ -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<User>()
+        `,
+        files
+      )
+
+      expect(props).toStrictEqual({
+        bar: ['String']
+      })
+      expect(deps && [...deps]).toStrictEqual(['/user.ts'])
+    })
+
     test('global types', () => {
       const files = {
         // ambient
index 8b0e8adfb062fbd406a93d71d13c297c8e968472..aa3a34d9c50123ec0bd21c75e59285038347196b 100644 (file)
@@ -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",
index d0fd9c5896cc5616e7bc73279dc22b9b38dfd409..bf29ae089bda6ce541eba337a4a958c0ca8edad6 100644 (file)
@@ -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<string, Import>
-  types: Record<string, ScopeTypeNode>
-  exportedTypes: Record<string, ScopeTypeNode>
-  declares: Record<string, ScopeTypeNode>
-  exportedDeclares: Record<string, ScopeTypeNode>
+export class TypeScope {
+  constructor(
+    public filename: string,
+    public source: string,
+    public offset: number = 0,
+    public imports: Record<string, Import> = Object.create(null),
+    public types: Record<string, ScopeTypeNode> = Object.create(null),
+    public declares: Record<string, ScopeTypeNode> = Object.create(null)
+  ) {}
+
+  resolvedImportSources: Record<string, string> = Object.create(null)
+  exportedTypes: Record<string, ScopeTypeNode> = Object.create(null)
+  exportedDeclares: Record<string, ScopeTypeNode> = 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<CachedConfig[]>()
+const tsConfigRefMap = new Map<string, string>()
 
 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<TypeScope>()
 
 /**
@@ -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
index 817ac5a0999524de00c3137d3777740ef5cb4257..8320181887ee98f29ba10f13d428db18117057b5 100644 (file)
@@ -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'}