diff --git a/.gitignore b/.gitignore index 08f09d0..de8c042 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,10 @@ /.phpunit.cache/ /tmp/ /tools/ +/benchmarks/storage/ .idea/ .env +.phpbench/ infection.log phive.phar phpcca.phar diff --git a/phpbench.json b/phpbench.json index a6b669c..ba837f2 100644 --- a/phpbench.json +++ b/phpbench.json @@ -3,5 +3,7 @@ "runner.bootstrap": "vendor/autoload.php", "runner.path": "benchmarks", "runner.time_unit": "milliseconds", - "runner.progress": "dots" + "runner.progress": "dots", + "storage.driver": "xml", + "storage.xml_storage_path": "benchmarks/storage" } diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index 4f791e5..f9d5380 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -81,6 +81,7 @@ private function getCodeFromFile(SplFileInfo $file): string private function findMetrics(iterable $files): CognitiveMetricsCollection { $metricsCollection = new CognitiveMetricsCollection(); + $fileCount = 0; foreach ($files as $file) { try { @@ -90,6 +91,14 @@ private function findMetrics(iterable $files): CognitiveMetricsCollection // Store ignored items from the parser $this->ignoredItems = $this->parser->getIgnored(); + + $fileCount++; + + // Clear memory periodically to prevent memory leaks + if ($fileCount % 50 === 0) { + $this->parser->clearStaticCaches(); + gc_collect_cycles(); + } } catch (Throwable $exception) { $this->messageBus->dispatch(new ParserFailed( $file, diff --git a/src/Business/Cognitive/Parser.php b/src/Business/Cognitive/Parser.php index 5873d7e..c2b8540 100644 --- a/src/Business/Cognitive/Parser.php +++ b/src/Business/Cognitive/Parser.php @@ -9,11 +9,13 @@ use Phauthentic\CognitiveCodeAnalysis\PhpParser\CognitiveMetricsVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\HalsteadMetricsVisitor; +use Phauthentic\CognitiveCodeAnalysis\PhpParser\CombinedMetricsVisitor; use PhpParser\NodeTraverserInterface; use PhpParser\Parser as PhpParser; use PhpParser\NodeTraverser; use PhpParser\Error; use PhpParser\ParserFactory; +use ReflectionClass; /** * @@ -25,6 +27,7 @@ class Parser protected CognitiveMetricsVisitor $cognitiveMetricsVisitor; protected CyclomaticComplexityVisitor $cyclomaticComplexityVisitor; protected HalsteadMetricsVisitor $halsteadMetricsVisitor; + protected CombinedMetricsVisitor $combinedVisitor; public function __construct( ParserFactory $parserFactory, @@ -47,6 +50,10 @@ public function __construct( $this->halsteadMetricsVisitor = new HalsteadMetricsVisitor(); $this->halsteadMetricsVisitor->setAnnotationVisitor($this->annotationVisitor); $this->traverser->addVisitor($this->halsteadMetricsVisitor); + + // Create the combined visitor for performance optimization + $this->combinedVisitor = new CombinedMetricsVisitor(); + $this->combinedVisitor->setAnnotationVisitor(); } /** @@ -58,14 +65,35 @@ public function parse(string $code): array // First, scan for annotations to collect ignored items $this->scanForAnnotations($code); - // Then parse for metrics - $this->traverseAbstractSyntaxTree($code); + // Then parse for metrics using the combined visitor for better performance + $this->traverseAbstractSyntaxTreeWithCombinedVisitor($code); + + // Get all metrics before resetting + $methodMetrics = $this->combinedVisitor->getMethodMetrics(); + $cyclomaticMetrics = $this->combinedVisitor->getMethodComplexity(); + $halsteadMetrics = $this->combinedVisitor->getHalsteadMethodMetrics(); - $methodMetrics = $this->cognitiveMetricsVisitor->getMethodMetrics(); - $this->cognitiveMetricsVisitor->resetValues(); + // Now reset the combined visitor + $this->combinedVisitor->resetAll(); - $methodMetrics = $this->getCyclomaticComplexityVisitor($methodMetrics); - $methodMetrics = $this->getHalsteadMetricsVisitor($methodMetrics); + // Add cyclomatic complexity to method metrics + foreach ($cyclomaticMetrics as $method => $complexityData) { + if (isset($methodMetrics[$method])) { + $complexity = $complexityData['complexity'] ?? $complexityData; + $riskLevel = $complexityData['risk_level'] ?? $this->getRiskLevel($complexity); + $methodMetrics[$method]['cyclomatic_complexity'] = [ + 'complexity' => $complexity, + 'risk_level' => $riskLevel + ]; + } + } + + // Add Halstead metrics to method metrics + foreach ($halsteadMetrics as $method => $metrics) { + if (isset($methodMetrics[$method])) { + $methodMetrics[$method]['halstead'] = $metrics; + } + } return $methodMetrics; } @@ -95,9 +123,10 @@ private function scanForAnnotations(string $code): void } /** + * Traverse the AST using the combined visitor for better performance. * @throws CognitiveAnalysisException */ - private function traverseAbstractSyntaxTree(string $code): void + private function traverseAbstractSyntaxTreeWithCombinedVisitor(string $code): void { try { $ast = $this->parser->parse($code); @@ -109,58 +138,12 @@ private function traverseAbstractSyntaxTree(string $code): void throw new CognitiveAnalysisException("Could not parse the code."); } - $this->traverser->traverse($ast); + // Create a new traverser for the combined visitor + $combinedTraverser = new NodeTraverser(); + $combinedTraverser->addVisitor($this->combinedVisitor); + $combinedTraverser->traverse($ast); } - /** - * @param array> $methodMetrics - * @return array> - */ - private function getHalsteadMetricsVisitor(array $methodMetrics): array - { - $halstead = $this->halsteadMetricsVisitor->getMetrics(); - foreach ($halstead['methods'] as $method => $metrics) { - // Skip ignored methods - if ($this->annotationVisitor->isMethodIgnored($method)) { - continue; - } - // Skip malformed method keys (ClassName::) - if (str_ends_with($method, '::')) { - continue; - } - // Only add Halstead metrics to methods that were processed by CognitiveMetricsVisitor - if (isset($methodMetrics[$method])) { - $methodMetrics[$method]['halstead'] = $metrics; - } - } - - return $methodMetrics; - } - - /** - * @param array> $methodMetrics - * @return array> - */ - private function getCyclomaticComplexityVisitor(array $methodMetrics): array - { - $cyclomatic = $this->cyclomaticComplexityVisitor->getComplexitySummary(); - foreach ($cyclomatic['methods'] as $method => $complexity) { - // Skip ignored methods - if ($this->annotationVisitor->isMethodIgnored($method)) { - continue; - } - // Skip malformed method keys (ClassName::) - if (str_ends_with($method, '::')) { - continue; - } - // Only add cyclomatic complexity to methods that were processed by CognitiveMetricsVisitor - if (isset($methodMetrics[$method])) { - $methodMetrics[$method]['cyclomatic_complexity'] = $complexity; - } - } - - return $methodMetrics; - } /** * Get all ignored classes and methods. @@ -191,4 +174,54 @@ public function getIgnoredMethods(): array { return $this->annotationVisitor->getIgnoredMethods(); } + + /** + * Clear static caches to prevent memory leaks during long-running processes. + */ + public function clearStaticCaches(): void + { + // Clear FQCN caches from all visitors + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\PhpParser\CognitiveMetricsVisitor', 'fqcnCache'); + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor', 'fqcnCache'); + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\PhpParser\HalsteadMetricsVisitor', 'fqcnCache'); + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\PhpParser\AnnotationVisitor', 'fqcnCache'); + + // Clear regex pattern caches + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\Business\DirectoryScanner', 'compiledPatterns'); + $this->clearStaticProperty('Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector', 'compiledPatterns'); + + // Clear accumulated data in visitors + $this->combinedVisitor->resetAllBetweenFiles(); + } + + /** + * Clear a static property using reflection. + */ + private function clearStaticProperty(string $className, string $propertyName): void + { + try { + /** @var class-string $className */ + $reflection = new ReflectionClass($className); + if ($reflection->hasProperty($propertyName)) { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue(null, []); + } + } catch (\ReflectionException $e) { + // Ignore reflection errors + } + } + + /** + * Calculate risk level based on cyclomatic complexity. + */ + private function getRiskLevel(int $complexity): string + { + return match (true) { + $complexity <= 5 => 'low', + $complexity <= 10 => 'medium', + $complexity <= 15 => 'high', + default => 'very_high', + }; + } } diff --git a/src/PhpParser/AnnotationVisitor.php b/src/PhpParser/AnnotationVisitor.php index d5fadcc..3ac2bb3 100644 --- a/src/PhpParser/AnnotationVisitor.php +++ b/src/PhpParser/AnnotationVisitor.php @@ -195,4 +195,13 @@ public function reset(): void $this->currentNamespace = ''; $this->currentClassName = ''; } + + /** + * Reset only the current context (for between-file cleanup). + */ + public function resetContext(): void + { + $this->currentNamespace = ''; + $this->currentClassName = ''; + } } diff --git a/src/PhpParser/CognitiveMetricsVisitor.php b/src/PhpParser/CognitiveMetricsVisitor.php index b8ac571..259a4b6 100644 --- a/src/PhpParser/CognitiveMetricsVisitor.php +++ b/src/PhpParser/CognitiveMetricsVisitor.php @@ -25,6 +25,11 @@ class CognitiveMetricsVisitor extends NodeVisitorAbstract private string $currentMethod = ''; private int $currentReturnCount = 0; + /** + * @var array Cache for normalized FQCNs + */ + private static array $fqcnCache = []; + /** * @var AnnotationVisitor|null The annotation visitor to check for ignored items */ @@ -73,6 +78,19 @@ public function resetValues(): void $this->ifCount = 0; } + /** + * Reset all data including method metrics (for memory cleanup between files). + */ + public function resetAll(): void + { + // Clear all accumulated data to prevent memory leaks + $this->methodMetrics = []; + $this->currentNamespace = ''; + $this->currentClassName = ''; + $this->currentMethod = ''; + $this->resetValues(); + } + /** * Create the initial metrics array for a method. */ @@ -237,10 +255,15 @@ private function setCurrentClassOnEnterNode(Node $node): bool /** * Ensures the FQCN always starts with a backslash. + * Uses caching to avoid repeated string operations. */ private function normalizeFqcn(string $fqcn): string { - return str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + if (!isset(self::$fqcnCache[$fqcn])) { + self::$fqcnCache[$fqcn] = str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + } + + return self::$fqcnCache[$fqcn]; } public function enterNode(Node $node): int|Node|null diff --git a/src/PhpParser/CombinedMetricsVisitor.php b/src/PhpParser/CombinedMetricsVisitor.php new file mode 100644 index 0000000..87ace32 --- /dev/null +++ b/src/PhpParser/CombinedMetricsVisitor.php @@ -0,0 +1,144 @@ +annotationVisitor = new AnnotationVisitor(); + $this->cognitiveVisitor = new CognitiveMetricsVisitor(); + $this->cyclomaticVisitor = new CyclomaticComplexityVisitor(); + $this->halsteadVisitor = new HalsteadMetricsVisitor(); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeTraverse(array $nodes): ?array + { + // Reset all visitors before traversal + $this->resetAll(); + + return null; + } + + public function enterNode(Node $node): int|Node|null + { + // Process all visitors in sequence + $result1 = $this->annotationVisitor->enterNode($node); + $result2 = $this->cognitiveVisitor->enterNode($node); + $result3 = $this->cyclomaticVisitor->enterNode($node); + $result4 = $this->halsteadVisitor->enterNode($node); + + // If any visitor wants to skip children, respect that + if ( + $result1 === NodeVisitor::DONT_TRAVERSE_CHILDREN || + $result2 === NodeVisitor::DONT_TRAVERSE_CHILDREN || + $result3 === NodeVisitor::DONT_TRAVERSE_CHILDREN || + $result4 === NodeVisitor::DONT_TRAVERSE_CHILDREN + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + return null; + } + + public function leaveNode(Node $node): int|Node|null + { + // Process all visitors in sequence + $this->annotationVisitor->leaveNode($node); + $this->cognitiveVisitor->leaveNode($node); + $this->cyclomaticVisitor->leaveNode($node); + $this->halsteadVisitor->leaveNode($node); + + return null; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterTraverse(array $nodes): ?array + { + return null; + } + + /** + * Reset all visitors to their initial state. + */ + public function resetAll(): void + { + $this->annotationVisitor->reset(); + $this->cognitiveVisitor->resetValues(); + $this->cyclomaticVisitor->resetAll(); + $this->halsteadVisitor->resetMetrics(); + } + + /** + * Reset all visitors between files (for memory cleanup). + */ + public function resetAllBetweenFiles(): void + { + $this->annotationVisitor->resetContext(); + $this->cognitiveVisitor->resetAll(); + $this->cyclomaticVisitor->resetAll(); + $this->halsteadVisitor->resetAll(); + } + + /** + * Get method metrics from the cognitive visitor. + */ + public function getMethodMetrics(): array + { + return $this->cognitiveVisitor->getMethodMetrics(); + } + + /** + * Get method complexity from the cyclomatic visitor. + */ + public function getMethodComplexity(): array + { + $summary = $this->cyclomaticVisitor->getComplexitySummary(); + return $summary['methods'] ?? []; + } + + /** + * Get method metrics from the Halstead visitor. + */ + public function getHalsteadMethodMetrics(): array + { + $metrics = $this->halsteadVisitor->getMetrics(); + return $metrics['methods'] ?? []; + } + + /** + * Get ignored items from the annotation visitor. + */ + public function getIgnored(): array + { + return $this->annotationVisitor->getIgnored(); + } + + /** + * Set the annotation visitor for the cognitive visitor. + */ + public function setAnnotationVisitor(): void + { + $this->cognitiveVisitor->setAnnotationVisitor($this->annotationVisitor); + } +} diff --git a/src/PhpParser/CyclomaticComplexityVisitor.php b/src/PhpParser/CyclomaticComplexityVisitor.php index c5655dd..e9c95fa 100644 --- a/src/PhpParser/CyclomaticComplexityVisitor.php +++ b/src/PhpParser/CyclomaticComplexityVisitor.php @@ -39,6 +39,11 @@ class CyclomaticComplexityVisitor extends NodeVisitorAbstract private string $currentClassName = ''; private string $currentMethod = ''; + /** + * @var array Cache for normalized FQCNs + */ + private static array $fqcnCache = []; + /** * @var AnnotationVisitor|null The annotation visitor to check for ignored items */ @@ -90,6 +95,21 @@ public function resetMethodCounters(): void $this->ternaryCount = 0; } + /** + * Reset all accumulated data (for memory cleanup between files). + */ + public function resetAll(): void + { + // Clear all accumulated data to prevent memory leaks + $this->classComplexity = []; + $this->methodComplexity = []; + $this->methodComplexityBreakdown = []; + $this->currentNamespace = ''; + $this->currentClassName = ''; + $this->currentMethod = ''; + $this->resetMethodCounters(); + } + public function enterNode(Node $node): void { $this->setCurrentNamespaceOnEnterNode($node); @@ -135,7 +155,11 @@ private function setCurrentClassOnEnterNode(Node $node): void private function normalizeFqcn(string $fqcn): string { - return str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + if (!isset(self::$fqcnCache[$fqcn])) { + self::$fqcnCache[$fqcn] = str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + } + + return self::$fqcnCache[$fqcn]; } private function handleClassMethodEnter(Node $node): void diff --git a/src/PhpParser/HalsteadMetricsVisitor.php b/src/PhpParser/HalsteadMetricsVisitor.php index 2828170..8186ea1 100644 --- a/src/PhpParser/HalsteadMetricsVisitor.php +++ b/src/PhpParser/HalsteadMetricsVisitor.php @@ -28,6 +28,11 @@ class HalsteadMetricsVisitor extends NodeVisitorAbstract private array $methodOperands = []; private array $methodMetrics = []; + /** + * @var array Cache for normalized FQCNs + */ + private static array $fqcnCache = []; + /** * @var AnnotationVisitor|null The annotation visitor to check for ignored items */ @@ -134,7 +139,11 @@ private function setCurrentClassName(Node $node): void */ private function normalizeFqcn(string $fqcn): string { - return str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + if (!isset(self::$fqcnCache[$fqcn])) { + self::$fqcnCache[$fqcn] = str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + } + + return self::$fqcnCache[$fqcn]; } private function addOperator(Node $node): void @@ -213,9 +222,31 @@ private function storeClassMetrics(): void public function resetMetrics(): void { + // Clear current state, but keep accumulated metrics for retrieval + $this->operators = []; + $this->operands = []; + $this->currentClassName = null; + $this->currentNamespace = null; + $this->currentMethodName = null; + $this->methodOperators = []; + $this->methodOperands = []; + } + + /** + * Reset all accumulated data (for memory cleanup between files). + */ + public function resetAll(): void + { + // Clear all accumulated data to prevent memory leaks $this->operators = []; $this->operands = []; $this->currentClassName = null; + $this->currentNamespace = null; + $this->classMetrics = []; + $this->currentMethodName = null; + $this->methodOperators = []; + $this->methodOperands = []; + $this->methodMetrics = []; } private function calculateMetrics(): array