/*
* Copyright 2026 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 {JavaScriptTypeMapping} from "./type-mapping";
// Free functions are not exported-type entries; only nominal types (and value modules, which are
// descended into for nested types) qualify.
const TYPE_SYMBOL_FLAGS =
ts.SymbolFlags.Class |
ts.SymbolFlags.Interface |
ts.SymbolFlags.Enum |
ts.SymbolFlags.TypeAlias |
ts.SymbolFlags.ValueModule;
/**
* Enumerate the OWN public exported types of one or more npm packages into the engine's
* {@link Type} model. The JS analog of the C# {@code AssemblyTypeEnumerator.Enumerate}:
* a {@code ts.Program} is rooted at each package's declaration entry, the {@code references}
* closure supplies node_modules resolution, and the types each own package exports are
* mapped with full members and methods. Types reached transitively come back shallow.
*
* @param ownArtifacts package directories whose public types should be defined (full bodies)
* @param references node_modules roots / sibling packages used only for symbol resolution
*/
export function exportedTypes(ownArtifacts: string[], references: string[]): Type.FullyQualified[] {
const entries: string[] = [];
for (const artifact of ownArtifacts) {
const entry = declarationFileEntry(artifact) ?? resolvePackageEntry(artifact);
if (entry) {
entries.push(path.normalize(entry));
}
}
if (entries.length === 0) {
return [];
}
const compilerOptions: ts.CompilerOptions = {
target: ts.ScriptTarget.Latest,
module: ts.ModuleKind.CommonJS,
moduleResolution: ts.ModuleResolutionKind.Node10,
noEmit: true,
allowJs: true,
checkJs: false,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: false,
};
const host = ts.createCompilerHost(compilerOptions);
// Default node resolution from each file's on-disk location, falling back to the
// explicit reference roots when a dependency isn't co-located with the own package.
host.resolveModuleNameLiterals = (moduleLiterals, containingFile) =>
moduleLiterals.map(literal => {
let resolved = ts.resolveModuleName(literal.text, containingFile, compilerOptions, host);
if (!resolved.resolvedModule) {
for (const ref of references) {
const probe = ts.resolveModuleName(literal.text, path.join(ref, "__probe__.ts"), compilerOptions, host);
if (probe.resolvedModule) {
resolved = probe;
break;
}
}
}
return resolved;
});
const program = ts.createProgram(entries, compilerOptions, host);
const checker = program.getTypeChecker();
const typeMapping = new JavaScriptTypeMapping(checker);
// Lowest arity wins for a given FQN, mirroring the C# byFqn dedup.
const byFqn = new Map();
const collect = (cls: Type.Class): void => {
const fqn = cls.fullyQualifiedName;
if (!fqn || fqn === "unknown" || fqn === "" || fqn === Type.FUNCTION_TYPE_NAME) {
return;
}
const existing = byFqn.get(fqn);
if (!existing || arity(cls) < arity(existing)) {
byFqn.set(fqn, cls);
}
};
const mapSymbols = (symbols: ts.Symbol[]): void => {
for (const symbol of symbols) {
// Isolate per-symbol mapping: one throwing export must not abort the coordinate.
try {
const declNodes = classLikeDeclarations(symbol);
if (declNodes.length > 0) {
const cls = typeMapping.declarationType(declNodes[0]);
if (Type.isClass(cls)) {
// Walk every declaration so merged interfaces / class+namespace merges keep
// all their methods; populateMethods dedups by signature across the calls.
for (const declNode of declNodes) {
populateMethods(typeMapping, declNode, cls);
}
collect(cls);
}
} else {
// Type aliases / namespaces: keep only those that resolve to a nominal class.
const mapped = typeMapping.exportedType(symbol);
if (Type.isClass(mapped)) {
collect(mapped);
}
}
} catch (e) {
console.warn(`exportedTypes: skipping export '${symbol.getName()}': ${e}`);
}
}
};
for (const entry of entries) {
const sourceFile = program.getSourceFile(entry);
if (!sourceFile) {
continue;
}
if (ts.isExternalModule(sourceFile)) {
const moduleSymbol = checker.getSymbolAtLocation(sourceFile) ?? (sourceFile as { symbol?: ts.Symbol }).symbol;
if (moduleSymbol) {
mapSymbols(enumerateTypeSymbols(checker, moduleSymbol));
}
continue;
}
// A global script (lib.*.d.ts / ambient .d.ts): globals live in global scope, not on a
// module symbol. Enumerate top-level ambient declarations + `declare module` ambients.
mapSymbols(globalTypeSymbols(checker, sourceFile));
for (const ambient of checker.getAmbientModules()) {
if (ambient.declarations?.some(d => d.getSourceFile() === sourceFile)) {
mapSymbols(enumerateTypeSymbols(checker, ambient));
}
}
}
return [...byFqn.values()];
}
function arity(cls: Type.Class): number {
return cls.typeParameters?.length ?? 0;
}
/** A TypeScript declaration/source FILE (e.g. a `lib.*.d.ts` of ambient globals), used directly as a program entry. */
function declarationFileEntry(artifact: string): string | undefined {
return ts.sys.fileExists(artifact) && /\.[cm]?tsx?$/.test(artifact) ? artifact : undefined;
}
/** Top-level ambient type declarations of a script (non-module) file — a `lib.*.d.ts`'s globals; namespaces descended for nested types. */
function globalTypeSymbols(checker: ts.TypeChecker, sourceFile: ts.SourceFile): ts.Symbol[] {
const out: ts.Symbol[] = [];
const seen = new Set();
const push = (name: ts.Node | undefined): void => {
const symbol = name ? checker.getSymbolAtLocation(name) : undefined;
if (symbol && !seen.has(symbol)) {
seen.add(symbol);
out.push(symbol);
}
};
const visit = (node: ts.Node): void => {
if (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node) ||
ts.isEnumDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
push(node.name);
} else if (ts.isModuleDeclaration(node)) {
push(node.name);
if (node.body && ts.isModuleBlock(node.body)) {
node.body.statements.forEach(visit);
}
}
};
sourceFile.statements.forEach(visit);
return out;
}
/**
* Resolve a package directory to its declaration entry: the package.json `types`/`typings`
* field, else the `exports["."]` types condition (modern packages declare types only there),
* else a top-level `index.d.ts` (then `index.ts`).
*/
function resolvePackageEntry(dir: string): string | undefined {
const packageJson = path.join(dir, "package.json");
if (ts.sys.fileExists(packageJson)) {
try {
const pkg = JSON.parse(ts.sys.readFile(packageJson) || "{}");
const typesField: unknown = pkg.types ?? pkg.typings;
const candidate = typeof typesField === "string"
? typesField
: exportsTypesEntry(pkg.exports);
if (typeof candidate === "string") {
const resolved = path.resolve(dir, candidate);
if (ts.sys.fileExists(resolved)) {
return resolved;
}
if (ts.sys.fileExists(resolved + ".d.ts")) {
return resolved + ".d.ts";
}
}
} catch {
// Malformed package.json; fall through to conventional entry points.
}
}
for (const candidate of ["index.d.ts", "index.ts", "index.tsx"]) {
const resolved = path.join(dir, candidate);
if (ts.sys.fileExists(resolved)) {
return resolved;
}
}
return undefined;
}
/** The declaration file for the package's `.` subpath from its `exports` map, if any. */
function exportsTypesEntry(exports: unknown): string | undefined {
if (typeof exports === "string") {
return typesFromCondition(exports);
}
if (!exports || typeof exports !== "object") {
return undefined;
}
const map = exports as Record;
// `exports["."]`, or the whole object when it is condition-only sugar (no subpath keys).
const dot = "." in map
? map["."]
: (Object.keys(map).some(k => k.startsWith(".")) ? undefined : map);
return dot === undefined ? undefined : typesFromCondition(dot);
}
/**
* Pull a declaration file out of an `exports` condition value: an explicit `types` condition,
* else the `types` nested under `import`/`require`/`default`. A bare target string is a runtime
* (JS) entry, not types, unless it is itself a declaration file.
*/
function typesFromCondition(entry: unknown): string | undefined {
if (typeof entry === "string") {
return /\.d\.[cm]?ts$/.test(entry) ? entry : undefined;
}
if (!entry || typeof entry !== "object") {
return undefined;
}
const o = entry as Record;
if (typeof o.types === "string") {
return o.types;
}
const nestedTypes = typesFromCondition(o.types);
if (nestedTypes) {
return nestedTypes;
}
for (const cond of ["import", "require", "default"]) {
const nested = typesFromCondition(o[cond]);
if (nested) {
return nested;
}
}
return undefined;
}
/**
* The exported symbols of a module that name a type, resolving aliases and descending one
* level into re-exported namespaces (e.g. `export = SomeNamespace`) to reach nested types.
*/
function enumerateTypeSymbols(checker: ts.TypeChecker, moduleSymbol: ts.Symbol): ts.Symbol[] {
const out: ts.Symbol[] = [];
const seen = new Set();
const queue: ts.Symbol[] = [...moduleExports(checker, moduleSymbol)];
while (queue.length > 0) {
let symbol = queue.shift()!;
if (symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol);
}
if (seen.has(symbol)) {
continue;
}
seen.add(symbol);
if (symbol.flags & TYPE_SYMBOL_FLAGS) {
out.push(symbol);
}
if (symbol.flags & (ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule)) {
for (const nested of moduleExports(checker, symbol)) {
queue.push(nested);
}
}
}
return out;
}
function moduleExports(checker: ts.TypeChecker, symbol: ts.Symbol): ts.Symbol[] {
try {
return checker.getExportsOfModule(symbol);
} catch {
const members: ts.Symbol[] = [];
symbol.exports?.forEach(s => members.push(s));
return members;
}
}
// All class/interface/enum declarations of a symbol. Returns more than one for declaration
// merging (e.g. two `interface Req {...}` blocks, or a class merged with a namespace).
function classLikeDeclarations(
symbol: ts.Symbol
): Array {
const out: Array = [];
for (const declaration of symbol.declarations ?? []) {
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isEnumDeclaration(declaration)) {
out.push(declaration);
}
}
return out;
}
/**
* Populate `classType.methods`, which the normal mapping path leaves empty. Scoped to the
* enumerator so parser output is unchanged: walk the declaration's method/constructor nodes,
* map each via the public {@link JavaScriptTypeMapping.methodType}, and dedup by signature.
*/
function populateMethods(
typeMapping: JavaScriptTypeMapping,
node: ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration,
classType: Type.Class
): void {
if (!ts.isClassDeclaration(node) && !ts.isInterfaceDeclaration(node)) {
return;
}
const seen = new Set(classType.methods.map(m => Type.signature(m)));
for (const member of node.members) {
if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member) || ts.isConstructorDeclaration(member)) {
const method = typeMapping.methodType(member);
if (!method) {
continue;
}
method.declaringType = classType;
const sig = Type.signature(method);
if (seen.has(sig)) {
continue;
}
seen.add(sig);
classType.methods.push(method);
}
}
}