/** * @angular-modernizer/plugin-angular - Service Injection Cleanup Orchestrator * * The central "brain" of the service injection cleanup transformation plugin. * Orchestrates the cleanup of manual service instantiations to use dependency injection. * * Philosophy: "API-Driven Orchestration" * - Receives all capabilities via TransformContext * - Uses PublicApi for all AST operations * - Stateless - no constructor dependencies * - Pure business logic, no low-level manipulation */ import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import { SyntaxKind, type SourceFile, type ClassDeclaration, type BinaryExpression, type ExpressionStatement, type Expression, type Block, } from 'ts-morph'; /** * Service Injection Cleanup Orchestrator * * @remarks * The central orchestrator for cleaning up manual service instantiations and converting * them to proper dependency injection using the inject() function. This class demonstrates * the "API-Driven Plugin" pattern where all capabilities are received via context objects * rather than constructor injection. * * ## Architecture Pattern * * This orchestrator follows three core principles: * 1. **Stateless Design** - No constructor dependencies * 2. **Context-Driven** - All capabilities injected via {@link TransformContext} * 3. **API-First** - Uses {@link PublicApi} for all AST operations * * ## Transformation Workflow * * The orchestrator performs the following steps: * 1. Find classes with manual service instantiations (`new Service()`) * 2. Identify service types using common Angular service patterns * 3. Convert manual instantiations to `inject(Service)` calls * 4. Remove manual instantiation statements from constructors and methods * 5. Clean up empty constructors and ensure proper imports * * ## Example Usage * * ```typescript * const orchestrator = new ServiceInjectionCleanupOrchestrator(); * const context = ContextFactory.createTransformContext({ * sourceFile, * project, * api, * config: {} * }); * orchestrator.run(context); * ``` * * @see {@link TransformContext} for context structure * @see {@link PublicApi} for available API tools * * @public */ export class ServiceInjectionCleanupOrchestrator { /** * Creates a new ServiceInjectionCleanupOrchestrator instance. * * @remarks * The constructor is intentionally stateless. All dependencies are provided * through the {@link TransformContext} passed to the {@link run} method. * This enables testing, composition, and prevents tight coupling. * * @public */ constructor() { // Intentionally empty - follows the new architecture pattern } /** * Executes the service injection cleanup transformation on a single source file. * * @remarks * This method is the main entry point for the orchestrator. It receives all * necessary dependencies (project, API, config) through the context parameter, * scans the file for classes with manual service instantiations, and converts * them to proper dependency injection. * * The method operates on one source file at a time and is safe to call * multiple times. It will: * - Find all classes in the file * - Identify manual service instantiations * - Convert them to inject() calls * - Clean up constructors and methods * - Ensure proper imports are in place * * ## Transformation Behavior * * **Property Initializers**: * ```typescript * // Before * private httpClient = new HttpClient(); * * // After * private httpClient = inject(HttpClient); * ``` * * **Constructor Assignments**: * ```typescript * // Before * constructor() { * this.userService = new UserService(); * } * * // After * private userService = inject(UserService); * ``` * * **Method Assignments**: * ```typescript * // Before * ngOnInit() { * this.logger = new LoggerService(); * } * * // After * private logger = inject(LoggerService); * ``` * * @param context - The transformation context containing: * - `sourceFile`: The TypeScript source file to transform * - `project`: The ts-morph Project for cross-file analysis * - `api`: The PublicApi with analysis and transformation tools * - `config`: User-provided configuration options * * @example * ```typescript * // Basic usage * orchestrator.run(context); * * // With custom configuration * const context = ContextFactory.createTransformContext({ * sourceFile, * project, * api, * config: { servicePatterns: ['CustomService$'] } * }); * orchestrator.run(context); * ``` * * @see {@link TransformContext} for context structure * * @public */ run(context: TransformContext): void { const { sourceFile } = context; const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { this.processClass(classDecl, context); } } /** * Process a single class for service injection cleanup. * * @param classDecl - The class declaration to process * @param context - Transform context with PublicApi access * * @private */ private processClass( classDecl: ClassDeclaration, context: TransformContext, ): void { // Find all manual service instantiations const manualInstantiations = this.findManualServiceInstantiations( classDecl, context, ); if (manualInstantiations.length === 0) { return; } // Convert manual instantiations to inject() calls this.convertToInjectCalls(classDecl, manualInstantiations); // Remove empty constructors if they only contained manual instantiations this.removeEmptyConstructors(classDecl); // Add inject import if not present this.ensureInjectImport(classDecl.getSourceFile()); } /** * Find all manual service instantiations in a class. * * @param classDecl - The class declaration to analyze * @param context - Transform context with PublicApi access * @returns Array of manual instantiation details * * @private */ private findManualServiceInstantiations( classDecl: ClassDeclaration, context: TransformContext, ): { propertyName: string; serviceType: string; isInConstructor: boolean; isInProperty: boolean; isInMethod: boolean; }[] { const instantiations: { propertyName: string; serviceType: string; isInConstructor: boolean; isInProperty: boolean; isInMethod: boolean; }[] = []; // Check property declarations const properties = classDecl.getProperties(); for (const prop of properties) { const initializer = prop.getInitializer(); if ( initializer && this.isManualServiceInstantiation(initializer, context) ) { const serviceType = this.getServiceTypeFromInstantiation(initializer); if (serviceType) { instantiations.push({ propertyName: prop.getName(), serviceType, isInConstructor: false, isInProperty: true, isInMethod: false, }); } } } // Check constructor const constructor = classDecl.getConstructors()[0]; if (constructor) { const body = constructor.getBody() as Block | undefined; const statements = body?.getStatements() ?? []; for (const statement of statements) { if (statement.getKind() === SyntaxKind.ExpressionStatement) { const expr = (statement as ExpressionStatement).getExpression(); if (expr.getKind() === SyntaxKind.BinaryExpression) { const binaryExpr = expr as BinaryExpression; if ( binaryExpr.getOperatorToken().getKind() === SyntaxKind.EqualsToken ) { const right = binaryExpr.getRight(); if (this.isManualServiceInstantiation(right, context)) { const left = binaryExpr.getLeft(); if (left.getKind() === SyntaxKind.PropertyAccessExpression) { const propertyName = left.getText().split('.')[1]; const serviceType = this.getServiceTypeFromInstantiation(right); if (serviceType && propertyName) { instantiations.push({ propertyName, serviceType, isInConstructor: true, isInProperty: false, isInMethod: false, }); } } } } } } } } // Check methods (like ngOnInit) const methods = classDecl.getMethods(); for (const method of methods) { const body = method.getBody() as Block | undefined; const statements = body?.getStatements() ?? []; for (const statement of statements) { if (statement.getKind() === SyntaxKind.ExpressionStatement) { const expr = (statement as ExpressionStatement).getExpression(); if (expr.getKind() === SyntaxKind.BinaryExpression) { const binaryExpr = expr as BinaryExpression; if ( binaryExpr.getOperatorToken().getKind() === SyntaxKind.EqualsToken ) { const right = binaryExpr.getRight(); if (this.isManualServiceInstantiation(right, context)) { const left = binaryExpr.getLeft(); if (left.getKind() === SyntaxKind.PropertyAccessExpression) { const propertyName = left.getText().split('.')[1]; const serviceType = this.getServiceTypeFromInstantiation(right); if (serviceType && propertyName) { instantiations.push({ propertyName, serviceType, isInConstructor: false, isInProperty: false, isInMethod: true, }); } } } } } } } } return instantiations; } /** * Check if an expression is a manual service instantiation. * * @param expression - The expression to check * @param context - Transform context with PublicApi access * @returns True if it's a manual service instantiation * * @private */ private isManualServiceInstantiation( expression: Expression, context: TransformContext, ): boolean { // Direct new expression if (expression.isKind(SyntaxKind.NewExpression)) { const typeName = expression.getExpression().getText(); return this.isServiceType(typeName, context); } // Binary expressions like `new Service() || null` if (expression.isKind(SyntaxKind.BinaryExpression)) { if (expression.getOperatorToken().isKind(SyntaxKind.BarBarToken)) { const left = expression.getLeft(); return this.isManualServiceInstantiation(left, context); } } return false; } /** * Check if a type name represents a service using ServicePatternRecognizer. * * Uses centralized service detection with confidence scoring: * - 1.0: @Injectable decorator present * - 0.7: Class name matches service patterns * - 0.6: File name matches .service.ts pattern * * For cleanup scenarios, uses lower confidence threshold (0.6) to capture * more potential services for migration. * * @param typeName - The type name to check * @param context - Transform context with PublicApi access * @returns True if the type matches service patterns * * @private */ private isServiceType( typeName: string, context: TransformContext, ): boolean { // Try to find the source file for this class const sourceFile = this.findSourceFileForClass(typeName, context); if (sourceFile) { // Use ServicePatternRecognizer for classification const result = context.api.analysis.servicePatternRecognizer.detectService( sourceFile.getClasses().find((c) => c.getName() === typeName)!, ); // Use lower threshold (0.6) for cleanup scenarios // Captures: @Injectable (1.0), suffix patterns (0.7), filename patterns (0.6) return result.isService && result.confidence >= 0.6; } // Fallback: Check if typeName matches known patterns // This handles cases where the service is imported from external modules // or when working with limited project scope const serviceSuffixes = [ 'Service', 'Client', 'Api', 'Repository', 'Manager', 'Provider', 'Factory', 'Handler', 'Resolver', 'Guard', 'Interceptor', ]; // Also check for common Angular platform services by exact name const angularPlatformServices = [ 'HttpClient', 'Router', 'ActivatedRoute', 'Location', 'Title', 'Meta', 'DomSanitizer', 'Renderer2', 'ElementRef', 'ChangeDetectorRef', 'ViewContainerRef', 'TemplateRef', 'ComponentFactoryResolver', ]; return ( serviceSuffixes.some((suffix) => typeName.endsWith(suffix)) || angularPlatformServices.includes(typeName) ); } /** * Find the source file containing a class definition. * * @param className - The class name to search for * @param context - Transform context with project access * @returns Source file if found, undefined otherwise * * @private */ private findSourceFileForClass( className: string, context: TransformContext, ): SourceFile | undefined { const project = context.api.project; const sourceFiles = project.getSourceFiles(); for (const sf of sourceFiles) { const classes = sf.getClasses(); for (const cls of classes) { if (cls.getName() === className) { return sf; } } } return undefined; } /** * Extract the service type from an instantiation expression. * * @param expression - The instantiation expression * @returns The service type name or null * * @private */ private getServiceTypeFromInstantiation( expression: Expression, ): string | null { if (expression.isKind(SyntaxKind.NewExpression)) { return expression.getExpression().getText(); } // For binary expressions like `new Service() || null`, get the type from the left side if (expression.isKind(SyntaxKind.BinaryExpression)) { if (expression.getOperatorToken().isKind(SyntaxKind.BarBarToken)) { const left = expression.getLeft(); return this.getServiceTypeFromInstantiation(left); } } return null; } /** * Convert manual instantiations to inject() calls. * * @param classDecl - The class declaration * @param instantiations - The manual instantiations to convert * * @private */ private convertToInjectCalls( classDecl: ClassDeclaration, instantiations: { propertyName: string; serviceType: string; isInConstructor: boolean; isInProperty: boolean; isInMethod: boolean; }[], ): void { // Group by property name to avoid duplicates const uniqueInstantiations = instantiations.reduce((acc, inst) => { if (!acc.has(inst.propertyName)) { acc.set(inst.propertyName, inst); } return acc; }, new Map()); for (const [propertyName, instantiation] of uniqueInstantiations) { // Find the property declaration const property = classDecl.getProperty(propertyName); if (property) { // Remove the initializer and type annotation property.removeInitializer(); property.removeType(); // Set the new initializer with inject() property.setInitializer(`inject(${instantiation.serviceType})`); } } // Remove constructor statements that assign to these properties const constructor = classDecl.getConstructors()[0]; if (constructor) { const body = constructor.getBody() as Block; if (body) { const statementsToRemove: ExpressionStatement[] = []; body.getStatements().forEach((statement) => { if (statement.isKind(SyntaxKind.ExpressionStatement)) { const expr = statement.getExpression(); if (expr.isKind(SyntaxKind.BinaryExpression)) { if (expr.getOperatorToken().isKind(SyntaxKind.EqualsToken)) { const left = expr.getLeft(); if (left.isKind(SyntaxKind.PropertyAccessExpression)) { const propName = left.getText().split('.')[1]; if (propName && uniqueInstantiations.has(propName)) { statementsToRemove.push(statement); } } } } } }); // Remove the statements for (const statement of statementsToRemove) { statement.remove(); } } } // Remove method statements that assign to these properties const methods = classDecl.getMethods(); for (const method of methods) { const body = method.getBody() as Block; if (body) { const statementsToRemove: ExpressionStatement[] = []; body.getStatements().forEach((statement) => { if (statement.isKind(SyntaxKind.ExpressionStatement)) { const expr = statement.getExpression(); if (expr.isKind(SyntaxKind.BinaryExpression)) { if (expr.getOperatorToken().isKind(SyntaxKind.EqualsToken)) { const left = expr.getLeft(); if (left.isKind(SyntaxKind.PropertyAccessExpression)) { const propName = left.getText().split('.')[1]; if (propName && uniqueInstantiations.has(propName)) { statementsToRemove.push(statement); } } } } } }); // Remove the statements for (const statement of statementsToRemove) { statement.remove(); } } } } /** * Remove empty constructors that only contained manual instantiations. * * @param classDecl - The class declaration * * @private */ private removeEmptyConstructors(classDecl: ClassDeclaration): void { const constructors = classDecl.getConstructors(); for (const constructor of constructors) { const body = constructor.getBody() as Block; if (body?.getStatements().length === 0) { // Check if constructor has parameters if (constructor.getParameters().length === 0) { constructor.remove(); } } } } /** * Ensure the inject function is imported from @angular/core. * * @param sourceFile - The source file to add the import to * * @private */ private ensureInjectImport(sourceFile: SourceFile): void { const injectImport = sourceFile.getImportDeclaration((decl) => { return ( decl.getModuleSpecifierValue() === '@angular/core' && decl.getNamedImports().some((named) => named.getName() === 'inject') ); }); if (!injectImport) { // Find existing @angular/core import const coreImport = sourceFile.getImportDeclaration('@angular/core'); if (coreImport) { // Add inject to existing import const namedImports = coreImport.getNamedImports(); const injectExists = namedImports.some( (named) => named.getName() === 'inject', ); if (!injectExists) { coreImport.addNamedImport('inject'); } } else { // Add new import sourceFile.addImportDeclaration({ moduleSpecifier: '@angular/core', namedImports: ['inject'], }); } } } }