/** * @angular-modernizer/plugin-angular - Method Body Analyzer */ import { type ClassDeclaration, type SourceFile, Node } from 'ts-morph'; export interface IdentifierUsage { name: string; moduleSpecifier: string; alias?: string; kind: 'named' | 'namespace' | 'default'; } /** * Analyzes method bodies using AST traversal to extract imported identifiers. * * This class replaces regex-based pattern matching with proper AST analysis * to accurately identify which imported symbols are used in method bodies. * * @example * ```typescript * const analyzer = new MethodBodyAnalyzer(); * const identifiers = analyzer.analyzeMethodBodies(classDecl, sourceFile); * // Returns: [ * // { name: 'LoginHelper', moduleSpecifier: '@shared/helpers/login-helper', kind: 'named' }, * // { name: 'map', moduleSpecifier: 'rxjs/operators', kind: 'named' } * // ] * ``` */ export class MethodBodyAnalyzer { /** * Analyzes method bodies using AST traversal to extract imported identifiers. * * Uses ts-morph's forEachDescendant() to traverse AST nodes and identify: * - Identifier nodes that reference imported symbols * - Property access expressions (e.g., LoginHelper.isUWISuperUser) * - Call expressions with imported functions * * Filters out: * - Local variables and parameters * - Built-in types (string, number, Array, etc.) * - Keywords (this, return, const, etc.) * - Class properties accessed via 'this' * * @param classDecl - The class declaration to analyze * @param originalServiceFile - The source file containing import declarations * @returns Array of imported identifiers used in method bodies */ analyzeMethodBodies( classDecl: ClassDeclaration, originalServiceFile: SourceFile, ): IdentifierUsage[] { const identifiers: IdentifierUsage[] = []; const importMap = this.buildImportMap(originalServiceFile); const methods = classDecl.getMethods(); for (const method of methods) { const body = method.getBody(); if (!body) { continue; } // AST traversal using ts-morph body.forEachDescendant((node) => { // Handle Identifier nodes if (Node.isIdentifier(node)) { const text = node.getText(); const importInfo = importMap.get(text); if (importInfo && !this.isLocalIdentifier(node)) { identifiers.push({ name: importInfo.originalName, moduleSpecifier: importInfo.moduleSpecifier, alias: importInfo.alias, kind: importInfo.kind, }); } } // Handle PropertyAccessExpression (e.g., LoginHelper.isUWISuperUser) if (Node.isPropertyAccessExpression(node)) { const expression = node.getExpression(); if (Node.isIdentifier(expression)) { const text = expression.getText(); const importInfo = importMap.get(text); if (importInfo) { identifiers.push({ name: importInfo.originalName, moduleSpecifier: importInfo.moduleSpecifier, alias: importInfo.alias, kind: importInfo.kind, }); } } } }); } // Remove duplicates return this.deduplicateIdentifiers(identifiers); } /** * Builds a map of all imports in the source file. * * Handles: * - Named imports: import { LoginHelper } from '@shared/helpers' * - Namespace imports: import * as _ from 'lodash' * - Default imports: import moment from 'moment' * - Aliased imports: import { Model as DomainModel } from '@core/models' * * @param sourceFile - The source file to extract imports from * @returns Map of identifier names to import information */ private buildImportMap(sourceFile: SourceFile): Map< string, { originalName: string; moduleSpecifier: string; alias?: string; kind: 'named' | 'namespace' | 'default'; } > { const importMap = new Map< string, { originalName: string; moduleSpecifier: string; alias?: string; kind: 'named' | 'namespace' | 'default'; } >(); const imports = sourceFile.getImportDeclarations(); for (const importDecl of imports) { const moduleSpecifier = importDecl.getModuleSpecifierValue(); // Named imports for (const namedImport of importDecl.getNamedImports()) { const originalName = namedImport.getName(); const alias = namedImport.getAliasNode()?.getText(); const keyInCode = alias ?? originalName; importMap.set(keyInCode, { originalName, moduleSpecifier, alias, kind: 'named', }); } // Namespace imports (import * as _ from 'lodash') const namespaceImport = importDecl.getNamespaceImport(); if (namespaceImport) { const name = namespaceImport.getText(); importMap.set(name, { originalName: name, moduleSpecifier, kind: 'namespace', }); } // Default imports (import moment from 'moment') const defaultImport = importDecl.getDefaultImport(); if (defaultImport) { const name = defaultImport.getText(); importMap.set(name, { originalName: name, moduleSpecifier, kind: 'default', }); } } return importMap; } /** * Checks if an identifier is a local variable, parameter, or property. * * Filters out: * - Variable declarations (const localVar = ...) * - Function parameters (function(param) {...}) * - Class properties (this.property) * - Property declarations (private myProp: string) * * @param node - The identifier node to check * @returns True if the identifier is local (should be filtered out) */ private isLocalIdentifier(node: Node): boolean { const parent = node.getParent(); // Skip if it's a variable declaration if (Node.isVariableDeclaration(parent)) { return true; } // Skip if it's a parameter if (Node.isParameterDeclaration(parent)) { return true; } // Skip if it's a property declaration if (Node.isPropertyDeclaration(parent)) { return true; } // Skip if it's part of 'this.identifier' if ( Node.isPropertyAccessExpression(parent) && parent.getExpression().getText() === 'this' ) { return true; } // Skip if it's the identifier being declared in a variable if (Node.isBindingElement(parent)) { return true; } return false; } /** * Removes duplicate identifiers from the list. * * Deduplication is based on the combination of: * - Module specifier * - Original name * - Alias (if present) * * @param identifiers - Array of identifiers to deduplicate * @returns Array with duplicates removed */ private deduplicateIdentifiers( identifiers: IdentifierUsage[], ): IdentifierUsage[] { const seen = new Set(); const result: IdentifierUsage[] = []; for (const identifier of identifiers) { const key = `${identifier.moduleSpecifier}::${identifier.name}::${identifier.alias ?? ''}`; if (!seen.has(key)) { seen.add(key); result.push(identifier); } } return result; } }