/** * @angular-modernizer/plugin-angular - Template Complexity Rule * * Detects complex operations in Angular templates that can cause performance issues * and side effects. * * Detection Patterns: * - Function calls in templates (method calls that may have side effects) * - Complex expressions in templates * - Multiple nested operations in template bindings * - Async operations in templates without proper handling * - Template expressions that are too long or complex * * This rule helps improve template performance and maintainability. */ import { SyntaxKind, type ClassDeclaration } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * Template Complexity Rule - detects complex operations in Angular templates. */ export class TemplateComplexityRule implements AnalysisRule { public readonly id = 'angular:template-complexity'; public readonly name = 'Template Complexity'; public readonly description = 'Detects complex operations in Angular templates that can cause performance issues and side effects'; public readonly severity = 'warning'; public readonly category = 'angular-performance'; public readonly tags = [ 'angular', 'template', 'performance', 'complexity', 'side-effects', ]; async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; // Find all class declarations (Angular components) const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { const classViolations = this.analyzeComponent( classDecl, sourceFile.getFilePath(), ); violations.push(...classViolations); } return violations; } private analyzeComponent( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const className = classDecl.getName(); if (!className) { return violations; } // Only analyze Angular components if (!this.isAngularComponent(classDecl)) { return violations; } const templateContent = this.extractTemplateContent(classDecl); if (!templateContent) { return violations; } // Analyze template for various complexity issues const templateViolations = this.analyzeTemplateContent( templateContent, className, filePath, ); violations.push(...templateViolations); // Analyze component methods that might be called from templates const methodViolations = this.analyzeTemplateMethods( classDecl, className, filePath, ); violations.push(...methodViolations); return violations; } private isAngularComponent(classDecl: ClassDeclaration): boolean { const decorators = classDecl.getDecorators(); return decorators.some((decorator) => decorator.getName() === 'Component'); } private extractTemplateContent(classDecl: ClassDeclaration): string | null { const componentDecorator = classDecl .getDecorators() .find((d) => d.getName() === 'Component'); if (!componentDecorator) { return null; } try { const decoratorText = componentDecorator.getFullText(); // Extract template from inline template or templateUrl const templateMatch = /template\s*:\s*[`'"]([^`'"]*)[`'"]/.exec( decoratorText, ); if (templateMatch?.[1]) { return templateMatch[1]; } // Check for templateUrl (we can't analyze external templates easily) if (decoratorText.includes('templateUrl')) { return null; // Skip external templates for now } return null; } catch (_error) { return null; } } private analyzeTemplateContent( templateContent: string, className: string, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const lines = templateContent.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line) { continue; } const lineNumber = i + 1; // Approximate line number // Rule 1: Function calls in templates (potential side effects) const functionCallRegex = /\b\w+\([^)]*\)/g; const functionCalls = line.match(functionCallRegex); if (functionCalls) { for (const call of functionCalls) { // Skip Angular built-in functions and simple property access if ( this.isAngularBuiltInFunction(call) || this.isSimplePropertyAccess(call) ) { continue; } violations.push({ ruleId: this.id, message: `Template in '${className}' contains function call '${call}' which may cause side effects. Consider using pure pipes or computed properties.`, filePath, line: lineNumber, column: line.indexOf(call), suggestedFix: `Replace with a pure pipe or move logic to component. Example: {{ getItemName(item) }} → {{ item | itemName }}`, metadata: { className, violationType: 'function-call-in-template', functionCall: call, templateSnippet: line.trim(), framework: 'angular', }, }); } } // Rule 2: Complex expressions (multiple operators) const complexExpressionRegex = /\{\{[^}]{50,}\}\}/g; // Expressions longer than 50 chars const complexExpressions = line.match(complexExpressionRegex); if (complexExpressions) { for (const expr of complexExpressions) { const operatorCount = (expr.match(/[+\-*/|&!<>=?]+/g) ?? []).length; if (operatorCount > 2) { violations.push({ ruleId: this.id, message: `Template in '${className}' contains complex expression with ${operatorCount} operators. Consider simplifying or moving to component.`, filePath, line: lineNumber, column: line.indexOf(expr), suggestedFix: `Create a computed property in component: get complexValue() { return ${expr.slice(2, -2)}; }`, metadata: { className, violationType: 'complex-expression', expression: expr, operatorCount, templateSnippet: line.trim(), framework: 'angular', }, }); } } } // Rule 3: Nested property access (potential null reference issues) const nestedPropertyRegex = /\w+(\.\w+){3,}/g; // More than 3 levels of nesting const nestedProperties = line.match(nestedPropertyRegex); if (nestedProperties) { for (const prop of nestedProperties) { violations.push({ ruleId: this.id, message: `Template in '${className}' contains deeply nested property access '${prop}'. Consider using safe navigation operator or computed property.`, filePath, line: lineNumber, column: line.indexOf(prop), suggestedFix: `Use safe navigation: ${prop.replaceAll('.', '?.')} or create computed property.`, metadata: { className, violationType: 'nested-property-access', property: prop, nestingLevel: prop.split('.').length, templateSnippet: line.trim(), framework: 'angular', }, }); } } // Rule 4: Async operations without proper handling if (line.includes('| async') && line.includes('subscribe')) { violations.push({ ruleId: this.id, message: `Template in '${className}' contains async pipe with subscribe. Async pipe handles subscription automatically.`, filePath, line: lineNumber, column: line.indexOf('| async'), suggestedFix: `Remove manual subscribe when using async pipe: {{ observable$ | async }}`, metadata: { className, violationType: 'async-pipe-with-subscribe', templateSnippet: line.trim(), framework: 'angular', }, }); } } return violations; } private analyzeTemplateMethods( classDecl: ClassDeclaration, className: string, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const methods = classDecl.getMethods(); for (const method of methods) { const methodName = method.getName(); if (!methodName) { continue; } // Check if method might be called from template (heuristic) const methodBody = method.getBody(); if (!methodBody) { continue; } const bodyText = methodBody.getFullText(); // Rule: Methods that modify state (potential side effects in templates) const hasStateModification = bodyText.includes('this.') && (bodyText.includes('=') || bodyText.includes('++') || bodyText.includes('--')); if (hasStateModification) { violations.push({ ruleId: this.id, message: `Method '${methodName}' in '${className}' modifies component state and may cause side effects if called from template.`, filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: `Avoid calling state-modifying methods from templates. Use events or computed properties instead.`, metadata: { className, methodName, violationType: 'state-modifying-method', framework: 'angular', }, }); } // Rule: Methods that perform expensive operations const hasExpensiveOperations = bodyText.includes('setTimeout') || bodyText.includes('setInterval') || bodyText.includes('XMLHttpRequest') || bodyText.includes('fetch(') || bodyText.includes('.subscribe('); if (hasExpensiveOperations) { violations.push({ ruleId: this.id, message: `Method '${methodName}' in '${className}' performs expensive operations and should not be called from template.`, filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: `Move expensive operations to ngOnInit or use events. Cache results in component properties.`, metadata: { className, methodName, violationType: 'expensive-method-call', framework: 'angular', }, }); } } return violations; } private isAngularBuiltInFunction(call: string): boolean { const angularBuiltIns = [ 'trackBy', 'slice(', 'substring(', 'toLowerCase(', 'toUpperCase(', 'indexOf(', 'includes(', 'length', 'size', 'count', ]; return angularBuiltIns.some((builtIn) => call.includes(builtIn)); } private isSimplePropertyAccess(call: string): boolean { // Simple property access like obj.prop or arr.length const hasParentheses = call.includes('('); const isSimpleAccess = /^\w+\.\w+$/.exec(call); return !hasParentheses || !!isSimpleAccess; } }