Skip to content
Merged
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
/.phpunit.cache/
/tmp/
/tools/
/benchmarks/storage/
.idea/
.env
.phpbench/
infection.log
phive.phar
phpcca.phar
4 changes: 3 additions & 1 deletion phpbench.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
9 changes: 9 additions & 0 deletions src/Business/Cognitive/CognitiveMetricsCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
147 changes: 90 additions & 57 deletions src/Business/Cognitive/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
*
Expand All @@ -25,6 +27,7 @@ class Parser
protected CognitiveMetricsVisitor $cognitiveMetricsVisitor;
protected CyclomaticComplexityVisitor $cyclomaticComplexityVisitor;
protected HalsteadMetricsVisitor $halsteadMetricsVisitor;
protected CombinedMetricsVisitor $combinedVisitor;

public function __construct(
ParserFactory $parserFactory,
Expand All @@ -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();
}

/**
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, array<string, int>> $methodMetrics
* @return array<string, array<string, int>>
*/
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<string, array<string, int>> $methodMetrics
* @return array<string, array<string, int>>
*/
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.
Expand Down Expand Up @@ -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',
};
}
}
9 changes: 9 additions & 0 deletions src/PhpParser/AnnotationVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
}
}
25 changes: 24 additions & 1 deletion src/PhpParser/CognitiveMetricsVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ class CognitiveMetricsVisitor extends NodeVisitorAbstract
private string $currentMethod = '';
private int $currentReturnCount = 0;

/**
* @var array<string, string> Cache for normalized FQCNs
*/
private static array $fqcnCache = [];

/**
* @var AnnotationVisitor|null The annotation visitor to check for ignored items
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down
Loading