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 = 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) { 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() 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 }