Skip to content

Commit 3982bef

Browse files
committed
feat(compiler-sfc): support resolving type imports from modules
1 parent 8451b92 commit 3982bef

File tree

11 files changed

+231
-44
lines changed

11 files changed

+231
-44
lines changed

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

+58-10
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import {
55
inferRuntimeType,
66
invalidateTypeCache,
77
recordImports,
8-
resolveTypeElements
8+
resolveTypeElements,
9+
registerTS
910
} from '../../src/script/resolveType'
1011

12+
import ts from 'typescript'
13+
registerTS(ts)
14+
1115
describe('resolveType', () => {
1216
test('type literal', () => {
1317
const { props, calls } = resolve(`type Target = {
@@ -86,6 +90,19 @@ describe('resolveType', () => {
8690
})
8791
})
8892

93+
test('reference class', () => {
94+
expect(
95+
resolve(`
96+
class Foo {}
97+
type Target = {
98+
foo: Foo
99+
}
100+
`).props
101+
).toStrictEqual({
102+
foo: ['Object']
103+
})
104+
})
105+
89106
test('function type', () => {
90107
expect(
91108
resolve(`
@@ -258,8 +275,8 @@ describe('resolveType', () => {
258275
type Target = P & PP
259276
`,
260277
{
261-
'foo.ts': 'export type P = { foo: number }',
262-
'bar.d.ts': 'type X = { bar: string }; export { X as Y }'
278+
'/foo.ts': 'export type P = { foo: number }',
279+
'/bar.d.ts': 'type X = { bar: string }; export { X as Y }'
263280
}
264281
).props
265282
).toStrictEqual({
@@ -277,9 +294,9 @@ describe('resolveType', () => {
277294
type Target = P & PP
278295
`,
279296
{
280-
'foo.vue':
297+
'/foo.vue':
281298
'<script lang="ts">export type P = { foo: number }</script>',
282-
'bar.vue':
299+
'/bar.vue':
283300
'<script setup lang="tsx">export type P = { bar: string }</script>'
284301
}
285302
).props
@@ -297,9 +314,9 @@ describe('resolveType', () => {
297314
type Target = P
298315
`,
299316
{
300-
'foo.ts': `import type { P as PP } from './nested/bar.vue'
317+
'/foo.ts': `import type { P as PP } from './nested/bar.vue'
301318
export type P = { foo: number } & PP`,
302-
'nested/bar.vue':
319+
'/nested/bar.vue':
303320
'<script setup lang="ts">export type P = { bar: string }</script>'
304321
}
305322
).props
@@ -317,11 +334,42 @@ describe('resolveType', () => {
317334
type Target = P
318335
`,
319336
{
320-
'foo.ts': `export { P as PP } from './bar'`,
321-
'bar.ts': 'export type P = { bar: string }'
337+
'/foo.ts': `export { P as PP } from './bar'`,
338+
'/bar.ts': 'export type P = { bar: string }'
339+
}
340+
).props
341+
).toStrictEqual({
342+
bar: ['String']
343+
})
344+
})
345+
346+
test('ts module resolve', () => {
347+
expect(
348+
resolve(
349+
`
350+
import { P } from 'foo'
351+
import { PP } from 'bar'
352+
type Target = P & PP
353+
`,
354+
{
355+
'/node_modules/foo/package.json': JSON.stringify({
356+
name: 'foo',
357+
version: '1.0.0',
358+
types: 'index.d.ts'
359+
}),
360+
'/node_modules/foo/index.d.ts': 'export type P = { foo: number }',
361+
'/tsconfig.json': JSON.stringify({
362+
compilerOptions: {
363+
paths: {
364+
bar: ['./other/bar.ts']
365+
}
366+
}
367+
}),
368+
'/other/bar.ts': 'export type PP = { bar: string }'
322369
}
323370
).props
324371
).toStrictEqual({
372+
foo: ['Number'],
325373
bar: ['String']
326374
})
327375
})
@@ -356,7 +404,7 @@ describe('resolveType', () => {
356404

357405
function resolve(code: string, files: Record<string, string> = {}) {
358406
const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
359-
filename: 'Test.vue'
407+
filename: '/Test.vue'
360408
})
361409
const ctx = new ScriptCompileContext(descriptor, {
362410
id: 'test',

packages/compiler-sfc/src/compileScript.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions {
115115
*/
116116
fs?: {
117117
fileExists(file: string): boolean
118-
readFile(file: string): string
118+
readFile(file: string): string | undefined
119119
}
120120
}
121121

packages/compiler-sfc/src/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export { compileTemplate } from './compileTemplate'
66
export { compileStyle, compileStyleAsync } from './compileStyle'
77
export { compileScript } from './compileScript'
88
export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
9-
export { invalidateTypeCache } from './script/resolveType'
109
export {
1110
shouldTransform as shouldTransformRef,
1211
transform as transformRef,
@@ -29,6 +28,9 @@ export {
2928
isStaticProperty
3029
} from '@vue/compiler-core'
3130

31+
// Internals for type resolution
32+
export { invalidateTypeCache, registerTS } from './script/resolveType'
33+
3234
// Types
3335
export type {
3436
SFCParseOptions,

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

+126-28
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@ import {
2020
TSTypeReference,
2121
TemplateLiteral
2222
} from '@babel/types'
23-
import { UNKNOWN_TYPE, getId, getImportedName } from './utils'
23+
import {
24+
UNKNOWN_TYPE,
25+
createGetCanonicalFileName,
26+
getId,
27+
getImportedName
28+
} from './utils'
2429
import { ScriptCompileContext, resolveParserPlugins } from './context'
2530
import { ImportBinding, SFCScriptCompileOptions } from '../compileScript'
2631
import { capitalize, hasOwn } from '@vue/shared'
27-
import path from 'path'
2832
import { parse as babelParse } from '@babel/parser'
2933
import { parse } from '../parse'
3034
import { createCache } from '../cache'
35+
import type TS from 'typescript'
36+
import { join, extname, dirname } from 'path'
3137

3238
type Import = Pick<ImportBinding, 'source' | 'imported'>
3339

@@ -480,54 +486,82 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] {
480486
}
481487
}
482488

489+
let ts: typeof TS
490+
491+
export function registerTS(_ts: any) {
492+
ts = _ts
493+
}
494+
495+
type FS = NonNullable<SFCScriptCompileOptions['fs']>
496+
483497
function resolveTypeFromImport(
484498
ctx: ScriptCompileContext,
485499
node: TSTypeReference | TSExpressionWithTypeArguments,
486500
name: string,
487501
scope: TypeScope
488502
): Node | undefined {
489-
const fs = ctx.options.fs
503+
const fs: FS = ctx.options.fs || ts?.sys
490504
if (!fs) {
491505
ctx.error(
492-
`fs options for compileScript are required for resolving imported types`,
493-
node,
494-
scope
506+
`No fs option provided to \`compileScript\` in non-Node environment. ` +
507+
`File system access is required for resolving imported types.`,
508+
node
495509
)
496510
}
497-
// TODO (hmr) register dependency file on ctx
511+
498512
const containingFile = scope.filename
499513
const { source, imported } = scope.imports[name]
514+
515+
let resolved: string | undefined
516+
500517
if (source.startsWith('.')) {
501518
// relative import - fast path
502-
const filename = path.join(containingFile, '..', source)
503-
const resolved = resolveExt(filename, fs)
504-
if (resolved) {
505-
return resolveTypeReference(
506-
ctx,
519+
const filename = join(containingFile, '..', source)
520+
resolved = resolveExt(filename, fs)
521+
} else {
522+
// module or aliased import - use full TS resolution, only supported in Node
523+
if (!__NODE_JS__) {
524+
ctx.error(
525+
`Type import from non-relative sources is not supported in the browser build.`,
507526
node,
508-
fileToScope(ctx, resolved, fs),
509-
imported,
510-
true
527+
scope
511528
)
512-
} else {
529+
}
530+
if (!ts) {
513531
ctx.error(
514-
`Failed to resolve import source ${JSON.stringify(
532+
`Failed to resolve type ${imported} from module ${JSON.stringify(
515533
source
516-
)} for type ${name}`,
534+
)}. ` +
535+
`typescript is required as a peer dep for vue in order ` +
536+
`to support resolving types from module imports.`,
517537
node,
518538
scope
519539
)
520540
}
541+
resolved = resolveWithTS(containingFile, source, fs)
542+
}
543+
544+
if (resolved) {
545+
// TODO (hmr) register dependency file on ctx
546+
return resolveTypeReference(
547+
ctx,
548+
node,
549+
fileToScope(ctx, resolved, fs),
550+
imported,
551+
true
552+
)
521553
} else {
522-
// TODO module or aliased import - use full TS resolution
523-
return
554+
ctx.error(
555+
`Failed to resolve import source ${JSON.stringify(
556+
source
557+
)} for type ${name}`,
558+
node,
559+
scope
560+
)
524561
}
525562
}
526563

527-
function resolveExt(
528-
filename: string,
529-
fs: NonNullable<SFCScriptCompileOptions['fs']>
530-
) {
564+
function resolveExt(filename: string, fs: FS) {
531565
const tryResolve = (filename: string) => {
532566
if (fs.fileExists(filename)) return filename
533567
}
@@ -540,23 +574,83 @@ function resolveExt(
540574
)
541575
}
542576

577+
const tsConfigCache = createCache<{
578+
options: TS.CompilerOptions
579+
cache: TS.ModuleResolutionCache
580+
}>()
581+
582+
function resolveWithTS(
583+
containingFile: string,
584+
source: string,
585+
fs: FS
586+
): string | undefined {
587+
if (!__NODE_JS__) return
588+
589+
// 1. resolve tsconfig.json
590+
const configPath = ts.findConfigFile(containingFile, fs.fileExists)
591+
// 2. load tsconfig.json
592+
let options: TS.CompilerOptions
593+
let cache: TS.ModuleResolutionCache | undefined
594+
if (configPath) {
595+
const cached = tsConfigCache.get(configPath)
596+
if (!cached) {
597+
// The only case where `fs` is NOT `ts.sys` is during tests.
598+
// parse config host requires an extra `readDirectory` method
599+
// during tests, which is stubbed.
600+
const parseConfigHost = __TEST__
601+
? {
602+
...fs,
603+
useCaseSensitiveFileNames: true,
604+
readDirectory: () => []
605+
}
606+
: ts.sys
607+
const parsed = ts.parseJsonConfigFileContent(
608+
ts.readConfigFile(configPath, fs.readFile).config,
609+
parseConfigHost,
610+
dirname(configPath),
611+
undefined,
612+
configPath
613+
)
614+
options = parsed.options
615+
cache = ts.createModuleResolutionCache(
616+
process.cwd(),
617+
createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames),
618+
options
619+
)
620+
tsConfigCache.set(configPath, { options, cache })
621+
} else {
622+
;({ options, cache } = cached)
623+
}
624+
} else {
625+
options = {}
626+
}
627+
628+
// 3. resolve
629+
const res = ts.resolveModuleName(source, containingFile, options, fs, cache)
630+
631+
if (res.resolvedModule) {
632+
return res.resolvedModule.resolvedFileName
633+
}
634+
}
635+
543636
const fileToScopeCache = createCache<TypeScope>()
544637

545638
export function invalidateTypeCache(filename: string) {
546639
fileToScopeCache.delete(filename)
640+
tsConfigCache.delete(filename)
547641
}
548642

549643
function fileToScope(
550644
ctx: ScriptCompileContext,
551645
filename: string,
552-
fs: NonNullable<SFCScriptCompileOptions['fs']>
646+
fs: FS
553647
): TypeScope {
554648
const cached = fileToScopeCache.get(filename)
555649
if (cached) {
556650
return cached
557651
}
558652

559-
const source = fs.readFile(filename)
653+
const source = fs.readFile(filename) || ''
560654
const body = parseFile(ctx, filename, source)
561655
const scope: TypeScope = {
562656
filename,
@@ -577,7 +671,7 @@ function parseFile(
577671
filename: string,
578672
content: string
579673
): Statement[] {
580-
const ext = path.extname(filename)
674+
const ext = extname(filename)
581675
if (ext === '.ts' || ext === '.tsx') {
582676
return babelParse(content, {
583677
plugins: resolveParserPlugins(
@@ -705,7 +799,8 @@ function recordType(node: Node, types: Record<string, Node>) {
705799
switch (node.type) {
706800
case 'TSInterfaceDeclaration':
707801
case 'TSEnumDeclaration':
708-
case 'TSModuleDeclaration': {
802+
case 'TSModuleDeclaration':
803+
case 'ClassDeclaration': {
709804
const id = node.id.type === 'Identifier' ? node.id.name : node.id.value
710805
types[id] = node
711806
break
@@ -899,6 +994,9 @@ export function inferRuntimeType(
899994
}
900995
}
901996

997+
case 'ClassDeclaration':
998+
return ['Object']
999+
9021000
default:
9031001
return [UNKNOWN_TYPE] // no runtime check
9041002
}

0 commit comments

Comments
 (0)