/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from "typescript";
import * as path from "path";
import {Type} from "../java";
import FUNCTION_TYPE_NAME = Type.FUNCTION_TYPE_NAME;
export class JavaScriptTypeMapping {
// Primary cache: Use type signatures (preferring type.id) as cache keys
// TypeScript assigns stable IDs to all types, so we don't need secondary caches
private readonly typeCache: Map = new Map();
private readonly methodCache: Map = new Map();
private readonly regExpSymbol: ts.Symbol | undefined;
private readonly stringWrapperType: ts.Type | undefined;
private readonly numberWrapperType: ts.Type | undefined;
private readonly booleanWrapperType: ts.Type | undefined;
constructor(
private readonly checker: ts.TypeChecker,
private readonly sourceRoot?: string
) {
this.regExpSymbol = checker.resolveName(
"RegExp",
undefined,
ts.SymbolFlags.Type,
false
);
// Resolve global wrapper types for primitives from TypeScript's lib
const stringSymbol = checker.resolveName("String", undefined, ts.SymbolFlags.Type, false);
const numberSymbol = checker.resolveName("Number", undefined, ts.SymbolFlags.Type, false);
const booleanSymbol = checker.resolveName("Boolean", undefined, ts.SymbolFlags.Type, false);
// Store the TypeScript types; conversion to Type happens on-demand
if (stringSymbol) {
this.stringWrapperType = checker.getDeclaredTypeOfSymbol(stringSymbol);
}
if (numberSymbol) {
this.numberWrapperType = checker.getDeclaredTypeOfSymbol(numberSymbol);
}
if (booleanSymbol) {
this.booleanWrapperType = checker.getDeclaredTypeOfSymbol(booleanSymbol);
}
}
type(node: ts.Node): Type | undefined {
// For identifiers, check if this references a variable
// This enables fieldType attribution for variable references
if (ts.isIdentifier(node)) {
const variableType = this.variableType(node);
if (variableType) {
return variableType;
}
// Fall through to regular type checking if not a variable
}
// TypeNode needs getTypeFromTypeNode (resolves the annotation itself).
// Everything else — expressions, declarations, specifiers, bindings, etc. —
// uses getTypeAtLocation which works for virtually all node kinds.
let type: ts.Type | undefined;
if (ts.isTypeNode(node)) {
type = this.checker.getTypeFromTypeNode(node);
} else {
type = this.checker.getTypeAtLocation(node);
}
return type && this.getType(type);
}
/**
* Resolve the type of a decorator from the symbol it references, naming it by the decorator's
* fully qualified name (e.g. `typeorm.Entity`) rather than the generic function type of the
* decorator factory. Decorators are the JavaScript/TypeScript analogue of Java annotations, so
* — like {@code J.Annotation} in the Java LST — they are typed as a {@link Type.FullyQualified}
* with {@code classKind = Annotation}, which lets recipes match them by fully qualified name.
*
* @param node the identifier or qualified name naming the decorator (e.g. `Entity` in `@Entity()`)
*/
annotationType(node: ts.Node): Type.FullyQualified | undefined {
const symbol = this.checker.getSymbolAtLocation(node);
if (!symbol) {
return undefined;
}
const fullyQualifiedName = this.getFullyQualifiedNameFromSymbol(symbol);
if (!fullyQualifiedName || fullyQualifiedName === 'unknown') {
return undefined;
}
return {
kind: Type.Kind.Class,
flags: 0,
classKind: Type.Class.Kind.Annotation,
fullyQualifiedName,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
}
/**
* Resolve the declared type of a class / interface / enum / class-expression.
*
* Using {@code getTypeAtLocation} on these declaration nodes is unsafe: TypeScript returns
* the type of the declared *value*, not the type itself. For string or numeric enums that
* ends up being the union of enum literals, which on the JS side resolves to
* {@link Type.Primitive} (e.g. String for string enums). The Java-side RPC receiver then
* rejects it with "A class can only be type attributed with a fully qualified type name",
* because {@code J.ClassDeclaration.type} must be {@link Type.FullyQualified}.
*
* Instead, we resolve through the declaration's own symbol. For classes and interfaces we
* reuse the full type mapping pipeline (so members, methods, supertypes and type parameters
* are populated). For enums — which have no class-shaped representation in TypeScript — and
* any residual non-FQ result, we build a minimal class shell whose FQN and {@code classKind}
* are derived from the declaration itself.
*/
declarationType(node: ts.ClassDeclaration | ts.ClassExpression | ts.InterfaceDeclaration | ts.EnumDeclaration): Type.FullyQualified | undefined {
const symbol = (node as { symbol?: ts.Symbol }).symbol
?? (node.name ? this.checker.getSymbolAtLocation(node.name) : undefined);
if (!symbol) {
return undefined;
}
// For classes and interfaces `getDeclaredTypeOfSymbol` yields a class-shaped ts.Type whose
// symbol we can feed through the normal pipeline. For enums, however, TypeScript returns the
// union of enum literals — which on the JS side resolves to `Type.Primitive` (e.g. String for
// string enums) and therefore is NOT `FullyQualified`. The Java side then rejects it with
// "A class can only be type attributed with a fully qualified type name".
//
// Since JavaScript/TypeScript has no separate enum runtime representation, we build the
// class-shaped type directly from the declaration's own symbol. The `classKind` is derived
// from the declaration node kind, not the (lossy) resolved type.
let classKind: Type.Class.Kind;
if (ts.isEnumDeclaration(node)) {
classKind = Type.Class.Kind.Enum;
} else if (ts.isInterfaceDeclaration(node)) {
classKind = Type.Class.Kind.Interface;
} else {
classKind = Type.Class.Kind.Class;
}
const fullyQualifiedName = this.getFullyQualifiedNameFromSymbol(symbol);
const cacheKey = `decl:${classKind}:${fullyQualifiedName}`;
const cached = this.typeCache.get(cacheKey);
if (cached && Type.isFullyQualified(cached)) {
return cached as Type.FullyQualified;
}
// For classes and interfaces reuse the existing type-based path so that members, methods,
// supertypes and type parameters are populated. Enums (and any fallback that produced a
// non-FQ result) get a minimal class shell with the correct FQN + kind.
if (!ts.isEnumDeclaration(node)) {
const declaredType = this.checker.getDeclaredTypeOfSymbol(symbol);
if (declaredType) {
const mapped = this.getType(declaredType);
if (Type.isFullyQualified(mapped)) {
this.typeCache.set(cacheKey, mapped);
return mapped;
}
}
}
const classType: Type.Class = {
kind: Type.Kind.Class,
flags: 0,
classKind,
fullyQualifiedName,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
this.typeCache.set(cacheKey, classType);
return classType;
}
/**
* Map an exported symbol (type alias, namespace, function, ...) to its {@link Type} via the
* symbol's declared or value type. Enumerator-only entry point ({@code exportedTypes}) for
* exports that are not class/interface/enum declarations; those go through {@link declarationType}.
*/
exportedType(symbol: ts.Symbol): Type | undefined {
const target = symbol.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(symbol) : symbol;
let type: ts.Type | undefined;
if (target.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.ValueModule)) {
type = this.checker.getDeclaredTypeOfSymbol(target);
} else if (target.valueDeclaration) {
type = this.checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration);
}
return type ? this.getType(type) : undefined;
}
private getType(type: ts.Type): Type {
// Check for error types first - these indicate type-checking failures
// and should not be processed further
if (type.flags & ts.TypeFlags.Any) {
const intrinsicName = (type as any).intrinsicName;
if (intrinsicName === 'error') {
return Type.unknownType;
}
}
// A type alias to an intersection or a plain anonymous object (e.g. kafkajs's
// `export type Producer = Sender & {...}`, or `export type Consumer = {...}`) loses its name
// when TypeScript resolves the structure, leaving an unnamed intersection / ``.
// TypeScript still records the originating alias via `aliasSymbol`; use it to attribute the
// nominal name (e.g. `kafkajs.Producer`) so the type is matchable. Unions keep their
// structural Union mapping (recipes rely on it), and mapped / instantiated utility types
// (`Partial`, `Record`, ...) are intentionally left untouched.
if (type.aliasSymbol) {
let nameableAlias = !!(type.flags & ts.TypeFlags.Intersection);
if (!nameableAlias && (type.flags & ts.TypeFlags.Object)) {
const objectFlags = (type as ts.ObjectType).objectFlags;
nameableAlias = !!(objectFlags & ts.ObjectFlags.Anonymous) &&
!(objectFlags & ts.ObjectFlags.Mapped) &&
!(objectFlags & ts.ObjectFlags.Instantiated);
}
if (nameableAlias) {
const fullyQualifiedName = this.getFullyQualifiedNameFromSymbol(type.aliasSymbol);
if (fullyQualifiedName && fullyQualifiedName !== 'unknown') {
const aliasSignature = this.getSignature(type);
const cached = this.typeCache.get(aliasSignature);
if (cached) {
return cached;
}
const aliasType: Type.Class = {
kind: Type.Kind.Class,
flags: 0,
classKind: Type.Class.Kind.Interface,
fullyQualifiedName,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
this.typeCache.set(aliasSignature, aliasType);
return aliasType;
}
}
}
// Skip problematic type constructs EARLY - before any caching or recursion
// These can cause deep recursion and are often computed/structural types
if (type.flags & ts.TypeFlags.Object) {
const objectFlags = (type as ts.ObjectType).objectFlags;
// Always skip mapped types (e.g., { [K in keyof T]: ... })
// These are structural/computed types that can cause infinite recursion
if (objectFlags & ts.ObjectFlags.Mapped) {
return Type.unknownType;
}
// Skip instantiated types ONLY if they're not type references
// Type references like Array, Promise are instantiated but should be mapped
// Other instantiated types (like object literals) should return unknown
if (objectFlags & ts.ObjectFlags.Instantiated) {
const isTypeReference = objectFlags & ts.ObjectFlags.Reference;
if (!isTypeReference) {
return Type.unknownType;
}
}
}
// Always skip conditional types (T extends U ? X : Y)
if (type.flags & ts.TypeFlags.Conditional) {
return Type.unknownType;
}
// Check cache using signature (type.id when available)
const signature = this.getSignature(type);
const existing = this.typeCache.get(signature);
if (existing) {
return existing;
}
// TypeScript represents `boolean` as a union of `false | true`, but the union
// type still has the Boolean flag set. Check this early to return Primitive.Boolean
// before we process it as a generic union type.
if (type.flags & ts.TypeFlags.Boolean) {
this.typeCache.set(signature, Type.Primitive.Boolean);
return Type.Primitive.Boolean;
}
// Get symbol for later use in type detection
const symbol = type.getSymbol?.();
// IMPORTANT: Check if this is a type reference to a parameterized type FIRST
if (type.flags & ts.TypeFlags.Object) {
const objectType = type as ts.ObjectType;
const isTypeReference = objectType.objectFlags & ts.ObjectFlags.Reference;
if (isTypeReference) {
const typeRef = type as ts.TypeReference;
const hasTypeArgs = typeRef.typeArguments && typeRef.typeArguments.length > 0;
if (hasTypeArgs) {
// This is a parameterized type reference (e.g., RefObject)
// Extract the base class type and type arguments to create a Parameterized type
// IMPORTANT: Check if the type arguments are actually type parameters (unsubstituted)
// This happens when TypeScript expands type aliases but doesn't substitute the type parameters
// For example, React.Ref expands to RefObject | RefCallback | null
// instead of RefObject | RefCallback | null
const hasUnsubstitutedTypeParams = typeRef.typeArguments!.some((arg: any) =>
(arg as ts.Type).flags & ts.TypeFlags.TypeParameter
);
if (!hasUnsubstitutedTypeParams) {
// Only create parameterized type if type arguments are actual types, not type parameters
if (symbol && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias))) {
const declaredType = this.checker.getDeclaredTypeOfSymbol(symbol);
// Get or create the base class type (this gets cached)
const declaredSig = this.getSignature(declaredType);
let classType = this.typeCache.get(declaredSig) as Type.Class | undefined;
if (!classType) {
classType = this.createEmptyClassType(declaredType);
this.typeCache.set(declaredSig, classType);
this.populateClassType(classType, declaredType);
}
// Shell-cache: Create parameterized type wrapper with empty typeParameters
// BEFORE resolving type arguments, to prevent infinite recursion
// when type argument resolution cycles back to this parameterized type
const parameterized = {
kind: Type.Kind.Parameterized,
type: classType,
typeParameters: [],
fullyQualifiedName: classType.fullyQualifiedName,
toJSON: function () {
return Type.signature(this);
}
} as Type.Parameterized;
this.typeCache.set(signature, parameterized);
// Resolve type arguments (may recursively reference this parameterized type)
const typeParameters: Type[] = [];
for (const typeArg of typeRef.typeArguments!) {
const resolvedArg = this.getType(typeArg as ts.Type);
typeParameters.push(resolvedArg);
}
// Update the shell with resolved type parameters
(parameterized as any).typeParameters = typeParameters;
return parameterized;
}
}
}
}
}
// Check for class/interface/enum types (including arrays)
// Arrays in JavaScript are objects with methods, so we treat them as class types
if (symbol) {
// Check for function symbols
if (symbol.flags & ts.SymbolFlags.Function) {
const callSignatures = type.getCallSignatures();
if (callSignatures.length > 0) {
// Shell-cache: Create stub, cache it, then populate (prevents cycles)
const functionType = this.createEmptyFunctionType();
this.typeCache.set(signature, functionType);
this.populateFunctionType(functionType, callSignatures[0]);
return functionType;
}
}
// Check for function-scoped or block-scoped variables that might be functions
if (symbol.flags & (ts.SymbolFlags.FunctionScopedVariable | ts.SymbolFlags.BlockScopedVariable)) {
const callSignatures = type.getCallSignatures();
if (callSignatures.length > 0) {
// Shell-cache: Create stub, cache it, then populate (prevents cycles)
const functionType = this.createEmptyFunctionType();
this.typeCache.set(signature, functionType);
this.populateFunctionType(functionType, callSignatures[0]);
return functionType;
}
}
if (symbol.flags & ts.SymbolFlags.ValueModule) {
const classType = this.createEmptyClassType(type);
this.typeCache.set(signature, classType);
this.populateClassType(classType, type);
return classType;
}
if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.ValueModule)) {
// Get the declared type from the symbol (analogous to symType = sym.type in Java)
// This is the base class type without specific type arguments
const declaredType = this.checker.getDeclaredTypeOfSymbol(symbol);
const declaredSig = this.getSignature(declaredType);
let classType = this.typeCache.get(declaredSig) as Type.Class | undefined;
if (!classType) {
// Shell-cache: Create stub, cache it, then populate (prevents cycles)
classType = this.createEmptyClassType(declaredType);
this.typeCache.set(declaredSig, classType);
this.populateClassType(classType, declaredType);
}
// Return the base class type (parameterized types are handled at the beginning of getType)
return classType;
}
if (symbol.flags & ts.SymbolFlags.TypeParameter) {
return this.createGenericTypeVariable(type as ts.TypeParameter, signature);
}
}
// Note on type aliases with type arguments (e.g., React.Ref):
// TypeScript automatically substitutes type parameters when resolving type aliases.
// For example, React.Ref = RefCallback | RefObject | null
// When we encounter Ref, TypeScript gives us:
// RefCallback | RefObject | null
// The type parameters are already correctly substituted in the constituent types,
// so we can just process the resolved type normally (falling through to union handling below).
// Check for union types (e.g., string | number)
if (type.flags & ts.TypeFlags.Union) {
return this.createUnionType(type as ts.UnionType, signature);
}
// Check for intersection types (e.g., A & B)
if (type.flags & ts.TypeFlags.Intersection) {
const intersectionType = type as ts.IntersectionType;
return this.createIntersectionType(intersectionType, signature);
}
// Check for function types without symbols (anonymous functions, function types)
const callSignatures = type.getCallSignatures();
if (callSignatures && callSignatures.length > 0) {
// Shell-cache: Create stub, cache it, then populate (prevents cycles)
const functionType = this.createEmptyFunctionType();
this.typeCache.set(signature, functionType);
this.populateFunctionType(functionType, callSignatures[0]);
return functionType;
}
// For anonymous object types that could have circular references
if (type.flags & ts.TypeFlags.Object) {
const objectFlags = (type as ts.ObjectType).objectFlags;
if (objectFlags & ts.ObjectFlags.Anonymous) {
return Type.unknownType;
}
}
// Pre-cache as unknownType before resolving type aliases to prevent infinite
// recursion when alias resolution cycles back through getType with different type.ids
this.typeCache.set(signature, Type.unknownType);
const result = this.createPrimitiveOrUnknownType(type);
this.typeCache.set(signature, result);
return result;
}
private getSignature(type: ts.Type): string | number {
// Try to use TypeScript's internal id if available
// TypeScript assigns stable IDs to all types, including parameterized, union, and intersection types
if ("id" in type && type.id !== undefined) {
return type.id as number;
}
// Fallback: Generate a string signature based on type characteristics
const typeString = this.checker.typeToString(type);
const symbol = type.getSymbol?.();
if (symbol) {
const declaration = symbol.valueDeclaration || symbol.declarations?.[0];
if (declaration) {
const sourceFile = declaration.getSourceFile();
const fileName = sourceFile.fileName;
const pos = declaration.pos;
// Create unique signature from file + position + type string
// This ensures types from different modules are distinguished
return `${fileName}:${pos}:${typeString}`;
}
}
// Last resort: use type string with a prefix to distinguish from numeric IDs
// This might happen for synthetic types or types without declarations
return `synthetic:${typeString}`;
}
primitiveType(node: ts.Node): Type.Primitive {
const type = this.type(node);
if (Type.isClass(type) && type.fullyQualifiedName === 'RegExp') {
return Type.Primitive.String;
}
return Type.isPrimitive(type) ? type : Type.Primitive.None;
}
variableType(node: ts.Node): Type.Variable | undefined {
let symbol: ts.Symbol | undefined;
let location: ts.Node = node;
// Get the symbol depending on node type
if (ts.isVariableDeclaration(node)) {
symbol = this.checker.getSymbolAtLocation(node.name);
} else if (ts.isParameter(node)) {
symbol = this.checker.getSymbolAtLocation(node.name);
} else if (ts.isIdentifier(node)) {
// For identifier references (like 'vi' in 'vi.fn()')
symbol = this.checker.getSymbolAtLocation(node);
} else if (ts.isPropertyDeclaration(node) || ts.isPropertySignature(node)) {
symbol = this.checker.getSymbolAtLocation(node.name);
} else {
// Not a variable/parameter/property we can handle
return undefined;
}
if (!symbol) {
return undefined;
}
// Get the variable declaration (resolve aliases if needed)
let actualSymbol = symbol;
if (symbol.flags & ts.SymbolFlags.Alias) {
actualSymbol = this.checker.getAliasedSymbol(symbol);
}
// Check if this symbol represents a variable, parameter, or property
// Exclude functions, classes, interfaces, namespaces, type aliases
const isExcluded = actualSymbol.flags & (
ts.SymbolFlags.Function |
ts.SymbolFlags.Class |
ts.SymbolFlags.Interface |
ts.SymbolFlags.Enum |
ts.SymbolFlags.ValueModule |
ts.SymbolFlags.NamespaceModule |
ts.SymbolFlags.TypeAlias |
ts.SymbolFlags.TypeParameter
);
if (isExcluded) {
// Not a variable - it's a type, function, class, namespace, etc.
return undefined;
}
const isVariable = actualSymbol.flags & (
ts.SymbolFlags.Variable |
ts.SymbolFlags.Property |
ts.SymbolFlags.FunctionScopedVariable |
ts.SymbolFlags.BlockScopedVariable
);
if (!isVariable) {
// Not a variable we recognize
return undefined;
}
// Get the type of the variable
const variableType = this.checker.getTypeOfSymbolAtLocation(actualSymbol, location);
const mappedType = this.getType(variableType);
// Get the owner (declaring type) for the variable
let ownerType: Type | undefined;
// Check if the variable is imported
if (symbol.flags & ts.SymbolFlags.Alias) {
// For imported variables, find the module specifier
const declarations = symbol.declarations;
if (declarations && declarations.length > 0) {
let importNode: ts.Node | undefined = declarations[0];
// Traverse up to find the ImportDeclaration
while (importNode && !ts.isImportDeclaration(importNode)) {
importNode = importNode.parent;
}
if (importNode && ts.isImportDeclaration(importNode)) {
const importDecl = importNode as ts.ImportDeclaration;
if (ts.isStringLiteral(importDecl.moduleSpecifier)) {
const moduleSpecifier = importDecl.moduleSpecifier.text;
// Create a Type.Class representing the module
ownerType = {
kind: Type.Kind.Class,
flags: 0,
classKind: Type.Class.Kind.Interface,
fullyQualifiedName: moduleSpecifier,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
}
}
}
} else {
// For non-imported variables, check if they belong to a class/interface/namespace
const parentSymbol = (actualSymbol as any).parent as ts.Symbol | undefined;
if (parentSymbol) {
const parentType = this.checker.getDeclaredTypeOfSymbol(parentSymbol);
if (parentType) {
ownerType = this.getType(parentType);
// If the parent is a namespace, try to find the module it came from
// This handles cases like React.forwardRef where the namespace is React
// but the module is "react"
if (parentSymbol.flags & ts.SymbolFlags.ValueModule ||
parentSymbol.flags & ts.SymbolFlags.NamespaceModule) {
// Check if this namespace was imported
const parentDeclarations = parentSymbol.declarations;
if (parentDeclarations && parentDeclarations.length > 0) {
const firstDecl = parentDeclarations[0];
const sourceFile = firstDecl.getSourceFile();
// If it's from node_modules or a .d.ts file, try to extract the module name
if (sourceFile.isDeclarationFile) {
const fileName = sourceFile.fileName;
const moduleName = this.extractModuleNameFromPath(fileName);
if (moduleName) {
// Store the module as the owningClass for now
// (This is a bit of a hack, but works with the current type system)
if (Type.isClass(ownerType)) {
(ownerType as any).owningClass = {
kind: Type.Kind.Class,
flags: 0,
classKind: Type.Class.Kind.Interface,
fullyQualifiedName: moduleName,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
}
}
}
}
}
}
}
}
// Create the Type.Variable
const variable = {
kind: Type.Kind.Variable,
name: actualSymbol.getName(),
owner: ownerType,
type: mappedType,
annotations: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Variable;
return variable;
}
/**
* Extract the npm module name from a file path.
* Handles various package manager layouts:
* - Standard: /path/node_modules/react/index.d.ts -> react
* - Scoped: /path/node_modules/@types/react/index.d.ts -> react
* - Scoped with __ encoding: /path/node_modules/@types/testing-library__react/index.d.ts -> @testing-library/react
* - Nested node_modules: /path/node_modules/pkg/node_modules/dep/index.d.ts -> dep
* - pnpm: /path/node_modules/.pnpm/react@18.2.0/node_modules/react/index.d.ts -> react
*
* @returns The module name, or undefined if not from node_modules
*/
private extractModuleNameFromPath(fileName: string): string | undefined {
if (!fileName.includes('node_modules/')) {
return undefined;
}
// Find the last occurrence of node_modules/ to handle nested dependencies
// This also correctly handles pnpm's .pnpm structure
const lastNodeModulesIndex = fileName.lastIndexOf('node_modules/');
const afterNodeModules = fileName.substring(lastNodeModulesIndex + 'node_modules/'.length);
// Split by '/' to get path segments
const segments = afterNodeModules.split('/');
if (segments.length === 0) {
return undefined;
}
let moduleName: string;
// Handle scoped packages (@scope/package)
if (segments[0].startsWith('@') && segments.length > 1) {
moduleName = `${segments[0]}/${segments[1]}`;
} else {
moduleName = segments[0];
}
// Skip pnpm's .pnpm directory - it contains versioned package paths
// In pnpm, the actual package is in: .pnpm/pkg@version/node_modules/pkg
// So we already handled this by using lastIndexOf above
if (moduleName === '.pnpm') {
return undefined;
}
return this.normalizePackageName(moduleName);
}
/**
* Normalize a node_modules package name to the specifier consumers actually import.
*
* DefinitelyTyped packages (`@types/`) are never importable under that name — the
* importable specifier is ``, with DefinitelyTyped's `__` scoped-package encoding
* decoded back to a `@scope/name` form. Using the importable specifier keeps attributed
* fully qualified names consistent regardless of whether a type is reached through a direct
* import (which already resolves via the module specifier) or transitively (e.g. a call's
* return type), which falls back to the declaration file's `node_modules` path.
*
* Examples:
* - `@types/express-serve-static-core` -> `express-serve-static-core`
* - `@types/node` -> `node`
* - `@types/testing-library__react` -> `@testing-library/react`
*/
private normalizePackageName(packageName: string): string {
if (packageName.startsWith('@types/')) {
packageName = packageName.substring('@types/'.length);
// Decode __ encoding for scoped packages: testing-library__react -> @testing-library/react
if (packageName.includes('__')) {
const parts = packageName.split('__');
if (parts.length === 2) {
packageName = `@${parts[0]}/${parts[1]}`;
}
}
}
return packageName;
}
/**
* Helper to create a Type.Method object from common parameters
*/
private createMethodType(
signature: ts.Signature,
node: ts.MethodSignature | ts.FunctionDeclaration | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.FunctionExpression | ts.CallExpression | ts.NewExpression,
declaringType: Type.FullyQualified,
name: string,
declaredFormalTypeNames: string[] = []
): Type.Method {
// Create composite cache key: declaring type + method name + signature
// This prevents cache collisions when methods with identical signatures have different names
// (e.g., util.isString(): any vs util.isArray(): any)
const declaringTypeSig = Type.signature(declaringType);
const signatureStr = this.checker.signatureToString(signature);
const cacheKey = `${declaringTypeSig}#${name}${signatureStr}`;
const cached = this.methodCache.get(cacheKey);
if (cached) {
return cached;
}
const returnType = signature.getReturnType();
const parameters = signature.getParameters();
const parameterTypes: Type[] = [];
const parameterNames: string[] = [];
for (const param of parameters) {
parameterNames.push(param.getName());
const paramType = this.checker.getTypeOfSymbolAtLocation(param, node);
parameterTypes.push(this.getType(paramType));
}
// Create the Type.Method object
const method = {
kind: Type.Kind.Method,
flags: 0, // FIXME - determine flags
declaringType: declaringType,
name: name,
returnType: this.getType(returnType),
parameterNames: parameterNames,
parameterTypes: parameterTypes,
thrownExceptions: [], // JavaScript doesn't have checked exceptions
annotations: [],
defaultValue: undefined,
declaredFormalTypeNames: declaredFormalTypeNames,
toJSON: function () {
return Type.signature(this);
}
} as Type.Method;
this.methodCache.set(cacheKey, method);
return method;
}
private wrapperType(declaringType: (Type.FullyQualified & Type.Primitive) | Type.FullyQualified) {
if (declaringType === Type.Primitive.String && this.stringWrapperType) {
return this.getType(this.stringWrapperType) as Type.FullyQualified;
} else if ((declaringType === Type.Primitive.Double || declaringType === Type.Primitive.BigInt) && this.numberWrapperType) {
return this.getType(this.numberWrapperType) as Type.FullyQualified;
} else if (declaringType === Type.Primitive.Boolean && this.booleanWrapperType) {
return this.getType(this.booleanWrapperType) as Type.FullyQualified;
} else {
// This should not really happen, but we'll fallback to unknown if needed
return Type.unknownType as Type.FullyQualified;
}
}
methodType(node: ts.Node): Type.Method | undefined {
let signature: ts.Signature | undefined;
let methodName: string;
let declaringType: Type.FullyQualified;
let declaredFormalTypeNames: string[] = [];
// Handle different kinds of nodes that represent methods or method invocations
if (ts.isCallOrNewExpression(node)) {
// For method invocations (e.g., _.map(...))
signature = this.checker.getResolvedSignature(node);
if (!signature) {
return undefined;
}
let symbol = this.checker.getSymbolAtLocation(node.expression);
if (!symbol && ts.isPropertyAccessExpression(node.expression)) {
// For property access expressions where we couldn't get a symbol,
// try to get the symbol from the signature's declaration
const declaration = signature?.getDeclaration();
if (declaration) {
symbol = this.checker.getSymbolAtLocation(declaration);
}
// If still no symbol but we have a signature, we can proceed with limited info
if (!symbol && signature) {
// For cases like util.isArray where the module is 'any' type
// We'll construct a basic method type from the signature
methodName = node.expression.name.getText();
// When there's no symbol but we have a signature, we need to work harder
// to find the declaring type. This happens with CommonJS require() calls
// where the module is typed as 'any' but methods still have signatures
// Try to trace back through the AST to find the require() call
let inferredDeclaringType: Type.FullyQualified | undefined;
const objExpr = node.expression.expression;
if (ts.isIdentifier(objExpr)) {
// Look for the variable declaration that assigns the require() result
const objSymbol = this.checker.getSymbolAtLocation(objExpr);
if (objSymbol && objSymbol.valueDeclaration) {
const valueDecl = objSymbol.valueDeclaration;
if (ts.isVariableDeclaration(valueDecl) && valueDecl.initializer) {
// Check if it's a require() call
if (ts.isCallExpression(valueDecl.initializer)) {
const callExpr = valueDecl.initializer;
if (ts.isIdentifier(callExpr.expression) &&
callExpr.expression.getText() === 'require' &&
callExpr.arguments.length > 0) {
// Extract the module name from require('module-name')
const moduleArg = callExpr.arguments[0];
if (ts.isStringLiteral(moduleArg)) {
const moduleName = moduleArg.text;
inferredDeclaringType = {
kind: Type.Kind.Class,
flags: 0, // TODO - determine flags
fullyQualifiedName: moduleName
} as Type.FullyQualified;
}
}
}
}
}
}
// Use the inferred type or fall back to unknown
declaringType = inferredDeclaringType || Type.unknownType as Type.FullyQualified;
// Create the method type using the helper
return this.createMethodType(signature, node, declaringType, methodName);
}
}
if (!symbol) {
return undefined;
}
// Get the method name
if (ts.isPropertyAccessExpression(node.expression)) {
methodName = node.expression.name.getText();
// Check if the object is an imported symbol
const objSymbol = this.checker.getSymbolAtLocation(node.expression.expression);
let isImport = false;
if (objSymbol) {
// Only call getAliasedSymbol if the symbol is actually an alias
if (objSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = this.checker.getAliasedSymbol(objSymbol);
isImport = aliasedSymbol && aliasedSymbol !== objSymbol;
}
}
const exprType = this.checker.getTypeAtLocation(node.expression.expression);
const mappedType = this.getType(exprType);
// Handle different types
if (mappedType && mappedType.kind === Type.Kind.Class) {
// Update the declaring type with the corrected FQN
if (isImport && objSymbol) {
const importName = objSymbol.getName();
const origFqn = (mappedType as Type.Class).fullyQualifiedName;
const lastDot = origFqn.lastIndexOf('.');
if (lastDot > 0) {
const typeName = origFqn.substring(lastDot + 1);
declaringType = {
kind: Type.Kind.Class,
flags: 0, // TODO - determine flags
fullyQualifiedName: `${importName}.${typeName}`
} as Type.FullyQualified;
} else {
declaringType = mappedType as Type.FullyQualified;
}
} else {
declaringType = mappedType as Type.FullyQualified;
}
} else if (mappedType && mappedType.kind === Type.Kind.Parameterized) {
// For parameterized types (e.g., Array, number[]), use the base class type
declaringType = (mappedType as Type.Parameterized).type;
} else if (mappedType && mappedType.kind === Type.Kind.Primitive) {
// Box the primitive to its wrapper type
declaringType = this.wrapperType(mappedType as Type.Primitive);
} else {
// Default to unknown if we can't determine the type
declaringType = Type.unknownType as Type.FullyQualified;
}
// For string methods like 'hello'.split(), ensure we have a proper declaring type for primitives
if (!isImport && declaringType === Type.unknownType) {
// If the expression type is a primitive string, use String as declaring type
const typeString = this.checker.typeToString(exprType);
if (typeString === 'string' || exprType.flags & ts.TypeFlags.String || exprType.flags & ts.TypeFlags.StringLiteral) {
declaringType = this.wrapperType(Type.Primitive.String);
} else if (typeString === 'number' || exprType.flags & ts.TypeFlags.Number || exprType.flags & ts.TypeFlags.NumberLiteral) {
declaringType = this.wrapperType(Type.Primitive.Double);
} else if (typeString === 'boolean' || exprType.flags & ts.TypeFlags.Boolean || exprType.flags & ts.TypeFlags.BooleanLiteral) {
declaringType = this.wrapperType(Type.Primitive.Boolean);
} else {
// Fallback for other primitive types or unknown
declaringType = Type.unknownType as Type.FullyQualified;
}
}
} else if (ts.isIdentifier(node.expression)) {
methodName = node.expression.getText();
// Check if this is an import first
const exprSymbol = this.checker.getSymbolAtLocation(node.expression);
let moduleSpecifier: string | undefined;
let aliasedSymbol: ts.Symbol | undefined;
if (exprSymbol) {
// Check if this is an aliased symbol (i.e., an import)
if (exprSymbol.flags & ts.SymbolFlags.Alias) {
aliasedSymbol = this.checker.getAliasedSymbol(exprSymbol);
}
// If getAliasedSymbol returns something different, it's an import
if (aliasedSymbol && aliasedSymbol !== exprSymbol) {
// This is definitely an imported symbol
const aliasedParentSymbol = (aliasedSymbol as any).parent as ts.Symbol | undefined;
if (aliasedParentSymbol && aliasedParentSymbol.declarations?.[0] &&
ts.isModuleDeclaration(aliasedParentSymbol.declarations[0]) &&
ts.isIdentifier(aliasedParentSymbol.declarations[0].name)) {
// For namespace imports, use the namespace symbol's `name` as the module specifier (e.g. `React` instead of `react`)
moduleSpecifier = aliasedParentSymbol.name;
} else {
// Now find the import declaration to get the module specifier
if (exprSymbol.declarations && exprSymbol.declarations.length > 0) {
let importNode: ts.Node = exprSymbol.declarations[0];
// Traverse up to find the ImportDeclaration
while (importNode && !ts.isImportDeclaration(importNode)) {
importNode = importNode.parent;
}
if (importNode && ts.isImportDeclaration(importNode)) {
const importDeclNode = importNode as ts.ImportDeclaration;
if (ts.isStringLiteral(importDeclNode.moduleSpecifier)) {
moduleSpecifier = importDeclNode.moduleSpecifier.text;
}
}
}
}
}
}
if (moduleSpecifier) {
// This is an imported function - use the module specifier as declaring type
if (moduleSpecifier.startsWith('node:')) {
// Node.js built-in module
declaringType = {
kind: Type.Kind.Class,
flags: 0, // TODO - determine flags
fullyQualifiedName: 'node'
} as Type.FullyQualified;
methodName = moduleSpecifier.substring(5); // Remove 'node:' prefix
} else {
// Regular module import
declaringType = {
kind: Type.Kind.Class,
flags: 0, // TODO - determine flags
fullyQualifiedName: moduleSpecifier
} as Type.FullyQualified;
// A default import binds an `ImportClause`; its aliased symbol carries the internal
// name of the default export (e.g. `e` for express), so represent it as ``.
// Named imports (`ImportSpecifier`) keep the original exported name.
const isDefaultImport = exprSymbol?.declarations?.some(ts.isImportClause) ?? false;
if (!isDefaultImport && aliasedSymbol && aliasedSymbol.name) {
methodName = aliasedSymbol.name;
} else {
methodName = '';
}
}
} else {
// Fall back to the original logic for non-imported functions
const exprType = this.checker.getTypeAtLocation(node.expression);
const funcType = this.getType(exprType);
if (funcType && funcType.kind === Type.Kind.Class) {
const fqn = (funcType as Type.Class).fullyQualifiedName;
const lastDot = fqn.lastIndexOf('.');
if (lastDot > 0) {
// For functions from modules, use the module part as declaring type
declaringType = {
kind: Type.Kind.Class,
flags: 0, // TODO - determine flags
fullyQualifiedName: fqn.substring(0, lastDot)
} as Type.FullyQualified;
} else {
// No dots in the name - the type IS the module itself
declaringType = funcType as Type.FullyQualified;
}
} else {
// Try to use the symbol's parent or module
const parent = (symbol as any).parent;
if (parent) {
const parentType = this.checker.getDeclaredTypeOfSymbol(parent);
declaringType = this.getType(parentType) as Type.FullyQualified;
} else {
declaringType = Type.unknownType as Type.FullyQualified;
}
}
}
} else {
methodName = symbol.getName();
declaringType = Type.unknownType as Type.FullyQualified;
}
// Get type parameters from signature
const typeParameters = signature.getTypeParameters();
if (typeParameters) {
for (const tp of typeParameters) {
declaredFormalTypeNames.push(tp.symbol.getName());
}
}
} else if (ts.isMethodDeclaration(node) || ts.isMethodSignature(node)) {
// For method declarations
const symbol = this.checker.getSymbolAtLocation(node.name!);
if (!symbol) {
return undefined;
}
signature = this.checker.getSignatureFromDeclaration(node);
if (!signature) {
return undefined;
}
methodName = symbol.getName();
// Get the declaring type (the class/interface that contains this method)
const parent = node.parent;
if (ts.isClassDeclaration(parent) || ts.isInterfaceDeclaration(parent) || ts.isObjectLiteralExpression(parent)) {
const parentType = this.checker.getTypeAtLocation(parent);
declaringType = this.getType(parentType) as Type.FullyQualified;
} else {
declaringType = Type.unknownType as Type.FullyQualified;
}
// Get type parameters from node
if (node.typeParameters) {
for (const tp of node.typeParameters) {
declaredFormalTypeNames.push(tp.name.getText());
}
}
} else if (ts.isConstructorDeclaration(node)) {
// For constructor declarations
signature = this.checker.getSignatureFromDeclaration(node);
if (!signature) {
return undefined;
}
methodName = "";
const parent = node.parent;
if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) {
const parentType = this.checker.getTypeAtLocation(parent);
declaringType = this.getType(parentType) as Type.FullyQualified;
} else {
declaringType = Type.unknownType as Type.FullyQualified;
}
} else if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
// For function declarations/expressions
signature = this.checker.getSignatureFromDeclaration(node);
if (!signature) {
return undefined;
}
methodName = node.name ? node.name.getText() : "";
// Derive declaring type from source file module path (like Go's type_mapper.go).
// Use the same relativization as getFullyQualifiedName() so that declarations
// and invocations produce matching FQNs.
let moduleFqn: string;
const fileName = node.getSourceFile().fileName;
if (this.sourceRoot && path.isAbsolute(fileName)) {
moduleFqn = path.relative(this.sourceRoot, fileName);
} else {
moduleFqn = fileName;
}
// Strip file extension to get the module name
moduleFqn = moduleFqn.replace(/\.[^/.]+$/, '');
declaringType = {
kind: Type.Kind.Class,
flags: 0,
fullyQualifiedName: moduleFqn
} as Type.FullyQualified;
// Get type parameters from node
if (node.typeParameters) {
for (const tp of node.typeParameters) {
declaredFormalTypeNames.push(tp.name.getText());
}
}
} else {
// For other node types, return undefined
return undefined;
}
// Create the method type using the helper
return this.createMethodType(signature, node, declaringType, methodName, declaredFormalTypeNames);
}
/**
* Get the fully qualified name for a TypeScript type.
* Uses TypeScript's built-in resolution which properly handles things like:
* - React.Component (not @types/react.Component)
* - _.LoDashStatic (not @types/lodash.LoDashStatic)
*/
private getFullyQualifiedName(type: ts.Type): string {
const symbol = type.getSymbol?.();
if (!symbol) {
return "unknown";
}
return this.getFullyQualifiedNameFromSymbol(symbol);
}
private getFullyQualifiedNameFromSymbol(symbol: ts.Symbol): string {
// First, check if this symbol is an import/alias
// For imported types, we want to use the module specifier instead of the file path
if (symbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = this.checker.getAliasedSymbol(symbol);
if (aliasedSymbol && aliasedSymbol !== symbol && symbol.declarations && symbol.declarations.length > 0) {
// Try to find the import declaration to get the module specifier
let importNode: ts.Node | undefined = symbol.declarations[0];
// Traverse up to find the ImportDeclaration or ImportSpecifier
while (importNode && importNode.parent && !ts.isImportDeclaration(importNode) && !ts.isImportSpecifier(importNode)) {
importNode = importNode.parent;
}
let moduleSpecifier: string | undefined;
if (importNode && ts.isImportSpecifier(importNode)) {
// Named import like: import { ClipLoader } from 'react-spinners'
// ImportSpecifier -> NamedImports -> ImportClause -> ImportDeclaration
const namedImports = importNode.parent; // NamedImports
if (namedImports && ts.isNamedImports(namedImports)) {
const importClause = namedImports.parent; // ImportClause
if (importClause && ts.isImportClause(importClause)) {
const importDecl = importClause.parent; // ImportDeclaration
if (importDecl && ts.isImportDeclaration(importDecl) && ts.isStringLiteral(importDecl.moduleSpecifier)) {
moduleSpecifier = importDecl.moduleSpecifier.text;
}
}
}
} else if (importNode && ts.isImportDeclaration(importNode)) {
// Default or namespace import
if (ts.isStringLiteral(importNode.moduleSpecifier)) {
moduleSpecifier = importNode.moduleSpecifier.text;
}
}
if (moduleSpecifier) {
// Build the fully qualified name from module specifier + symbol name
const symbolName = symbol.getName();
return `${moduleSpecifier}.${symbolName}`;
}
}
}
// Fall back to TypeScript's built-in getFullyQualifiedName
// This returns names with quotes that we need to clean up
// e.g., '"React"."Component"' -> 'React.Component'
const tsQualifiedName = this.checker.getFullyQualifiedName(symbol);
let cleanedName = tsQualifiedName.replace(/"/g, '');
// Check if this is a file path from node_modules (happens with some packages)
// TypeScript sometimes returns full paths instead of module names
if (cleanedName.includes('node_modules/')) {
// Extract the module name from the path
// Example: /private/var/.../node_modules/react-spinners/src/index.ClipLoader
// Should become: react-spinners.ClipLoader
const nodeModulesIndex = cleanedName.indexOf('node_modules/');
const afterNodeModules = cleanedName.substring(nodeModulesIndex + 'node_modules/'.length);
// Split by '/' to get parts of the path
const pathParts = afterNodeModules.split('/');
if (pathParts.length > 0) {
// First part is the package name (might be scoped like @types)
let packageName = pathParts[0];
// Handle scoped packages
if (packageName.startsWith('@') && pathParts.length > 1) {
packageName = `${packageName}/${pathParts[1]}`;
}
// Normalize `@types/` to the importable specifier `` so that types
// reached transitively (e.g. a call's return type, resolved via the declaration
// file's node_modules path) match the names used for directly imported types.
packageName = this.normalizePackageName(packageName);
// Find the symbol name (everything after the last dot in the original cleaned name)
const lastDotIndex = cleanedName.lastIndexOf('.');
if (lastDotIndex > 0) {
const symbolName = cleanedName.substring(lastDotIndex + 1);
cleanedName = `${packageName}.${symbolName}`;
} else {
cleanedName = packageName;
}
}
} else if (path.isAbsolute(cleanedName)) {
// TypeScript returns absolute file paths as module names for project source files
// that are ES modules (have imports/exports). Relativize using sourceRoot.
// Example: /var/moderne/.../BookStack/resources/js/foo.MyClass
// Should become: resources/js/foo.MyClass
if (this.sourceRoot) {
cleanedName = path.relative(this.sourceRoot, cleanedName);
}
} else if (!cleanedName.includes('.') &&
(symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.Enum))) {
// Bare class/interface/enum name whose declaring package was dropped (e.g. `Logger`
// from a package that does `export = Logger`). TypeScript's getFullyQualifiedName drops
// the package here because the symbol has no `parent`, so the package would otherwise be
// lost. Recover it from the symbol's declaration file — but only for genuine module
// exports from node_modules. Excluded by construction:
// - namespace/module objects (e.g. the `React` namespace), whose bare name is intentional
// and which are not Class/Interface/Enum symbols;
// - local declarations (extractModuleNameFromPath returns undefined);
// - ambient globals (which are `global.`-qualified, hence not bare).
const declarationFile = symbol.declarations?.[0]?.getSourceFile();
if (declarationFile && ts.isExternalModule(declarationFile)) {
const packageName = this.extractModuleNameFromPath(declarationFile.fileName);
if (packageName) {
cleanedName = `${packageName}.${cleanedName}`;
}
}
} else if (cleanedName.includes('.')) {
// Namespace-qualified name such as `Bull.Queue` or `request.SuperAgentStatic`, where
// the leading segment is the package's internal `export =` namespace rather than the
// importable package name. Replace it with the declaring package so the FQN identifies
// the package the type actually comes from.
//
// Preserved as-is:
// - UMD globals (`export as namespace X`, e.g. React, lodash `_`, jQuery `$`): the
// namespace name is the conventional public identifier;
// - `global.*` (TypeScript's marker for ambient globals);
// - namespaces that already match the package name, and any non-node_modules type.
const namespaceName = cleanedName.substring(0, cleanedName.indexOf('.'));
if (namespaceName !== 'global') {
const declarationFile = symbol.declarations?.[0]?.getSourceFile();
if (declarationFile && ts.isExternalModule(declarationFile)) {
const packageName = this.extractModuleNameFromPath(declarationFile.fileName);
if (packageName && packageName !== namespaceName &&
!this.isUmdGlobalNamespace(declarationFile, namespaceName)) {
cleanedName = packageName + cleanedName.substring(cleanedName.indexOf('.'));
}
}
}
}
return cleanedName.endsWith('Constructor') ?
cleanedName.substring(0, cleanedName.length - 'Constructor'.length) :
cleanedName;
}
/**
* Whether {@code namespaceName} is exposed as a UMD global from the given declaration file
* (e.g. `export as namespace React`). The namespace name of a UMD global is the conventional
* public identifier (React, lodash `_`, jQuery `$`) and must be preserved rather than replaced
* with the package name.
*/
private isUmdGlobalNamespace(sourceFile: ts.SourceFile, namespaceName: string): boolean {
for (const statement of sourceFile.statements) {
if (ts.isNamespaceExportDeclaration(statement) && statement.name.text === namespaceName) {
return true;
}
}
return false;
}
/**
* Create an empty JavaType.Class shell from a TypeScript type.
* The shell will be populated later to handle circular references.
*/
private createEmptyClassType(type: ts.Type): Type.Class {
// Use our getFullyQualifiedName method which uses TypeScript's built-in resolution
const fullyQualifiedName = this.getFullyQualifiedName(type);
// Determine the class kind based on symbol flags
let classKind = Type.Class.Kind.Interface; // Default to interface
const symbol = type.getSymbol?.();
if (symbol) {
if (symbol.flags & ts.SymbolFlags.Class) {
classKind = Type.Class.Kind.Class;
} else if (symbol.flags & ts.SymbolFlags.Enum) {
classKind = Type.Class.Kind.Enum;
} else if (symbol.flags & ts.SymbolFlags.Interface) {
classKind = Type.Class.Kind.Interface;
}
}
// Create empty class type shell (no members yet to avoid recursion)
return {
kind: Type.Kind.Class,
flags: 0, // TODO - determine flags
classKind: classKind,
fullyQualifiedName: fullyQualifiedName,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
}
/**
* Populates the class type with members, methods, heritage, and type parameters
* Since the shell is already in the cache, any recursive references will find it
*/
private populateClassType(classType: Type.Class, type: ts.Type): void {
const symbol = type.getSymbol?.();
// Try to get base types using TypeScript's getBaseTypes API
// This works for both local and external types (from node_modules)
if (type.flags & ts.TypeFlags.Object) {
let baseTypes: ts.Type[] | undefined;
// Check if this is a class or interface type that supports getBaseTypes
const objectType = type as ts.ObjectType;
if (objectType.objectFlags & (ts.ObjectFlags.Class | ts.ObjectFlags.Interface)) {
try {
baseTypes = (this.checker as any).getBaseTypes?.(type as ts.InterfaceType);
} catch (e) {
// getBaseTypes might fail for some types, fall back to declaration-based extraction
}
} else if (symbol) {
// For constructor functions or type references, we need to get the actual class type
// Try to get the type of the class itself (not the constructor or instance)
const classSymbol = symbol.flags & ts.SymbolFlags.Alias ?
this.checker.getAliasedSymbol(symbol) : symbol;
if (classSymbol && classSymbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) {
// Get the type of the class declaration itself
const declaredType = this.checker.getDeclaredTypeOfSymbol(classSymbol);
if (declaredType && declaredType !== type) {
try {
baseTypes = (this.checker as any).getBaseTypes?.(declaredType as ts.InterfaceType);
} catch (e) {
// getBaseTypes might fail, fall back to declaration-based extraction
}
}
} else if (classSymbol && classSymbol.valueDeclaration && ts.isClassDeclaration(classSymbol.valueDeclaration)) {
// Handle the case where the symbol is for a class value (constructor function)
// Get the instance type of the class
const instanceType = this.checker.getDeclaredTypeOfSymbol(classSymbol);
if (instanceType && instanceType !== type) {
try {
baseTypes = (this.checker as any).getBaseTypes?.(instanceType as ts.InterfaceType);
} catch (e) {
// getBaseTypes might fail, fall back to declaration-based extraction
}
}
}
}
if (baseTypes && baseTypes.length > 0) {
// For classes, the first base type is usually the superclass
// Additional base types are interfaces
if (classType.classKind === Type.Class.Kind.Class) {
const firstBase = this.getType(baseTypes[0]);
// Handle both Class and Parameterized (e.g., Component)
if (Type.isClass(firstBase)) {
(classType as any).supertype = firstBase;
} else if (Type.isParameterized(firstBase)) {
// For parameterized types, use the base class as the supertype
(classType as any).supertype = (firstBase as Type.Parameterized).type;
}
// Rest are interfaces
for (let i = 1; i < baseTypes.length; i++) {
const interfaceType = this.getType(baseTypes[i]);
if (Type.isClass(interfaceType)) {
classType.interfaces.push(interfaceType);
}
}
} else {
// For interfaces, all base types are extended interfaces
for (const baseType of baseTypes) {
const interfaceType = this.getType(baseType);
if (Type.isClass(interfaceType)) {
classType.interfaces.push(interfaceType);
}
}
}
}
}
// Extract type parameters from declarations (not provided by getBaseTypes)
if (symbol?.declarations) {
for (const declaration of symbol.declarations) {
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
// Extract type parameters
if (declaration.typeParameters) {
for (const tp of declaration.typeParameters) {
const tpType = this.checker.getTypeAtLocation(tp);
classType.typeParameters.push(this.getType(tpType));
}
}
break; // Only process the first declaration
}
}
}
// Get properties and methods
const properties = this.checker.getPropertiesOfType(type);
for (const prop of properties) {
const declaration = prop.valueDeclaration || prop.declarations?.[0];
if (!declaration) {
// Skip properties without declarations (synthetic/built-in properties)
continue;
}
if (prop.flags & ts.SymbolFlags.Method) {
// TODO: Create Type.Method when method support is added
// For now, skip methods
continue;
} else {
// Create Type.Variable for fields/properties
const propType = this.checker.getTypeOfSymbolAtLocation(prop, declaration);
const variable: Type.Variable = {
kind: Type.Kind.Variable,
name: prop.getName(),
owner: classType, // Cyclic reference to the containing class (already in cache)
type: this.getType(propType), // This will find classType in cache if it's recursive
annotations: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Variable;
classType.members.push(variable);
}
}
}
/**
* Note: Object/Class/Interface types are handled in getType() to properly manage circular references
* This method should only be called for primitive and unknown types
*/
private createPrimitiveOrUnknownType(type: ts.Type): Type {
// Check for literals first
if (type.isLiteral()) {
if (type.isNumberLiteral()) {
return Type.Primitive.Double;
} else if (type.isStringLiteral()) {
return Type.Primitive.String;
}
}
// Check for primitive types
// Note: Using bitwise & instead of === for robustness, as TypeScript may assign
// multiple flags to a single type (e.g., Boolean + Union)
if (type.flags & ts.TypeFlags.Null) {
return Type.Primitive.Null;
} else if (type.flags & ts.TypeFlags.Undefined) {
return Type.Primitive.None;
} else if (type.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral | ts.TypeFlags.NumberLike)) {
return Type.Primitive.Double;
} else if (type.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral | ts.TypeFlags.StringLike)) {
return Type.Primitive.String;
} else if (type.flags & ts.TypeFlags.Void) {
return Type.Primitive.Void;
} else if (type.flags & (ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral | ts.TypeFlags.BigIntLike)) {
return Type.Primitive.BigInt;
} else if (type.symbol !== undefined && type.symbol === this.regExpSymbol) {
return Type.Primitive.String;
} else if (type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.BooleanLike)) {
return Type.Primitive.Boolean;
}
// Check for type aliases that may resolve to primitives
const symbol = type.getSymbol?.();
if (symbol && symbol.flags & ts.SymbolFlags.TypeAlias) {
// Type aliases may resolve to primitive types
const aliasedType = this.checker.getDeclaredTypeOfSymbol(symbol);
if (aliasedType !== type) {
return this.getType(aliasedType);
}
}
return Type.unknownType;
}
/**
* Create a union type from TypeScript union type (e.g., string | number)
* Note: Cache check is done in getType() before calling this method
*/
private createUnionType(unionType: ts.UnionType, cacheKey: string | number): Type.Union {
// Shell-cache FIRST to prevent infinite recursion (before resolving constituent types)
const union = {
kind: Type.Kind.Union,
bounds: []
} as Type.Union;
this.typeCache.set(cacheKey, union);
// Now map all constituent types (may recursively reference this union)
const bounds: Type[] = [];
for (const constituentType of unionType.types) {
bounds.push(this.getType(constituentType));
}
// Update the bounds in the union we created
(union as any).bounds = bounds;
return union;
}
/**
* Create an intersection type from TypeScript intersection type (e.g., A & B)
* Note: Cache check is done in getType() before calling this method
*/
private createIntersectionType(intersectionType: ts.IntersectionType, cacheKey: string | number): Type.Intersection {
// Shell-cache FIRST to prevent infinite recursion (before resolving constituent types)
const intersection = {
kind: Type.Kind.Intersection,
bounds: []
} as Type.Intersection;
this.typeCache.set(cacheKey, intersection);
// Now map all constituent types (may recursively reference this intersection)
const bounds: Type[] = [];
for (const constituentType of intersectionType.types) {
bounds.push(this.getType(constituentType));
}
// Update the bounds in the intersection we created
(intersection as any).bounds = bounds;
return intersection;
}
/**
* Create a generic type variable from a TypeScript type parameter.
* Examples: T, K extends string, V extends keyof T
* Note: Cache check is done in getType() before calling this method
*/
private createGenericTypeVariable(typeParam: ts.TypeParameter, cacheKey: string | number): Type.GenericTypeVariable {
const symbol = typeParam.getSymbol();
const name = symbol ? symbol.getName() : '?';
// Shell-cache: Create stub, cache it, then populate (prevents cycles)
const gtv = {
kind: Type.Kind.GenericTypeVariable,
name: name,
variance: Type.GenericTypeVariable.Variance.Invariant,
bounds: []
} as Type.GenericTypeVariable;
this.typeCache.set(cacheKey, gtv);
// Get the constraint (upper bound) if it exists
const constraint = typeParam.getConstraint();
let bounds: Type[] = [];
let variance = Type.GenericTypeVariable.Variance.Invariant;
if (constraint) {
const boundType = this.getType(constraint);
// Only add bounds if it's not just "object" (the default constraint)
if (!(Type.isClass(boundType) && boundType.fullyQualifiedName === 'object')) {
bounds = [boundType];
variance = Type.GenericTypeVariable.Variance.Covariant;
}
}
// Update the variance and bounds
(gtv as any).variance = variance;
(gtv as any).bounds = bounds;
return gtv;
}
/**
* Create an empty function type shell with FQN 𝑓.
* The shell will be populated later to handle circular references.
*/
private createEmptyFunctionType(): Type.Class {
return {
kind: Type.Kind.Class,
flags: 0,
classKind: Type.Class.Kind.Interface,
fullyQualifiedName: FUNCTION_TYPE_NAME,
typeParameters: [],
annotations: [],
interfaces: [],
members: [],
methods: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Class;
}
/**
* Populate a function type with signature information.
* The function type has generic type parameters for return type (first) and parameter types (subsequent),
* and contains an apply() method with the matching signature.
* Since the shell is already in the cache, any recursive references will find it.
*/
private populateFunctionType(functionClass: Type.Class, signature: ts.Signature): void {
const returnType = this.getType(signature.getReturnType());
const parameters = signature.getParameters();
const parameterTypes: Type[] = [];
const parameterNames: string[] = [];
// Get parameter types
for (const param of parameters) {
const declaration = param.valueDeclaration || param.declarations?.[0];
if (declaration) {
const paramType = this.checker.getTypeOfSymbolAtLocation(param, declaration);
parameterTypes.push(this.getType(paramType));
parameterNames.push(param.getName());
}
}
// Build the type parameters list with proper variance:
// - Return type is covariant (R)
// - Parameter types are contravariant (P1, P2, ...)
const typeParameters: Type[] = [];
// Return type parameter (covariant)
typeParameters.push({
kind: Type.Kind.GenericTypeVariable,
name: 'R',
variance: Type.GenericTypeVariable.Variance.Covariant,
bounds: [returnType]
} as Type.GenericTypeVariable);
// Parameter type variables (contravariant)
parameterTypes.forEach((paramType, index) => {
typeParameters.push({
kind: Type.Kind.GenericTypeVariable,
name: `P${index + 1}`,
variance: Type.GenericTypeVariable.Variance.Contravariant,
bounds: [paramType]
} as Type.GenericTypeVariable);
});
functionClass.typeParameters = typeParameters;
// Create the apply() method
const applyMethod = {
kind: Type.Kind.Method,
flags: 0,
declaringType: functionClass,
name: 'apply',
returnType: returnType,
parameterNames: parameterNames,
parameterTypes: parameterTypes,
thrownExceptions: [],
annotations: [],
defaultValue: undefined,
declaredFormalTypeNames: [],
toJSON: function () {
return Type.signature(this);
}
} as Type.Method;
// Add the apply method to the function class
functionClass.methods.push(applyMethod);
}
}