feat(compiler-sfc): support resolving type imports from modules

This commit is contained in:
Evan You 2023-04-14 17:27:50 +08:00
parent 8451b92a7a
commit 3982bef533
11 changed files with 234 additions and 47 deletions

View File

@ -5,9 +5,13 @@ import {
inferRuntimeType,
invalidateTypeCache,
recordImports,
resolveTypeElements
resolveTypeElements,
registerTS
} from '../../src/script/resolveType'
import ts from 'typescript'
registerTS(ts)
describe('resolveType', () => {
test('type literal', () => {
const { props, calls } = resolve(`type Target = {
@ -86,6 +90,19 @@ describe('resolveType', () => {
})
})
test('reference class', () => {
expect(
resolve(`
class Foo {}
type Target = {
foo: Foo
}
`).props
).toStrictEqual({
foo: ['Object']
})
})
test('function type', () => {
expect(
resolve(`
@ -258,8 +275,8 @@ describe('resolveType', () => {
type Target = P & PP
`,
{
'foo.ts': 'export type P = { foo: number }',
'bar.d.ts': 'type X = { bar: string }; export { X as Y }'
'/foo.ts': 'export type P = { foo: number }',
'/bar.d.ts': 'type X = { bar: string }; export { X as Y }'
}
).props
).toStrictEqual({
@ -277,9 +294,9 @@ describe('resolveType', () => {
type Target = P & PP
`,
{
'foo.vue':
'/foo.vue':
'<script lang="ts">export type P = { foo: number }</script>',
'bar.vue':
'/bar.vue':
'<script setup lang="tsx">export type P = { bar: string }</script>'
}
).props
@ -297,9 +314,9 @@ describe('resolveType', () => {
type Target = P
`,
{
'foo.ts': `import type { P as PP } from './nested/bar.vue'
'/foo.ts': `import type { P as PP } from './nested/bar.vue'
export type P = { foo: number } & PP`,
'nested/bar.vue':
'/nested/bar.vue':
'<script setup lang="ts">export type P = { bar: string }</script>'
}
).props
@ -317,14 +334,45 @@ describe('resolveType', () => {
type Target = P
`,
{
'foo.ts': `export { P as PP } from './bar'`,
'bar.ts': 'export type P = { bar: string }'
'/foo.ts': `export { P as PP } from './bar'`,
'/bar.ts': 'export type P = { bar: string }'
}
).props
).toStrictEqual({
bar: ['String']
})
})
test('ts module resolve', () => {
expect(
resolve(
`
import { P } from 'foo'
import { PP } from 'bar'
type Target = P & PP
`,
{
'/node_modules/foo/package.json': JSON.stringify({
name: 'foo',
version: '1.0.0',
types: 'index.d.ts'
}),
'/node_modules/foo/index.d.ts': 'export type P = { foo: number }',
'/tsconfig.json': JSON.stringify({
compilerOptions: {
paths: {
bar: ['./other/bar.ts']
}
}
}),
'/other/bar.ts': 'export type PP = { bar: string }'
}
).props
).toStrictEqual({
foo: ['Number'],
bar: ['String']
})
})
})
describe('errors', () => {
@ -356,7 +404,7 @@ describe('resolveType', () => {
function resolve(code: string, files: Record<string, string> = {}) {
const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
filename: 'Test.vue'
filename: '/Test.vue'
})
const ctx = new ScriptCompileContext(descriptor, {
id: 'test',

View File

@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions {
*/
fs?: {
fileExists(file: string): boolean
readFile(file: string): string
readFile(file: string): string | undefined
}
}

View File

@ -6,7 +6,6 @@ export { compileTemplate } from './compileTemplate'
export { compileStyle, compileStyleAsync } from './compileStyle'
export { compileScript } from './compileScript'
export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
export { invalidateTypeCache } from './script/resolveType'
export {
shouldTransform as shouldTransformRef,
transform as transformRef,
@ -29,6 +28,9 @@ export {
isStaticProperty
} from '@vue/compiler-core'
// Internals for type resolution
export { invalidateTypeCache, registerTS } from './script/resolveType'
// Types
export type {
SFCParseOptions,

View File

@ -20,14 +20,20 @@ import {
TSTypeReference,
TemplateLiteral
} from '@babel/types'
import { UNKNOWN_TYPE, getId, getImportedName } from './utils'
import {
UNKNOWN_TYPE,
createGetCanonicalFileName,
getId,
getImportedName
} from './utils'
import { ScriptCompileContext, resolveParserPlugins } from './context'
import { ImportBinding, SFCScriptCompileOptions } from '../compileScript'
import { capitalize, hasOwn } from '@vue/shared'
import path from 'path'
import { parse as babelParse } from '@babel/parser'
import { parse } from '../parse'
import { createCache } from '../cache'
import type TS from 'typescript'
import { join, extname, dirname } from 'path'
type Import = Pick<ImportBinding, 'source' | 'imported'>
@ -480,54 +486,82 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] {
}
}
let ts: typeof TS
export function registerTS(_ts: any) {
ts = _ts
}
type FS = NonNullable<SFCScriptCompileOptions['fs']>
function resolveTypeFromImport(
ctx: ScriptCompileContext,
node: TSTypeReference | TSExpressionWithTypeArguments,
name: string,
scope: TypeScope
): Node | undefined {
const fs = ctx.options.fs
const fs: FS = ctx.options.fs || ts?.sys
if (!fs) {
ctx.error(
`fs options for compileScript are required for resolving imported types`,
node,
scope
`No fs option provided to \`compileScript\` in non-Node environment. ` +
`File system access is required for resolving imported types.`,
node
)
}
// TODO (hmr) register dependency file on ctx
const containingFile = scope.filename
const { source, imported } = scope.imports[name]
let resolved: string | undefined
if (source.startsWith('.')) {
// relative import - fast path
const filename = path.join(containingFile, '..', source)
const resolved = resolveExt(filename, fs)
if (resolved) {
return resolveTypeReference(
ctx,
node,
fileToScope(ctx, resolved, fs),
imported,
true
)
} else {
const filename = join(containingFile, '..', source)
resolved = resolveExt(filename, fs)
} else {
// module or aliased import - use full TS resolution, only supported in Node
if (!__NODE_JS__) {
ctx.error(
`Failed to resolve import source ${JSON.stringify(
source
)} for type ${name}`,
`Type import from non-relative sources is not supported in the browser build.`,
node,
scope
)
}
if (!ts) {
ctx.error(
`Failed to resolve type ${imported} from module ${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(containingFile, source, fs)
}
if (resolved) {
// TODO (hmr) register dependency file on ctx
return resolveTypeReference(
ctx,
node,
fileToScope(ctx, resolved, fs),
imported,
true
)
} else {
// TODO module or aliased import - use full TS resolution
return
ctx.error(
`Failed to resolve import source ${JSON.stringify(
source
)} for type ${name}`,
node,
scope
)
}
}
function resolveExt(
filename: string,
fs: NonNullable<SFCScriptCompileOptions['fs']>
) {
function resolveExt(filename: string, fs: FS) {
const tryResolve = (filename: string) => {
if (fs.fileExists(filename)) return filename
}
@ -540,23 +574,83 @@ function resolveExt(
)
}
const tsConfigCache = createCache<{
options: TS.CompilerOptions
cache: TS.ModuleResolutionCache
}>()
function resolveWithTS(
containingFile: string,
source: string,
fs: FS
): string | undefined {
if (!__NODE_JS__) return
// 1. resolve tsconfig.json
const configPath = ts.findConfigFile(containingFile, fs.fileExists)
// 2. load tsconfig.json
let options: TS.CompilerOptions
let cache: TS.ModuleResolutionCache | undefined
if (configPath) {
const cached = tsConfigCache.get(configPath)
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: () => []
}
: ts.sys
const parsed = ts.parseJsonConfigFileContent(
ts.readConfigFile(configPath, fs.readFile).config,
parseConfigHost,
dirname(configPath),
undefined,
configPath
)
options = parsed.options
cache = ts.createModuleResolutionCache(
process.cwd(),
createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames),
options
)
tsConfigCache.set(configPath, { options, cache })
} else {
;({ options, cache } = cached)
}
} else {
options = {}
}
// 3. resolve
const res = ts.resolveModuleName(source, containingFile, options, fs, cache)
if (res.resolvedModule) {
return res.resolvedModule.resolvedFileName
}
}
const fileToScopeCache = createCache<TypeScope>()
export function invalidateTypeCache(filename: string) {
fileToScopeCache.delete(filename)
tsConfigCache.delete(filename)
}
function fileToScope(
ctx: ScriptCompileContext,
filename: string,
fs: NonNullable<SFCScriptCompileOptions['fs']>
fs: FS
): TypeScope {
const cached = fileToScopeCache.get(filename)
if (cached) {
return cached
}
const source = fs.readFile(filename)
const source = fs.readFile(filename) || ''
const body = parseFile(ctx, filename, source)
const scope: TypeScope = {
filename,
@ -577,7 +671,7 @@ function parseFile(
filename: string,
content: string
): Statement[] {
const ext = path.extname(filename)
const ext = extname(filename)
if (ext === '.ts' || ext === '.tsx') {
return babelParse(content, {
plugins: resolveParserPlugins(
@ -705,7 +799,8 @@ function recordType(node: Node, types: Record<string, Node>) {
switch (node.type) {
case 'TSInterfaceDeclaration':
case 'TSEnumDeclaration':
case 'TSModuleDeclaration': {
case 'TSModuleDeclaration':
case 'ClassDeclaration': {
const id = node.id.type === 'Identifier' ? node.id.name : node.id.value
types[id] = node
break
@ -899,6 +994,9 @@ export function inferRuntimeType(
}
}
case 'ClassDeclaration':
return ['Object']
default:
return [UNKNOWN_TYPE] // no runtime check
}

View File

@ -78,3 +78,22 @@ export function getId(node: Expression) {
? node.value
: null
}
const identity = (str: string) => str
const fileNameLowerCaseRegExp = /[^\u0130\u0131\u00DFa-z0-9\\/:\-_\. ]+/g
const toLowerCase = (str: string) => str.toLowerCase()
function toFileNameLowerCase(x: string) {
return fileNameLowerCaseRegExp.test(x)
? x.replace(fileNameLowerCaseRegExp, toLowerCase)
: x
}
/**
* We need `getCanonicalFileName` when creating ts module resolution cache,
* but TS does not expose it directly. This implementation is repllicated from
* the TS source code.
*/
export function createGetCanonicalFileName(useCaseSensitiveFileNames: boolean) {
return useCaseSensitiveFileNames ? identity : toFileNameLowerCase
}

View File

@ -6,7 +6,7 @@ import Moon from './icons/Moon.vue'
import Share from './icons/Share.vue'
import Download from './icons/Download.vue'
import GitHub from './icons/GitHub.vue'
import { ReplStore } from '@vue/repl'
import type { ReplStore } from '@vue/repl'
const props = defineProps<{
store: ReplStore

View File

@ -7,7 +7,18 @@ import execa from 'execa'
const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)
export default defineConfig({
plugins: [vue(), copyVuePlugin()],
plugins: [
vue({
script: {
// @ts-ignore
fs: {
fileExists: fs.existsSync,
readFile: file => fs.readFileSync(file, 'utf-8')
}
}
}),
copyVuePlugin()
],
define: {
__COMMIT__: JSON.stringify(commit),
__VUE_PROD_DEVTOOLS__: JSON.stringify(true)

View File

@ -1 +1,3 @@
module.exports = require('@vue/compiler-sfc')
require('./register-ts.js')

View File

@ -1 +1,3 @@
export * from '@vue/compiler-sfc'
export * from '@vue/compiler-sfc'
import './register-ts.js'

View File

@ -2,4 +2,4 @@
"main": "index.js",
"module": "index.mjs",
"types": "index.d.ts"
}
}

View File

@ -0,0 +1,5 @@
if (typeof require !== 'undefined') {
try {
require('@vue/compiler-sfc').registerTS(require('typescript'))
} catch (e) {}
}