/** * Parser module - AST parsing and import resolution */ import * as fs from 'fs' import * as path from 'path' import * as ts from 'typescript' import type { AnalyzedFile, TypeAliasInfo } from './types.js' /** * Parses a TypeScript declaration file and extracts type nodes from various contexts. * * Analyzes the file's AST to identify types that need normalization: * - Type alias declarations (`type Foo = ...`) * - Variable/const declarations with type annotations (`const X: Type`) * - Function parameter and return types * - Property signatures in interfaces and type literals * - Import and export declarations for dependency graph building * * @param filePath - Path to the .d.ts file to parse (relative or absolute) * @param verbose - Whether to output verbose logging * @returns Analyzed file containing source AST, type nodes, and import dependencies */ export function parseDeclarationFile( filePath: string, verbose = false, ): AnalyzedFile { const absolutePath = path.resolve(filePath) const sourceText = fs.readFileSync(absolutePath, 'utf-8') const sourceFile = ts.createSourceFile( absolutePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ) const typeAliases: TypeAliasInfo[] = [] const importedFiles: string[] = [] /** * Helper to add a type node for normalization, avoiding duplicates */ function addTypeNode(typeNode: ts.TypeNode): void { const originalText = typeNode.getText(sourceFile) const start = typeNode.getStart(sourceFile) const end = typeNode.getEnd() // Skip if we already have a type at this position (avoid duplicates) const isDuplicate = typeAliases.some( (existing) => existing.start === start && existing.end === end, ) if (isDuplicate) return typeAliases.push({ filePath: absolutePath, start, end, originalText, normalizedText: '', // Will be filled by normalizer node: typeNode, }) } // Recursive AST visitor that traverses all nodes in the syntax tree. // TypeScript's compiler API requires this pattern to explore all // declarations, statements, and type expressions in the file. function visit(node: ts.Node): void { // Extract import declarations if (ts.isImportDeclaration(node)) { const moduleSpecifier = node.moduleSpecifier if (ts.isStringLiteral(moduleSpecifier)) { const importPath = resolveImportPath(moduleSpecifier.text, absolutePath) if (importPath) { importedFiles.push(importPath) } else if (verbose && moduleSpecifier.text.startsWith('.')) { console.warn( ` ⚠ Could not resolve import: "${moduleSpecifier.text}" from ${absolutePath}`, ) } } } // Extract export declarations (export * from './foo') if (ts.isExportDeclaration(node) && node.moduleSpecifier) { if (ts.isStringLiteral(node.moduleSpecifier)) { const exportPath = resolveImportPath( node.moduleSpecifier.text, absolutePath, ) if (exportPath) { importedFiles.push(exportPath) } else if (verbose && node.moduleSpecifier.text.startsWith('.')) { console.warn( ` ⚠ Could not resolve export: "${node.moduleSpecifier.text}" from ${absolutePath}`, ) } } } // Extract type alias declarations: type Foo = ... if (ts.isTypeAliasDeclaration(node) && node.type) { addTypeNode(node.type) } // Extract variable declarations with type annotations: const X: Type = ... if (ts.isVariableDeclaration(node) && node.type) { addTypeNode(node.type) } // Extract function declarations with return types if (ts.isFunctionDeclaration(node) && node.type) { addTypeNode(node.type) } // Extract method declarations with return types if (ts.isMethodDeclaration(node) && node.type) { addTypeNode(node.type) } // Extract method signatures with return types (in interfaces) if (ts.isMethodSignature(node) && node.type) { addTypeNode(node.type) } // Extract property signatures with types (in interfaces) if (ts.isPropertySignature(node) && node.type) { addTypeNode(node.type) } // Extract property declarations with types (in classes) if (ts.isPropertyDeclaration(node) && node.type) { addTypeNode(node.type) } // Extract parameter declarations with types if (ts.isParameter(node) && node.type) { addTypeNode(node.type) } // Extract get/set accessors with types if (ts.isGetAccessorDeclaration(node) && node.type) { addTypeNode(node.type) } if (ts.isSetAccessorDeclaration(node)) { // Set accessors have parameters, handled by isParameter above } // Extract index signatures: [key: string]: Type if (ts.isIndexSignatureDeclaration(node) && node.type) { addTypeNode(node.type) } ts.forEachChild(node, visit) } visit(sourceFile) return { filePath: absolutePath, sourceFile, typeAliases, importedFiles, } } /** * Resolves a relative import specifier to an absolute file path. * * Handles TypeScript's ESM output where declaration files import `.js` files * but reference `.d.ts` files on disk. Tries multiple resolution strategies: * 1. Direct path + `.d.ts` * 2. Path + `/index.d.ts` * 3. Exact path (if already includes extension) * * @param importSpecifier - The import path (e.g., './types' or './types.js') * @param fromFile - Absolute path of the importing file * @returns Absolute path to the declaration file, or null if not found or non-relative * * @remarks * Returns null for non-relative imports (e.g., 'typescript', 'node:fs') since * those reference external packages, not local declaration files. */ function resolveImportPath( importSpecifier: string, fromFile: string, ): string | null { // Skip non-relative imports (e.g., 'typescript', '@types/node') if (!importSpecifier.startsWith('.')) { return null } const dir = path.dirname(fromFile) let resolved = path.resolve(dir, importSpecifier) // Strip .js extension if present (declaration files reference .js but exist as .d.ts) if (resolved.endsWith('.js')) { resolved = resolved.slice(0, -3) } // Try adding .d.ts extension if not present if (!resolved.endsWith('.d.ts')) { const candidates = [`${resolved}.d.ts`, `${resolved}/index.d.ts`] for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate } } } // Check if the exact path exists if (fs.existsSync(resolved)) { return resolved } return null } /** * Builds the complete file graph starting from an entry point. * * Performs a breadth-first traversal of the import graph, starting from the * entry point and following all relative imports to build a complete map of * all declaration files in the project. * * @param entryPoint - Path to the entry point declaration file * @param verbose - Whether to output verbose logging * @returns Map of absolute file paths to their analyzed content */ export function buildFileGraph( entryPoint: string, verbose = false, ): Map { const fileMap = new Map() const queue: string[] = [path.resolve(entryPoint)] const visited = new Set() while (queue.length > 0) { const currentFile = queue.shift() if (!currentFile) break // Explicit null check instead of non-null assertion if (visited.has(currentFile)) { continue } visited.add(currentFile) // Skip if file doesn't exist if (!fs.existsSync(currentFile)) { if (verbose) { console.warn(` ⚠ Skipping non-existent file: ${currentFile}`) } continue } const analyzed = parseDeclarationFile(currentFile, verbose) fileMap.set(currentFile, analyzed) // Add imported files to the queue for (const importedFile of analyzed.importedFiles) { if (!visited.has(importedFile)) { queue.push(importedFile) } } } return fileMap }