379 lines
11 KiB
TypeScript

import * as fs from 'fs'
import * as ts from 'typescript'
import {argparse, arg} from '@rondo/argparse'
import {error, info} from '../log'
function isObjectType(type: ts.Type): type is ts.ObjectType {
return !!(type.flags & ts.TypeFlags.Object)
}
function isTypeReference(type: ts.ObjectType): type is ts.TypeReference {
return !!(type.objectFlags & ts.ObjectFlags.Reference)
}
function isAnonymous(type: ts.Type): boolean {
return isObjectType(type) && !!(
type.objectFlags & ts.ObjectFlags.Anonymous)
}
function filterInvisibleProperties(type: ts.Symbol): boolean {
const flags = ts.getCombinedModifierFlags(type.valueDeclaration)
return !(flags & ts.ModifierFlags.NonPublicAccessibilityModifier)
}
interface IClassProperty {
name: string
type: ts.Type
relevantTypes: ts.Type[]
typeString: string
optional: boolean
}
interface IClassDefinition {
name: string
type: ts.Type
typeParameters: ts.TypeParameter[]
relevantTypeParameters: ts.Type[]
allRelevantTypes: ts.Type[]
properties: IClassProperty[]
}
/*
* TODO
*
* Interfaces generated from exported class delcarations will be prefixed with
* "I". A few cases need to be differentiated:
*
* a) Private (non-exported) types / interfaces / classes defined and used in
* same module. In case of non-exported classes, an error can be thrown.
* These can be copied and perhaps indexed to prevent collisions.
* b) Referenced exported classes from the same file
* c) Referenced exported classes from a neighbouring file
* d) Referenced imported classes from external modules. Real world example:
* entities in @rondo/comments-server import and use entities from
* @rondo/comments. These types will have to be processed by this module.
* e) Referenced interfaces should be re-imported in the output file.
*
*/
export function intergen(...argv: string[]): string {
const args = argparse({
input: arg('string', {alias: 'i', required: true}),
debug: arg('boolean'),
help: arg('boolean', {alias: 'h'}),
output: arg('string', {default: '-'}),
}).parse(argv)
function debug(m: string, ...meta: any[]) {
if (args.debug) {
error(m, ...meta)
}
}
/** Generate interfaces for all exported classes in a set of .ts files */
function classesToInterfaces(
fileNames: string[],
options: ts.CompilerOptions,
): string[] {
// Build a program using the set of root file names in fileNames
const program = ts.createProgram(fileNames, options)
// Get the checker, we will use it to find more about classes
const checker = program.getTypeChecker()
const classDefs: IClassDefinition[] = []
function typeToString(type: ts.Type): string {
return checker.typeToString(type)
}
/**
* Can be used to filters out global types like Array or string from a list
* of types. For example: types.filter(filterGlobalTypes)
*/
function filterGlobalTypes(type: ts.Type): boolean {
debug('filterGlobalTypes: %s', typeToString(type))
if (type.aliasSymbol) {
// keep type aliases
return true
}
const symbol = type.getSymbol()
if (!symbol) {
debug(' no symbol')
// e.g. string or number types have no symbol
return false
}
if (symbol && symbol.flags & ts.SymbolFlags.Transient) {
debug(' is transient')
// Array is transient. not sure if this is the best way to figure this
return false
}
// if (symbol && !((symbol as any).parent)) {
// // debug(' no parent', symbol)
// // e.g. Array symbol has no parent
// return false
// }
if (type.isLiteral()) {
debug(' is literal')
return false
}
if (type.isUnionOrIntersection()) {
debug(' is u or i')
return false
}
if (isObjectType(type) && isTypeReference(type)) {
debug(' is object type')
if (isObjectType(type.target) &&
type.target.objectFlags & ts.ObjectFlags.Tuple) {
debug(' is tuple')
return false
}
}
debug(' keep!')
return true
}
/**
* Converts a generic type to the target of the type reference.
*/
function mapGenericTypes(type: ts.Type): ts.Type {
if (type.aliasSymbol) {
return checker.getDeclaredTypeOfSymbol(type.aliasSymbol)
}
if (isObjectType(type) && isTypeReference(type)) {
return type.target
}
return type
}
/**
* Removes duplicates from an array of types
*/
function filterDuplicates(type: ts.Type, i: number, arr: ts.Type[]) {
// TODO improve performance of this method
return i === arr.indexOf(type)
}
/**
* Recursively retrieves a list of all type parameters.
*/
function getAllTypeParameters(type: ts.Type): ts.Type[] {
function collectTypeParams(
type2: ts.Type, params?: readonly ts.Type[],
): ts.Type[] {
const types: ts.Type[] = [type2]
if (params) {
params.forEach(t => {
const atp = getAllTypeParameters(t)
types.push(...atp)
})
}
return types
}
if (type.aliasSymbol) {
return collectTypeParams(type, type.aliasTypeArguments)
}
if (isObjectType(type) && isTypeReference(type)) {
return collectTypeParams(type, type.typeArguments)
}
if (type.isUnionOrIntersection()) {
return collectTypeParams(type, type.types)
}
if (type.isClassOrInterface()) {
return collectTypeParams(type, type.typeParameters)
}
return [type]
}
/**
* True if this is visible outside this file, false otherwise
*/
function isNodeExported(node: ts.Node): boolean {
return (
(ts.getCombinedModifierFlags(node as any) &
ts.ModifierFlags.Export) !== 0
// (!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile)
)
}
function handleClassDeclaration(node: ts.ClassDeclaration) {
if (!node.name) {
return
}
// This is a top level class, get its symbol
const symbol = checker.getSymbolAtLocation(node.name)
if (!symbol) {
return
}
const type = checker.getDeclaredTypeOfSymbol(symbol)
handleType(type)
}
const typeDefinitions: Map<ts.Type, IClassDefinition> = new Map()
function handleType(type: ts.Type) {
if (typeDefinitions.has(type)) {
return
}
if (type.aliasSymbol) {
throw new Error('Type aliases are not supported')
}
const typeParameters: ts.TypeParameter[] = []
const expandedTypeParameters: ts.Type[] = []
const allRelevantTypes: ts.Type[] = []
function handleTypeParameters(typeParams: readonly ts.Type[]) {
typeParams.forEach(tp => {
const constraint = tp.getConstraint()
if (constraint) {
expandedTypeParameters.push(...getAllTypeParameters(tp))
}
const def = tp.getDefault()
if (def) {
expandedTypeParameters.push(...getAllTypeParameters(tp))
}
typeParameters.push(tp)
})
}
if (type.isClassOrInterface() && type.typeParameters) {
handleTypeParameters(type.typeParameters)
}
if (type.aliasSymbol && type.aliasTypeArguments) {
handleTypeParameters(type.aliasTypeArguments)
}
const properties = type.getApparentProperties()
const filterClassTypeParameters =
(t: ts.Type) => typeParameters.every(tp => tp !== t)
const classProperties: IClassProperty[] = properties
.filter(filterInvisibleProperties)
.map(p => {
const vd = p.valueDeclaration
const optional = ts.isPropertyDeclaration(vd) && !!vd.questionToken
const propType = checker.getTypeOfSymbolAtLocation(p, vd)
const typeParams = getAllTypeParameters(propType)
const relevantTypes = typeParams
.filter(filterGlobalTypes)
.filter(filterClassTypeParameters)
.map(mapGenericTypes)
.filter(filterDuplicates)
allRelevantTypes.push(...relevantTypes)
return {
name: p.getName(),
type: propType,
relevantTypes,
typeString: typeToString(propType),
optional,
}
})
const relevantTypeParameters = expandedTypeParameters
.filter(filterGlobalTypes)
.filter(mapGenericTypes)
.filter(filterDuplicates)
allRelevantTypes.push(...relevantTypeParameters)
const classDef: IClassDefinition = {
name: typeToString(type),
type,
// name: symbol.getName(),
typeParameters,
allRelevantTypes: allRelevantTypes
.filter(filterClassTypeParameters)
.filter(filterDuplicates),
relevantTypeParameters,
properties: classProperties,
}
if (!isAnonymous(type)) {
// Prevent defining anonymous declarations as interfaces
classDefs.push(classDef)
}
typeDefinitions.set(type, classDef)
classDef.allRelevantTypes.forEach(handleType)
}
/**
* Visit nodes finding exported classes
*/
function visit(node: ts.Node) {
// Only consider exported nodes
if (!isNodeExported(node)) {
return
}
if (ts.isClassDeclaration(node)) {
handleClassDeclaration(node)
}
}
// Visit every sourceFile in the program
for (const sourceFile of program.getSourceFiles()) {
if (!sourceFile.isDeclarationFile) {
// Walk the tree to search for classes
ts.forEachChild(sourceFile, visit)
}
}
function setTypeName(type: ts.Type, mappings: Map<ts.Type, string>) {
if (isAnonymous(type)) {
return
}
const name = typeToString(type)
// (type as any).symbol.name = 'I' + type.symbol.name
mappings.set(type, `${name}`)
}
const nameMappings = new Map<ts.Type, string>()
for (const classDef of classDefs) {
setTypeName(classDef.type, nameMappings)
for (const t of classDef.allRelevantTypes) {
setTypeName(classDef.type, nameMappings)
}
}
function createInterface(classDef: IClassDefinition): string {
const name = nameMappings.get(classDef.type)!
const start = `export interface ${name} {`
const properties = classDef.properties.map(p => {
return ` ${p.name}: ${nameMappings.get(p.type) || p.typeString}`
})
.join('\n')
const end = '}'
return `${start}\n${properties}\n${end}`
}
return classDefs.map(createInterface)
}
const interfaces = classesToInterfaces([args.input], {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS,
})
const value = interfaces.join('\n\n')
if (args.output === '-') {
info(value)
} else {
fs.writeFileSync(args.output, value)
}
return value
}