/** * @angular-modernizer/plugin-angular - RxJS Optimization Rule * * Detects inefficient RxJS operator usage and suggests optimizations for better performance and maintainability. * * Detection Patterns: * - Multiple chained .pipe() calls (should consolidate) * - Missing shareReplay() for expensive operations (HTTP calls) * - Redundant sequential operators (map → map, filter → filter) * - Missing debounceTime()/throttleTime() on user input streams * - Business logic inside .subscribe() blocks (should use pipe operators) * - Nested observable calls inside subscribe (should use flattening operators) * * This rule helps improve RxJS performance and code maintainability. */ import { SyntaxKind, type ClassDeclaration, type Node } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; export interface RxJSOptimizationConfig { maxPipeChains: number; maxConsecutiveOperators: number; requireDebounceOnUserInput: boolean; maxSubscribeComplexity: number; } const DEFAULT_CONFIG: RxJSOptimizationConfig = { maxPipeChains: 1, maxConsecutiveOperators: 1, requireDebounceOnUserInput: true, maxSubscribeComplexity: 2, }; /** * RxJS Optimization Rule - detects inefficient RxJS operator usage and optimization opportunities. */ export class RxJSOptimizationRule implements AnalysisRule { public readonly id = 'plugin-angular:rxjs-optimization'; public readonly name = 'RxJS Optimization'; public readonly description = 'Detects inefficient RxJS operator usage and suggests optimizations for better performance and maintainability'; public readonly severity = 'warning'; public readonly category = 'rxjs-optimization'; public readonly tags = [ 'angular', 'rxjs', 'performance', 'optimization', 'operators', ]; private readonly config: RxJSOptimizationConfig; constructor(config: Partial = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; } async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; // Find all class declarations const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { const classViolations = this.analyzeClass( classDecl, sourceFile.getFilePath(), ); violations.push(...classViolations); } return violations; } private analyzeClass( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Analyze pipe chains const pipeViolations = this.detectMultiplePipeChains(classDecl, filePath); violations.push(...pipeViolations); // Analyze shareReplay usage const shareReplayViolations = this.detectMissingShareReplay( classDecl, filePath, ); violations.push(...shareReplayViolations); // Analyze redundant operators const redundantViolations = this.detectRedundantOperators( classDecl, filePath, ); violations.push(...redundantViolations); // Analyze debounce usage const debounceViolations = this.detectMissingDebounce(classDecl, filePath); violations.push(...debounceViolations); // Analyze subscribe logic const subscribeViolations = this.detectLogicInSubscribe( classDecl, filePath, ); violations.push(...subscribeViolations); // Analyze subscribe in subscribe patterns const subscribeInSubscribeViolations = this.detectSubscribeInSubscribe( classDecl, filePath, ); violations.push(...subscribeInSubscribeViolations); // Analyze incorrect Subject usage const incorrectSubjectViolations = this.detectIncorrectSubjectUsage( classDecl, filePath, ); violations.push(...incorrectSubjectViolations); // Detect silent HTTP catchError (Cat 4) const silentHttpViolations = this.detectSilentHttpCatchError( classDecl, filePath, ); violations.push(...silentHttpViolations); return violations; } private detectMultiplePipeChains( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Find all methods in the class const methods = classDecl.getDescendantsOfKind( SyntaxKind.MethodDeclaration, ); for (const method of methods) { const methodText = method .getSourceFile() .getFullText() .substring(method.getStart(), method.getEnd()); // Count chained pipe calls in this method let pipeCount = 0; let searchStart = 0; while (true) { const pipeIndex = methodText.indexOf('.pipe(', searchStart); if (pipeIndex === -1) { break; } pipeCount++; searchStart = pipeIndex + 6; // Skip past this .pipe( } // If chained pipes exceed the configured limit, flag it if (pipeCount > this.config.maxPipeChains) { const relativePipeIndex = methodText.indexOf('.pipe('); if (relativePipeIndex !== -1) { const absolutePipeIndex = method.getStart() + relativePipeIndex; const lineNumber = method .getSourceFile() .getLineAndColumnAtPos(absolutePipeIndex).line; violations.push({ ruleId: this.id, message: 'Multiple chained .pipe() calls should be consolidated into a single pipe', filePath, line: lineNumber, column: method .getSourceFile() .getLineAndColumnAtPos(absolutePipeIndex).column, suggestedFix: 'Combine all pipe operators into a single pipe() call', metadata: { violationType: 'operator-optimization', pattern: 'multiple-pipe-chains', principle: 'rxjs-efficiency', framework: 'angular', impact: 'medium', refactoringComplexity: 'low', }, }); } } } return violations; } private detectMissingShareReplay( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Find HTTP calls using AST-based detection instead of string matching const httpCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => this.isHttpCall(call)); // Group HTTP calls by URL/endpoint const httpCallMap = new Map(); for (const httpCall of httpCalls) { const args = httpCall.getArguments(); if (args.length > 0 && args[0]) { const urlArg = args[0].getText(); if (urlArg) { if (!httpCallMap.has(urlArg)) { httpCallMap.set(urlArg, []); } httpCallMap.get(urlArg)!.push(httpCall); } } } // Check for multiple subscriptions to same HTTP call for (const [url, calls] of httpCallMap) { if (calls.length > 1) { // Check if any of these calls has shareReplay let hasShareReplay = false; for (const call of calls) { const parentChain = this.getParentChain(call); if ( parentChain.some((expr) => expr.getText().includes('shareReplay')) ) { hasShareReplay = true; break; } } if (!hasShareReplay && calls.length > 0 && calls[0]) { violations.push({ ruleId: this.id, message: `HTTP call to ${url} is subscribed multiple times without shareReplay. Consider using shareReplay() to cache the result.`, filePath, line: calls[0].getStartLineNumber(), column: calls[0].getStart() - calls[0].getStartLinePos(), suggestedFix: 'Add .pipe(shareReplay(1)) to cache HTTP response for multiple subscribers', metadata: { violationType: 'operator-optimization', pattern: 'missing-share-replay', principle: 'rxjs-efficiency', framework: 'angular', impact: 'high', refactoringComplexity: 'medium', subscriptionCount: calls.length, }, }); } } } return violations; } private detectRedundantOperators( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Find pipe calls with redundant operators const pipeCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return exprText.endsWith('.pipe') || exprText === 'pipe'; }); for (const pipeCall of pipeCalls) { const args = pipeCall.getArguments(); // Check for consecutive identical operators const operators: string[] = []; for (const arg of args) { const argText = arg.getText(); // Extract operator name (e.g., 'map' from 'map(x => x)') const operatorMatch = /^(\w+)\s*\(/.exec(argText); if (operatorMatch?.[1]) { operators.push(operatorMatch[1]); } } // Check for consecutive same operators for (let i = 0; i < operators.length - 1; i++) { if (operators[i] === operators[i + 1]) { violations.push({ ruleId: this.id, message: `Consecutive ${operators[i]} operators can be combined into a single operator`, filePath, line: pipeCall.getStartLineNumber(), column: pipeCall.getStart() - pipeCall.getStartLinePos(), suggestedFix: `Combine the ${operators[i]} operators into a single operation`, metadata: { violationType: 'operator-optimization', pattern: 'redundant-operators', principle: 'rxjs-efficiency', framework: 'angular', impact: 'medium', refactoringComplexity: 'low', operator: operators[i], }, }); break; // Only report once per pipe chain } } } return violations; } private detectMissingDebounce( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; if (!this.config.requireDebounceOnUserInput) { return violations; } // Find pipe calls that start with valueChanges const pipeCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return ( exprText.endsWith('.pipe') && exprText.includes('.valueChanges.') ); }); for (const pipeCall of pipeCalls) { const args = pipeCall.getArguments(); // Check if any argument contains debounceTime or throttleTime let hasDebounce = false; for (const arg of args) { const argText = arg.getText(); if ( argText.includes('debounceTime') || argText.includes('throttleTime') ) { hasDebounce = true; break; } } if (!hasDebounce) { violations.push({ ruleId: this.id, message: 'FormControl valueChanges should use debounceTime or throttleTime to prevent excessive API calls', filePath, line: pipeCall.getStartLineNumber(), column: pipeCall.getStart() - pipeCall.getStartLinePos(), suggestedFix: 'Add debounceTime(300) or throttleTime(300) to prevent excessive emissions', metadata: { violationType: 'operator-optimization', pattern: 'missing-debounce', principle: 'rxjs-efficiency', framework: 'angular', impact: 'high', refactoringComplexity: 'low', }, }); } } // Also check for direct valueChanges.subscribe() calls (for performance test) const subscribeCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return ( exprText.endsWith('.subscribe') && exprText.includes('.valueChanges.') && !exprText.includes('.pipe') ); }); for (const subscribeCall of subscribeCalls) { violations.push({ ruleId: this.id, message: 'FormControl valueChanges should use debounceTime or throttleTime to prevent excessive API calls', filePath, line: subscribeCall.getStartLineNumber(), column: subscribeCall.getStart() - subscribeCall.getStartLinePos(), suggestedFix: 'Add debounceTime(300) or throttleTime(300) to prevent excessive emissions', metadata: { violationType: 'operator-optimization', pattern: 'missing-debounce', principle: 'rxjs-efficiency', framework: 'angular', impact: 'high', refactoringComplexity: 'low', }, }); } return violations; } private detectLogicInSubscribe( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Find subscribe calls - look for call expressions where the expression ends with .subscribe const subscribeCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return exprText.endsWith('.subscribe') || exprText === 'subscribe'; }); for (const subscribeCall of subscribeCalls) { const args = subscribeCall.getArguments(); if (args.length > 0 && args[0]) { const callback = args[0]; // Check if callback contains complex logic const callbackText = callback.getText(); // Count complexity indicators - exclude simple assignments const statementCount = (callbackText.match(/;/g) ?? []).length; const hasControlFlow = callbackText.includes('if ') || callbackText.includes('for ') || callbackText.includes('while ') || callbackText.includes('switch ') || callbackText.includes('try ') || callbackText.includes('catch '); const hasComplexExpressions = callbackText.includes('&& ') || callbackText.includes('|| ') || callbackText.includes('?.') || callbackText.includes('?? '); const hasMethodCalls = callbackText.includes('.map(') || callbackText.includes('.filter(') || callbackText.includes('.reduce(') || callbackText.includes('.find(') || callbackText.includes('.sort(') || callbackText.includes('.slice('); // Calculate total complexity score let complexityScore = statementCount; if (hasControlFlow) { complexityScore += 2; } if (hasComplexExpressions) { complexityScore += 1; } // Method calls are already counted in statement complexity // Don't flag simple assignments like "this.result = data;" const isSimpleAssignment = /^[^;]*this\.\w+\s*=\s*[^;]+;?\s*$/.test(callbackText.trim()) && !hasControlFlow && !hasComplexExpressions && !hasMethodCalls; if ( complexityScore > this.config.maxSubscribeComplexity && !isSimpleAssignment ) { violations.push({ ruleId: this.id, message: 'Complex business logic in subscribe block should be moved to pipe operators for better testability and reusability', filePath, line: subscribeCall.getStartLineNumber(), column: subscribeCall.getStart() - subscribeCall.getStartLinePos(), suggestedFix: 'Extract logic into pipe operators (map, filter, tap) before subscribe', metadata: { violationType: 'operator-optimization', pattern: 'logic-in-subscribe', principle: 'rxjs-efficiency', framework: 'angular', impact: 'medium', refactoringComplexity: 'medium', }, }); } } } return violations; } private detectSubscribeInSubscribe( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Find subscribe calls const subscribeCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return exprText.endsWith('.subscribe') || exprText === 'subscribe'; }); for (const subscribeCall of subscribeCalls) { const args = subscribeCall.getArguments(); if (args.length > 0 && args[0]) { const callback = args[0]; // Check if callback contains HTTP calls or other subscribe calls const callbackText = callback.getText(); // Look for HTTP calls inside subscribe callback const hasHttpCalls = callbackText.includes('this.http.') || callbackText.includes('http.'); // Look for other subscribe calls inside the callback const hasNestedSubscribe = callbackText.includes('.subscribe(') && !hasHttpCalls; // Look for observable method calls that suggest nested async operations const hasObservableMethods = callbackText.includes('.pipe(') || callbackText.includes('Observable.') || callbackText.includes('Subject.') || callbackText.includes('BehaviorSubject.') || /\w+\.\w+\([^)]*\)\.subscribe\(/.test(callbackText); // Method calls followed by .subscribe // Check if the outer observable is already using flattening operators let isAlreadyFlattened = false; // Check parent chain for flattening operators const parentChain = this.getParentChain(subscribeCall); isAlreadyFlattened = parentChain.some((expr) => { const exprText = expr.getText(); return ( exprText.includes('switchMap') || exprText.includes('mergeMap') || exprText.includes('concatMap') || exprText.includes('exhaustMap') ); }); // If not already flattened and contains nested async operations, flag it if ( !isAlreadyFlattened && (hasHttpCalls || hasNestedSubscribe || hasObservableMethods) ) { // Additional check: make sure it's not just a simple assignment or method call const hasComplexNestedLogic = callbackText.includes('=>') && (callbackText.includes('{') || callbackText.includes(';')); if (hasComplexNestedLogic) { // Determine the type of nested operations with proper priority let nestedOperations = 'unknown'; if (hasHttpCalls) { nestedOperations = 'http-calls'; } else if (hasObservableMethods) { nestedOperations = 'observable-methods'; } else if (hasNestedSubscribe) { nestedOperations = 'nested-subscribe'; } violations.push({ ruleId: this.id, message: 'Nested observable calls inside subscribe should use flattening operators (switchMap/mergeMap/concatMap) instead of nested subscriptions', filePath, line: subscribeCall.getStartLineNumber(), column: subscribeCall.getStart() - subscribeCall.getStartLinePos(), suggestedFix: 'Replace nested subscribe with switchMap, mergeMap, or concatMap operator', metadata: { violationType: 'operator-optimization', pattern: 'subscribe-in-subscribe', principle: 'rxjs-efficiency', framework: 'angular', impact: 'high', refactoringComplexity: 'medium', nestedOperations, }, }); } } } } return violations; } private detectIncorrectSubjectUsage( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; // Find Subject declarations (not BehaviorSubject) const subjectDeclarations = classDecl .getDescendantsOfKind(SyntaxKind.PropertyDeclaration) .filter((decl) => { // Check initializer for Subject constructor calls const initializer = decl.getInitializer(); if (!initializer) { return false; } const initText = initializer.getText(); return ( initText.includes('new Subject') && !initText.includes('new BehaviorSubject') ); }); for (const subjectDecl of subjectDeclarations) { const subjectName = subjectDecl.getName(); // Look for patterns that suggest state management usage // 1. Check if it's exposed as an observable (common state pattern) const exposedProperties = classDecl .getDescendantsOfKind(SyntaxKind.PropertyDeclaration) .filter((prop) => { const initializer = prop.getInitializer(); if (!initializer) { return false; } const initText = initializer.getText(); // Check if it references our subject and calls asObservable() return ( initText.includes(`${subjectName}.asObservable()`) || initText.includes(`${subjectName}.asObservable`) ); }); if (exposedProperties.length > 0) { // Look for methods that call .next() on this subject (state updates) const nextCalls = classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return ( exprText === `${subjectName}.next` || exprText === `this.${subjectName}.next` ); }); // Look for state-like property names const hasStateLikeName = exposedProperties.some( (prop) => prop.getName().toLowerCase().includes('current') || prop.getName().toLowerCase().includes('state') || prop.getName().toLowerCase().includes('selected') || prop.getName().toLowerCase().includes('active'), ); // This looks like state management - Subject used for exposing state // Check if it's in a service (common pattern) - but allow components to have local subjects const decorators = classDecl.getDecorators(); const isInService = decorators.some( (decorator) => decorator.getName() === 'Injectable' || classDecl.getName()?.toLowerCase().includes('service'), ); const isInComponent = decorators.some( (decorator) => decorator.getName() === 'Component' || classDecl.getName()?.toLowerCase().includes('component'), ); // For components, be more restrictive - only flag if it has clear state management patterns if (isInComponent && !isInService) { // Only flag in components if it has multiple state-like indicators const stateIndicators = [ nextCalls.length > 0, hasStateLikeName, exposedProperties.length > 1, // Multiple exposed observables ].filter(Boolean).length; if (stateIndicators < 2) { continue; // Not enough evidence of state management in component } } // If it has state-like usage patterns, flag it if (isInService || nextCalls.length > 0 || hasStateLikeName) { violations.push({ ruleId: this.id, message: 'Subject used for state management should be BehaviorSubject to provide initial values to new subscribers', filePath, line: subjectDecl.getStartLineNumber(), column: subjectDecl.getStart() - subjectDecl.getStartLinePos(), suggestedFix: 'Replace Subject with BehaviorSubject and provide an initial value', metadata: { violationType: 'operator-optimization', pattern: 'incorrect-subject-usage', principle: 'rxjs-efficiency', framework: 'angular', impact: 'medium', refactoringComplexity: 'low', subjectName, exposedAsObservable: exposedProperties.length > 0, hasStateMethods: nextCalls.length > 0, isInService, }, }); } } } return violations; } /** * Detects HTTP observables that silently swallow errors via catchError returning * of(null), of([]), of(undefined), or EMPTY — callers cannot distinguish success * from failure. */ private detectSilentHttpCatchError( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const className = classDecl.getName() ?? 'AnonymousClass'; // Pattern: catchError(err => ...) | catchError(() => ...) | catchError((err) => ...) // returning of(null), of([]), of(undefined), or EMPTY with no re-throw const silentPattern = /catchError\s*\(\s*(?:\(\s*\w*\s*\)|\w+\s*)?\s*=>\s*(?:of\s*\(\s*(?:null|undefined|\[\])\s*\)|EMPTY\b)/; for (const method of classDecl.getMethods()) { const methodText = method.getFullText(); // Must involve an HTTP client call const hasHttpCall = methodText.includes('this.http.') || methodText.includes('this.httpClient.'); if (!hasHttpCall) { continue; } if (!methodText.includes('catchError(')) { continue; } if (!silentPattern.test(methodText)) { continue; } violations.push({ ruleId: this.id, message: 'HTTP observable swallows errors silently. Callers cannot distinguish success from failure.', filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: 'Log the error and re-throw, or let it propagate to a global error handler.', metadata: { violationType: 'operator-optimization', pattern: 'silent-http-catch-error', principle: 'rxjs-efficiency', framework: 'angular', impact: 'high', refactoringComplexity: 'low', className, }, }); } return violations; } private isHttpCall(call: Node): boolean { // Check if this is a CallExpression if (!call.isKind(SyntaxKind.CallExpression)) { return false; } const callExpr = call; // Type assertion for ts-morph CallExpression const expression = callExpr.getExpression(); // Check if it's a property access expression (like this.httpClient.get()) if (expression.isKind(SyntaxKind.PropertyAccessExpression)) { const propAccess = expression; // Type assertion for PropertyAccessExpression const methodName = propAccess.getName(); // Common HttpClient method names const httpMethods = [ 'get', 'post', 'put', 'delete', 'patch', 'head', 'options', ]; if (httpMethods.includes(methodName)) { // Check if the property being accessed is likely an HttpClient // Look at the expression being accessed (the part before the dot) const accessedExpr = propAccess.getExpression(); // Check if it's a property access on 'this' (this.httpClient) if (accessedExpr.isKind(SyntaxKind.PropertyAccessExpression)) { const nestedPropAccess = accessedExpr; if (nestedPropAccess.getExpression().getText() === 'this') { // It's this.something.method() - check if 'something' looks like HttpClient const propertyName = nestedPropAccess.getName().toLowerCase(); return ( propertyName.includes('http') || propertyName.includes('client') || propertyName.includes('api') || propertyName.includes('service') ); } } // Check if it's a direct property access that might be HttpClient if (accessedExpr.isKind(SyntaxKind.Identifier)) { const identifierName = accessedExpr.getText().toLowerCase(); return ( identifierName.includes('http') || identifierName.includes('client') || identifierName.includes('api') || identifierName.includes('service') ); } // Check if it's an injected property by looking at constructor parameters // This is more complex but would require analyzing the class constructor // For now, we'll rely on the method name and property naming heuristics } } return false; } private getParentChain(node: Node): Node[] { const chain: Node[] = []; let current = node; while (current) { const parentCall = current.getParentIfKind(SyntaxKind.CallExpression); if (!parentCall) { break; } chain.push(parentCall.getExpression()); current = parentCall; } return chain; } }