/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ /* * Copyright (C) 2021 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ import ts from 'typescript'; import {cleanupEmpty, expandPathToFilesSync, mapNotNil, rejectNil} from './util.js'; import { extractComment, filterChildrenTo, filterNodeTo, getModifiers, isArrayLikeType, isClassLikeNode, isEnumLikeNode, isProperty, resolvePropertyName, resolveTypeName, } from './ast-internal-util.js'; import {isEnumLiteralType, isTypeVariable} from './ast-util.js'; import {LightweightAliasDefinition} from './types/lightweight-alias-definition.js'; import {LightweightClassDefinition} from './types/lightweight-class-definition.js'; import {LightweightDefinition} from './types/lightweight-definition.js'; import {LightweightDefinitionKind} from './types/lightweight-definition-kind.js'; import {LightweightProject} from './types/lightweight-project.js'; import {LightweightType} from './types/lightweight-type.js'; import path from 'path'; import {LightweightProperty} from './types/lightweight-property.js'; import {mapValues, groupBy, keyBy} from '@openstapps/collection-utils'; /** * Convert a TypeScript project to a lightweight Type-AST representation of the project * @param sourcePath either a directory or a set of input files * @param includeComments if comments should be included (default true) */ export function lightweightProjectFromPath( sourcePath: string | string[], includeComments = true, ): LightweightProject { return new LightweightDefinitionBuilder(sourcePath, includeComments).convert(); } /** * Convert a TypeScript project to a set of lightweight definition ASTs * @param sourcePath either a directory or a set of input files * @param includeComments if comments should be included (default true) */ export function lightweightDefinitionsFromPath( sourcePath: string | string[], includeComments = true, ): LightweightDefinition[] { return rejectNil(new LightweightDefinitionBuilder(sourcePath, includeComments).convertToList()); } /** * Reads the reflection model and converts it into a flatter, easier to handle model */ class LightweightDefinitionBuilder { readonly program: ts.Program; readonly sourceFiles: readonly ts.SourceFile[]; readonly typeChecker: ts.TypeChecker; constructor(sourcePath: string | string[], readonly includeComments: boolean) { const rootNames = Array.isArray(sourcePath) ? sourcePath : expandPathToFilesSync(path.resolve(sourcePath), it => it.endsWith('.ts')); this.program = ts.createProgram({ rootNames: rootNames, options: { alwaysStrict: true, charset: 'utf8', declaration: true, esModuleInterop: true, experimentalDecorators: true, inlineSourceMap: true, module: ts.ModuleKind.NodeNext, strict: true, target: ts.ScriptTarget.ES2015, }, }); this.typeChecker = this.program.getTypeChecker(); this.sourceFiles = mapNotNil(this.program.getRootFileNames(), it => this.program.getSourceFile(it)); } private convertAliasLike( enumLike: ts.EnumDeclaration | ts.TypeAliasDeclaration, ): LightweightAliasDefinition { return cleanupEmpty({ comment: this.includeComments ? extractComment(enumLike) : undefined, name: enumLike.name.getText() ?? 'ERROR', kind: LightweightDefinitionKind.ALIAS_LIKE, modifiers: getModifiers(enumLike.getText(), ts.isEnumDeclaration(enumLike) ? 'enum' : 'type'), type: ts.isEnumDeclaration(enumLike) ? enumLike.members.length > 0 ? { flags: 1_048_576, specificationTypes: enumLike.members.map(it => this.lightweightTypeAtNode(it)), } : undefined : this.lightweightTypeFromType(this.typeChecker.getTypeFromTypeNode(enumLike.type), enumLike.type), }); } private convertClassLike( classLike: ts.ClassDeclaration | ts.InterfaceDeclaration, ): LightweightClassDefinition { const heritages = mapValues( groupBy([...(classLike.heritageClauses || [])], it => it.token.toString()), heritages => heritages.flatMap(it => it.types), ); return cleanupEmpty({ comment: this.includeComments ? extractComment(classLike) : undefined, name: classLike.name?.escapedText ?? 'ERROR', kind: LightweightDefinitionKind.CLASS_LIKE, modifiers: getModifiers(classLike.getText(), ts.isClassDeclaration(classLike) ? 'class' : 'interface'), extendedDefinitions: heritages[ts.SyntaxKind.ExtendsKeyword]?.map(it => this.lightweightTypeAtNode(it)), implementedDefinitions: heritages[ts.SyntaxKind.ImplementsKeyword]?.map(it => this.lightweightTypeAtNode(it), ), indexSignatures: keyBy( filterNodeTo( classLike.members as ts.NodeArray, ts.isIndexSignatureDeclaration, ).map(indexSignature => cleanupEmpty({ name: this.typeChecker.getSignatureFromDeclaration(indexSignature)?.parameters?.[0]?.escapedName ?? 'UNRESOLVED_INDEX_SIGNATURE', type: this.lightweightTypeFromType( this.typeChecker.getTypeFromTypeNode(indexSignature.type), indexSignature.type, ), indexSignatureType: this.lightweightTypeFromType( this.typeChecker.getTypeFromTypeNode(indexSignature.parameters[0].type!), indexSignature.parameters[0].type!, ), }), ), it => it.name, ), typeParameters: classLike.typeParameters?.map(it => it.name.getText()), properties: this.collectProperties(classLike.members), }); } collectProperties( members: ts.NodeArray, ): Record | undefined { return members ? keyBy( filterNodeTo(members as ts.NodeArray, isProperty).map(property => cleanupEmpty({ comment: this.includeComments ? extractComment(property) : undefined, name: resolvePropertyName(property.name) ?? property.getText(), type: this.lightweightTypeAtNode(property), properties: this.collectProperties((property.type as ts.TypeLiteralNode)?.members), optional: ts.isPropertyDeclaration(property) ? property.questionToken === undefined ? undefined : true : undefined, }), ), it => it.name, ) : undefined; } private lightweightTypeAtNode(node: ts.Node): LightweightType { const type = this.typeChecker.getTypeAtLocation(node); return this.lightweightTypeFromType(type, this.typeChecker.typeToTypeNode(type, node, undefined)); } private lightweightTypeFromType(type: ts.Type, typeNode?: ts.TypeNode): LightweightType { if (typeNode?.kind === ts.SyntaxKind.ConditionalType) { return {value: 'UNSUPPORTED_CONDITIONAL_TYPE', flags: ts.TypeFlags.Unknown}; } if (isArrayLikeType(typeNode)) { const elementType = ts.isArrayTypeNode(typeNode) ? typeNode.elementType : typeNode.typeArguments?.[0]!; const out = this.lightweightTypeFromType( this.typeChecker.getTypeFromTypeNode(elementType), elementType, ); out.isArray = true; return out; } const isReference = typeNode !== undefined && ts.isTypeReferenceNode(typeNode) && !isEnumLiteralType(type); const isTypeLiteral = typeNode !== undefined && ts.isTypeLiteralNode(typeNode); // @ts-expect-error intrinsic name & value exist const intrinsicName = (type.intrinsicName ?? type.value) as string | undefined; return cleanupEmpty({ value: intrinsicName, referenceName: isTypeLiteral ? undefined : resolveTypeName(typeNode) ?? (type.symbol?.escapedName as string | undefined), flags: type.flags, genericsTypes: isTypeVariable(type) ? undefined : this.typeChecker .getApparentType(type) // @ts-expect-error resolvedTypeArguments exits ?.resolvedTypeArguments?.filter(it => !it.isThisType) ?.map((it: ts.Type) => this.lightweightTypeFromType(it)), specificationTypes: type.isUnionOrIntersection() && !isReference ? type.types.map(it => this.lightweightTypeFromType(it, this.typeChecker.typeToTypeNode(it, undefined, undefined)), ) : undefined, }); } /** * Start the conversion process */ convert(): LightweightProject { return mapValues( keyBy([...this.sourceFiles], it => it.fileName), file => keyBy( [ ...filterChildrenTo(file, isClassLikeNode).map(it => this.convertClassLike(it)), ...filterChildrenTo(file, isEnumLikeNode).map(it => this.convertAliasLike(it)), ], it => it.name, ), ); } /** * Same as conversion, but generates a simple list of all definitions. */ convertToList(): LightweightDefinition[] { return Object.values(this.convert()).flatMap(it => it.values); } }