/** * @angular-modernizer/plugin-angular - Container and Presentational Components Orchestrator * * Orchestrator that converts Angular components with service dependencies * into presentational components using @Input/@Output decorators. * * Philosophy: "Thin Rule, Thick Orchestrator" * - The orchestrator contains all business logic * - The rule is a minimal protocol wrapper * - Complex AST manipulation happens here */ import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import { ServicePattern } from '@angular-modernizer/api'; import type { SourceFile, ClassDeclaration, MethodDeclaration, PropertyDeclaration, ParameterDeclaration, } from 'ts-morph'; import { SyntaxKind } from 'ts-morph'; /** * Represents a service dependency in a component. */ interface ServiceDependency { name: string; type: string; parameter: ParameterDeclaration; } /** * Represents a property that should be converted to @Input. */ interface DataProperty { name: string; type: string; property: PropertyDeclaration; } /** * Represents an event that should be converted to @Output. */ interface EventEmitter { name: string; eventType: string; methods: MethodDeclaration[]; } /** * Orchestrator for transforming components into presentational components. * * @remarks * This orchestrator handles the complex logic for: * - Detecting components with service dependencies * - Removing service dependencies from constructors * - Adding @Input properties for data * - Adding @Output EventEmitters for user interactions * - Converting service-calling methods to event-emitting methods * - Updating imports * * The orchestrator uses ts-morph for AST manipulation and ServicePatternRecognizer * for centralized service detection, following the existing pattern of other * transformation orchestrators. * * MIGRATION NOTE: Updated to use ServicePatternRecognizer instead of hardcoded suffixes. * - Replaced 120-line serviceSuffixes array with centralized service detection * - Uses confidence-based filtering for more accurate service identification * - Code reduction: ~120 lines removed * * @public */ export class ContainerPresentationalOrchestrator { // No longer needs serviceSuffixes array - using ServicePatternRecognizer from context.api /** * Run the container/presentational transformation on the given context. * * @param context - Transform context with source file and project access * * @public */ run(context: TransformContext): void { const { sourceFile } = context; // Find all class declarations const classes = sourceFile.getClasses(); for (const classDecl of classes) { // Only transform Angular components if (!this.isAngularComponent(classDecl)) { continue; } // Check if the component needs transformation if (this.needsTransformation(classDecl, context)) { this.convertToPresentational(classDecl, context); } } } /** * Check if a class is an Angular component. * * @param classDecl - The class declaration to check * @returns true if the class has a @Component decorator * * @private */ private isAngularComponent(classDecl: ClassDeclaration): boolean { const decorators = classDecl.getDecorators(); return decorators.some((decorator) => decorator.getName() === 'Component'); } /** * Check if a component needs transformation. * * @param classDecl - The component class declaration * @param context - Transform context with ServicePatternRecognizer * @returns true if the component has service dependencies * * @private */ private needsTransformation( classDecl: ClassDeclaration, context: TransformContext, ): boolean { return this.hasServiceDependencies(classDecl, context); } /** * Check if a component has service dependencies. * * @param classDecl - The component class declaration * @param context - Transform context with ServicePatternRecognizer * @returns true if the constructor has service parameters * * @private */ private hasServiceDependencies( classDecl: ClassDeclaration, context: TransformContext, ): boolean { const constructor = classDecl.getConstructors()[0]; if (!constructor) { return false; } const params = constructor.getParameters(); return params.some((param) => { const type = param.getType().getText(); return this.isServiceType(type, context); }); } /** * Check if a type name represents a service. * * Uses ServicePatternRecognizer for centralized service detection. * * @param typeName - The type name to check * @param context - Transform context with access to ServicePatternRecognizer * @returns true if the type matches service patterns * * @private */ private isServiceType( typeName: string, context: TransformContext, ): boolean { // Known Angular framework services that should be treated as dependencies const angularServices = new Set([ 'HttpClient', 'HttpBackend', 'Router', 'ActivatedRoute', 'Location', 'PlatformLocation', 'Title', 'Meta', ]); // Check if it's a known Angular service if (angularServices.has(typeName)) { return true; } // Use ServicePatternRecognizer patterns to check if this is a known service pattern // Check against all known service patterns (excluding UNKNOWN) const servicePatterns = [ ServicePattern.INJECTABLE, ServicePattern.SERVICE, ServicePattern.REPOSITORY, ServicePattern.PROVIDER, ServicePattern.FACADE, ServicePattern.STORE, ServicePattern.HELPER, ServicePattern.UTILITY, ]; // Check by type name suffix const matchesSuffix = servicePatterns.some((pattern) => typeName.toLowerCase().endsWith(pattern.toLowerCase()), ); if (matchesSuffix) { return true; } // Second try: if we have a source file for this type, use ServicePatternRecognizer // This provides more accurate detection for @Injectable services try { const project = context.api.project; // Try to find the source file for this type const sourceFiles = project.getSourceFiles(); for (const sf of sourceFiles) { const classes = sf.getClasses(); for (const cls of classes) { if (cls.getName() === typeName) { const classification = context.api.analysis.servicePatternRecognizer.classifyService(sf); // High confidence (>0.7) means it's definitely a service return ( classification.pattern !== ServicePattern.UNKNOWN && classification.confidence > 0.7 ); } } } } catch { // Fall back to suffix check if recognizer fails } return false; } /** * Analyze and extract service dependencies from a component. * * @param classDecl - The component class declaration * @param context - Transform context with ServicePatternRecognizer * @returns Array of service dependencies * * @private */ private analyzeServiceDependencies( classDecl: ClassDeclaration, context: TransformContext, ): ServiceDependency[] { const constructor = classDecl.getConstructors()[0]; if (!constructor) { return []; } const dependencies: ServiceDependency[] = []; const params = constructor.getParameters(); for (const param of params) { const paramName = param.getName(); const paramType = param.getType().getText(); if (this.isServiceType(paramType, context)) { dependencies.push({ name: paramName, type: paramType, parameter: param, }); } } return dependencies; } /** * Convert a component to presentational by removing services and adding @Input/@Output. * * @param classDecl - The component class declaration * @param context - Transform context * * @private */ private convertToPresentational( classDecl: ClassDeclaration, context: TransformContext, ): void { const { sourceFile } = context; // Step 1: Analyze service dependencies const serviceDeps = this.analyzeServiceDependencies(classDecl, context); if (serviceDeps.length === 0) { return; } // Step 2: Identify data properties that should become @Input const dataProperties = this.identifyDataProperties(classDecl, serviceDeps); // Step 3: Identify methods that should emit events via @Output const eventEmitters = this.identifyEventEmitters(classDecl, serviceDeps); // Step 4: Remove service dependencies from constructor this.removeServiceDependencies(classDecl, serviceDeps); // Step 5: Add @Input decorators to data properties this.addInputDecorators(classDecl, dataProperties); // Step 6: Add @Output EventEmitters this.addOutputEventEmitters(classDecl, eventEmitters); // Step 7: Convert methods to emit events this.convertMethodsToEmitEvents(classDecl, eventEmitters); // Step 8: Update imports this.updateImports(sourceFile, context); } /** * Identify properties that should be converted to @Input. * * @param classDecl - The component class declaration * @param serviceDeps - Service dependencies * @returns Array of data properties * * @private */ private identifyDataProperties( classDecl: ClassDeclaration, _serviceDeps: ServiceDependency[], ): DataProperty[] { const dataProperties: DataProperty[] = []; const properties = classDecl.getProperties(); for (const prop of properties) { // Skip properties that already have @Input const hasInput = prop .getDecorators() .some((d) => d.getName() === 'Input'); if (hasInput) { continue; } // Skip private/protected properties if (prop.hasModifier(SyntaxKind.PrivateKeyword)) { continue; } if (prop.hasModifier(SyntaxKind.ProtectedKeyword)) { continue; } // Include properties that might hold data const propName = prop.getName(); const propType = prop.getType().getText(); // Common data property patterns if ( /^(data|items|list|users|products|records|entities)$/i.exec(propName) || propType.includes('[]') || propType.includes('Observable') ) { dataProperties.push({ name: propName, type: propType, property: prop, }); } } return dataProperties; } /** * Identify methods that should be converted to event emitters. * * @param classDecl - The component class declaration * @param serviceDeps - Service dependencies * @returns Array of event emitters to create * * @private */ private identifyEventEmitters( classDecl: ClassDeclaration, serviceDeps: ServiceDependency[], ): EventEmitter[] { const eventEmitters: EventEmitter[] = []; const methods = classDecl.getMethods(); const serviceNames = new Set(serviceDeps.map((d) => d.name)); for (const method of methods) { // Skip lifecycle hooks if (this.isLifecycleHook(method.getName())) { continue; } // Check if method calls services const methodBody = method.getBodyText(); if (!methodBody) { continue; } const callsService = Array.from(serviceNames).some((serviceName) => methodBody.includes(`this.${serviceName}.`), ); if (callsService) { const methodName = method.getName(); const eventName = this.generateEventName(methodName); const eventType = this.inferEventType(method); eventEmitters.push({ name: eventName, eventType, methods: [method], }); } } return eventEmitters; } /** * Check if a method name is a lifecycle hook. * * @param methodName - The method name to check * @returns true if it's a lifecycle hook * * @private */ private isLifecycleHook(methodName: string): boolean { const lifecycleHooks = [ 'ngOnInit', 'ngOnDestroy', 'ngOnChanges', 'ngDoCheck', 'ngAfterContentInit', 'ngAfterContentChecked', 'ngAfterViewInit', 'ngAfterViewChecked', ]; return lifecycleHooks.includes(methodName); } /** * Generate an event name from a method name. * * @param methodName - The original method name * @returns Event name (e.g., 'deleteUser' -> 'userDelete') * * @private */ private generateEventName(methodName: string): string { // Convert method names to event names // e.g., 'deleteUser' -> 'userDelete' // e.g., 'handleClick' -> 'click' if (methodName.startsWith('on')) { // 'onClick' -> 'click' return methodName.slice(2, 3).toLowerCase() + methodName.slice(3); } if (methodName.startsWith('handle')) { // 'handleSubmit' -> 'submit' return methodName.slice(6, 7).toLowerCase() + methodName.slice(7); } // For other methods, just append 'Event' or use as-is return methodName; } /** * Infer the event type from a method. * * @param method - The method declaration * @returns Event type (default: 'void') * * @private */ private inferEventType(method: MethodDeclaration): string { const params = method.getParameters(); // If method has a single parameter, use that type if (params.length === 1 && params[0]) { return params[0].getType().getText(); } // Otherwise, use void return 'void'; } /** * Remove service dependencies from the constructor. * * @param classDecl - The component class declaration * @param serviceDeps - Service dependencies to remove * * @private */ private removeServiceDependencies( classDecl: ClassDeclaration, serviceDeps: ServiceDependency[], ): void { const constructor = classDecl.getConstructors()[0]; if (!constructor) { return; } // Remove service parameters for (const dep of serviceDeps) { dep.parameter.remove(); } // Remove constructor if it's now empty if (constructor.getParameters().length === 0) { const body = constructor.getBody(); const bodyText = body?.getText() ?? ''; // Only remove if body is empty (just braces) if (bodyText.trim() === '{}' || bodyText.trim() === '') { constructor.remove(); } } } /** * Add @Input decorators to data properties. * * @param classDecl - The component class declaration * @param dataProperties - Properties to add @Input to * * @private */ private addInputDecorators( _classDecl: ClassDeclaration, dataProperties: DataProperty[], ): void { for (const dataProp of dataProperties) { // Check if @Input already exists const hasInput = dataProp.property .getDecorators() .some((d) => d.getName() === 'Input'); if (!hasInput) { dataProp.property.addDecorator({ name: 'Input', arguments: [], }); } } } /** * Add @Output EventEmitter properties. * * @param classDecl - The component class declaration * @param eventEmitters - Event emitters to add * * @private */ private addOutputEventEmitters( classDecl: ClassDeclaration, eventEmitters: EventEmitter[], ): void { for (const emitter of eventEmitters) { // Check if property already exists const existingProp = classDecl.getProperty(emitter.name); if (existingProp) { continue; } // Add new @Output property classDecl.addProperty({ name: emitter.name, type: `EventEmitter<${emitter.eventType}>`, initializer: `new EventEmitter<${emitter.eventType}>()`, decorators: [ { name: 'Output', arguments: [], }, ], }); } } /** * Convert methods to emit events instead of calling services. * * @param classDecl - The component class declaration * @param eventEmitters - Event emitters for methods * * @private */ private convertMethodsToEmitEvents( _classDecl: ClassDeclaration, eventEmitters: EventEmitter[], ): void { for (const emitter of eventEmitters) { for (const method of emitter.methods) { const params = method.getParameters(); // Simple conversion: replace method body with event emission if (params.length === 0) { method.setBodyText(`this.${emitter.name}.emit();`); } else if (params.length === 1 && params[0]) { const paramName = params[0].getName(); method.setBodyText(`this.${emitter.name}.emit(${paramName});`); } else { // For multiple parameters, emit an object const paramNames = params.map((p) => p.getName()).join(', '); method.setBodyText(`this.${emitter.name}.emit({ ${paramNames} });`); } } } } /** * Update imports to include Input/Output/EventEmitter and remove unused services. * * @param sourceFile - The source file * @param context - Transform context * * @private */ private updateImports( sourceFile: SourceFile, _context: TransformContext, ): void { // Add Input, Output, EventEmitter imports from @angular/core const coreImport = sourceFile.getImportDeclaration('@angular/core'); if (coreImport) { const namedImports = coreImport.getNamedImports(); const importNames = namedImports.map((ni) => ni.getName()); // Add missing imports const neededImports = ['Input', 'Output', 'EventEmitter']; for (const neededImport of neededImports) { if (!importNames.includes(neededImport)) { coreImport.addNamedImport(neededImport); } } } else { // Create new @angular/core import sourceFile.addImportDeclaration({ moduleSpecifier: '@angular/core', namedImports: ['Input', 'Output', 'EventEmitter'], }); } // Note: We don't remove service imports automatically as they might be // used in type annotations or other places. Let the user handle cleanup // or use a separate "remove unused imports" transformation. } }