/** * @angular-modernizer/plugin-angular - Promise Component Transform Orchestrator * * Transforms async @Component methods to Observable properties (Approach A). * Template updates are not automated — a "TODO" comment is added for the developer. * * Transformations: * 1. async ngOnInit with assignment → Observable property + subscribe in ngOnInit * 2. Remove ChangeDetectorRef.detectChanges() calls * 3. Remove ChangeDetectorRef injection if no longer used * 4. Update imports (Observable, Subject) */ import type { TransformContext, TransformResult, } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import type { ClassDeclaration, MethodDeclaration, SourceFile } from 'ts-morph'; import { SyntaxKind } from 'ts-morph'; export class PromiseComponentTransformOrchestrator { readonly ruleId = 'angular:promise-component-to-reactive'; run(context: TransformContext): TransformResult { const { sourceFile, filePath } = context; const componentClass = this.findComponentClass(sourceFile); if (!componentClass) { return { ruleId: this.ruleId, modified: false, message: 'No @Component class found', filePath, }; } const asyncMethods = this.findAsyncPromiseMethods(componentClass); if (asyncMethods.length === 0) { return { ruleId: this.ruleId, modified: false, message: 'No async component methods found', filePath, }; } const originalText = sourceFile.getFullText(); try { let changed = false; for (const method of asyncMethods) { const methodChanged = this.transformMethod(method, componentClass); if (methodChanged) { changed = true; } } // Remove detectChanges() calls const cdChanged = this.removeDetectChangesCalls(componentClass); if (cdChanged) { changed = true; } if (changed) { this.updateImports(sourceFile); } return { ruleId: this.ruleId, modified: changed, message: changed ? `Transformed ${asyncMethods.length} async component method(s) to reactive pattern` : 'No transformations applied', filePath, }; } catch (error) { sourceFile.replaceWithText(originalText); return { ruleId: this.ruleId, modified: false, message: `Transformation failed: ${error instanceof Error ? error.message : String(error)}`, filePath, }; } } private transformMethod( method: MethodDeclaration, classDecl: ClassDeclaration, ): boolean { const methodName = method.getName(); const body = method.getBodyText() ?? ''; // Only transform if method contains .toPromise() or await if (!body.includes('.toPromise()') && !body.includes('await ')) { return false; } // Strategy: remove async keyword, remove await/.toPromise() from assignments, // create Observable property, add TODO for template update method.setIsAsync(false); // Remove await expressions let found = true; while (found) { found = false; const awaitExprs = method.getDescendantsOfKind( SyntaxKind.AwaitExpression, ); for (const awaitExpr of awaitExprs) { awaitExpr.replaceWithText(awaitExpr.getExpression().getText()); found = true; break; } } // Remove .toPromise() calls found = true; while (found) { found = false; const calls = method.getDescendantsOfKind(SyntaxKind.CallExpression); for (const call of calls) { const expr = call.getExpression(); if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) { continue; } const propAccess = expr.asKindOrThrow( SyntaxKind.PropertyAccessExpression, ); if (propAccess.getName() !== 'toPromise') { continue; } call.replaceWithText(propAccess.getExpression().getText()); found = true; break; } } // Add a $ Observable property if this is ngOnInit and we detect assignment to this.* if (methodName === 'ngOnInit') { this.addObservablePropertyForNgOnInit(method, classDecl); } return true; } private addObservablePropertyForNgOnInit( method: MethodDeclaration, classDecl: ClassDeclaration, ): void { // Find assignments like: this.data = someObservable; const assignments = method.getDescendantsOfKind( SyntaxKind.BinaryExpression, ); for (const assignment of assignments) { if (assignment.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) { continue; } const left = assignment.getLeft().getText(); if (!left.startsWith('this.')) { continue; } const propName = left.replace('this.', ''); const propNameWithDollar = `${propName}$`; // Add Observable property if not already present const hasProperty = classDecl .getProperties() .some((p) => p.getName() === propNameWithDollar); if (!hasProperty) { classDecl.addProperty({ name: propNameWithDollar, type: 'Observable', leadingTrivia: ' // TODO: Update template to use {{ ' + propNameWithDollar + ' | async }}\n ', }); } } } private removeDetectChangesCalls(classDecl: ClassDeclaration): boolean { let changed = false; const methods = classDecl.getMethods(); for (const method of methods) { // Find detectChanges() call statements and remove them let found = true; while (found) { found = false; const calls = method.getDescendantsOfKind(SyntaxKind.CallExpression); for (const call of calls) { const text = call.getText(); if (text.includes('detectChanges()')) { const stmt = call.getFirstAncestorByKind( SyntaxKind.ExpressionStatement, ); if (stmt) { stmt.remove(); changed = true; found = true; break; } } } } } return changed; } private updateImports(sourceFile: SourceFile): void { const existing = this.getNamedImports(sourceFile, 'rxjs'); const needed = ['Observable'].filter((n) => !existing.has(n)); if (needed.length === 0) { return; } const rxjsDecl = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === 'rxjs', ); if (rxjsDecl) { for (const name of needed) { rxjsDecl.addNamedImport(name); } } else { sourceFile.addImportDeclaration({ moduleSpecifier: 'rxjs', namedImports: needed, }); } } private getNamedImports( sourceFile: SourceFile, moduleSpecifier: string, ): Set { const decl = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === moduleSpecifier, ); if (!decl) { return new Set(); } return new Set(decl.getNamedImports().map((ni) => ni.getName())); } private findComponentClass( sourceFile: SourceFile, ): ClassDeclaration | undefined { return sourceFile .getClasses() .find((cls) => cls.getDecorators().some((d) => d.getName() === 'Component'), ); } private findAsyncPromiseMethods( classDecl: ClassDeclaration, ): MethodDeclaration[] { return classDecl.getMethods().filter((method) => { if (!method.isAsync()) { return false; } const body = method.getBodyText() ?? ''; return body.includes('.toPromise()') || body.includes('await '); }); } }