import { SyntaxKind, type ClassDeclaration, type MethodDeclaration, type PropertyDeclaration, type ConstructorDeclaration, type SourceFile, type Project, } from 'ts-morph'; import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { ImportManager, PublicApi } from '@angular-modernizer/api'; export interface StaticClassTransformResult { classesTransformed: number; functionsCreated: number; constantsCreated: number; inFileRefsUpdated: number; crossFileCallersUpdated: number; skipped: string[]; } const ANGULAR_DECORATORS = new Set([ 'Component', 'Injectable', 'Directive', 'Pipe', 'NgModule', ]); /** * StaticClassTransformOrchestrator * * Scans every class in the source file, identifies static-only classes, and * transforms each one to module-level function/const declarations in-place. * In-file ClassName.member() references are rewritten to bare member() calls. * * Out of scope: cross-file import rewrites. Callers in other files must be * updated manually or by a future "import-rewrite" pass. * * Idempotency: after transformation the class no longer exists, so a second * run finds nothing to transform. */ export class StaticClassTransformOrchestrator { run(context: TransformContext): StaticClassTransformResult { const { sourceFile } = context; const result: StaticClassTransformResult = { classesTransformed: 0, functionsCreated: 0, constantsCreated: 0, inFileRefsUpdated: 0, crossFileCallersUpdated: 0, skipped: [], }; // Collect eligible classes first (snapshot — don't mutate while iterating) const classes = sourceFile .getClasses() .filter((cls) => this.isEligible(cls)); // Transform bottom-up so earlier class positions are unaffected by replacements const sorted = [...classes].sort((a, b) => b.getStart() - a.getStart()); for (const cls of sorted) { const className = cls.getName() ?? '(anonymous)'; const methods = cls.getMethods().filter((m) => m.isStatic()); const props = cls .getProperties() .filter((p) => p.isStatic() && p.isReadonly()); const memberNames = new Set([ ...methods.map((m) => m.getName()), ...props.map((p) => p.getName()), ]); // Build replacement text. // Body refs (e.g. ClassName.member inside a method body) are rewritten // at string level here so we never mutate the live AST before replacement. const lines: string[] = []; for (const prop of props) { lines.push(this.propertyToConst(prop)); } for (const method of methods) { lines.push(this.methodToFunction(method, className, memberNames)); } const replacement = lines.join('\n\n'); // Atomically replace the class with the new declarations. // replaceWithText() handles its own position bookkeeping — no separate // remove()+insertText() needed, which avoids stale-position errors. cls.replaceWithText(replacement); result.classesTransformed++; result.functionsCreated += methods.length; result.constantsCreated += props.length; // Rewrite any remaining ClassName.member refs that were OUTSIDE the class // body (e.g. other functions in the same file that called ClassName.member). result.inFileRefsUpdated += this.rewriteExternalRefs( sourceFile, className, memberNames, ); // Rewrite imports and call sites in all other files that import ClassName. result.crossFileCallersUpdated += this.rewriteCrossFileCallers( context.project, sourceFile.getFilePath(), className, memberNames, context.api.transformation.importManager, ); } return result; } private isEligible(cls: ClassDeclaration): boolean { if (cls.getDecorators().some((d) => ANGULAR_DECORATORS.has(d.getName()))) { return false; } if (cls.getExtends()) { return false; } if (cls.isAbstract()) { return false; } const ctor = cls.getConstructors()[0]; if (ctor && this.hasCtorLogic(ctor)) { return false; } const methods = cls.getMethods(); const props = cls.getProperties(); if (methods.some((m) => !m.isStatic())) { return false; } if (props.some((p) => !p.isStatic())) { return false; } if (props.some((p) => p.isStatic() && !p.isReadonly())) { return false; } const allMembers = [...methods, ...props]; if ( allMembers.some( (m) => m.hasModifier(SyntaxKind.PrivateKeyword) || m.hasModifier(SyntaxKind.ProtectedKeyword), ) ) { return false; } // All member names must be valid JS identifiers — numeric names (e.g. LEVEL[0]) // cannot be exported as `export const 0 = ...` or used as named imports. if (allMembers.some((m) => !this.isValidIdentifier(m.getName()))) { return false; } return methods.length > 0 || props.length > 0; } private isValidIdentifier(name: string): boolean { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/u.test(name); } private hasCtorLogic(ctor: ConstructorDeclaration): boolean { return ctor.getStatements().length > 0; } private propertyToConst(prop: PropertyDeclaration): string { const jsDoc = prop .getJsDocs() .map((d) => d.getFullText()) .join('\n'); const name = prop.getName(); const typeNode = prop.getTypeNode(); const typeAnnotation = typeNode ? `: ${typeNode.getText()}` : ''; const init = prop.getInitializer()?.getText() ?? 'undefined'; return `${jsDoc ? jsDoc + '\n' : ''}export const ${name}${typeAnnotation} = ${init};`; } private methodToFunction( method: MethodDeclaration, className: string, memberNames: Set, ): 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()}` : ''; // Rewrite ClassName.member refs in the body text at string level const body = this.rewriteRefsInText( method.getBodyText() ?? '', className, memberNames, ); return `${jsDoc ? jsDoc + '\n' : ''}export ${asyncKw}function ${name}${typeParamsText}(${params})${returnType} {\n${body}\n}`; } /** * Rewrites `ClassName.member` → `member` in plain text (method body strings). * Uses word-boundary regex to avoid partial matches. */ private rewriteRefsInText( text: string, className: string, memberNames: Set, ): string { let result = text; const escapedClass = this.escapeRegExp(className); for (const memberName of memberNames) { const escaped = this.escapeRegExp(memberName); // ClassName.member(…) → member(…) result = result.replaceAll( new RegExp(String.raw`\b${escapedClass}\.${escaped}\b`, 'g'), memberName, ); // this.member(…) → member(…) — valid in static methods, breaks after class removal result = result.replaceAll( new RegExp(String.raw`\bthis\.${escaped}\b`, 'g'), memberName, ); } return result; } /** * Updates all other project files that import ClassName from the transformed file: * - Removes the `ClassName` named import from their import declaration. * - Adds a named import for each extracted member (function/const). * - Rewrites `ClassName.member` call sites to bare `member` references. * * Skipped: aliased imports (`import { Cls as Alias }`), type-only callers * (handled separately by TypeScript), and .d.ts files. */ private rewriteCrossFileCallers( project: Project, transformedFilePath: string, className: string, memberNames: 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; } // Only handle non-aliased named imports of the class const classImport = importDecl .getNamedImports() .find((ni) => ni.getName() === className && !ni.getAliasNode()); if (!classImport) { continue; } const moduleSpecifier = importDecl.getModuleSpecifierValue(); // Swap: remove class import, add individual member imports importManager.removeNamedImport(sf, className, moduleSpecifier); for (const memberName of memberNames) { importManager.addNamedImport(sf, moduleSpecifier, memberName); } // Fix call sites in this caller file count += this.rewriteExternalRefs(sf, className, memberNames); break; // one import decl per module specifier is the norm } } return count; } /** * Rewrites remaining `ClassName.member` references in the file via live AST * (for refs that were outside the class body — other functions, top-level code, etc.). * Processes nodes in reverse order to preserve positions. */ private rewriteExternalRefs( sourceFile: SourceFile, className: string, memberNames: Set, ): number { let count = 0; const propAccesses = sourceFile.getDescendantsOfKind( SyntaxKind.PropertyAccessExpression, ); for (const pa of [...propAccesses].reverse()) { if (pa.getExpression().getText() !== className) { continue; } if (!memberNames.has(pa.getName())) { continue; } pa.replaceWithText(pa.getName()); count++; } return count; } private escapeRegExp(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } }