/** * @angular-modernizer/plugin-angular - Constructor to Inject Orchestrator * * The central "brain" of the constructor to inject transformation plugin. * Orchestrates the conversion of Angular constructor injection to inject() function calls. * * 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 type { SourceFile, ClassDeclaration, ParameterDeclaration, } from 'ts-morph'; import { Scope } from 'ts-morph'; /** * Constructor to Inject Orchestrator * * @remarks * The central orchestrator for converting Angular constructor injection to the * inject() function pattern introduced in Angular 14+. 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. Detect classes with constructor injection * 2. Extract constructor parameters and their types * 3. Convert parameters to property declarations with inject() calls * 4. Add inject import from @angular/core if not present * 5. Remove or simplify the constructor * * ## Example Usage * * ```typescript * const orchestrator = new ConstructorToInjectOrchestrator(); * 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 ConstructorToInjectOrchestrator { /** * Creates a new ConstructorToInjectOrchestrator 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 constructor to inject 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 constructor injection, and converts them * to use inject() function calls. * * The method operates on one source file at a time and is safe to call * multiple times. It will: * - Find all classes with constructors that have parameters * - Convert constructor parameters to property declarations with inject() * - Add the inject import if not already present * - Remove or simplify constructors that become empty * * ## Transformation Behavior * * **Constructor Parameters**: * - `constructor(private http: HttpClient)` → `private http = inject(HttpClient)` * - `constructor(private router: Router, private service: MyService)` → * `private router = inject(Router); private service = inject(MyService)` * - Preserves access modifiers (private, protected, public) * - Preserves parameter names and types * * **Constructor Cleanup**: * - Removes constructors that become empty after parameter conversion * - Keeps constructors with additional logic (super() calls, initialization) * * @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: {} * }); * orchestrator.run(context); * ``` * * @see {@link TransformContext} for context structure * * @public */ run(context: TransformContext): void { const { sourceFile, api } = context; // Find all classes in the file const classes = sourceFile.getClasses(); for (const classDecl of classes) { this.transformClass(classDecl, sourceFile, api); } } /** * Transform a single class by converting its constructor injection to inject() calls. * * This method now includes enhanced error handling to prevent file corruption when * invalid types are encountered. It validates all parameters and provides clear * warnings for any that cannot be converted. * * @param classDecl - The class declaration to transform * @param sourceFile - The source file containing the class * @param api - The PublicApi with transformation tools * * @remarks * **Enhanced Error Handling:** * - If ALL parameters are invalid → File unchanged, warning logged * - If SOME parameters are invalid → Valid ones converted, invalid ones skipped with warning * - Clear console warnings indicate which parameters need manual review * * This prevents corruption from invalid types like `HttpClient | undefined` * being used directly in inject() calls. * * @private */ private transformClass( classDecl: ClassDeclaration, sourceFile: SourceFile, api: PublicApi, ): void { // Find the constructor const constructor = classDecl.getConstructors()[0]; if (!constructor) { return; // No constructor to transform } // Get constructor parameters const parameters = constructor.getParameters(); if (parameters.length === 0) { return; // No parameters to transform } // Convert parameters to inject() property declarations with validation const injectProperties = this.convertParametersToInjectProperties(parameters); if (injectProperties.length === 0) { // All parameters were invalid - don't modify the file console.warn( `⚠️ Class '${classDecl.getName()}' has ${parameters.length} constructor parameter(s), ` + `but none could be converted to inject() calls. Manual migration required for:\n` + parameters .map((p) => ` - ${p.getName()}: ${p.getType().getText()}`) .join('\n'), ); return; // CRITICAL: Don't corrupt the file } if (injectProperties.length < parameters.length) { // Some parameters were skipped const skippedCount = parameters.length - injectProperties.length; console.warn( `⚠️ Class '${classDecl.getName()}': Converted ${injectProperties.length} of ` + `${parameters.length} parameters. ${skippedCount} parameter(s) skipped (see warnings above).`, ); } // Safe to proceed - we have at least one valid parameter this.addInjectPropertiesToClass(classDecl, injectProperties); // Add inject import if not already present this.ensureInjectImport(sourceFile, api); // Remove the constructor constructor.remove(); } /** * Convert constructor parameters to inject() property declarations. * * This method validates each parameter type to ensure it can be used as an Angular * dependency injection token. Invalid types (primitives, arrays, etc.) are skipped * with a warning logged to the console. * * @param parameters - The constructor parameters to convert * @returns Array of property declaration info (only valid parameters) * * @remarks * Parameters are validated using {@link normalizeInjectionType}. Invalid parameters * are skipped rather than corrupting the file. This ensures: * - Primitives (string, number) are not used as injection tokens * - Array types are not used without proper InjectionToken wrapper * - Union types with undefined/null are normalized to base type * - Warnings are logged for manual review * * @private */ private convertParametersToInjectProperties( parameters: ParameterDeclaration[], ): { name: string; type: string; scope?: Scope; isReadonly: boolean; warning?: string; }[] { const properties: { name: string; type: string; scope?: Scope; isReadonly: boolean; warning?: string; }[] = []; for (const param of parameters) { const paramName = param.getName(); const rawType = param.getType().getText(); const scope = param.getScope(); const isReadonly = param.isReadonly(); // Validate and normalize type const normalized = this.normalizeInjectionType(rawType); if (!normalized) { // Type cannot be used as injection token - skip with warning console.warn( `⚠️ Skipping parameter '${paramName}' with type '${rawType}': ` + `Cannot be used as injection token. Arrays and primitives require InjectionToken wrapper.`, ); continue; // Skip this parameter } properties.push({ name: paramName, type: normalized.type, scope: scope !== Scope.Private ? scope : undefined, // Default is private isReadonly, warning: normalized.warning, }); } return properties; } /** * Add inject() property declarations to the class. * * @param classDecl - The class to add properties to * @param injectProperties - Array of property declaration info * * @remarks * Logs warnings to console if any properties were normalized (e.g., extracted from * union types or had optional modifiers removed). * * @private */ private addInjectPropertiesToClass( classDecl: ClassDeclaration, injectProperties: { name: string; type: string; scope?: Scope; isReadonly: boolean; warning?: string; }[], ): void { // Find the best place to insert properties (after existing properties or at the beginning) const existingProperties = classDecl.getProperties(); let insertIndex = 0; if (existingProperties.length > 0) { // Insert after the last property const lastProperty = existingProperties.at(-1); if (lastProperty) { insertIndex = lastProperty.getChildIndex() + 1; } } // Add each property for (const prop of injectProperties) { // Log warning if type was normalized if (prop.warning) { console.warn(`⚠️ Property '${prop.name}': ${prop.warning}`); } classDecl.insertProperty(insertIndex, { name: prop.name, type: prop.type, initializer: `inject(${prop.type})`, scope: prop.scope, isReadonly: prop.isReadonly, }); insertIndex++; } } /** * Ensure the inject function is imported from @angular/core. * * **CRITICAL FIX:** This method now ALWAYS uses ImportManager API for consistency * and safety. The previous implementation used direct ts-morph API (addNamedImport) * which bypassed validation and could create malformed imports. * * @param sourceFile - The source file to add the import to * @param api - The PublicApi with import management tools * * @remarks * **Bug Fix (Line 270):** * Previous code used `importDecl.addNamedImport('inject')` which directly called * ts-morph's API, bypassing ImportManager's: * - Deduplication logic * - Validation * - Proper formatting * - Edge case handling (comments, whitespace, aliases) * * This caused file corruption in production use. * * **New behavior:** * - Always uses ImportManager.addNamedImport() for ALL import operations * - ImportManager handles merging with existing @angular/core imports * - Automatically deduplicates if inject already exists * - Properly formats complex import statements * * @private */ private ensureInjectImport(sourceFile: SourceFile, api: PublicApi): void { const importManager = api.transformation.importManager; // Add inject import using ImportManager API // ImportManager handles: // - Checking if inject is already imported (lines 76-80) // - Merging with existing @angular/core imports (line 85) // - Creating new import if @angular/core not imported (lines 65-72) // - Deduplication and proper formatting importManager.addNamedImport(sourceFile, '@angular/core', 'inject'); } /** * Normalize a TypeScript type string to a valid Angular injection token. * * This method validates and normalizes parameter types to ensure they can be used * as Angular dependency injection tokens. It handles common TypeScript patterns * like union types, optional types, and generics. * * @param typeText - Raw type string from param.getType().getText() * @returns Normalized type with optional warning, or null if type is invalid * * @remarks * **Valid injection tokens:** * - Class types: `HttpClient`, `Router`, `MyService` * - InjectionToken: `InjectionToken` * - Abstract classes: `AbstractControl` * * **Invalid injection tokens (returned as null):** * - Primitives: `string`, `number`, `boolean` (need InjectionToken wrapper) * - Arrays: `HttpClient[]`, `Array` (need InjectionToken pattern) * - Observable/Promise generics: `Observable` (not injection tokens) * - Multiple union types: `ServiceA | ServiceB` (ambiguous, needs manual review) * * **Normalized patterns:** * - Union with undefined/null: `HttpClient | undefined` → `HttpClient` * - Optional types: `Router?` → `Router` * * @example * ```typescript * // Valid types * normalizeInjectionType("HttpClient") → { type: "HttpClient" } * normalizeInjectionType("HttpClient | undefined") → { type: "HttpClient", warning: "..." } * normalizeInjectionType("Router?") → { type: "Router", warning: "..." } * * // Invalid types (return null) * normalizeInjectionType("string") → null * normalizeInjectionType("HttpClient[]") → null * normalizeInjectionType("Observable") → null * ``` * * @private */ private normalizeInjectionType(typeText: string): { type: string; warning?: string; } | null { const cleanType = typeText.trim(); // Reject array types - arrays need InjectionToken pattern if (cleanType.endsWith('[]') || /Array<.*>/.test(cleanType)) { return null; } // Reject primitive types - primitives need InjectionToken wrapper const primitives = [ 'string', 'number', 'boolean', 'any', 'unknown', 'void', ]; if (primitives.includes(cleanType)) { return null; } // Handle union types (extract first non-null/undefined type) if (cleanType.includes('|')) { const types = cleanType.split('|').map((t) => t.trim()); const validTypes = types.filter( (t) => t !== 'null' && t !== 'undefined' && !primitives.includes(t), ); if (validTypes.length === 0) { return null; // No valid types found } if (validTypes.length > 1) { // Multiple non-null types - needs manual review return null; } // TypeScript: validTypes[0] is guaranteed to exist because length > 0 const extractedType = validTypes[0]; if (!extractedType) { return null; // Type safety guard } return { type: extractedType, warning: `Extracted '${extractedType}' from union type '${cleanType}'`, }; } // Handle optional types (remove ? modifier) if (cleanType.endsWith('?')) { return { type: cleanType.slice(0, -1), warning: `Removed optional modifier from '${cleanType}'`, }; } // Handle generic types if (cleanType.includes('<')) { // InjectionToken is valid if (cleanType.startsWith('InjectionToken<')) { return { type: cleanType }; } // Observable, Promise, etc. are NOT valid injection tokens const invalidGenerics = [ 'Observable', 'Promise', 'Subject', 'BehaviorSubject', ]; const baseType = cleanType.split('<')[0]; if (!baseType) { return null; // Type safety guard } if (invalidGenerics.includes(baseType)) { return null; } // For other generics, keep but warn return { type: cleanType, warning: `Generic type '${cleanType}' kept - verify it's a valid injection token`, }; } // Simple class/interface type - should be valid return { type: cleanType }; } }