From 3982bef533b451d1b59fa243560184a13fe8c18c Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 14 Apr 2023 17:27:50 +0800 Subject: [PATCH] feat(compiler-sfc): support resolving type imports from modules --- .../compileScript/resolveType.spec.ts | 68 ++++++-- packages/compiler-sfc/src/compileScript.ts | 2 +- packages/compiler-sfc/src/index.ts | 4 +- .../compiler-sfc/src/script/resolveType.ts | 160 ++++++++++++++---- packages/compiler-sfc/src/script/utils.ts | 19 +++ packages/sfc-playground/src/Header.vue | 2 +- packages/sfc-playground/vite.config.ts | 13 +- packages/vue/compiler-sfc/index.js | 2 + packages/vue/compiler-sfc/index.mjs | 4 +- packages/vue/compiler-sfc/package.json | 2 +- packages/vue/compiler-sfc/register-ts.js | 5 + 11 files changed, 234 insertions(+), 47 deletions(-) create mode 100644 packages/vue/compiler-sfc/register-ts.js diff --git a/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts b/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts index 3e2a5ee17..6045cbd3d 100644 --- a/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts @@ -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': '', - 'bar.vue': + '/bar.vue': '' } ).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': '' } ).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 = {}) { const { descriptor } = parse(``, { - filename: 'Test.vue' + filename: '/Test.vue' }) const ctx = new ScriptCompileContext(descriptor, { id: 'test', diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts index 593e8e072..989f61cb4 100644 --- a/packages/compiler-sfc/src/compileScript.ts +++ b/packages/compiler-sfc/src/compileScript.ts @@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions { */ fs?: { fileExists(file: string): boolean - readFile(file: string): string + readFile(file: string): string | undefined } } diff --git a/packages/compiler-sfc/src/index.ts b/packages/compiler-sfc/src/index.ts index 0b936553a..e171ac088 100644 --- a/packages/compiler-sfc/src/index.ts +++ b/packages/compiler-sfc/src/index.ts @@ -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, diff --git a/packages/compiler-sfc/src/script/resolveType.ts b/packages/compiler-sfc/src/script/resolveType.ts index c48e192f6..9d306d7bc 100644 --- a/packages/compiler-sfc/src/script/resolveType.ts +++ b/packages/compiler-sfc/src/script/resolveType.ts @@ -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 @@ -480,54 +486,82 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] { } } +let ts: typeof TS + +export function registerTS(_ts: any) { + ts = _ts +} + +type FS = NonNullable + 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 -) { +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() export function invalidateTypeCache(filename: string) { fileToScopeCache.delete(filename) + tsConfigCache.delete(filename) } function fileToScope( ctx: ScriptCompileContext, filename: string, - fs: NonNullable + 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) { 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 } diff --git a/packages/compiler-sfc/src/script/utils.ts b/packages/compiler-sfc/src/script/utils.ts index 780c780e2..6d874f8a6 100644 --- a/packages/compiler-sfc/src/script/utils.ts +++ b/packages/compiler-sfc/src/script/utils.ts @@ -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 +} diff --git a/packages/sfc-playground/src/Header.vue b/packages/sfc-playground/src/Header.vue index 91ce3efc4..b55f02409 100644 --- a/packages/sfc-playground/src/Header.vue +++ b/packages/sfc-playground/src/Header.vue @@ -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 diff --git a/packages/sfc-playground/vite.config.ts b/packages/sfc-playground/vite.config.ts index 44d5a5350..5176b9cf0 100644 --- a/packages/sfc-playground/vite.config.ts +++ b/packages/sfc-playground/vite.config.ts @@ -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) diff --git a/packages/vue/compiler-sfc/index.js b/packages/vue/compiler-sfc/index.js index 774f9da27..2b85ad129 100644 --- a/packages/vue/compiler-sfc/index.js +++ b/packages/vue/compiler-sfc/index.js @@ -1 +1,3 @@ module.exports = require('@vue/compiler-sfc') + +require('./register-ts.js') diff --git a/packages/vue/compiler-sfc/index.mjs b/packages/vue/compiler-sfc/index.mjs index 8df9a989d..ae5d6e8e5 100644 --- a/packages/vue/compiler-sfc/index.mjs +++ b/packages/vue/compiler-sfc/index.mjs @@ -1 +1,3 @@ -export * from '@vue/compiler-sfc' \ No newline at end of file +export * from '@vue/compiler-sfc' + +import './register-ts.js' diff --git a/packages/vue/compiler-sfc/package.json b/packages/vue/compiler-sfc/package.json index 1b15fb844..778c7ebf5 100644 --- a/packages/vue/compiler-sfc/package.json +++ b/packages/vue/compiler-sfc/package.json @@ -2,4 +2,4 @@ "main": "index.js", "module": "index.mjs", "types": "index.d.ts" -} \ No newline at end of file +} diff --git a/packages/vue/compiler-sfc/register-ts.js b/packages/vue/compiler-sfc/register-ts.js new file mode 100644 index 000000000..87f61b648 --- /dev/null +++ b/packages/vue/compiler-sfc/register-ts.js @@ -0,0 +1,5 @@ +if (typeof require !== 'undefined') { + try { + require('@vue/compiler-sfc').registerTS(require('typescript')) + } catch (e) {} +}