/** * @angular-modernizer/plugin-angular - Import Cleanup Helper * * Helper for managing imports in focused services after Service Bag transformation. * Works WITH ImportManager, SymbolLocator, and DependencyAnalyzer from API. * * Philosophy: "Smart Import Management" * - Analyzes what types/dependencies are actually used in code * - Adds required imports for dependencies and types * - Removes unused imports from original service * - Resolves correct import paths via SymbolLocator * * Use Case: Service Bag Transform * - Add imports to focused services (dependencies + types) * - Remove unused imports from original service after splitting * - Preserve imports still used in original service */ import type { ClassDeclaration, SourceFile } from 'ts-morph'; import type { ImportManager, SymbolLocator } from '@angular-modernizer/api'; import type { ConstructorDependency } from './constructor-dependency-helper.js'; import { MethodBodyAnalyzer } from './method-body-analyzer.js'; /** * Represents information about imports to add to a file. */ export interface ImportsToAdd { /** * Named imports to add (e.g., 'HttpClient', 'Observable'). * Supports aliases: { name: 'Model', alias: 'DomainModel', moduleSpecifier: './model' } */ namedImports: { name: string; moduleSpecifier: string; alias?: string; kind?: 'named' | 'namespace' | 'default'; }[]; /** * Type imports to add (e.g., 'User', 'Order'). * Supports aliases: { name: 'User', alias: 'UserModel', moduleSpecifier: './user' } */ typeImports: { name: string; moduleSpecifier: string; alias?: string; kind?: 'named' | 'namespace' | 'default'; }[]; } /** * Represents information about imports to remove from a file. */ export interface ImportsToRemove { /** * Import names to remove (e.g., 'HttpClient', 'Observable'). */ importNames: string[]; } /** * Import Cleanup Helper - manages imports in focused and original services. * * This helper works WITH the existing ImportManager and SymbolLocator from @angular-modernizer/api. * It focuses on analyzing what imports are needed and orchestrating the add/remove operations. * * @example * ```typescript * const helper = new ImportCleanupHelper(importManager, symbolLocator); * * // Add imports to focused service * const importsToAdd = helper.analyzeRequiredImports( * focusedServiceClass, * dependencies, * originalServicePath * ); * helper.addImports(focusedServiceFile, importsToAdd); * * // Remove unused imports from original service * const importsToRemove = helper.analyzeUnusedImports( * originalServiceClass, * ['HttpClient', 'Observable'] * ); * helper.removeImports(originalServiceFile, importsToRemove); * ``` */ export class ImportCleanupHelper { private readonly methodBodyAnalyzer = new MethodBodyAnalyzer(); constructor( private readonly importManager: ImportManager, private readonly symbolLocator: SymbolLocator, ) {} /** * Analyzes what imports are required for a focused service. * * Determines: * 1. Dependency imports (HttpClient, LoggerService, etc.) * 2. Type imports used in method signatures (User, Order, etc.) * 3. Correct module specifiers via SymbolLocator * * @param classDecl - Focused service class declaration * @param dependencies - Constructor dependencies used by this service * @param originalServiceFile - Original service source file (for relative imports) * @returns Object with named imports and type imports to add * * @example * ```typescript * const importsToAdd = helper.analyzeRequiredImports( * dataRetrievalServiceClass, * [{ name: 'http', type: 'HttpClient', ... }], * originalSourceFile * ); * // Result: { * // namedImports: [{ name: 'HttpClient', moduleSpecifier: '@angular/common/http' }], * // typeImports: [{ name: 'User', moduleSpecifier: './user.model' }] * // } * ``` */ analyzeRequiredImports( classDecl: ClassDeclaration, dependencies: ConstructorDependency[], originalServiceFile: SourceFile, ): ImportsToAdd { const namedImports: { name: string; moduleSpecifier: string; alias?: string; kind?: 'named' | 'namespace' | 'default'; }[] = []; const typeImports: { name: string; moduleSpecifier: string; alias?: string; kind?: 'named' | 'namespace' | 'default'; }[] = []; // 1. Add dependency imports (constructor parameters) for (const dep of dependencies) { const moduleSpecifier = this.resolveModuleSpecifier( dep.type, originalServiceFile, classDecl.getSourceFile(), ); if (moduleSpecifier) { // Check if this type is imported with an alias const aliasInfo = this.findAliasInfo(dep.type, originalServiceFile); namedImports.push({ name: aliasInfo?.originalName ?? dep.type, moduleSpecifier, alias: aliasInfo?.alias, }); } } // 2. Add type imports (used in method signatures) const typeReferences = this.extractTypeReferences(classDecl); for (const typeRef of typeReferences) { const moduleSpecifier = this.resolveModuleSpecifier( typeRef, originalServiceFile, classDecl.getSourceFile(), ); if (moduleSpecifier) { // Check if this type is imported with an alias const aliasInfo = this.findAliasInfo(typeRef, originalServiceFile); typeImports.push({ name: aliasInfo?.originalName ?? typeRef, moduleSpecifier, alias: aliasInfo?.alias, }); } } // 3. Add imports for identifiers used in method bodies (e.g., RxJS operators, utility functions) const bodyIdentifiers = this.extractMethodBodyIdentifiers( classDecl, originalServiceFile, ); for (const { name, moduleSpecifier, alias, kind } of bodyIdentifiers) { // Avoid duplicates (check name, moduleSpecifier, alias, and kind) if ( !namedImports.some( (imp) => imp.name === name && imp.moduleSpecifier === moduleSpecifier && imp.alias === alias && imp.kind === kind, ) ) { namedImports.push({ name, moduleSpecifier, alias, kind }); } } // 4. Always add Injectable decorator if (!namedImports.some((imp) => imp.name === 'Injectable')) { namedImports.push({ name: 'Injectable', moduleSpecifier: '@angular/core', }); } return { namedImports, typeImports }; } /** * Analyzes what imports are no longer used in the original service. * * After splitting, some imports may no longer be needed in the original service. * This method identifies which imports can be safely removed by checking ALL imports * in the source file, not just moved dependencies. * * @param classDecl - Original service class declaration * @param movedDependencies - Dependency types that were moved to focused services (for reference) * @returns Object with import names to remove * * @example * ```typescript * const importsToRemove = helper.analyzeUnusedImports( * originalServiceClass, * ['HttpClient'] // Moved constructor dependencies * ); * // Result: { importNames: ['HttpClient', 'Observable', 'map'] } // All unused imports * ``` */ analyzeUnusedImports( classDecl: ClassDeclaration, _movedDependencies: string[], ): ImportsToRemove { const importNames: string[] = []; // Get source file to check all imports const sourceFile = classDecl.getSourceFile(); // Get all imports in source file const allImports = sourceFile.getImportDeclarations(); // Check each import to see if it's still used for (const importDecl of allImports) { const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { const importName = namedImport.getName(); const alias = namedImport.getAliasNode()?.getText(); // Skip Angular core imports (Injectable, etc.) - always keep these const moduleSpec = importDecl.getModuleSpecifierValue(); if (moduleSpec === '@angular/core') { continue; } // AST-based check: Is this import still used in the class? // Check both original name AND alias (if present) let isUsed = false; // Helper function to check if a text contains the import (original or alias) const containsImport = (text: string): boolean => { if (text.includes(importName)) { return true; } if (alias && text.includes(alias)) { return true; } return false; }; // 1. Check constructor parameters const constructors = classDecl.getConstructors(); for (const ctor of constructors) { for (const param of ctor.getParameters()) { const paramTypeText = param.getType().getText(); if (containsImport(paramTypeText)) { isUsed = true; break; } } if (isUsed) { break; } } // 2. Check method signatures (return types + parameters) if (!isUsed) { const methods = classDecl.getMethods(); for (const method of methods) { // Check return type const returnTypeText = method.getReturnType().getText(); if (containsImport(returnTypeText)) { isUsed = true; break; } // Check parameter types for (const param of method.getParameters()) { const paramTypeText = param.getType().getText(); if (containsImport(paramTypeText)) { isUsed = true; break; } } if (isUsed) { break; } } } // 3. Check properties if (!isUsed) { const properties = classDecl.getProperties(); for (const prop of properties) { const propTypeText = prop.getType().getText(); if (containsImport(propTypeText)) { isUsed = true; break; } } } // 4. Check method bodies for usage (especially important for aliases) if (!isUsed) { const methods = classDecl.getMethods(); for (const method of methods) { const bodyText = method.getBodyText(); if (bodyText && containsImport(bodyText)) { isUsed = true; break; } } } // If not used anywhere, mark for removal if (!isUsed) { importNames.push(importName); } } } return { importNames }; } /** * Adds imports to a source file using ImportManager. * * Adds both named imports (classes, functions) and type imports (interfaces, types). * Uses ImportManager to handle the actual AST manipulation. * Supports aliased imports: `import { Model as DomainModel } from './model'` * * @param sourceFile - Source file to add imports to * @param imports - Imports to add * * @example * ```typescript * helper.addImports(focusedServiceFile, { * namedImports: [ * { name: 'HttpClient', moduleSpecifier: '@angular/common/http' }, * { name: 'Injectable', moduleSpecifier: '@angular/core' }, * { name: 'Model', alias: 'DomainModel', moduleSpecifier: './model' } * ], * typeImports: [ * { name: 'User', moduleSpecifier: './user.model' } * ] * }); * ``` */ addImports(sourceFile: SourceFile, imports: ImportsToAdd): void { // Add named imports for (const { name, moduleSpecifier, alias, kind = 'named', } of imports.namedImports) { if (kind === 'default') { // Default import: import moment from 'moment' let importDecl = sourceFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = sourceFile.addImportDeclaration({ moduleSpecifier, defaultImport: name, }); } else if (!importDecl.getDefaultImport()) { importDecl.setDefaultImport(name); } } else if (kind === 'namespace') { // Namespace import: import * as _ from 'lodash' let importDecl = sourceFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = sourceFile.addImportDeclaration({ moduleSpecifier, namespaceImport: name, }); } else if (!importDecl.getNamespaceImport()) { importDecl.setNamespaceImport(name); } } else if (alias) { // Named import with alias: import { Model as DomainModel } let importDecl = sourceFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = sourceFile.addImportDeclaration({ moduleSpecifier, }); } // Check if this specific named import already exists const existingNamedImport = importDecl .getNamedImports() .find( (ni) => ni.getName() === name && ni.getAliasNode()?.getText() === alias, ); if (!existingNamedImport) { importDecl.addNamedImport({ name, alias }); } } else { // Named import: import { map } from 'rxjs/operators' this.importManager.addNamedImport(sourceFile, moduleSpecifier, name); } } // Add type imports for (const { name, moduleSpecifier, alias, kind = 'named', } of imports.typeImports) { if (kind === 'default') { // Default import: import moment from 'moment' let importDecl = sourceFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = sourceFile.addImportDeclaration({ moduleSpecifier, defaultImport: name, }); } else if (!importDecl.getDefaultImport()) { importDecl.setDefaultImport(name); } } else if (kind === 'namespace') { // Namespace import: import * as _ from 'lodash' let importDecl = sourceFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = sourceFile.addImportDeclaration({ moduleSpecifier, namespaceImport: name, }); } else if (!importDecl.getNamespaceImport()) { importDecl.setNamespaceImport(name); } } else if (alias) { // Named import with alias let importDecl = sourceFile.getImportDeclaration(moduleSpecifier); if (!importDecl) { importDecl = sourceFile.addImportDeclaration({ moduleSpecifier, }); } const existingNamedImport = importDecl .getNamedImports() .find( (ni) => ni.getName() === name && ni.getAliasNode()?.getText() === alias, ); if (!existingNamedImport) { importDecl.addNamedImport({ name, alias }); } } else { // Named import this.importManager.addNamedImport(sourceFile, moduleSpecifier, name); } } } /** * Removes imports from a source file using ImportManager. * * Removes imports that are no longer used in the file. * Uses ImportManager to handle the actual AST manipulation. * * @param sourceFile - Source file to remove imports from * @param imports - Imports to remove * * @example * ```typescript * helper.removeImports(originalServiceFile, { * importNames: ['HttpClient', 'Observable'] * }); * ``` */ removeImports(sourceFile: SourceFile, imports: ImportsToRemove): void { for (const importName of imports.importNames) { this.importManager.removeNamedImport(sourceFile, importName); } } /** * Extracts type references from method signatures in a class. * * Finds types used in: * - Return types: `getData(): Observable` * - Parameter types: `processUser(user: User)` * - Generic types: `Observable`, `Array` * * @param classDecl - Class declaration to analyze * @returns Array of type names * @private */ private extractTypeReferences(classDecl: ClassDeclaration): string[] { const typeReferences = new Set(); // Get all methods const methods = classDecl.getMethods(); for (const method of methods) { // Analyze return type const returnType = method.getReturnType(); this.extractTypesFromTypeNode(returnType.getText(), typeReferences); // Analyze parameter types for (const param of method.getParameters()) { const paramType = param.getType(); this.extractTypesFromTypeNode(paramType.getText(), typeReferences); } } return Array.from(typeReferences); } /** * Extracts type names from a type node text. * * Handles: * - Simple types: `User` → ['User'] * - Generic types: `Observable` → ['Observable', 'User'] * - Union types: `User | Admin` → ['User', 'Admin'] * - Array types: `User[]` → ['User'] * * @param typeText - Type node text * @param typeReferences - Set to add type references to * @private */ private extractTypesFromTypeNode( typeText: string, typeReferences: Set, ): void { // Remove common primitives and built-in types const builtInTypes = new Set([ 'string', 'number', 'boolean', 'void', 'any', 'unknown', 'never', 'undefined', 'null', 'Array', 'Promise', 'Date', 'RegExp', ]); // Extract type names (simple pattern - handles most cases) const typePattern = /\b([A-Z][a-zA-Z0-9]*)\b/g; const matches = typeText.matchAll(typePattern); for (const match of matches) { const typeName = match[1]; if (typeName && !builtInTypes.has(typeName)) { typeReferences.add(typeName); } } } /** * Finds alias information for a type name in the original service file. * * Checks if the given type name is imported with an alias in the original service. * For example, if the original service has `import { Model as DomainModel }`, * calling this with 'DomainModel' returns { originalName: 'Model', alias: 'DomainModel' }. * * @param typeName - Type name to check (could be original name or alias) * @param originalServiceFile - Original service source file * @returns Alias info if found, undefined otherwise * @private */ private findAliasInfo( typeName: string, originalServiceFile: SourceFile, ): { originalName: string; alias: string } | undefined { const imports = originalServiceFile.getImportDeclarations(); for (const importDecl of imports) { const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { const originalName = namedImport.getName(); const alias = namedImport.getAliasNode()?.getText(); // Check if typeName matches the alias or original name if (alias && (alias === typeName || originalName === typeName)) { return { originalName, alias }; } } } return undefined; } /** * Extracts identifiers used in method bodies and maps them to their module specifiers. * * Scans all method bodies for identifiers (function calls, property accesses) and * maps them back to their import declarations in the original service file. * * Handles: * - Named imports: `import { map, filter } from 'rxjs/operators'` * - Namespace imports: `import * as _ from 'lodash'` * - Default imports: `import moment from 'moment'` * - Aliased imports: `import { Model as DomainModel } from './model'` * * Examples: * - RxJS operators: `.pipe(map(...))` → `{ name: 'map', moduleSpecifier: 'rxjs/operators' }` * - Utility functions: `parseData(...)` → `{ name: 'parseData', moduleSpecifier: './utils' }` * - Aliased imports: `new DomainModel()` → `{ name: 'Model', alias: 'DomainModel', moduleSpecifier: './model' }` * * @param classDecl - Class declaration to analyze * @param originalServiceFile - Original service source file to resolve imports from * @returns Array of identifiers with their module specifiers and optional aliases * @private */ private extractMethodBodyIdentifiers( classDecl: ClassDeclaration, originalServiceFile: SourceFile, ): { name: string; moduleSpecifier: string; alias?: string; kind: 'named' | 'namespace' | 'default'; }[] { // Use AST-based analyzer instead of regex const identifiers = this.methodBodyAnalyzer.analyzeMethodBodies( classDecl, originalServiceFile, ); return identifiers.map((id) => ({ name: id.name, moduleSpecifier: id.moduleSpecifier, alias: id.alias, kind: id.kind, })); } /** * Resolves module specifier for a type using SymbolLocator. * * Determines correct import path (relative or absolute). * Falls back to original file's imports if not found in symbol map. * * @param typeName - Type name to resolve (e.g., 'HttpClient', 'User') * @param originalServiceFile - Original service source file * @param focusedServiceFile - Focused service source file to check for existing imports * @returns Module specifier or undefined if not found * @private */ private resolveModuleSpecifier( typeName: string, originalServiceFile: SourceFile, focusedServiceFile: SourceFile, ): string | undefined { // Try to find in original service's existing imports first // Check both original name AND alias const existingImports = originalServiceFile.getImportDeclarations(); for (const importDecl of existingImports) { const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { const name = namedImport.getName(); const alias = namedImport.getAliasNode()?.getText(); // Match either the original name or the alias if (name === typeName || alias === typeName) { return importDecl.getModuleSpecifierValue(); } } } // Use SymbolLocator to build symbol map and resolve const project = focusedServiceFile.getProject(); const symbolMap = this.symbolLocator.buildProjectSymbolMap(project); const symbolInfo = symbolMap.get(typeName); if (symbolInfo) { return this.symbolLocator.calculateRelativeImportPath( focusedServiceFile, symbolInfo.sourceFile, ); } // Common Angular imports const angularImports: Record = { Injectable: '@angular/core', HttpClient: '@angular/common/http', Observable: 'rxjs', Subject: 'rxjs', BehaviorSubject: 'rxjs', }; return angularImports[typeName]; } }