From 03a9f1175cae06ca3f42582689324ac603c44cbf Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 31 Oct 2023 12:11:16 +0800 Subject: [PATCH] refactor(component-meta): APIs reborn (#3697) --- extensions/vscode/scripts/build.js | 5 +- packages/component-meta/package.json | 5 +- packages/component-meta/src/base.ts | 878 +++++++++++++++++ packages/component-meta/src/index.ts | 879 +----------------- packages/component-meta/src/types.ts | 2 + packages/component-meta/tests/index.spec.ts | 8 +- .../src/languageServerPlugin.ts | 6 +- pnpm-lock.yaml | 9 +- 8 files changed, 911 insertions(+), 881 deletions(-) create mode 100644 packages/component-meta/src/base.ts diff --git a/extensions/vscode/scripts/build.js b/extensions/vscode/scripts/build.js index 9ea3b48a72..3dbefd0636 100644 --- a/extensions/vscode/scripts/build.js +++ b/extensions/vscode/scripts/build.js @@ -11,10 +11,7 @@ require('esbuild').context({ bundle: true, metafile: process.argv.includes('--metafile'), outdir: './dist', - external: [ - 'vscode', - 'typescript', // vue-component-meta - ], + external: ['vscode'], format: 'cjs', platform: 'node', tsconfig: './tsconfig.json', diff --git a/packages/component-meta/package.json b/packages/component-meta/package.json index ad61beea1b..5b9ce44734 100644 --- a/packages/component-meta/package.json +++ b/packages/component-meta/package.json @@ -15,7 +15,7 @@ "dependencies": { "@volar/typescript": "~1.10.9", "@vue/language-core": "1.8.22", - "typesafe-path": "^0.2.2", + "path-browserify": "^1.0.1", "vue-component-type-helpers": "1.8.22" }, "peerDependencies": { @@ -27,6 +27,7 @@ } }, "devDependencies": { - "@types/node": "latest" + "@types/node": "latest", + "@types/path-browserify": "latest" } } diff --git a/packages/component-meta/src/base.ts b/packages/component-meta/src/base.ts new file mode 100644 index 0000000000..c05494f4a9 --- /dev/null +++ b/packages/component-meta/src/base.ts @@ -0,0 +1,878 @@ +import * as vue from '@vue/language-core'; +import type * as ts from 'typescript/lib/tsserverlibrary'; +import * as path from 'path-browserify'; +import { code as typeHelpersCode } from 'vue-component-type-helpers'; +import { createLanguageServiceHost, decorateLanguageService } from '@volar/typescript'; + +import type { + MetaCheckerOptions, + ComponentMeta, + EventMeta, + ExposeMeta, + PropertyMeta, + PropertyMetaSchema, + SlotMeta, + Declaration +} from './types'; + +export * from './types'; + +const windowsPathReg = /\\/g; + +export function createCheckerByJsonBase( + ts: typeof import('typescript/lib/tsserverlibrary'), + rootPath: string, + json: any, + checkerOptions: MetaCheckerOptions = {}, +) { + rootPath = rootPath.replace(windowsPathReg, '/'); + return createCheckerWorker( + ts, + () => vue.createParsedCommandLineByJson(ts, ts.sys, rootPath, json), + checkerOptions, + rootPath, + path.join(rootPath, 'jsconfig.json.global.vue'), + ); +} + +export function createCheckerBase( + ts: typeof import('typescript/lib/tsserverlibrary'), + tsconfig: string, + checkerOptions: MetaCheckerOptions = {}, +) { + tsconfig = tsconfig.replace(windowsPathReg, '/'); + return createCheckerWorker( + ts, + () => vue.createParsedCommandLine(ts, ts.sys, tsconfig), + checkerOptions, + path.dirname(tsconfig), + tsconfig + '.global.vue', + ); +} + +function createCheckerWorker( + ts: typeof import('typescript/lib/tsserverlibrary'), + loadParsedCommandLine: () => vue.ParsedCommandLine, + checkerOptions: MetaCheckerOptions, + rootPath: string, + globalComponentName: string, +) { + + /** + * Original Host + */ + + let parsedCommandLine = loadParsedCommandLine(); + let fileNames = parsedCommandLine.fileNames.map(path => path.replace(windowsPathReg, '/')); + let projectVersion = 0; + + const scriptSnapshots = new Map(); + const _host: vue.TypeScriptLanguageHost = { + workspacePath: rootPath, + rootPath: rootPath, + getProjectVersion: () => projectVersion.toString(), + getCompilationSettings: () => parsedCommandLine.options, + getScriptFileNames: () => fileNames, + getProjectReferences: () => parsedCommandLine.projectReferences, + getScriptSnapshot: (fileName) => { + if (!scriptSnapshots.has(fileName)) { + const fileText = ts.sys.readFile(fileName); + if (fileText !== undefined) { + scriptSnapshots.set(fileName, ts.ScriptSnapshot.fromString(fileText)); + } + } + return scriptSnapshots.get(fileName); + }, + }; + + return { + ...baseCreate(ts, _host, vue.resolveVueCompilerOptions(parsedCommandLine.vueOptions), checkerOptions, globalComponentName), + updateFile(fileName: string, text: string) { + fileName = fileName.replace(windowsPathReg, '/'); + scriptSnapshots.set(fileName, ts.ScriptSnapshot.fromString(text)); + projectVersion++; + }, + deleteFile(fileName: string) { + fileName = fileName.replace(windowsPathReg, '/'); + fileNames = fileNames.filter(f => f !== fileName); + projectVersion++; + }, + reload() { + parsedCommandLine = loadParsedCommandLine(); + fileNames = parsedCommandLine.fileNames.map(path => path.replace(windowsPathReg, '/')); + this.clearCache(); + }, + clearCache() { + scriptSnapshots.clear(); + projectVersion++; + }, + }; +} + +export function baseCreate( + ts: typeof import('typescript/lib/tsserverlibrary'), + _host: vue.TypeScriptLanguageHost, + vueCompilerOptions: vue.VueCompilerOptions, + checkerOptions: MetaCheckerOptions, + globalComponentName: string, +) { + const globalComponentSnapshot = ts.ScriptSnapshot.fromString(''); + const metaSnapshots: Record = {}; + const host = new Proxy>({ + getScriptFileNames: () => { + const names = _host.getScriptFileNames(); + return [ + ...names, + ...names.map(getMetaFileName), + globalComponentName, + getMetaFileName(globalComponentName), + ]; + }, + getScriptSnapshot: fileName => { + if (isMetaFileName(fileName)) { + if (!metaSnapshots[fileName]) { + metaSnapshots[fileName] = ts.ScriptSnapshot.fromString(getMetaScriptContent(fileName)); + } + return metaSnapshots[fileName]; + } + else if (fileName === globalComponentName) { + return globalComponentSnapshot; + } + else { + return _host.getScriptSnapshot(fileName); + } + }, + }, { + get(target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + return _host[prop as keyof typeof _host]; + }, + }) as vue.TypeScriptLanguageHost; + const vueLanguages = vue.createLanguages( + ts, + host.getCompilationSettings(), + vueCompilerOptions, + ); + const core = vue.createLanguageContext(host, vueLanguages); + const tsLsHost = createLanguageServiceHost(core, ts, ts.sys); + const tsLs = ts.createLanguageService(tsLsHost); + + decorateLanguageService(core.virtualFiles, tsLs, false); + + if (checkerOptions.forceUseTs) { + const getScriptKind = tsLsHost.getScriptKind; + tsLsHost.getScriptKind = (fileName) => { + if (fileName.endsWith('.vue.js')) { + return ts.ScriptKind.TS; + } + if (fileName.endsWith('.vue.jsx')) { + return ts.ScriptKind.TSX; + } + return getScriptKind!(fileName); + }; + } + + let globalPropNames: string[] | undefined; + + return { + getExportNames, + getComponentMeta, + __internal__: { + tsLs, + }, + }; + + function isMetaFileName(fileName: string) { + return fileName.endsWith('.meta.ts'); + } + + function getMetaFileName(fileName: string) { + return (fileName.endsWith('.vue') ? fileName : fileName.substring(0, fileName.lastIndexOf('.'))) + '.meta.ts'; + } + + function getMetaScriptContent(fileName: string) { + let code = ` +import * as Components from '${fileName.substring(0, fileName.length - '.meta.ts'.length)}'; +export default {} as { [K in keyof typeof Components]: ComponentMeta; }; + +interface ComponentMeta { + type: ComponentType; + props: ComponentProps; + emit: ComponentEmit; + slots: ${vueCompilerOptions.target < 3 ? 'Vue2ComponentSlots' : 'ComponentSlots'}; + exposed: ComponentExposed; +}; + +${typeHelpersCode} +`.trim(); + return code; + } + + function getExportNames(componentPath: string) { + const program = tsLs.getProgram()!; + const typeChecker = program.getTypeChecker(); + return _getExports(program, typeChecker, componentPath).exports.map(e => e.getName()); + } + + function getComponentMeta(componentPath: string, exportName = 'default'): ComponentMeta { + + const program = tsLs.getProgram()!; + const typeChecker = program.getTypeChecker(); + const { symbolNode, exports } = _getExports(program, typeChecker, componentPath); + const _export = exports.find((property) => property.getName() === exportName); + + if (!_export) { + throw `Could not find export ${exportName}`; + } + + const componentType = typeChecker.getTypeOfSymbolAtLocation(_export, symbolNode!); + const symbolProperties = componentType.getProperties() ?? []; + + let _type: ReturnType | undefined; + let _props: ReturnType | undefined; + let _events: ReturnType | undefined; + let _slots: ReturnType | undefined; + let _exposed: ReturnType | undefined; + + return { + get type() { + return _type ?? (_type = getType()); + }, + get props() { + return _props ?? (_props = getProps()); + }, + get events() { + return _events ?? (_events = getEvents()); + }, + get slots() { + return _slots ?? (_slots = getSlots()); + }, + get exposed() { + return _exposed ?? (_exposed = getExposed()); + }, + }; + + function getType() { + + const $type = symbolProperties.find(prop => prop.escapedName === 'type'); + + if ($type) { + const type = typeChecker.getTypeOfSymbolAtLocation($type, symbolNode!); + return Number(typeChecker.typeToString(type)); + } + + return 0; + } + + function getProps() { + + const $props = symbolProperties.find(prop => prop.escapedName === 'props'); + const propEventRegex = /^(on[A-Z])/; + let result: PropertyMeta[] = []; + + if ($props) { + const type = typeChecker.getTypeOfSymbolAtLocation($props, symbolNode!); + const properties = type.getProperties(); + + result = properties + .map((prop) => { + const { + resolveNestedProperties, + } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); + + return resolveNestedProperties(prop); + }) + .filter((prop) => !prop.name.match(propEventRegex)); + } + + // fill global + if (componentPath !== globalComponentName) { + globalPropNames ??= getComponentMeta(globalComponentName).props.map(prop => prop.name); + for (const prop of result) { + prop.global = globalPropNames.includes(prop.name); + } + } + + // fill defaults + const printer = ts.createPrinter(checkerOptions.printer); + const snapshot = host.getScriptSnapshot(componentPath)!; + + const vueSourceFile = core.virtualFiles.getSource(componentPath)?.root; + const vueDefaults = vueSourceFile && exportName === 'default' + ? (vueSourceFile instanceof vue.VueFile ? readVueComponentDefaultProps(vueSourceFile, printer, ts, vueCompilerOptions) : {}) + : {}; + const tsDefaults = !vueSourceFile ? readTsComponentDefaultProps( + componentPath.substring(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx + snapshot.getText(0, snapshot.getLength()), + exportName, + printer, + ts, + ) : {}; + + for (const [propName, defaultExp] of Object.entries({ + ...vueDefaults, + ...tsDefaults, + })) { + const prop = result.find(p => p.name === propName); + if (prop) { + prop.default = defaultExp.default; + + if (defaultExp.required !== undefined) { + prop.required = defaultExp.required; + } + + if (prop.default !== undefined) { + prop.required = false; // props with default are always optional + } + } + } + + return result; + } + + function getEvents() { + + const $emit = symbolProperties.find(prop => prop.escapedName === 'emit'); + + if ($emit) { + const type = typeChecker.getTypeOfSymbolAtLocation($emit, symbolNode!); + const calls = type.getCallSignatures(); + + return calls.map((call) => { + + const { + resolveEventSignature, + } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); + + return resolveEventSignature(call); + }).filter(event => event.name); + } + + return []; + } + + function getSlots() { + + const $slots = symbolProperties.find(prop => prop.escapedName === 'slots'); + + if ($slots) { + const type = typeChecker.getTypeOfSymbolAtLocation($slots, symbolNode!); + const properties = type.getProperties(); + + return properties.map((prop) => { + const { + resolveSlotProperties, + } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); + + return resolveSlotProperties(prop); + }); + } + + return []; + } + + function getExposed() { + + const $exposed = symbolProperties.find(prop => prop.escapedName === 'exposed'); + + if ($exposed) { + const type = typeChecker.getTypeOfSymbolAtLocation($exposed, symbolNode!); + const properties = type.getProperties().filter(prop => + // only exposed props will not have a valueDeclaration + !(prop as any).valueDeclaration + ); + + return properties.map((prop) => { + const { + resolveExposedProperties, + } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); + + return resolveExposedProperties(prop); + }); + } + + return []; + } + } + + function _getExports( + program: ts.Program, + typeChecker: ts.TypeChecker, + componentPath: string, + ) { + + const sourceFile = program?.getSourceFile(getMetaFileName(componentPath)); + if (!sourceFile) { + throw 'Could not find main source file'; + } + + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) { + throw 'Could not find module symbol'; + } + + const exportedSymbols = typeChecker.getExportsOfModule(moduleSymbol); + + let symbolNode: ts.Expression | undefined; + + for (const symbol of exportedSymbols) { + + const [declaration] = symbol.getDeclarations() ?? []; + + if (ts.isExportAssignment(declaration)) { + symbolNode = declaration.expression; + } + } + + if (!symbolNode) { + throw 'Could not find symbol node'; + } + + const exportDefaultType = typeChecker.getTypeAtLocation(symbolNode); + const exports = exportDefaultType.getProperties(); + + return { + symbolNode, + exports, + }; + } +} + + +function createSchemaResolvers( + typeChecker: ts.TypeChecker, + symbolNode: ts.Expression, + { rawType, schema: options, noDeclarations }: MetaCheckerOptions, + ts: typeof import('typescript/lib/tsserverlibrary'), + core: vue.LanguageContext, +) { + const visited = new Set(); + + function shouldIgnore(subtype: ts.Type) { + const name = typeChecker.typeToString(subtype); + if (name === 'any') { + return true; + } + + if (visited.has(subtype)) { + return true; + } + + if (typeof options === 'object') { + for (const item of options.ignore ?? []) { + if (typeof item === 'function') { + const result = item(name, subtype, typeChecker); + if (typeof result === 'boolean') + return result; + } + else if (name === item) { + return true; + } + } + } + + return false; + } + + function reducer(acc: any, cur: any) { + acc[cur.name] = cur; + return acc; + } + + function resolveNestedProperties(prop: ts.Symbol): PropertyMeta { + const subtype = typeChecker.getTypeOfSymbolAtLocation(prop, symbolNode!); + let schema: PropertyMetaSchema; + let declarations: Declaration[]; + + return { + name: prop.getEscapedName().toString(), + global: false, + description: ts.displayPartsToString(prop.getDocumentationComment(typeChecker)), + tags: prop.getJsDocTags(typeChecker).map(tag => ({ + name: tag.name, + text: tag.text !== undefined ? ts.displayPartsToString(tag.text) : undefined, + })), + required: !(prop.flags & ts.SymbolFlags.Optional), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + get declarations() { + return declarations ??= getDeclarations(prop.declarations ?? []); + }, + get schema() { + return schema ??= resolveSchema(subtype); + }, + }; + } + function resolveSlotProperties(prop: ts.Symbol): SlotMeta { + const propType = typeChecker.getNonNullableType(typeChecker.getTypeOfSymbolAtLocation(prop, symbolNode!)); + const signatures = propType.getCallSignatures(); + const paramType = signatures[0].parameters[0]; + const subtype = typeChecker.getTypeOfSymbolAtLocation(paramType, symbolNode!); + let schema: PropertyMetaSchema; + let declarations: Declaration[]; + + return { + name: prop.getName(), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + description: ts.displayPartsToString(prop.getDocumentationComment(typeChecker)), + get declarations() { + return declarations ??= getDeclarations(prop.declarations ?? []); + }, + get schema() { + return schema ??= resolveSchema(subtype); + }, + }; + } + function resolveExposedProperties(expose: ts.Symbol): ExposeMeta { + const subtype = typeChecker.getTypeOfSymbolAtLocation(expose, symbolNode!); + let schema: PropertyMetaSchema; + let declarations: Declaration[]; + + return { + name: expose.getName(), + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + description: ts.displayPartsToString(expose.getDocumentationComment(typeChecker)), + get declarations() { + return declarations ??= getDeclarations(expose.declarations ?? []); + }, + get schema() { + return schema ??= resolveSchema(subtype); + }, + }; + } + function resolveEventSignature(call: ts.Signature): EventMeta { + const subtype = typeChecker.getTypeOfSymbolAtLocation(call.parameters[1], symbolNode!); + let schema: PropertyMetaSchema[]; + let declarations: Declaration[]; + + return { + name: (typeChecker.getTypeOfSymbolAtLocation(call.parameters[0], symbolNode!) as ts.StringLiteralType).value, + type: typeChecker.typeToString(subtype), + rawType: rawType ? subtype : undefined, + signature: typeChecker.signatureToString(call), + get declarations() { + return declarations ??= call.declaration ? getDeclarations([call.declaration]) : []; + }, + get schema() { + return schema ??= typeChecker.getTypeArguments(subtype as ts.TypeReference).map(resolveSchema); + }, + }; + } + function resolveCallbackSchema(signature: ts.Signature): PropertyMetaSchema { + let schema: PropertyMetaSchema[] | undefined; + + return { + kind: 'event', + type: typeChecker.signatureToString(signature), + get schema() { + return schema ??= signature.parameters.length > 0 + ? typeChecker + .getTypeArguments(typeChecker.getTypeOfSymbolAtLocation(signature.parameters[0], symbolNode) as ts.TypeReference) + .map(resolveSchema) + : undefined; + }, + }; + } + function resolveSchema(subtype: ts.Type): PropertyMetaSchema { + const type = typeChecker.typeToString(subtype); + + if (shouldIgnore(subtype)) { + return type; + } + + visited.add(subtype); + + if (subtype.isUnion()) { + let schema: PropertyMetaSchema[]; + return { + kind: 'enum', + type, + get schema() { + return schema ??= subtype.types.map(resolveSchema); + }, + }; + } + + // @ts-ignore - typescript internal, isArrayLikeType exists + else if (typeChecker.isArrayLikeType(subtype)) { + let schema: PropertyMetaSchema[]; + return { + kind: 'array', + type, + get schema() { + return schema ??= typeChecker.getTypeArguments(subtype as ts.TypeReference).map(resolveSchema); + }, + }; + } + + else if ( + subtype.getCallSignatures().length === 0 && + (subtype.isClassOrInterface() || subtype.isIntersection() || (subtype as ts.ObjectType).objectFlags & ts.ObjectFlags.Anonymous) + ) { + let schema: Record; + return { + kind: 'object', + type, + get schema() { + return schema ??= subtype.getProperties().map(resolveNestedProperties).reduce(reducer, {}); + }, + }; + } + + else if (subtype.getCallSignatures().length === 1) { + return resolveCallbackSchema(subtype.getCallSignatures()[0]); + } + + return type; + } + function getDeclarations(declaration: ts.Declaration[]): Declaration[] { + if (noDeclarations) { + return []; + } + return declaration.map(getDeclaration).filter(d => !!d) as Declaration[]; + } + function getDeclaration(declaration: ts.Declaration): Declaration | undefined { + const fileName = declaration.getSourceFile().fileName; + const [virtualFile] = core.virtualFiles.getVirtualFile(fileName); + if (virtualFile) { + const maps = core.virtualFiles.getMaps(virtualFile); + for (const [source, [_, map]] of maps) { + const start = map.toSourceOffset(declaration.getStart()); + const end = map.toSourceOffset(declaration.getEnd()); + if (start && end) { + return { + file: source, + range: [start[0], end[0]], + }; + }; + } + return undefined; + } + return { + file: declaration.getSourceFile().fileName, + range: [declaration.getStart(), declaration.getEnd()], + }; + } + + return { + resolveNestedProperties, + resolveSlotProperties, + resolveEventSignature, + resolveExposedProperties, + resolveSchema, + }; +} + +function readVueComponentDefaultProps( + vueSourceFile: vue.VueFile, + printer: ts.Printer | undefined, + ts: typeof import('typescript/lib/tsserverlibrary'), + vueCompilerOptions: vue.VueCompilerOptions, +) { + let result: Record = {}; + + scriptSetupWorker(); + scriptWorker(); + + return result; + + function scriptSetupWorker() { + + const descriptor = vueSourceFile.sfc; + const scriptSetupRanges = descriptor.scriptSetup ? vue.parseScriptSetupRanges(ts, descriptor.scriptSetup.ast, vueCompilerOptions) : undefined; + + if (descriptor.scriptSetup && scriptSetupRanges?.props.withDefaults?.arg) { + + const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.props.withDefaults.arg.start, scriptSetupRanges.props.withDefaults.arg.end); + const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest); + const obj = findObjectLiteralExpression(ast); + + if (obj) { + for (const prop of obj.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name.getText(ast); + const expNode = resolveDefaultOptionExpression(prop.initializer, ts); + const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast); + + result[name] = { + default: expText, + }; + } + } + } + } else if (descriptor.scriptSetup && scriptSetupRanges?.props.define?.arg) { + const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.props.define.arg.start, scriptSetupRanges.props.define.arg.end); + const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest); + const obj = findObjectLiteralExpression(ast); + + if (obj) { + result = { + ...result, + ...resolvePropsOption(ast, obj, printer, ts), + }; + } + } + + function findObjectLiteralExpression(node: ts.Node) { + if (ts.isObjectLiteralExpression(node)) { + return node; + } + let result: ts.ObjectLiteralExpression | undefined; + node.forEachChild(child => { + if (!result) { + result = findObjectLiteralExpression(child); + } + }); + return result; + } + } + + function scriptWorker() { + + const descriptor = vueSourceFile.sfc; + + if (descriptor.script) { + const scriptResult = readTsComponentDefaultProps(descriptor.script.lang, descriptor.script.content, 'default', printer, ts); + for (const [key, value] of Object.entries(scriptResult)) { + result[key] = value; + } + } + } +} + +function readTsComponentDefaultProps( + lang: string, + tsFileText: string, + exportName: string, + printer: ts.Printer | undefined, + ts: typeof import('typescript/lib/tsserverlibrary'), +) { + + const ast = ts.createSourceFile('/tmp.' + lang, tsFileText, ts.ScriptTarget.Latest); + const props = getPropsNode(); + + if (props) { + return resolvePropsOption(ast, props, printer, ts); + } + + return {}; + + function getComponentNode() { + + let result: ts.Node | undefined; + + if (exportName === 'default') { + ast.forEachChild(child => { + if (ts.isExportAssignment(child)) { + result = child.expression; + } + }); + } + else { + ast.forEachChild(child => { + if ( + ts.isVariableStatement(child) + && child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) + ) { + for (const dec of child.declarationList.declarations) { + if (dec.name.getText(ast) === exportName) { + result = dec.initializer; + } + } + } + }); + } + + return result; + } + + function getComponentOptionsNode() { + + const component = getComponentNode(); + + if (component) { + + // export default { ... } + if (ts.isObjectLiteralExpression(component)) { + return component; + } + // export default defineComponent({ ... }) + // export default Vue.extend({ ... }) + else if (ts.isCallExpression(component)) { + if (component.arguments.length) { + const arg = component.arguments[0]; + if (ts.isObjectLiteralExpression(arg)) { + return arg; + } + } + } + } + } + + function getPropsNode() { + const options = getComponentOptionsNode(); + const props = options?.properties.find(prop => prop.name?.getText(ast) === 'props'); + if (props && ts.isPropertyAssignment(props)) { + if (ts.isObjectLiteralExpression(props.initializer)) { + return props.initializer; + } + } + } +} + +function resolvePropsOption( + ast: ts.SourceFile, + props: ts.ObjectLiteralExpression, + printer: ts.Printer | undefined, + ts: typeof import('typescript/lib/tsserverlibrary'), +) { + + const result: Record = {}; + + for (const prop of props.properties) { + if (ts.isPropertyAssignment(prop)) { + const name = prop.name?.getText(ast); + if (ts.isObjectLiteralExpression(prop.initializer)) { + + const defaultProp = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'default') as ts.PropertyAssignment | undefined; + const requiredProp = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'required') as ts.PropertyAssignment | undefined; + + result[name] = {}; + + if (requiredProp) { + const exp = requiredProp.initializer.getText(ast); + result[name].required = exp === 'true'; + } + if (defaultProp) { + const expNode = resolveDefaultOptionExpression((defaultProp as any).initializer, ts); + const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast); + result[name].default = expText; + } + } + } + } + + return result; +} + +function resolveDefaultOptionExpression( + _default: ts.Expression, + ts: typeof import('typescript/lib/tsserverlibrary'), +) { + if (ts.isArrowFunction(_default)) { + if (ts.isBlock(_default.body)) { + return _default; // TODO + } + else if (ts.isParenthesizedExpression(_default.body)) { + return _default.body.expression; + } + else { + return _default.body; + } + } + return _default; +} diff --git a/packages/component-meta/src/index.ts b/packages/component-meta/src/index.ts index 92a8893971..641b0720e7 100644 --- a/packages/component-meta/src/index.ts +++ b/packages/component-meta/src/index.ts @@ -1,880 +1,29 @@ -import * as vue from '@vue/language-core'; -import type * as ts from 'typescript/lib/tsserverlibrary'; -import * as path from 'typesafe-path/posix'; -import { code as typeHelpersCode } from 'vue-component-type-helpers'; -import { createLanguageServiceHost, decorateLanguageService } from '@volar/typescript'; - -import type { - MetaCheckerOptions, - ComponentMeta, - EventMeta, - ExposeMeta, - PropertyMeta, - PropertyMetaSchema, - SlotMeta, - Declaration -} from './types'; +import * as ts from 'typescript'; +import { createCheckerByJsonBase, createCheckerBase } from './base'; +import type { MetaCheckerOptions } from './types'; export * from './types'; -export type ComponentMetaChecker = ReturnType; - -const windowsPathReg = /\\/g; - -export function createComponentMetaCheckerByJsonConfig( - _rootPath: string, +export function createCheckerByJson( + rootPath: string, json: any, checkerOptions: MetaCheckerOptions = {}, - ts: typeof import('typescript/lib/tsserverlibrary') = require('typescript'), ) { - const rootPath = _rootPath.replace(windowsPathReg, '/') as path.PosixPath; - return createComponentMetaCheckerWorker( - () => vue.createParsedCommandLineByJson(ts, ts.sys, rootPath, json), - checkerOptions, + return createCheckerByJsonBase( + ts as any, rootPath, - path.join(rootPath, 'jsconfig.json.global.vue' as path.PosixPath), - ts, + json, + checkerOptions, ); } -export function createComponentMetaChecker( - _tsconfig: string, +export function createChecker( + tsconfig: string, checkerOptions: MetaCheckerOptions = {}, - ts: typeof import('typescript/lib/tsserverlibrary') = require('typescript'), ) { - const tsconfig = _tsconfig.replace(windowsPathReg, '/') as path.PosixPath; - return createComponentMetaCheckerWorker( - () => vue.createParsedCommandLine(ts, ts.sys, tsconfig), + return createCheckerBase( + ts as any, + tsconfig, checkerOptions, - path.dirname(tsconfig), - tsconfig + '.global.vue', - ts, ); } - -function createComponentMetaCheckerWorker( - loadParsedCommandLine: () => vue.ParsedCommandLine, - checkerOptions: MetaCheckerOptions, - rootPath: string, - globalComponentName: string, - ts: typeof import('typescript/lib/tsserverlibrary'), -) { - - /** - * Original Host - */ - - let parsedCommandLine = loadParsedCommandLine(); - let fileNames = (parsedCommandLine.fileNames as path.OsPath[]).map(path => path.replace(windowsPathReg, '/') as path.PosixPath); - let projectVersion = 0; - - const scriptSnapshots = new Map(); - const _host: vue.TypeScriptLanguageHost = { - workspacePath: rootPath, - rootPath: rootPath, - getProjectVersion: () => projectVersion.toString(), - getCompilationSettings: () => parsedCommandLine.options, - getScriptFileNames: () => fileNames, - getProjectReferences: () => parsedCommandLine.projectReferences, - getScriptSnapshot: (fileName) => { - if (!scriptSnapshots.has(fileName)) { - const fileText = ts.sys.readFile(fileName); - if (fileText !== undefined) { - scriptSnapshots.set(fileName, ts.ScriptSnapshot.fromString(fileText)); - } - } - return scriptSnapshots.get(fileName); - }, - }; - - return { - ...baseCreate(_host, vue.resolveVueCompilerOptions(parsedCommandLine.vueOptions), checkerOptions, globalComponentName, ts), - updateFile(fileName: string, text: string) { - fileName = (fileName as path.OsPath).replace(windowsPathReg, '/') as path.PosixPath; - scriptSnapshots.set(fileName, ts.ScriptSnapshot.fromString(text)); - projectVersion++; - }, - deleteFile(fileName: string) { - fileName = (fileName as path.OsPath).replace(windowsPathReg, '/') as path.PosixPath; - fileNames = fileNames.filter(f => f !== fileName); - projectVersion++; - }, - reload() { - parsedCommandLine = loadParsedCommandLine(); - fileNames = (parsedCommandLine.fileNames as path.OsPath[]).map(path => path.replace(windowsPathReg, '/') as path.PosixPath); - this.clearCache(); - }, - clearCache() { - scriptSnapshots.clear(); - projectVersion++; - }, - }; -} - -export function baseCreate( - _host: vue.TypeScriptLanguageHost, - vueCompilerOptions: vue.VueCompilerOptions, - checkerOptions: MetaCheckerOptions, - globalComponentName: string, - ts: typeof import('typescript/lib/tsserverlibrary'), -) { - const globalComponentSnapshot = ts.ScriptSnapshot.fromString(''); - const metaSnapshots: Record = {}; - const host = new Proxy>({ - getScriptFileNames: () => { - const names = _host.getScriptFileNames(); - return [ - ...names, - ...names.map(getMetaFileName), - globalComponentName, - getMetaFileName(globalComponentName), - ]; - }, - getScriptSnapshot: fileName => { - if (isMetaFileName(fileName)) { - if (!metaSnapshots[fileName]) { - metaSnapshots[fileName] = ts.ScriptSnapshot.fromString(getMetaScriptContent(fileName)); - } - return metaSnapshots[fileName]; - } - else if (fileName === globalComponentName) { - return globalComponentSnapshot; - } - else { - return _host.getScriptSnapshot(fileName); - } - }, - }, { - get(target, prop) { - if (prop in target) { - return target[prop as keyof typeof target]; - } - return _host[prop as keyof typeof _host]; - }, - }) as vue.TypeScriptLanguageHost; - const vueLanguages = vue.createLanguages( - ts, - host.getCompilationSettings(), - vueCompilerOptions, - ); - const core = vue.createLanguageContext(host, vueLanguages); - const tsLsHost = createLanguageServiceHost(core, ts, ts.sys); - const tsLs = ts.createLanguageService(tsLsHost); - - decorateLanguageService(core.virtualFiles, tsLs, false); - - if (checkerOptions.forceUseTs) { - const getScriptKind = tsLsHost.getScriptKind; - tsLsHost.getScriptKind = (fileName) => { - if (fileName.endsWith('.vue.js')) { - return ts.ScriptKind.TS; - } - if (fileName.endsWith('.vue.jsx')) { - return ts.ScriptKind.TSX; - } - return getScriptKind!(fileName); - }; - } - - let globalPropNames: string[] | undefined; - - return { - getExportNames, - getComponentMeta, - __internal__: { - tsLs, - }, - }; - - function isMetaFileName(fileName: string) { - return fileName.endsWith('.meta.ts'); - } - - function getMetaFileName(fileName: string) { - return (fileName.endsWith('.vue') ? fileName : fileName.substring(0, fileName.lastIndexOf('.'))) + '.meta.ts'; - } - - function getMetaScriptContent(fileName: string) { - let code = ` -import * as Components from '${fileName.substring(0, fileName.length - '.meta.ts'.length)}'; -export default {} as { [K in keyof typeof Components]: ComponentMeta; }; - -interface ComponentMeta { - type: ComponentType; - props: ComponentProps; - emit: ComponentEmit; - slots: ${vueCompilerOptions.target < 3 ? 'Vue2ComponentSlots' : 'ComponentSlots'}; - exposed: ComponentExposed; -}; - -${typeHelpersCode} -`.trim(); - return code; - } - - function getExportNames(componentPath: string) { - const program = tsLs.getProgram()!; - const typeChecker = program.getTypeChecker(); - return _getExports(program, typeChecker, componentPath).exports.map(e => e.getName()); - } - - function getComponentMeta(componentPath: string, exportName = 'default'): ComponentMeta { - - const program = tsLs.getProgram()!; - const typeChecker = program.getTypeChecker(); - const { symbolNode, exports } = _getExports(program, typeChecker, componentPath); - const _export = exports.find((property) => property.getName() === exportName); - - if (!_export) { - throw `Could not find export ${exportName}`; - } - - const componentType = typeChecker.getTypeOfSymbolAtLocation(_export, symbolNode!); - const symbolProperties = componentType.getProperties() ?? []; - - let _type: ReturnType | undefined; - let _props: ReturnType | undefined; - let _events: ReturnType | undefined; - let _slots: ReturnType | undefined; - let _exposed: ReturnType | undefined; - - return { - get type() { - return _type ?? (_type = getType()); - }, - get props() { - return _props ?? (_props = getProps()); - }, - get events() { - return _events ?? (_events = getEvents()); - }, - get slots() { - return _slots ?? (_slots = getSlots()); - }, - get exposed() { - return _exposed ?? (_exposed = getExposed()); - }, - }; - - function getType() { - - const $type = symbolProperties.find(prop => prop.escapedName === 'type'); - - if ($type) { - const type = typeChecker.getTypeOfSymbolAtLocation($type, symbolNode!); - return Number(typeChecker.typeToString(type)); - } - - return 0; - } - - function getProps() { - - const $props = symbolProperties.find(prop => prop.escapedName === 'props'); - const propEventRegex = /^(on[A-Z])/; - let result: PropertyMeta[] = []; - - if ($props) { - const type = typeChecker.getTypeOfSymbolAtLocation($props, symbolNode!); - const properties = type.getProperties(); - - result = properties - .map((prop) => { - const { - resolveNestedProperties, - } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); - - return resolveNestedProperties(prop); - }) - .filter((prop) => !prop.name.match(propEventRegex)); - } - - // fill global - if (componentPath !== globalComponentName) { - globalPropNames ??= getComponentMeta(globalComponentName).props.map(prop => prop.name); - for (const prop of result) { - prop.global = globalPropNames.includes(prop.name); - } - } - - // fill defaults - const printer = ts.createPrinter(checkerOptions.printer); - const snapshot = host.getScriptSnapshot(componentPath)!; - - const vueSourceFile = core.virtualFiles.getSource(componentPath)?.root; - const vueDefaults = vueSourceFile && exportName === 'default' - ? (vueSourceFile instanceof vue.VueFile ? readVueComponentDefaultProps(vueSourceFile, printer, ts, vueCompilerOptions) : {}) - : {}; - const tsDefaults = !vueSourceFile ? readTsComponentDefaultProps( - componentPath.substring(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx - snapshot.getText(0, snapshot.getLength()), - exportName, - printer, - ts, - ) : {}; - - for (const [propName, defaultExp] of Object.entries({ - ...vueDefaults, - ...tsDefaults, - })) { - const prop = result.find(p => p.name === propName); - if (prop) { - prop.default = defaultExp.default; - - if (defaultExp.required !== undefined) { - prop.required = defaultExp.required; - } - - if (prop.default !== undefined) { - prop.required = false; // props with default are always optional - } - } - } - - return result; - } - - function getEvents() { - - const $emit = symbolProperties.find(prop => prop.escapedName === 'emit'); - - if ($emit) { - const type = typeChecker.getTypeOfSymbolAtLocation($emit, symbolNode!); - const calls = type.getCallSignatures(); - - return calls.map((call) => { - - const { - resolveEventSignature, - } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); - - return resolveEventSignature(call); - }).filter(event => event.name); - } - - return []; - } - - function getSlots() { - - const $slots = symbolProperties.find(prop => prop.escapedName === 'slots'); - - if ($slots) { - const type = typeChecker.getTypeOfSymbolAtLocation($slots, symbolNode!); - const properties = type.getProperties(); - - return properties.map((prop) => { - const { - resolveSlotProperties, - } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); - - return resolveSlotProperties(prop); - }); - } - - return []; - } - - function getExposed() { - - const $exposed = symbolProperties.find(prop => prop.escapedName === 'exposed'); - - if ($exposed) { - const type = typeChecker.getTypeOfSymbolAtLocation($exposed, symbolNode!); - const properties = type.getProperties().filter(prop => - // only exposed props will not have a valueDeclaration - !(prop as any).valueDeclaration - ); - - return properties.map((prop) => { - const { - resolveExposedProperties, - } = createSchemaResolvers(typeChecker, symbolNode!, checkerOptions, ts, core); - - return resolveExposedProperties(prop); - }); - } - - return []; - } - } - - function _getExports( - program: ts.Program, - typeChecker: ts.TypeChecker, - componentPath: string, - ) { - - const sourceFile = program?.getSourceFile(getMetaFileName(componentPath)); - if (!sourceFile) { - throw 'Could not find main source file'; - } - - const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); - if (!moduleSymbol) { - throw 'Could not find module symbol'; - } - - const exportedSymbols = typeChecker.getExportsOfModule(moduleSymbol); - - let symbolNode: ts.Expression | undefined; - - for (const symbol of exportedSymbols) { - - const [declaration] = symbol.getDeclarations() ?? []; - - if (ts.isExportAssignment(declaration)) { - symbolNode = declaration.expression; - } - } - - if (!symbolNode) { - throw 'Could not find symbol node'; - } - - const exportDefaultType = typeChecker.getTypeAtLocation(symbolNode); - const exports = exportDefaultType.getProperties(); - - return { - symbolNode, - exports, - }; - } -} - - -function createSchemaResolvers( - typeChecker: ts.TypeChecker, - symbolNode: ts.Expression, - { rawType, schema: options, noDeclarations }: MetaCheckerOptions, - ts: typeof import('typescript/lib/tsserverlibrary'), - core: vue.LanguageContext, -) { - const visited = new Set(); - - function shouldIgnore(subtype: ts.Type) { - const name = typeChecker.typeToString(subtype); - if (name === 'any') { - return true; - } - - if (visited.has(subtype)) { - return true; - } - - if (typeof options === 'object') { - for (const item of options.ignore ?? []) { - if (typeof item === 'function') { - const result = item(name, subtype, typeChecker); - if (typeof result === 'boolean') - return result; - } - else if (name === item) { - return true; - } - } - } - - return false; - } - - function reducer(acc: any, cur: any) { - acc[cur.name] = cur; - return acc; - } - - function resolveNestedProperties(prop: ts.Symbol): PropertyMeta { - const subtype = typeChecker.getTypeOfSymbolAtLocation(prop, symbolNode!); - let schema: PropertyMetaSchema; - let declarations: Declaration[]; - - return { - name: prop.getEscapedName().toString(), - global: false, - description: ts.displayPartsToString(prop.getDocumentationComment(typeChecker)), - tags: prop.getJsDocTags(typeChecker).map(tag => ({ - name: tag.name, - text: tag.text !== undefined ? ts.displayPartsToString(tag.text) : undefined, - })), - required: !(prop.flags & ts.SymbolFlags.Optional), - type: typeChecker.typeToString(subtype), - rawType: rawType ? subtype : undefined, - get declarations() { - return declarations ??= getDeclarations(prop.declarations ?? []); - }, - get schema() { - return schema ??= resolveSchema(subtype); - }, - }; - } - function resolveSlotProperties(prop: ts.Symbol): SlotMeta { - const propType = typeChecker.getNonNullableType(typeChecker.getTypeOfSymbolAtLocation(prop, symbolNode!)); - const signatures = propType.getCallSignatures(); - const paramType = signatures[0].parameters[0]; - const subtype = typeChecker.getTypeOfSymbolAtLocation(paramType, symbolNode!); - let schema: PropertyMetaSchema; - let declarations: Declaration[]; - - return { - name: prop.getName(), - type: typeChecker.typeToString(subtype), - rawType: rawType ? subtype : undefined, - description: ts.displayPartsToString(prop.getDocumentationComment(typeChecker)), - get declarations() { - return declarations ??= getDeclarations(prop.declarations ?? []); - }, - get schema() { - return schema ??= resolveSchema(subtype); - }, - }; - } - function resolveExposedProperties(expose: ts.Symbol): ExposeMeta { - const subtype = typeChecker.getTypeOfSymbolAtLocation(expose, symbolNode!); - let schema: PropertyMetaSchema; - let declarations: Declaration[]; - - return { - name: expose.getName(), - type: typeChecker.typeToString(subtype), - rawType: rawType ? subtype : undefined, - description: ts.displayPartsToString(expose.getDocumentationComment(typeChecker)), - get declarations() { - return declarations ??= getDeclarations(expose.declarations ?? []); - }, - get schema() { - return schema ??= resolveSchema(subtype); - }, - }; - } - function resolveEventSignature(call: ts.Signature): EventMeta { - const subtype = typeChecker.getTypeOfSymbolAtLocation(call.parameters[1], symbolNode!); - let schema: PropertyMetaSchema[]; - let declarations: Declaration[]; - - return { - name: (typeChecker.getTypeOfSymbolAtLocation(call.parameters[0], symbolNode!) as ts.StringLiteralType).value, - type: typeChecker.typeToString(subtype), - rawType: rawType ? subtype : undefined, - signature: typeChecker.signatureToString(call), - get declarations() { - return declarations ??= call.declaration ? getDeclarations([call.declaration]) : []; - }, - get schema() { - return schema ??= typeChecker.getTypeArguments(subtype as ts.TypeReference).map(resolveSchema); - }, - }; - } - function resolveCallbackSchema(signature: ts.Signature): PropertyMetaSchema { - let schema: PropertyMetaSchema[] | undefined; - - return { - kind: 'event', - type: typeChecker.signatureToString(signature), - get schema() { - return schema ??= signature.parameters.length > 0 - ? typeChecker - .getTypeArguments(typeChecker.getTypeOfSymbolAtLocation(signature.parameters[0], symbolNode) as ts.TypeReference) - .map(resolveSchema) - : undefined; - }, - }; - } - function resolveSchema(subtype: ts.Type): PropertyMetaSchema { - const type = typeChecker.typeToString(subtype); - - if (shouldIgnore(subtype)) { - return type; - } - - visited.add(subtype); - - if (subtype.isUnion()) { - let schema: PropertyMetaSchema[]; - return { - kind: 'enum', - type, - get schema() { - return schema ??= subtype.types.map(resolveSchema); - }, - }; - } - - // @ts-ignore - typescript internal, isArrayLikeType exists - else if (typeChecker.isArrayLikeType(subtype)) { - let schema: PropertyMetaSchema[]; - return { - kind: 'array', - type, - get schema() { - return schema ??= typeChecker.getTypeArguments(subtype as ts.TypeReference).map(resolveSchema); - }, - }; - } - - else if ( - subtype.getCallSignatures().length === 0 && - (subtype.isClassOrInterface() || subtype.isIntersection() || (subtype as ts.ObjectType).objectFlags & ts.ObjectFlags.Anonymous) - ) { - let schema: Record; - return { - kind: 'object', - type, - get schema() { - return schema ??= subtype.getProperties().map(resolveNestedProperties).reduce(reducer, {}); - }, - }; - } - - else if (subtype.getCallSignatures().length === 1) { - return resolveCallbackSchema(subtype.getCallSignatures()[0]); - } - - return type; - } - function getDeclarations(declaration: ts.Declaration[]): Declaration[] { - if (noDeclarations) { - return []; - } - return declaration.map(getDeclaration).filter(d => !!d) as Declaration[]; - } - function getDeclaration(declaration: ts.Declaration): Declaration | undefined { - const fileName = declaration.getSourceFile().fileName; - const [virtualFile] = core.virtualFiles.getVirtualFile(fileName); - if (virtualFile) { - const maps = core.virtualFiles.getMaps(virtualFile); - for (const [source, [_, map]] of maps) { - const start = map.toSourceOffset(declaration.getStart()); - const end = map.toSourceOffset(declaration.getEnd()); - if (start && end) { - return { - file: source, - range: [start[0], end[0]], - }; - }; - } - return undefined; - } - return { - file: declaration.getSourceFile().fileName, - range: [declaration.getStart(), declaration.getEnd()], - }; - } - - return { - resolveNestedProperties, - resolveSlotProperties, - resolveEventSignature, - resolveExposedProperties, - resolveSchema, - }; -} - -function readVueComponentDefaultProps( - vueSourceFile: vue.VueFile, - printer: ts.Printer | undefined, - ts: typeof import('typescript/lib/tsserverlibrary'), - vueCompilerOptions: vue.VueCompilerOptions, -) { - let result: Record = {}; - - scriptSetupWorker(); - scriptWorker(); - - return result; - - function scriptSetupWorker() { - - const descriptor = vueSourceFile.sfc; - const scriptSetupRanges = descriptor.scriptSetup ? vue.parseScriptSetupRanges(ts, descriptor.scriptSetup.ast, vueCompilerOptions) : undefined; - - if (descriptor.scriptSetup && scriptSetupRanges?.props.withDefaults?.arg) { - - const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.props.withDefaults.arg.start, scriptSetupRanges.props.withDefaults.arg.end); - const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest); - const obj = findObjectLiteralExpression(ast); - - if (obj) { - for (const prop of obj.properties) { - if (ts.isPropertyAssignment(prop)) { - const name = prop.name.getText(ast); - const expNode = resolveDefaultOptionExpression(prop.initializer, ts); - const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast); - - result[name] = { - default: expText, - }; - } - } - } - } else if (descriptor.scriptSetup && scriptSetupRanges?.props.define?.arg) { - const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.props.define.arg.start, scriptSetupRanges.props.define.arg.end); - const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest); - const obj = findObjectLiteralExpression(ast); - - if (obj) { - result = { - ...result, - ...resolvePropsOption(ast, obj, printer, ts), - }; - } - } - - function findObjectLiteralExpression(node: ts.Node) { - if (ts.isObjectLiteralExpression(node)) { - return node; - } - let result: ts.ObjectLiteralExpression | undefined; - node.forEachChild(child => { - if (!result) { - result = findObjectLiteralExpression(child); - } - }); - return result; - } - } - - function scriptWorker() { - - const descriptor = vueSourceFile.sfc; - - if (descriptor.script) { - const scriptResult = readTsComponentDefaultProps(descriptor.script.lang, descriptor.script.content, 'default', printer, ts); - for (const [key, value] of Object.entries(scriptResult)) { - result[key] = value; - } - } - } -} - -function readTsComponentDefaultProps( - lang: string, - tsFileText: string, - exportName: string, - printer: ts.Printer | undefined, - ts: typeof import('typescript/lib/tsserverlibrary'), -) { - - const ast = ts.createSourceFile('/tmp.' + lang, tsFileText, ts.ScriptTarget.Latest); - const props = getPropsNode(); - - if (props) { - return resolvePropsOption(ast, props, printer, ts); - } - - return {}; - - function getComponentNode() { - - let result: ts.Node | undefined; - - if (exportName === 'default') { - ast.forEachChild(child => { - if (ts.isExportAssignment(child)) { - result = child.expression; - } - }); - } - else { - ast.forEachChild(child => { - if ( - ts.isVariableStatement(child) - && child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) - ) { - for (const dec of child.declarationList.declarations) { - if (dec.name.getText(ast) === exportName) { - result = dec.initializer; - } - } - } - }); - } - - return result; - } - - function getComponentOptionsNode() { - - const component = getComponentNode(); - - if (component) { - - // export default { ... } - if (ts.isObjectLiteralExpression(component)) { - return component; - } - // export default defineComponent({ ... }) - // export default Vue.extend({ ... }) - else if (ts.isCallExpression(component)) { - if (component.arguments.length) { - const arg = component.arguments[0]; - if (ts.isObjectLiteralExpression(arg)) { - return arg; - } - } - } - } - } - - function getPropsNode() { - const options = getComponentOptionsNode(); - const props = options?.properties.find(prop => prop.name?.getText(ast) === 'props'); - if (props && ts.isPropertyAssignment(props)) { - if (ts.isObjectLiteralExpression(props.initializer)) { - return props.initializer; - } - } - } -} - -function resolvePropsOption( - ast: ts.SourceFile, - props: ts.ObjectLiteralExpression, - printer: ts.Printer | undefined, - ts: typeof import('typescript/lib/tsserverlibrary'), -) { - - const result: Record = {}; - - for (const prop of props.properties) { - if (ts.isPropertyAssignment(prop)) { - const name = prop.name?.getText(ast); - if (ts.isObjectLiteralExpression(prop.initializer)) { - - const defaultProp = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'default') as ts.PropertyAssignment | undefined; - const requiredProp = prop.initializer.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'required') as ts.PropertyAssignment | undefined; - - result[name] = {}; - - if (requiredProp) { - const exp = requiredProp.initializer.getText(ast); - result[name].required = exp === 'true'; - } - if (defaultProp) { - const expNode = resolveDefaultOptionExpression((defaultProp as any).initializer, ts); - const expText = printer?.printNode(ts.EmitHint.Expression, expNode, ast) ?? expNode.getText(ast); - result[name].default = expText; - } - } - } - } - - return result; -} - -function resolveDefaultOptionExpression( - _default: ts.Expression, - ts: typeof import('typescript/lib/tsserverlibrary'), -) { - if (ts.isArrowFunction(_default)) { - if (ts.isBlock(_default.body)) { - return _default; // TODO - } - else if (ts.isParenthesizedExpression(_default.body)) { - return _default.body.expression; - } - else { - return _default.body; - } - } - return _default; -} diff --git a/packages/component-meta/src/types.ts b/packages/component-meta/src/types.ts index d77ebb3d92..2baefe7149 100644 --- a/packages/component-meta/src/types.ts +++ b/packages/component-meta/src/types.ts @@ -1,5 +1,7 @@ import type * as ts from 'typescript/lib/tsserverlibrary'; +export type Checker = ReturnType; + export interface Declaration { file: string; range: [number, number]; diff --git a/packages/component-meta/tests/index.spec.ts b/packages/component-meta/tests/index.spec.ts index f1f443977e..6a87a9628c 100644 --- a/packages/component-meta/tests/index.spec.ts +++ b/packages/component-meta/tests/index.spec.ts @@ -1,8 +1,8 @@ import * as path from 'path'; import { describe, expect, test } from 'vitest'; -import { createComponentMetaChecker, createComponentMetaCheckerByJsonConfig, MetaCheckerOptions, ComponentMetaChecker, TypeMeta } from '../out'; +import { createChecker, createCheckerByJson, MetaCheckerOptions, Checker, TypeMeta } from '../out'; -const worker = (checker: ComponentMetaChecker, withTsconfig: boolean) => describe(`vue-component-meta ${withTsconfig ? 'with tsconfig' : 'without tsconfig'}`, () => { +const worker = (checker: Checker, withTsconfig: boolean) => describe(`vue-component-meta ${withTsconfig ? 'with tsconfig' : 'without tsconfig'}`, () => { test('empty-component', () => { const componentPath = path.resolve(__dirname, '../../../test-workspace/component-meta/empty-component/component.vue'); @@ -761,11 +761,11 @@ const checkerOptions: MetaCheckerOptions = { schema: { ignore: ['MyIgnoredNestedProps'] }, printer: { newLine: 1 }, }; -const tsconfigChecker = createComponentMetaChecker( +const tsconfigChecker = createChecker( path.resolve(__dirname, '../../../test-workspace/component-meta/tsconfig.json'), checkerOptions, ); -const noTsConfigChecker = createComponentMetaCheckerByJsonConfig( +const noTsConfigChecker = createCheckerByJson( path.resolve(__dirname, '../../../test-workspace/component-meta'), { "extends": "../tsconfig.json", diff --git a/packages/language-server/src/languageServerPlugin.ts b/packages/language-server/src/languageServerPlugin.ts index d69c85ca5d..6c46c45cb9 100644 --- a/packages/language-server/src/languageServerPlugin.ts +++ b/packages/language-server/src/languageServerPlugin.ts @@ -6,7 +6,7 @@ import * as nameCasing from '@vue/language-service'; import { DetectNameCasingRequest, GetConvertAttrCasingEditsRequest, GetConvertTagCasingEditsRequest, ParseSFCRequest, GetComponentMeta } from './protocol'; import { VueServerInitializationOptions } from './types'; import type * as ts from 'typescript/lib/tsserverlibrary'; -import * as componentMeta from 'vue-component-meta'; +import * as componentMeta from 'vue-component-meta/out/base'; import { VueCompilerOptions } from '@vue/language-core'; import { createSys } from '@volar/typescript'; @@ -110,7 +110,7 @@ export function createServerPlugin(connection: Connection) { } }); - const checkers = new WeakMap(); + const checkers = new WeakMap(); connection.onRequest(GetComponentMeta.type, async params => { @@ -123,11 +123,11 @@ export function createServerPlugin(connection: Connection) { let checker = checkers.get(host); if (!checker) { checker = componentMeta.baseCreate( + ts, host, hostToVueOptions.get(host)!, {}, host.rootPath + '/tsconfig.json.global.vue', - ts, ); checkers.set(host, checker); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 146a088b26..70a1d97d64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,9 @@ importers: '@vue/language-core': specifier: 1.8.22 version: link:../language-core - typesafe-path: - specifier: ^0.2.2 - version: 0.2.2 + path-browserify: + specifier: ^1.0.1 + version: 1.0.1 typescript: specifier: '*' version: 5.2.2 @@ -97,6 +97,9 @@ importers: '@types/node': specifier: latest version: 20.8.9 + '@types/path-browserify': + specifier: latest + version: 1.0.1 packages/component-type-helpers: {}