/** * @angular-modernizer/plugin-angular - Dto Object Literal Orchestrator * * Contains all business logic for the DTO Object Literal Transform Rule. * Rewrites `const r = new SomeResponse(); r.prop = val;` patterns into * equivalent typed object literals: `const r: SomeResponse = { prop: val };`. * * Only "simple cases" are transformed. A case is simple when: * (a) The NewExpression has zero constructor arguments. * (b) All consecutive ExpressionStatements immediately following the variable * declaration are unconditional property assignments of the form * `varName.prop = value`. Any remaining statements must not assign to * the variable's properties. * (c) The class declaration is resolvable and has an empty or absent constructor * body (syntax-only check — no type inference). * * Idempotency: after rewriting, no NewExpression for the variable remains, so a * second run produces zero candidates and no modifications. */ import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import { SyntaxKind, Node, type SourceFile, type VariableDeclaration, type ExpressionStatement, type NewExpression, type ClassDeclaration, } from 'ts-morph'; export interface DtoDetectionConfig { classNamePatterns: (string | RegExp)[]; } export interface DtoTransformResult { changeCount: number; skippedCount: number; errors: string[]; } interface Candidate { varDecl: VariableDeclaration; newExpr: NewExpression; varName: string; assignments: ExpressionStatement[]; typeArgs: string[]; } export class DtoObjectLiteralOrchestrator { run( context: TransformContext, config: DtoDetectionConfig, ): DtoTransformResult { const { sourceFile } = context; const result: DtoTransformResult = { changeCount: 0, skippedCount: 0, errors: [], }; const candidates = this.findDtoInstantiations(sourceFile, config); // Read phase: classify all candidates before any AST modifications const simpleCases: Candidate[] = []; for (const candidate of candidates) { if (this.isSimpleCase(candidate, sourceFile)) { simpleCases.push(candidate); } else { result.skippedCount++; result.errors.push( `Skipped '${candidate.varName}' (${this.getClassName(candidate.newExpr)}): complex instantiation pattern`, ); } } // Write phase: apply changes bottom-up to keep earlier node references valid for (const candidate of [...simpleCases].reverse()) { this.rewriteAsObjectLiteral(candidate); result.changeCount++; } return result; } private findDtoInstantiations( file: SourceFile, config: DtoDetectionConfig, ): Candidate[] { const candidates: Candidate[] = []; const newExprs = file.getDescendantsOfKind(SyntaxKind.NewExpression); for (const newExpr of newExprs) { const className = this.extractClassName(newExpr); if (!className) { continue; } if (!this.matchesPattern(className, config.classNamePatterns)) { continue; } const varDecl = newExpr .getParent() .asKind(SyntaxKind.VariableDeclaration); if (!varDecl) { continue; } const nameNode = varDecl.getNameNode(); if (nameNode.getKind() !== SyntaxKind.Identifier) { continue; } const varName = nameNode.getText(); // Verify structural parent chain: VariableDeclarationList → VariableStatement → Block const varDeclList = varDecl.getParent(); if (!varDeclList) { continue; } const varStatement = varDeclList.getParent(); if (varStatement?.getKind() !== SyntaxKind.VariableStatement) { continue; } const block = varStatement.getParent(); if (block.getKind() !== SyntaxKind.Block) { continue; } const typeArgs = newExpr.getTypeArguments().map((ta) => ta.getText()); candidates.push({ varDecl, newExpr, varName, assignments: [], typeArgs }); } return candidates; } private isSimpleCase(candidate: Candidate, file: SourceFile): boolean { // (a) zero constructor call arguments if (candidate.newExpr.getArguments().length !== 0) { return false; } const varDeclList = candidate.varDecl.getParent(); const varStatement = varDeclList.getParent(); const block = varStatement.getParent(); const blockNode = block.asKind(SyntaxKind.Block); if (!blockNode) { return false; } const statements = blockNode.getStatements(); const varStmtIdx = statements.indexOf(varStatement); if (varStmtIdx === -1) { return false; } const statementsAfter = statements.slice(varStmtIdx + 1); // (b) Collect consecutive ExpressionStatement property assignments const assignments: ExpressionStatement[] = []; let collectionDone = false; for (const stmt of statementsAfter) { if (!collectionDone) { if (this.isPropertyAssignmentStatement(stmt, candidate.varName)) { assignments.push(stmt as ExpressionStatement); continue; } collectionDone = true; } // After consecutive run: any property assignment in subtree → complex if (this.containsPropertyAssignmentTo(stmt, candidate.varName)) { return false; } } candidate.assignments = assignments; // (c) Class must be resolvable with empty or absent constructor body const classDecl = this.resolveClass(candidate.newExpr, file); if (classDecl === null) { return false; } const constructors = classDecl.getConstructors(); if (constructors.length > 0) { const hasNonEmptyBody = constructors.some( (ctor) => (ctor.getBodyText()?.trim() ?? '') !== '', ); if (hasNonEmptyBody) { return false; } } return true; } private rewriteAsObjectLiteral(candidate: Candidate): void { // Build object literal from collected property assignments const props: { name: string; value: string }[] = []; for (const stmt of candidate.assignments) { const expr = stmt.getExpression(); if (!Node.isBinaryExpression(expr)) { continue; } const left = expr.getLeft().asKind(SyntaxKind.PropertyAccessExpression); if (!left) { continue; } props.push({ name: left.getName(), value: expr.getRight().getText() }); } const objectText = props.length > 0 ? '{ ' + props.map((p) => `${p.name}: ${p.value}`).join(', ') + ' }' : '{}'; // Add type annotation only when type arguments are present if (candidate.typeArgs.length > 0) { const className = this.getClassName(candidate.newExpr); candidate.varDecl.setType( `${className}<${candidate.typeArgs.join(', ')}>`, ); } candidate.varDecl.setInitializer(objectText); // Remove assignment statements in reverse order (preserves earlier node validity) for (const stmt of [...candidate.assignments].reverse()) { stmt.remove(); } } private isPropertyAssignmentStatement(stmt: Node, varName: string): boolean { const exprStmt = stmt.asKind(SyntaxKind.ExpressionStatement); if (!exprStmt) { return false; } const binExpr = exprStmt .getExpression() .asKind(SyntaxKind.BinaryExpression); if (!binExpr) { return false; } if (binExpr.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) { return false; } const pae = binExpr.getLeft().asKind(SyntaxKind.PropertyAccessExpression); if (!pae) { return false; } const obj = pae.getExpression(); return Node.isIdentifier(obj) && obj.getText() === varName; } private containsPropertyAssignmentTo(node: Node, varName: string): boolean { return node .getDescendantsOfKind(SyntaxKind.BinaryExpression) .some((binExpr) => { if (binExpr.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) { return false; } const pae = binExpr .getLeft() .asKind(SyntaxKind.PropertyAccessExpression); if (!pae) { return false; } const obj = pae.getExpression(); return Node.isIdentifier(obj) && obj.getText() === varName; }); } private resolveClass( newExpr: NewExpression, file: SourceFile, ): ClassDeclaration | null { const className = this.extractClassName(newExpr); if (!className) { return null; } // Check locally defined classes first const localClass = file.getClass(className); if (localClass) { return localClass; } // Follow named imports for (const importDecl of file.getImportDeclarations()) { const namedImport = importDecl .getNamedImports() .find((ni) => ni.getName() === className); if (!namedImport) { continue; } const importedFile = importDecl.getModuleSpecifierSourceFile(); if (!importedFile) { return null; } // unresolvable const cls = importedFile.getClass(className); if (cls) { return cls; } return null; // in file but class not found (re-export etc.) } return null; // not found in any import } private extractClassName(newExpr: NewExpression): string | null { const expr = newExpr.getExpression(); if (Node.isPropertyAccessExpression(expr)) { return expr.getName(); } if (Node.isIdentifier(expr)) { return expr.getText(); } return null; } private getClassName(newExpr: NewExpression): string { return this.extractClassName(newExpr) ?? ''; } private matchesPattern(name: string, patterns: (string | RegExp)[]): boolean { return patterns.some((p) => { if (typeof p === 'string') { return new RegExp(p).test(name); } return p.test(name); }); } }