UNPKG

@travetto/transformer

Version:

Functionality for AST transformations, with transformer registration, and general utils

369 lines (340 loc) 14.7 kB
/* eslint-disable no-bitwise */ import ts from 'typescript'; import { path, ManifestModuleUtil } from '@travetto/manifest'; import { DocUtil } from '../util/doc.ts'; import { CoreUtil } from '../util/core.ts'; import { DeclarationUtil } from '../util/declaration.ts'; import { LiteralUtil } from '../util/literal.ts'; import { transformCast, type TemplateLiteralPart } from '../types/shared.ts'; import type { Type, AnyType, CompositionType, TransformResolver, TemplateType, MappedType } from './types.ts'; import { CoerceUtil } from './coerce.ts'; const UNDEFINED = Symbol(); const MAPPED_TYPE_SET = new Set(['Omit', 'Pick', 'Required', 'Partial']); const isMappedType = (type: string | undefined): type is MappedType['operation'] => MAPPED_TYPE_SET.has(type!); const getMappedFields = (type: ts.Type): string[] | undefined => { if (type.isStringLiteral()) { return [type.value]; } else if (type.isUnion() && type.types.every(subType => subType.isStringLiteral())) { return type.types.map(subType => subType.value); } }; /** * List of global types that can be parameterized */ const GLOBAL_COMPLEX: Record<string, Function> = { Array, Promise, Set, Map, ReadonlyArray: Array, Iterator: function Iterator() { }, Iterable: function Iterable() { }, IterableIterator: function IterableIterator() { }, AsyncIterator: function AsyncIterator() { }, PropertyDescriptor: Object, TypedPropertyDescriptor: Object }; /** * List of global types that are simple */ const UNDEFINED_GLOBAL = { undefined: 1, void: 1, null: 1 }; const SIMPLE_NAMES: Record<string, string> = { String: 'string', Number: 'number', Boolean: 'boolean', Object: 'object' }; const GLOBAL_SIMPLE: Record<string, Function> = { RegExp, Date, Number, Boolean, String, Function, Object, Error, PromiseConstructor: Promise.constructor }; type Category = 'tuple' | 'shape' | 'literal' | 'template' | 'managed' | 'composition' | 'foreign' | 'concrete' | 'unknown' | 'mapped'; /** * Type categorizer, input for builder */ export function TypeCategorize(resolver: TransformResolver, type: ts.Type): { category: Category, type: ts.Type } { const flags = type.getFlags(); const objectFlags = DeclarationUtil.getObjectFlags(type) ?? 0; if (flags & (ts.TypeFlags.TemplateLiteral)) { return { category: 'template', type }; } else if (flags & ( ts.TypeFlags.BigIntLike | ts.TypeFlags.BooleanLike | ts.TypeFlags.NumberLike | ts.TypeFlags.StringLike | ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void )) { return { category: 'literal', type }; } else if (DocUtil.hasDocTag(type, 'concrete')) { return { category: 'concrete', type }; } else if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) { // Any or unknown return { category: 'unknown', type }; } else if (objectFlags & ts.ObjectFlags.Reference && !CoreUtil.getSymbol(type)) { // Tuple type? return { category: 'tuple', type }; } else if (type.isUnionOrIntersection()) { return { category: 'composition', type }; } else if (objectFlags & ts.ObjectFlags.Anonymous) { try { const source = DeclarationUtil.getPrimaryDeclarationNode(type).getSourceFile(); const sourceFile = source.fileName; if (sourceFile && ManifestModuleUtil.TYPINGS_EXT_REGEX.test(sourceFile) && !resolver.isKnownFile(sourceFile)) { return { category: 'foreign', type }; } } catch { } const text = resolver.getTypeAsString(type); if (flags & (ts.SymbolFlags.TypeAlias | ts.SymbolFlags.ExportValue) && text?.startsWith('typeof') ) { return { category: 'managed', type }; } return { category: 'shape', type }; } else if (objectFlags & (ts.ObjectFlags.Reference | ts.ObjectFlags.Class | ts.ObjectFlags.Interface)) { let resolvedType = type; if (CoreUtil.hasTarget(resolvedType)) { resolvedType = resolvedType.target; // If resolved target has a concrete type if (DocUtil.hasDocTag(resolvedType, 'concrete')) { return { category: 'concrete', type: resolvedType }; } } const source = DeclarationUtil.getPrimaryDeclarationNode(resolvedType).getSourceFile(); const sourceFile = source.fileName; if (sourceFile?.includes('typescript/lib')) { return { category: 'literal', type }; } else if (sourceFile && ManifestModuleUtil.TYPINGS_EXT_REGEX.test(sourceFile) && !resolver.isKnownFile(sourceFile)) { return { category: 'foreign', type: resolvedType }; } else if (!resolvedType.isClass()) { // Not a real type return { category: 'shape', type: resolvedType }; } else { return { category: 'managed', type: resolvedType }; } } else if (objectFlags & ts.ObjectFlags.Tuple) { return { category: 'tuple', type }; } else if (type.isLiteral()) { return { category: 'shape', type }; } else if (objectFlags & ts.ObjectFlags.Mapped) { // Mapped types if (type.getProperties().some(property => property.declarations || property.valueDeclaration)) { return { category: 'mapped', type }; } } return { category: 'literal', type }; } /** * Type builder */ export const TypeBuilder: { [K in Category]: { build(resolver: TransformResolver, type: ts.Type, context: { alias?: ts.Symbol, node?: ts.Node }): AnyType | undefined; finalize?(type: Type<K>): AnyType; } } = { unknown: { build: (resolver, type) => { const optional = UNDEFINED in type; return { key: 'unknown', nullable: optional, undefinable: optional }; } }, tuple: { build: (resolver, type) => ({ key: 'tuple', tsTupleTypes: resolver.getAllTypeArguments(type), subTypes: [] }) }, template: { build: (resolver, type) => { // If we are have a template literal type, we need to make our own type node if (type.flags & ts.TypeFlags.TemplateLiteral) { const values: TemplateLiteralPart[] = []; const texts = 'texts' in type && (typeof type.texts === 'object') && Array.isArray(type.texts) ? type.texts : undefined; const types = 'types' in type && (typeof type.types === 'object') && Array.isArray(type.types) ? type.types : undefined; if (texts?.length && types?.length) { for (let i = 0; i < texts?.length; i += 1) { if (texts[i] && texts[i] !== 'undefined') { values.push(texts[i]); } if (types[i]) { switch (types[i].intrinsicName) { case 'number': values.push(Number); break; case 'string': values.push(String); break; case 'boolean': values.push(Boolean); break; case 'undefined': values.push(''); break; } } } if (values.length > 0) { return ({ key: 'template', template: { operation: 'and', values }, ctor: String }); } } } return { key: 'literal', ctor: Object }; } }, literal: { build: (resolver, type) => { // Handle void/undefined const name = resolver.getTypeAsString(type) ?? ''; const complexName = CoreUtil.getSymbol(type)?.getName() ?? ''; if (name in UNDEFINED_GLOBAL) { return { key: 'literal', ctor: undefined, name }; } else if (name in GLOBAL_SIMPLE) { const cons = GLOBAL_SIMPLE[name]; const literal = LiteralUtil.isLiteralType(type) ? CoerceUtil.coerce(type.value, transformCast(cons), false) : undefined; return { key: 'literal', ctor: cons, name: SIMPLE_NAMES[cons.name] ?? cons.name, value: literal }; } else if (complexName in GLOBAL_COMPLEX) { const cons = GLOBAL_COMPLEX[complexName]; return { key: 'literal', name: cons.name, ctor: cons, tsTypeArguments: resolver.getAllTypeArguments(type) }; } else { return { key: 'literal', name: 'Object', ctor: Object }; } } }, foreign: { build: (resolver, type) => { const name = CoreUtil.getSymbol(type)?.getName(); const source = DeclarationUtil.getPrimaryDeclarationNode(type).getSourceFile(); return { key: 'foreign', name, source: source.fileName, classId: `${source.fileName.split('node_modules/')[1]}+${name}` }; } }, managed: { build: (resolver, type) => { const name = CoreUtil.getSymbol(type)?.getName(); const importName = resolver.getTypeImportName(type)!; const tsTypeArguments = resolver.getAllTypeArguments(type); return { key: 'managed', name, importName, tsTypeArguments }; } }, composition: { build: (resolver, uType: ts.UnionOrIntersectionType) => { let undefinable = false; let nullable = false; const remainder = uType.types.filter(ut => { const isUndefined = (ut.getFlags() & (ts.TypeFlags.Undefined)) > 0; const isNull = (ut.getFlags() & (ts.TypeFlags.Null)) > 0; undefinable ||= isUndefined; nullable ||= isNull; return !(isUndefined || isNull); }); const name = CoreUtil.getSymbol(uType)?.getName(); return { key: 'composition', name, undefinable, nullable, tsSubTypes: remainder, subTypes: [], operation: uType.isUnion() ? 'or' : 'and' }; }, finalize: (type: CompositionType) => { const { undefinable, nullable, subTypes } = type; if (subTypes.length === 0) { // We have an unknown type? return { key: 'unknown', nullable, undefinable }; } const [first] = subTypes; if (first.key === 'template') { return { key: 'template', ctor: String, nullable: type.nullable, undefinable: type.undefinable, template: { operation: 'or', values: subTypes.map(subType => transformCast<TemplateType>(subType).template!) } }; } else if (subTypes.length === 1) { return { undefinable, nullable, ...first }; } else if (first.key === 'literal' && subTypes.every(item => item.name === first.name)) { // We have a common type.commonType = first; } else if (type.operation === 'and' && first.key === 'shape' && subTypes.every(item => item.key === 'shape')) { // All shapes return { importName: first.importName, name: first.name, key: 'shape', fieldTypes: subTypes.reduce((map, subType) => ({ ...map, ...subType.fieldTypes }), {}) }; } return type; } }, mapped: { build: (resolver, type, context) => { let mainType: ts.Type | undefined; let fields: string[] | undefined; let operation: string | undefined; let name: string | undefined; const declarations = DeclarationUtil.getDeclarations(type).filter(declaration => ts.isTypeAliasDeclaration(declaration)); const ref = declarations[0]?.type; if (ref && ts.isTypeReferenceNode(ref) && ref.typeArguments && ref.typeArguments.length > 0) { const [first, second] = ref.typeArguments; mainType = resolver.getType(first); operation = ref.typeName.getText(); name = resolver.getTypeAsString(type)!; fields = !second ? [] : getMappedFields(resolver.getType(second)); } else if (type.aliasTypeArguments && type.aliasSymbol) { mainType = type.aliasTypeArguments[0]; operation = type.aliasSymbol.escapedName.toString(); fields = (type.aliasTypeArguments.length > 1) ? getMappedFields(type.aliasTypeArguments[1]) : []; name = `${resolver.getTypeAsString(mainType)!}_${operation}_${fields?.join('_')}`; } if (!isMappedType(operation) || fields === undefined || !mainType || !mainType.isClass()) { return TypeBuilder.shape.build(resolver, type, context); } const importName = resolver.getTypeImportName(mainType) ?? '<unknown>'; const mappedClassName = resolver.getTypeAsString(mainType)!; return { key: 'mapped', name, original: mainType, operation, importName, mappedClassName, fields }; } }, shape: { build: (resolver, type, context) => { const tsFieldTypes: Record<string, ts.Type> = {}; const name = CoreUtil.getSymbol(context?.alias ?? type)?.getName(); const importName = resolver.getTypeImportName(type) ?? '<unknown>'; const tsTypeArguments = resolver.getAllTypeArguments(type); const properties = resolver.getPropertiesOfType(type); if (properties.length === 0) { return { key: 'literal', name: 'Object', ctor: Object, importName }; } for (const member of properties) { const decorator = DeclarationUtil.getPrimaryDeclarationNode(member); if (DeclarationUtil.isPublic(decorator)) { // If public const memberType = resolver.getType(decorator); if ( !member.getName().includes('@') && // if not a symbol !memberType.getCallSignatures().length // if not a function ) { if ((ts.isPropertySignature(decorator) || ts.isPropertyDeclaration(decorator)) && !!decorator.questionToken) { Object.defineProperty(memberType, UNDEFINED, { value: true }); } tsFieldTypes[member.getName()] = memberType; } } } return { key: 'shape', name, importName, tsFieldTypes, tsTypeArguments, fieldTypes: {} }; } }, concrete: { build: (resolver, type) => { if (!DocUtil.hasDocTag(type, 'concrete')) { return; } const [tag] = DocUtil.readDocTag(type, 'concrete'); let [importName, name] = tag?.split('#') ?? []; // Resolving relative to source file if (!importName || importName.startsWith('.')) { const rawSourceFile: string = DeclarationUtil.getDeclarations(type) ?.find(declaration => ts.getAllJSDocTags(declaration, (node): node is ts.JSDocTag => node.tagName.getText() === 'concrete').length) ?.getSourceFile().fileName ?? ''; if (!importName || importName === '.') { importName = resolver.getFileImportName(rawSourceFile); } else { const base = path.dirname(rawSourceFile); importName = resolver.getFileImportName(path.resolve(base, importName)); } } // Convert name to $Concrete suffix if not provided if (!name) { const [primaryDeclaration] = DeclarationUtil.getDeclarations(type) .filter(declaration => ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration)); name = `${primaryDeclaration.name.text}$Concrete`; } return { key: 'managed', name, importName }; } } };