Skip to content

Commit 1c0be5c

Browse files
committed
feat(compiler-sfc): support project references when resolving types
close #8140
1 parent a370e80 commit 1c0be5c

File tree

4 files changed

+194
-95
lines changed

4 files changed

+194
-95
lines changed

packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,44 @@ describe('resolveType', () => {
607607
])
608608
})
609609

610+
test('ts module resolve w/ project reference & extends', () => {
611+
const files = {
612+
'/tsconfig.json': JSON.stringify({
613+
references: [
614+
{
615+
path: './tsconfig.app.json'
616+
}
617+
]
618+
}),
619+
'/tsconfig.app.json': JSON.stringify({
620+
include: ['**/*.ts', '**/*.vue'],
621+
extends: './tsconfig.web.json'
622+
}),
623+
'/tsconfig.web.json': JSON.stringify({
624+
compilerOptions: {
625+
composite: true,
626+
paths: {
627+
bar: ['./user.ts']
628+
}
629+
}
630+
}),
631+
'/user.ts': 'export type User = { bar: string }'
632+
}
633+
634+
const { props, deps } = resolve(
635+
`
636+
import { User } from 'bar'
637+
defineProps<User>()
638+
`,
639+
files
640+
)
641+
642+
expect(props).toStrictEqual({
643+
bar: ['String']
644+
})
645+
expect(deps && [...deps]).toStrictEqual(['/user.ts'])
646+
})
647+
610648
test('global types', () => {
611649
const files = {
612650
// ambient

packages/compiler-sfc/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"hash-sum": "^2.0.0",
5252
"lru-cache": "^5.1.1",
5353
"merge-source-map": "^1.1.0",
54+
"minimatch": "^9.0.0",
5455
"postcss-modules": "^4.0.0",
5556
"postcss-selector-parser": "^6.0.4",
5657
"pug": "^3.0.1",

packages/compiler-sfc/src/script/resolveType.ts

Lines changed: 146 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { parse } from '../parse'
4040
import { createCache } from '../cache'
4141
import type TS from 'typescript'
4242
import { extname, dirname } from 'path'
43+
import { minimatch as isMatch } from 'minimatch'
4344

4445
/**
4546
* TypeResolveContext is compatible with ScriptCompileContext
@@ -77,15 +78,19 @@ interface WithScope {
7778
type ScopeTypeNode = Node &
7879
WithScope & { _ns?: TSModuleDeclaration & WithScope }
7980

80-
export interface TypeScope {
81-
filename: string
82-
source: string
83-
offset: number
84-
imports: Record<string, Import>
85-
types: Record<string, ScopeTypeNode>
86-
exportedTypes: Record<string, ScopeTypeNode>
87-
declares: Record<string, ScopeTypeNode>
88-
exportedDeclares: Record<string, ScopeTypeNode>
81+
export class TypeScope {
82+
constructor(
83+
public filename: string,
84+
public source: string,
85+
public offset: number = 0,
86+
public imports: Record<string, Import> = Object.create(null),
87+
public types: Record<string, ScopeTypeNode> = Object.create(null),
88+
public declares: Record<string, ScopeTypeNode> = Object.create(null)
89+
) {}
90+
91+
resolvedImportSources: Record<string, string> = Object.create(null)
92+
exportedTypes: Record<string, ScopeTypeNode> = Object.create(null)
93+
exportedDeclares: Record<string, ScopeTypeNode> = Object.create(null)
8994
}
9095

9196
export interface MaybeWithScope {
@@ -716,33 +721,38 @@ function importSourceToScope(
716721
scope
717722
)
718723
}
719-
let resolved
720-
if (source.startsWith('.')) {
721-
// relative import - fast path
722-
const filename = joinPaths(scope.filename, '..', source)
723-
resolved = resolveExt(filename, fs)
724-
} else {
725-
// module or aliased import - use full TS resolution, only supported in Node
726-
if (!__NODE_JS__) {
727-
ctx.error(
728-
`Type import from non-relative sources is not supported in the browser build.`,
729-
node,
730-
scope
731-
)
724+
725+
let resolved: string | undefined = scope.resolvedImportSources[source]
726+
if (!resolved) {
727+
if (source.startsWith('.')) {
728+
// relative import - fast path
729+
const filename = joinPaths(scope.filename, '..', source)
730+
resolved = resolveExt(filename, fs)
731+
} else {
732+
// module or aliased import - use full TS resolution, only supported in Node
733+
if (!__NODE_JS__) {
734+
ctx.error(
735+
`Type import from non-relative sources is not supported in the browser build.`,
736+
node,
737+
scope
738+
)
739+
}
740+
if (!ts) {
741+
ctx.error(
742+
`Failed to resolve import source ${JSON.stringify(source)}. ` +
743+
`typescript is required as a peer dep for vue in order ` +
744+
`to support resolving types from module imports.`,
745+
node,
746+
scope
747+
)
748+
}
749+
resolved = resolveWithTS(scope.filename, source, fs)
732750
}
733-
if (!ts) {
734-
ctx.error(
735-
`Failed to resolve import source ${JSON.stringify(source)}. ` +
736-
`typescript is required as a peer dep for vue in order ` +
737-
`to support resolving types from module imports.`,
738-
node,
739-
scope
740-
)
751+
if (resolved) {
752+
resolved = scope.resolvedImportSources[source] = normalizePath(resolved)
741753
}
742-
resolved = resolveWithTS(scope.filename, source, fs)
743754
}
744755
if (resolved) {
745-
resolved = normalizePath(resolved)
746756
// (hmr) register dependency file on ctx
747757
;(ctx.deps || (ctx.deps = new Set())).add(resolved)
748758
return fileToScope(ctx, resolved)
@@ -768,10 +778,13 @@ function resolveExt(filename: string, fs: FS) {
768778
)
769779
}
770780

771-
const tsConfigCache = createCache<{
772-
options: TS.CompilerOptions
773-
cache: TS.ModuleResolutionCache
774-
}>()
781+
interface CachedConfig {
782+
config: TS.ParsedCommandLine
783+
cache?: TS.ModuleResolutionCache
784+
}
785+
786+
const tsConfigCache = createCache<CachedConfig[]>()
787+
const tsConfigRefMap = new Map<string, string>()
775788

776789
function resolveWithTS(
777790
containingFile: string,
@@ -783,51 +796,102 @@ function resolveWithTS(
783796
// 1. resolve tsconfig.json
784797
const configPath = ts.findConfigFile(containingFile, fs.fileExists)
785798
// 2. load tsconfig.json
786-
let options: TS.CompilerOptions
787-
let cache: TS.ModuleResolutionCache | undefined
799+
let tsCompilerOptions: TS.CompilerOptions
800+
let tsResolveCache: TS.ModuleResolutionCache | undefined
788801
if (configPath) {
802+
let configs: CachedConfig[]
789803
const normalizedConfigPath = normalizePath(configPath)
790804
const cached = tsConfigCache.get(normalizedConfigPath)
791805
if (!cached) {
792-
// The only case where `fs` is NOT `ts.sys` is during tests.
793-
// parse config host requires an extra `readDirectory` method
794-
// during tests, which is stubbed.
795-
const parseConfigHost = __TEST__
796-
? {
797-
...fs,
798-
useCaseSensitiveFileNames: true,
799-
readDirectory: () => []
806+
configs = loadTSConfig(configPath, fs).map(config => ({ config }))
807+
tsConfigCache.set(normalizedConfigPath, configs)
808+
} else {
809+
configs = cached
810+
}
811+
let matchedConfig: CachedConfig | undefined
812+
if (configs.length === 1) {
813+
matchedConfig = configs[0]
814+
} else {
815+
// resolve which config matches the current file
816+
for (const c of configs) {
817+
const base = normalizePath(
818+
(c.config.options.pathsBasePath as string) ||
819+
dirname(c.config.options.configFilePath as string)
820+
)
821+
const included: string[] = c.config.raw?.include
822+
const excluded: string[] = c.config.raw?.exclude
823+
if (
824+
(!included && (!base || containingFile.startsWith(base))) ||
825+
included.some(p => isMatch(containingFile, joinPaths(base, p)))
826+
) {
827+
if (
828+
excluded &&
829+
excluded.some(p => isMatch(containingFile, joinPaths(base, p)))
830+
) {
831+
continue
800832
}
801-
: ts.sys
802-
const parsed = ts.parseJsonConfigFileContent(
803-
ts.readConfigFile(configPath, fs.readFile).config,
804-
parseConfigHost,
805-
dirname(configPath),
806-
undefined,
807-
configPath
808-
)
809-
options = parsed.options
810-
cache = ts.createModuleResolutionCache(
833+
matchedConfig = c
834+
break
835+
}
836+
}
837+
if (!matchedConfig) {
838+
matchedConfig = configs[configs.length - 1]
839+
}
840+
}
841+
tsCompilerOptions = matchedConfig.config.options
842+
tsResolveCache =
843+
matchedConfig.cache ||
844+
(matchedConfig.cache = ts.createModuleResolutionCache(
811845
process.cwd(),
812846
createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames),
813-
options
814-
)
815-
tsConfigCache.set(normalizedConfigPath, { options, cache })
816-
} else {
817-
;({ options, cache } = cached)
818-
}
847+
tsCompilerOptions
848+
))
819849
} else {
820-
options = {}
850+
tsCompilerOptions = {}
821851
}
822852

823853
// 3. resolve
824-
const res = ts.resolveModuleName(source, containingFile, options, fs, cache)
854+
const res = ts.resolveModuleName(
855+
source,
856+
containingFile,
857+
tsCompilerOptions,
858+
fs,
859+
tsResolveCache
860+
)
825861

826862
if (res.resolvedModule) {
827863
return res.resolvedModule.resolvedFileName
828864
}
829865
}
830866

867+
function loadTSConfig(configPath: string, fs: FS): TS.ParsedCommandLine[] {
868+
// The only case where `fs` is NOT `ts.sys` is during tests.
869+
// parse config host requires an extra `readDirectory` method
870+
// during tests, which is stubbed.
871+
const parseConfigHost = __TEST__
872+
? {
873+
...fs,
874+
useCaseSensitiveFileNames: true,
875+
readDirectory: () => []
876+
}
877+
: ts.sys
878+
const config = ts.parseJsonConfigFileContent(
879+
ts.readConfigFile(configPath, fs.readFile).config,
880+
parseConfigHost,
881+
dirname(configPath),
882+
undefined,
883+
configPath
884+
)
885+
const res = [config]
886+
if (config.projectReferences) {
887+
for (const ref of config.projectReferences) {
888+
tsConfigRefMap.set(ref.path, configPath)
889+
res.unshift(...loadTSConfig(ref.path, fs))
890+
}
891+
}
892+
return res
893+
}
894+
831895
const fileToScopeCache = createCache<TypeScope>()
832896

833897
/**
@@ -837,6 +901,8 @@ export function invalidateTypeCache(filename: string) {
837901
filename = normalizePath(filename)
838902
fileToScopeCache.delete(filename)
839903
tsConfigCache.delete(filename)
904+
const affectedConfig = tsConfigRefMap.get(filename)
905+
if (affectedConfig) tsConfigCache.delete(affectedConfig)
840906
}
841907

842908
export function fileToScope(
@@ -852,16 +918,7 @@ export function fileToScope(
852918
const fs = ctx.options.fs || ts?.sys
853919
const source = fs.readFile(filename) || ''
854920
const body = parseFile(filename, source, ctx.options.babelParserPlugins)
855-
const scope: TypeScope = {
856-
filename,
857-
source,
858-
offset: 0,
859-
imports: recordImports(body),
860-
types: Object.create(null),
861-
exportedTypes: Object.create(null),
862-
declares: Object.create(null),
863-
exportedDeclares: Object.create(null)
864-
}
921+
const scope = new TypeScope(filename, source, 0, recordImports(body))
865922
recordTypes(ctx, body, scope, asGlobal)
866923
fileToScopeCache.set(filename, scope)
867924
return scope
@@ -923,19 +980,12 @@ function ctxToScope(ctx: TypeResolveContext): TypeScope {
923980
? [...ctx.scriptAst.body, ...ctx.scriptSetupAst!.body]
924981
: ctx.scriptSetupAst!.body
925982

926-
const scope: TypeScope = {
927-
filename: ctx.filename,
928-
source: ctx.source,
929-
offset: 'startOffset' in ctx ? ctx.startOffset! : 0,
930-
imports:
931-
'userImports' in ctx
932-
? Object.create(ctx.userImports)
933-
: recordImports(body),
934-
types: Object.create(null),
935-
exportedTypes: Object.create(null),
936-
declares: Object.create(null),
937-
exportedDeclares: Object.create(null)
938-
}
983+
const scope = new TypeScope(
984+
ctx.filename,
985+
ctx.source,
986+
'startOffset' in ctx ? ctx.startOffset! : 0,
987+
'userImports' in ctx ? Object.create(ctx.userImports) : recordImports(body)
988+
)
939989

940990
recordTypes(ctx, body, scope)
941991

@@ -950,14 +1000,15 @@ function moduleDeclToScope(
9501000
if (node._resolvedChildScope) {
9511001
return node._resolvedChildScope
9521002
}
953-
const scope: TypeScope = {
954-
...parentScope,
955-
imports: Object.create(parentScope.imports),
956-
types: Object.create(parentScope.types),
957-
declares: Object.create(parentScope.declares),
958-
exportedTypes: Object.create(null),
959-
exportedDeclares: Object.create(null)
960-
}
1003+
1004+
const scope = new TypeScope(
1005+
parentScope.filename,
1006+
parentScope.source,
1007+
parentScope.offset,
1008+
Object.create(parentScope.imports),
1009+
Object.create(parentScope.types),
1010+
Object.create(parentScope.declares)
1011+
)
9611012

9621013
if (node.body.type === 'TSModuleDeclaration') {
9631014
const decl = node.body as TSModuleDeclaration & WithScope

0 commit comments

Comments
 (0)