]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(compiler-sfc): support resolving type imports from modules
authorEvan You <yyx990803@gmail.com>
Fri, 14 Apr 2023 09:27:50 +0000 (17:27 +0800)
committerEvan You <yyx990803@gmail.com>
Sat, 15 Apr 2023 14:08:39 +0000 (22:08 +0800)
packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts
packages/compiler-sfc/src/compileScript.ts
packages/compiler-sfc/src/index.ts
packages/compiler-sfc/src/script/resolveType.ts
packages/compiler-sfc/src/script/utils.ts
packages/sfc-playground/src/Header.vue
packages/sfc-playground/vite.config.ts
packages/vue/compiler-sfc/index.js
packages/vue/compiler-sfc/index.mjs
packages/vue/compiler-sfc/package.json
packages/vue/compiler-sfc/register-ts.js [new file with mode: 0644]

index 3e2a5ee17763650baf96cb180da543f5d89efa6f..6045cbd3d7ac7fed7feb3928c25a17a998164b39 100644 (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,11 +334,42 @@ 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']
       })
     })
@@ -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',
index 593e8e072c638b2fe67c6077d47ae876aff56dd8..989f61cb44466384dd0ed7fc4ed24555ee5ce725 100644 (file)
@@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions {
    */
   fs?: {
     fileExists(file: string): boolean
-    readFile(file: string): string
+    readFile(file: string): string | undefined
   }
 }
 
index 0b936553a32f6790dd2ecd5312129f7d96425b41..e171ac0885c7ac31bcd007515ceca7069ab3ba5a 100644 (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,
index c48e192f64140dc120443adfe0050ae737f32e3e..9d306d7bc5c765e76326e89134c0c6fcefa0a863 100644 (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,
+    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(
+        `Type import from non-relative sources is not supported in the browser build.`,
         node,
-        fileToScope(ctx, resolved, fs),
-        imported,
-        true
+        scope
       )
-    } else {
+    }
+    if (!ts) {
       ctx.error(
-        `Failed to resolve import source ${JSON.stringify(
+        `Failed to resolve type ${imported} from module ${JSON.stringify(
           source
-        )} for type ${name}`,
+        )}. ` +
+          `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
   }
index 780c780e2ccb125d88ae52d76914057d4c0fdd4d..6d874f8a6db484d7ce604a8130ffa07b62c860c9 100644 (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
+}
index 91ce3efc46ec1ffff86582cc3d66197b122e6341..b55f024090687ba89b20777a1647c25dc5e96d31 100644 (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
index 44d5a53509f721c858f4ba70032e819d49033959..5176b9cf061342f0255bc0493735804292a97441 100644 (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)
index 774f9da2742d99ba49b7ebf7f90c2008f80184f6..2b85ad129efc8b61e98f098d1fde83b6587cb31f 100644 (file)
@@ -1 +1,3 @@
 module.exports = require('@vue/compiler-sfc')
+
+require('./register-ts.js')
index 8df9a989d18fd516007d87303671a6084d624379..ae5d6e8e5ca935a6b1b5cd7b417735c66b9f0299 100644 (file)
@@ -1 +1,3 @@
-export * from '@vue/compiler-sfc'
\ No newline at end of file
+export * from '@vue/compiler-sfc'
+
+import './register-ts.js'
index 1b15fb844ac53504e3131068f07a74734f30e35a..778c7ebf51c78c2c3cedb5c629d7fe4b03f10db2 100644 (file)
@@ -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 (file)
index 0000000..87f61b6
--- /dev/null
@@ -0,0 +1,5 @@
+if (typeof require !== 'undefined') {
+  try {
+    require('@vue/compiler-sfc').registerTS(require('typescript'))
+  } catch (e) {}
+}