/** * @angular-modernizer/plugin-angular - Static Extract Transform Orchestrator */ import { type ClassDeclaration, type MethodDeclaration, type SourceFile, type Project, SyntaxKind, Scope, } from 'ts-morph'; import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { ImportManager, PublicApi } from '@angular-modernizer/api'; export interface StaticExtractResult { extractedCount: number; inFileRefsUpdated: number; crossFileCallersUpdated: number; skipped: boolean; skipReason?: string; } const ANGULAR_DECORATORS = new Set([ 'Component', 'Injectable', 'Directive', 'Pipe', 'NgModule', ]); /** * StaticExtractTransformOrchestrator * * For each class in the file that mixes public static methods with instance * state, extracts the public static methods to module-level functions inserted * immediately above the class, then removes them from the class body. * * Intra-file ClassName.method() references are rewritten to bare method(). * * Idempotency: After extraction the class no longer has static methods, so a * second run finds no violations and makes no changes. */ export class StaticExtractTransformOrchestrator { run(context: TransformContext): StaticExtractResult { const { sourceFile } = context; let extractedCount = 0; let inFileRefsUpdated = 0; let crossFileCallersUpdated = 0; // Process bottom-up: re-fetch after each insertText to avoid stale nodes. // insertText invalidates existing node references so we cannot pre-collect. while (true) { const eligible = sourceFile .getClasses() .filter((cls) => this.isEligible(cls)); if (eligible.length === 0) { break; } // Pick the bottom-most class first (reverse order) const cls = eligible.reduce((a, b) => a.getStart() > b.getStart() ? a : b, ); const className = cls.getName() ?? '(anonymous)'; const publicStaticMethods = cls .getMethods() .filter((m) => m.isStatic() && m.getScope() === Scope.Public); if (publicStaticMethods.length === 0) { break; } // isEligible ensures this won't happen const methodNames = new Set(publicStaticMethods.map((m) => m.getName())); // Capture function text BEFORE removing from class const functionTexts = publicStaticMethods.map((m) => this.methodToFunction(m), ); // Rewrite intra-file references before removing methods inFileRefsUpdated += this.rewriteIntraFileRefs( sourceFile, className, publicStaticMethods, ); // Remove static methods from class (reverse order, node-safe) for (const m of [...publicStaticMethods].reverse()) { m.remove(); } // Insert function declarations above the class const insertPos = cls.getStart(); const insertion = functionTexts.join('\n\n') + '\n\n'; sourceFile.insertText(insertPos, insertion); extractedCount += publicStaticMethods.length; // Rewrite imports and call sites in all other files that import ClassName crossFileCallersUpdated += this.rewriteCrossFileCallers( context.project, sourceFile.getFilePath(), className, methodNames, context.api.transformation.importManager, ); } return { extractedCount, inFileRefsUpdated, crossFileCallersUpdated, skipped: false, }; } private isEligible(cls: ClassDeclaration): boolean { if (cls.isAbstract()) { return false; } if (cls.getDecorators().some((d) => ANGULAR_DECORATORS.has(d.getName()))) { return false; } const methods = cls.getMethods(); const props = cls.getProperties(); const hasPublicStatic = methods.some( (m) => m.isStatic() && m.getScope() === Scope.Public, ); const hasInstanceState = methods.some((m) => !m.isStatic()) || props.some((p) => !p.isStatic()); return hasPublicStatic && hasInstanceState; } private methodToFunction(method: MethodDeclaration): string { const jsDoc = method .getJsDocs() .map((d) => d.getFullText()) .join('\n'); const asyncKw = method.isAsync() ? 'async ' : ''; const name = method.getName(); const typeParams = method.getTypeParameters(); const typeParamsText = typeParams.length > 0 ? `<${typeParams.map((tp) => tp.getText()).join(', ')}>` : ''; const params = method .getParameters() .map((p) => p.getText()) .join(', '); const returnTypeNode = method.getReturnTypeNode(); const returnType = returnTypeNode ? `: ${returnTypeNode.getText()}` : ''; const body = method.getBodyText() ?? ''; return `${jsDoc ? jsDoc + '\n' : ''}export ${asyncKw}function ${name}${typeParamsText}(${params})${returnType} {\n${body}\n}`; } private rewriteIntraFileRefs( sourceFile: SourceFile, className: string, methods: MethodDeclaration[], ): number { const methodNames = new Set(methods.map((m) => m.getName())); return this.rewritePropertyAccesses(sourceFile, className, methodNames); } /** Low-level: rewrites `ClassName.name` → `name` for all names in the set. */ private rewritePropertyAccesses( sourceFile: SourceFile, className: string, names: Set, ): number { const propAccesses = sourceFile.getDescendantsOfKind( SyntaxKind.PropertyAccessExpression, ); let count = 0; for (const pa of [...propAccesses].reverse()) { if (pa.getExpression().getText() !== className) { continue; } if (!names.has(pa.getName())) { continue; } pa.replaceWithText(pa.getName()); count++; } return count; } /** * Updates all other project files that import ClassName from the transformed file: * - Removes the `ClassName` named import. * - Adds a named import for each extracted function. * - Rewrites `ClassName.method` call sites to bare `method` references. * * Skips aliased imports (`import { Cls as Alias }`) and .d.ts files. */ private rewriteCrossFileCallers( project: Project, transformedFilePath: string, className: string, methodNames: Set, importManager: ImportManager, ): number { let count = 0; for (const sf of project.getSourceFiles()) { if (sf.getFilePath() === transformedFilePath) { continue; } if (sf.getFilePath().endsWith('.d.ts')) { continue; } for (const importDecl of sf.getImportDeclarations()) { const resolvedFile = importDecl.getModuleSpecifierSourceFile(); if ( !resolvedFile || resolvedFile.getFilePath() !== transformedFilePath ) { continue; } const classImport = importDecl .getNamedImports() .find((ni) => ni.getName() === className && !ni.getAliasNode()); if (!classImport) { continue; } const moduleSpecifier = importDecl.getModuleSpecifierValue(); importManager.removeNamedImport(sf, className, moduleSpecifier); for (const methodName of methodNames) { importManager.addNamedImport(sf, moduleSpecifier, methodName); } count += this.rewritePropertyAccesses(sf, className, methodNames); break; } } return count; } }