mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-20 08:33:11 +00:00
256 lines
9.6 KiB
TypeScript
256 lines
9.6 KiB
TypeScript
/* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
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.ClassElement | ts.TypeElement>,
|
|
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<ts.ClassElement | ts.TypeElement>,
|
|
): Record<string, LightweightProperty> | undefined {
|
|
return members
|
|
? keyBy(
|
|
filterNodeTo(members as ts.NodeArray<ts.ClassElement | ts.TypeElement>, 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);
|
|
}
|
|
}
|