feat(compiler-sfc): support specifying global types for sfc macros

ref: https://github.com/vuejs/core/pull/8083#issuecomment-1508468713
This commit is contained in:
Evan You 2023-04-16 15:49:41 +08:00
parent f22e32e365
commit 4e028b9669
4 changed files with 149 additions and 79 deletions

View File

@ -1,5 +1,5 @@
import { Identifier } from '@babel/types' import { Identifier } from '@babel/types'
import { parse } from '../../src' import { SFCScriptCompileOptions, parse } from '../../src'
import { ScriptCompileContext } from '../../src/script/context' import { ScriptCompileContext } from '../../src/script/context'
import { import {
inferRuntimeType, inferRuntimeType,
@ -410,6 +410,32 @@ describe('resolveType', () => {
'/pp.ts' '/pp.ts'
]) ])
}) })
test('global types', () => {
const files = {
// ambient
'/app.d.ts':
'declare namespace App { interface User { name: string } }',
// module - should only respect the declare global block
'/global.d.ts': `
declare type PP = { bar: number }
declare global {
type PP = { bar: string }
}
export {}
`
}
const { props, deps } = resolve(`defineProps<App.User & PP>()`, files, {
globalTypeFiles: Object.keys(files)
})
expect(props).toStrictEqual({
name: ['String'],
bar: ['String']
})
expect(deps && [...deps]).toStrictEqual(Object.keys(files))
})
}) })
describe('errors', () => { describe('errors', () => {
@ -444,7 +470,11 @@ describe('resolveType', () => {
}) })
}) })
function resolve(code: string, files: Record<string, string> = {}) { function resolve(
code: string,
files: Record<string, string> = {},
options?: Partial<SFCScriptCompileOptions>
) {
const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, { const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
filename: '/Test.vue' filename: '/Test.vue'
}) })
@ -457,7 +487,8 @@ function resolve(code: string, files: Record<string, string> = {}) {
readFile(file) { readFile(file) {
return files[file] return files[file]
} }
} },
...options
}) })
for (const file in files) { for (const file in files) {

View File

@ -72,6 +72,11 @@ export interface SFCScriptCompileOptions {
* https://babeljs.io/docs/en/babel-parser#plugins * https://babeljs.io/docs/en/babel-parser#plugins
*/ */
babelParserPlugins?: ParserPlugin[] babelParserPlugins?: ParserPlugin[]
/**
* A list of files to parse for global types to be made available for type
* resolving in SFC macros. The list must be fully resolved file system paths.
*/
globalTypeFiles?: string[]
/** /**
* Compile the template and inline the resulting render function * Compile the template and inline the resulting render function
* directly inside setup(). * directly inside setup().

View File

@ -24,6 +24,7 @@ export class ScriptCompileContext {
// import / type analysis // import / type analysis
scope?: TypeScope scope?: TypeScope
globalScopes?: TypeScope[]
userImports: Record<string, ImportBinding> = Object.create(null) userImports: Record<string, ImportBinding> = Object.create(null)
// macros presence check // macros presence check
@ -101,7 +102,7 @@ export class ScriptCompileContext {
sourceType: 'module' sourceType: 'module'
}).program }).program
} catch (e: any) { } catch (e: any) {
e.message = `[@vue/compiler-sfc] ${e.message}\n\n${ e.message = `[vue/compiler-sfc] ${e.message}\n\n${
descriptor.filename descriptor.filename
}\n${generateCodeFrame( }\n${generateCodeFrame(
descriptor.source, descriptor.source,
@ -113,15 +114,12 @@ export class ScriptCompileContext {
} }
this.scriptAst = this.scriptAst =
this.descriptor.script && descriptor.script &&
parse( parse(descriptor.script.content, descriptor.script.loc.start.offset)
this.descriptor.script.content,
this.descriptor.script.loc.start.offset
)
this.scriptSetupAst = this.scriptSetupAst =
this.descriptor.scriptSetup && descriptor.scriptSetup &&
parse(this.descriptor.scriptSetup!.content, this.startOffset!) parse(descriptor.scriptSetup!.content, this.startOffset!)
} }
getString(node: Node, scriptSetup = true): string { getString(node: Node, scriptSetup = true): string {

View File

@ -56,7 +56,7 @@ export type SimpleTypeResolveContext = Pick<
// required // required
'source' | 'filename' | 'error' | 'options' 'source' | 'filename' | 'error' | 'options'
> & > &
Partial<Pick<ScriptCompileContext, 'scope' | 'deps'>> & { Partial<Pick<ScriptCompileContext, 'scope' | 'globalScopes' | 'deps'>> & {
ast: Statement[] ast: Statement[]
} }
@ -64,25 +64,18 @@ export type TypeResolveContext = ScriptCompileContext | SimpleTypeResolveContext
type Import = Pick<ImportBinding, 'source' | 'imported'> type Import = Pick<ImportBinding, 'source' | 'imported'>
type ScopeTypeNode = Node & {
// scope types always has ownerScope attached
_ownerScope: TypeScope
}
export interface TypeScope { export interface TypeScope {
filename: string filename: string
source: string source: string
offset: number offset: number
imports: Record<string, Import> imports: Record<string, Import>
types: Record< types: Record<string, ScopeTypeNode>
string, exportedTypes: Record<string, ScopeTypeNode>
Node & {
// scope types always has ownerScope attached
_ownerScope: TypeScope
}
>
exportedTypes: Record<
string,
Node & {
// scope types always has ownerScope attached
_ownerScope: TypeScope
}
>
} }
export interface WithScope { export interface WithScope {
@ -492,12 +485,12 @@ function resolveBuiltin(
function resolveTypeReference( function resolveTypeReference(
ctx: TypeResolveContext, ctx: TypeResolveContext,
node: (TSTypeReference | TSExpressionWithTypeArguments) & { node: (TSTypeReference | TSExpressionWithTypeArguments) & {
_resolvedReference?: Node _resolvedReference?: ScopeTypeNode
}, },
scope?: TypeScope, scope?: TypeScope,
name?: string, name?: string,
onlyExported = false onlyExported = false
): (Node & WithScope) | undefined { ): ScopeTypeNode | undefined {
if (node._resolvedReference) { if (node._resolvedReference) {
return node._resolvedReference return node._resolvedReference
} }
@ -516,13 +509,26 @@ function innerResolveTypeReference(
name: string | string[], name: string | string[],
node: TSTypeReference | TSExpressionWithTypeArguments, node: TSTypeReference | TSExpressionWithTypeArguments,
onlyExported: boolean onlyExported: boolean
): Node | undefined { ): ScopeTypeNode | undefined {
if (typeof name === 'string') { if (typeof name === 'string') {
if (scope.imports[name]) { if (scope.imports[name]) {
return resolveTypeFromImport(ctx, node, name, scope) return resolveTypeFromImport(ctx, node, name, scope)
} else { } else {
const types = onlyExported ? scope.exportedTypes : scope.types const types = onlyExported ? scope.exportedTypes : scope.types
return types[name] if (types[name]) {
return types[name]
} else {
// fallback to global
const globalScopes = resolveGlobalScope(ctx)
if (globalScopes) {
for (const s of globalScopes) {
if (s.types[name]) {
;(ctx.deps || (ctx.deps = new Set())).add(s.filename)
return s.types[name]
}
}
}
}
} }
} else { } else {
const ns = innerResolveTypeReference( const ns = innerResolveTypeReference(
@ -539,7 +545,7 @@ function innerResolveTypeReference(
childScope, childScope,
name.length > 2 ? name.slice(1) : name[name.length - 1], name.length > 2 ? name.slice(1) : name[name.length - 1],
node, node,
true !ns.declare
) )
} }
} }
@ -564,6 +570,19 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] {
} }
} }
function resolveGlobalScope(ctx: TypeResolveContext): TypeScope[] | undefined {
if (ctx.options.globalTypeFiles) {
const fs: FS = ctx.options.fs || ts?.sys
if (!fs) {
throw new Error('[vue/compiler-sfc] globalTypeFiles requires fs access.')
}
return ctx.options.globalTypeFiles.map(file =>
// TODO: differentiate ambient vs non-ambient module
fileToScope(file, fs, ctx.options.babelParserPlugins, true)
)
}
}
let ts: typeof TS let ts: typeof TS
/** /**
@ -580,7 +599,7 @@ function resolveTypeFromImport(
node: TSTypeReference | TSExpressionWithTypeArguments, node: TSTypeReference | TSExpressionWithTypeArguments,
name: string, name: string,
scope: TypeScope scope: TypeScope
): Node | undefined { ): ScopeTypeNode | undefined {
const fs: FS = ctx.options.fs || ts?.sys const fs: FS = ctx.options.fs || ts?.sys
if (!fs) { if (!fs) {
ctx.error( ctx.error(
@ -629,7 +648,7 @@ function resolveTypeFromImport(
return resolveTypeReference( return resolveTypeReference(
ctx, ctx,
node, node,
fileToScope(ctx, resolved, fs), fileToScope(resolved, fs, ctx.options.babelParserPlugins),
imported, imported,
true true
) )
@ -726,10 +745,11 @@ export function invalidateTypeCache(filename: string) {
tsConfigCache.delete(filename) tsConfigCache.delete(filename)
} }
function fileToScope( export function fileToScope(
ctx: TypeResolveContext,
filename: string, filename: string,
fs: FS fs: FS,
parserPlugins: SFCScriptCompileOptions['babelParserPlugins'],
asGlobal = false
): TypeScope { ): TypeScope {
const cached = fileToScopeCache.get(filename) const cached = fileToScopeCache.get(filename)
if (cached) { if (cached) {
@ -737,33 +757,30 @@ function fileToScope(
} }
const source = fs.readFile(filename) || '' const source = fs.readFile(filename) || ''
const body = parseFile(ctx, filename, source) const body = parseFile(filename, source, parserPlugins)
const scope: TypeScope = { const scope: TypeScope = {
filename, filename,
source, source,
offset: 0, offset: 0,
imports: recordImports(body),
types: Object.create(null), types: Object.create(null),
exportedTypes: Object.create(null), exportedTypes: Object.create(null)
imports: recordImports(body)
} }
recordTypes(body, scope) recordTypes(body, scope, asGlobal)
fileToScopeCache.set(filename, scope) fileToScopeCache.set(filename, scope)
return scope return scope
} }
function parseFile( function parseFile(
ctx: TypeResolveContext,
filename: string, filename: string,
content: string content: string,
parserPlugins?: SFCScriptCompileOptions['babelParserPlugins']
): Statement[] { ): Statement[] {
const ext = extname(filename) const ext = extname(filename)
if (ext === '.ts' || ext === '.tsx') { if (ext === '.ts' || ext === '.tsx') {
return babelParse(content, { return babelParse(content, {
plugins: resolveParserPlugins( plugins: resolveParserPlugins(ext.slice(1), parserPlugins),
ext.slice(1),
ctx.options.babelParserPlugins
),
sourceType: 'module' sourceType: 'module'
}).program.body }).program.body
} else if (ext === '.vue') { } else if (ext === '.vue') {
@ -792,7 +809,7 @@ function parseFile(
} }
const lang = script?.lang || scriptSetup?.lang const lang = script?.lang || scriptSetup?.lang
return babelParse(scriptContent, { return babelParse(scriptContent, {
plugins: resolveParserPlugins(lang!, ctx.options.babelParserPlugins), plugins: resolveParserPlugins(lang!, parserPlugins),
sourceType: 'module' sourceType: 'module'
}).program.body }).program.body
} }
@ -830,52 +847,71 @@ function ctxToScope(ctx: TypeResolveContext): TypeScope {
function moduleDeclToScope( function moduleDeclToScope(
node: TSModuleDeclaration & { _resolvedChildScope?: TypeScope }, node: TSModuleDeclaration & { _resolvedChildScope?: TypeScope },
parent: TypeScope parentScope: TypeScope
): TypeScope { ): TypeScope {
if (node._resolvedChildScope) { if (node._resolvedChildScope) {
return node._resolvedChildScope return node._resolvedChildScope
} }
const scope: TypeScope = { const scope: TypeScope = {
...parent, ...parentScope,
types: Object.create(parent.types), types: Object.create(parentScope.types),
imports: Object.create(parent.imports) imports: Object.create(parentScope.imports)
} }
recordTypes((node.body as TSModuleBlock).body, scope) recordTypes((node.body as TSModuleBlock).body, scope)
return (node._resolvedChildScope = scope) return (node._resolvedChildScope = scope)
} }
function recordTypes(body: Statement[], scope: TypeScope) { const importExportRE = /^Import|^Export/
function recordTypes(body: Statement[], scope: TypeScope, asGlobal = false) {
const { types, exportedTypes, imports } = scope const { types, exportedTypes, imports } = scope
const isAmbient = asGlobal
? !body.some(s => importExportRE.test(s.type))
: false
for (const stmt of body) { for (const stmt of body) {
recordType(stmt, types) if (asGlobal) {
if (isAmbient) {
if ((stmt as any).declare) {
recordType(stmt, types)
}
} else if (stmt.type === 'TSModuleDeclaration' && stmt.global) {
for (const s of (stmt.body as TSModuleBlock).body) {
recordType(s, types)
}
}
} else {
recordType(stmt, types)
}
} }
for (const stmt of body) { if (!asGlobal) {
if (stmt.type === 'ExportNamedDeclaration') { for (const stmt of body) {
if (stmt.declaration) { if (stmt.type === 'ExportNamedDeclaration') {
recordType(stmt.declaration, types) if (stmt.declaration) {
recordType(stmt.declaration, exportedTypes) recordType(stmt.declaration, types)
} else { recordType(stmt.declaration, exportedTypes)
for (const spec of stmt.specifiers) { } else {
if (spec.type === 'ExportSpecifier') { for (const spec of stmt.specifiers) {
const local = spec.local.name if (spec.type === 'ExportSpecifier') {
const exported = getId(spec.exported) const local = spec.local.name
if (stmt.source) { const exported = getId(spec.exported)
// re-export, register an import + export as a type reference if (stmt.source) {
imports[local] = { // re-export, register an import + export as a type reference
source: stmt.source.value, imports[local] = {
imported: local source: stmt.source.value,
imported: local
}
exportedTypes[exported] = {
type: 'TSTypeReference',
typeName: {
type: 'Identifier',
name: local
},
_ownerScope: scope
}
} else if (types[local]) {
// exporting local defined type
exportedTypes[exported] = types[local]
} }
exportedTypes[exported] = {
type: 'TSTypeReference',
typeName: {
type: 'Identifier',
name: local
},
_ownerScope: scope
}
} else if (types[local]) {
// exporting local defined type
exportedTypes[exported] = types[local]
} }
} }
} }