From b0372299da1529e5c818f0bffc0d2d8acaad16fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Sun, 7 Sep 2025 18:34:41 +0200 Subject: [PATCH 1/2] Implementing the sorting feature --- config.yml | 1 + src/Application.php | 5 + .../Cognitive/CognitiveMetricsCollection.php | 8 +- .../Cognitive/CognitiveMetricsCollector.php | 38 ++ .../Cognitive/CognitiveMetricsSorter.php | 159 +++++++++ src/Business/Cognitive/Parser.php | 84 +++++ src/Business/MetricsFacade.php | 30 ++ src/Command/CognitiveMetricsCommand.php | 32 +- .../CognitiveMetricSummaryTextRenderer.php | 56 +++ .../CognitiveMetricTextRenderer.php | 336 +++++------------- src/Command/Presentation/MetricFormatter.php | 93 +++++ .../Presentation/TableHeaderBuilder.php | 103 ++++++ src/Command/Presentation/TableRowBuilder.php | 217 +++++++++++ src/Config/CognitiveConfig.php | 1 + src/Config/ConfigFactory.php | 3 +- src/Config/ConfigLoader.php | 3 + src/PhpParser/AnnotationVisitor.php | 198 +++++++++++ src/PhpParser/CognitiveMetricsVisitor.php | 177 ++++++--- src/PhpParser/CyclomaticComplexityVisitor.php | 45 ++- src/PhpParser/HalsteadMetricsVisitor.php | 36 +- tests/TraitTestCode/ComplexTrait.php | 92 +++++ tests/TraitTestCode/EmptyTrait.php | 6 + tests/TraitTestCode/TestTrait.php | 35 ++ .../Cognitive/CognitiveMetricsSorterTest.php | 234 ++++++++++++ tests/Unit/Business/Cognitive/ParserTest.php | 270 ++++++++++++++ .../Command/CognitiveMetricsCommandTest.php | 49 +++ .../Unit/PhpParser/AnnotationVisitorTest.php | 289 +++++++++++++++ 27 files changed, 2303 insertions(+), 297 deletions(-) create mode 100644 src/Business/Cognitive/CognitiveMetricsSorter.php create mode 100644 src/Command/Presentation/CognitiveMetricSummaryTextRenderer.php create mode 100644 src/Command/Presentation/MetricFormatter.php create mode 100644 src/Command/Presentation/TableHeaderBuilder.php create mode 100644 src/Command/Presentation/TableRowBuilder.php create mode 100644 src/PhpParser/AnnotationVisitor.php create mode 100644 tests/TraitTestCode/ComplexTrait.php create mode 100644 tests/TraitTestCode/EmptyTrait.php create mode 100644 tests/TraitTestCode/TestTrait.php create mode 100644 tests/Unit/Business/Cognitive/CognitiveMetricsSorterTest.php create mode 100644 tests/Unit/Business/Cognitive/ParserTest.php create mode 100644 tests/Unit/PhpParser/AnnotationVisitorTest.php diff --git a/config.yml b/config.yml index ad888c1..a571bb6 100644 --- a/config.yml +++ b/config.yml @@ -5,6 +5,7 @@ cognitive: showOnlyMethodsExceedingThreshold: false showHalsteadComplexity: false showCyclomaticComplexity: false + groupByClass: true metrics: lineCount: threshold: 60 diff --git a/src/Application.php b/src/Application.php index 540d57e..74880ff 100644 --- a/src/Application.php +++ b/src/Application.php @@ -8,6 +8,7 @@ use Phauthentic\CognitiveCodeAnalysis\Business\Churn\ChurnCalculator; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollector; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsSorter; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\FileProcessed; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\ParserFailed; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Events\SourceFilesFound; @@ -97,6 +98,9 @@ private function registerServices(): void $this->containerBuilder->register(Baseline::class, Baseline::class) ->setPublic(true); + $this->containerBuilder->register(CognitiveMetricsSorter::class, CognitiveMetricsSorter::class) + ->setPublic(true); + $this->containerBuilder->register(Processor::class, Processor::class) ->setPublic(true); @@ -218,6 +222,7 @@ private function registerCommands(): void new Reference(CognitiveMetricTextRendererInterface::class), new Reference(Baseline::class), new Reference(CognitiveMetricsReportHandler::class), + new Reference(CognitiveMetricsSorter::class), ]) ->setPublic(true); diff --git a/src/Business/Cognitive/CognitiveMetricsCollection.php b/src/Business/Cognitive/CognitiveMetricsCollection.php index 87a9b83..d537c6a 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollection.php +++ b/src/Business/Cognitive/CognitiveMetricsCollection.php @@ -10,11 +10,12 @@ use InvalidArgumentException; use IteratorAggregate; use JsonSerializable; +use Traversable; /** * CognitiveMetricsCollection class * - * @implements IteratorAggregate + * @implements IteratorAggregate */ class CognitiveMetricsCollection implements IteratorAggregate, Countable, JsonSerializable { @@ -51,9 +52,10 @@ public function filter(Closure $callback): self /** * Get an iterator for the collection * - * @return ArrayIterator + * @return Traversable */ - public function getIterator(): ArrayIterator + #[\ReturnTypeWillChange] + public function getIterator(): Traversable { return new ArrayIterator($this->metrics); } diff --git a/src/Business/Cognitive/CognitiveMetricsCollector.php b/src/Business/Cognitive/CognitiveMetricsCollector.php index 0dccf28..c3f6737 100644 --- a/src/Business/Cognitive/CognitiveMetricsCollector.php +++ b/src/Business/Cognitive/CognitiveMetricsCollector.php @@ -21,6 +21,11 @@ */ class CognitiveMetricsCollector { + /** + * @var array>|null Cached ignored items from the last parsing operation + */ + private ?array $ignoredItems = null; + public function __construct( protected readonly Parser $parser, protected readonly DirectoryScanner $directoryScanner, @@ -82,6 +87,9 @@ private function findMetrics(iterable $files): CognitiveMetricsCollection $metrics = $this->parser->parse( $this->getCodeFromFile($file) ); + + // Store ignored items from the parser + $this->ignoredItems = $this->parser->getIgnored(); } catch (Throwable $exception) { $this->messageBus->dispatch(new ParserFailed( $file, @@ -162,4 +170,34 @@ private function findSourceFiles(string $path, array $exclude = []): iterable { return $this->directoryScanner->scan([$path], ['^(?!.*\.php$).+'] + $exclude); } + + /** + * Get all ignored classes and methods from the last parsing operation. + * + * @return array> Array with 'classes' and 'methods' keys + */ + public function getIgnored(): array + { + return $this->ignoredItems ?? ['classes' => [], 'methods' => []]; + } + + /** + * Get ignored classes from the last parsing operation. + * + * @return array Array of ignored class FQCNs + */ + public function getIgnoredClasses(): array + { + return $this->ignoredItems['classes'] ?? []; + } + + /** + * Get ignored methods from the last parsing operation. + * + * @return array Array of ignored method keys (ClassName::methodName) + */ + public function getIgnoredMethods(): array + { + return $this->ignoredItems['methods'] ?? []; + } } diff --git a/src/Business/Cognitive/CognitiveMetricsSorter.php b/src/Business/Cognitive/CognitiveMetricsSorter.php new file mode 100644 index 0000000..1797ded --- /dev/null +++ b/src/Business/Cognitive/CognitiveMetricsSorter.php @@ -0,0 +1,159 @@ +getFieldValue($alpha, $sortBy); + $valueB = $this->getFieldValue($beta, $sortBy); + + $comparison = $this->compareValues($valueA, $valueB); + + return strtolower($sortOrder) === 'desc' ? -$comparison : $comparison; + }); + + $sortedCollection = new CognitiveMetricsCollection(); + foreach ($metricsArray as $metric) { + $sortedCollection->add($metric); + } + + return $sortedCollection; + } + + /** + * Get the value of a field from a CognitiveMetrics object + * + * @param CognitiveMetrics $metrics + * @param string $field + * @return mixed + */ + private function getFieldValue(CognitiveMetrics $metrics, string $field): mixed + { + return match ($field) { + 'score' => $metrics->getScore(), + 'halstead' => $metrics->getHalstead()?->getVolume() ?? 0.0, + 'cyclomatic' => $metrics->getCyclomatic()?->complexity ?? 0, // @phpstan-ignore-line + 'class' => $metrics->getClass(), + 'method' => $metrics->getMethod(), + 'file' => $metrics->getFileName(), + 'lineCount' => $metrics->getLineCount(), + 'argCount' => $metrics->getArgCount(), + 'returnCount' => $metrics->getReturnCount(), + 'variableCount' => $metrics->getVariableCount(), + 'propertyCallCount' => $metrics->getPropertyCallCount(), + 'ifCount' => $metrics->getIfCount(), + 'ifNestingLevel' => $metrics->getIfNestingLevel(), + 'elseCount' => $metrics->getElseCount(), + 'lineCountWeight' => $metrics->getLineCountWeight(), + 'argCountWeight' => $metrics->getArgCountWeight(), + 'returnCountWeight' => $metrics->getReturnCountWeight(), + 'variableCountWeight' => $metrics->getVariableCountWeight(), + 'propertyCallCountWeight' => $metrics->getPropertyCallCountWeight(), + 'ifCountWeight' => $metrics->getIfCountWeight(), + 'ifNestingLevelWeight' => $metrics->getIfNestingLevelWeight(), + 'elseCountWeight' => $metrics->getElseCountWeight(), + default => throw new InvalidArgumentException("Unknown field: $field") + }; + } + + /** + * Compare two values for sorting + * + * @param mixed $alpha + * @param mixed $beta + * @return int + */ + private function compareValues(mixed $alpha, mixed $beta): int + { + if (is_numeric($alpha) && is_numeric($beta)) { + return $alpha <=> $beta; + } + + if (is_string($alpha) && is_string($beta)) { + return strcasecmp($alpha, $beta); + } + + // Handle mixed types by converting to string + return strcasecmp((string) $alpha, (string) $beta); + } + + /** + * Get all available sortable fields + * + * @return array + */ + public function getSortableFields(): array + { + return self::SORTABLE_FIELDS; + } +} diff --git a/src/Business/Cognitive/Parser.php b/src/Business/Cognitive/Parser.php index 2dc37ff..0791c49 100644 --- a/src/Business/Cognitive/Parser.php +++ b/src/Business/Cognitive/Parser.php @@ -5,11 +5,13 @@ namespace Phauthentic\CognitiveCodeAnalysis\Business\Cognitive; use Phauthentic\CognitiveCodeAnalysis\CognitiveAnalysisException; +use Phauthentic\CognitiveCodeAnalysis\PhpParser\AnnotationVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CognitiveMetricsVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\CyclomaticComplexityVisitor; use Phauthentic\CognitiveCodeAnalysis\PhpParser\HalsteadMetricsVisitor; use PhpParser\NodeTraverserInterface; use PhpParser\Parser as PhpParser; +use PhpParser\NodeTraverser; use PhpParser\Error; use PhpParser\ParserFactory; @@ -19,6 +21,7 @@ class Parser { protected PhpParser $parser; + protected AnnotationVisitor $annotationVisitor; protected CognitiveMetricsVisitor $cognitiveMetricsVisitor; protected CyclomaticComplexityVisitor $cyclomaticComplexityVisitor; protected HalsteadMetricsVisitor $halsteadMetricsVisitor; @@ -29,13 +32,20 @@ public function __construct( ) { $this->parser = $parserFactory->createForHostVersion(); + // Create the annotation visitor but don't add it to the traverser + // It will be used by other visitors to check for ignored items + $this->annotationVisitor = new AnnotationVisitor(); + $this->cognitiveMetricsVisitor = new CognitiveMetricsVisitor(); + $this->cognitiveMetricsVisitor->setAnnotationVisitor($this->annotationVisitor); $this->traverser->addVisitor($this->cognitiveMetricsVisitor); $this->cyclomaticComplexityVisitor = new CyclomaticComplexityVisitor(); + $this->cyclomaticComplexityVisitor->setAnnotationVisitor($this->annotationVisitor); $this->traverser->addVisitor($this->cyclomaticComplexityVisitor); $this->halsteadMetricsVisitor = new HalsteadMetricsVisitor(); + $this->halsteadMetricsVisitor->setAnnotationVisitor($this->annotationVisitor); $this->traverser->addVisitor($this->halsteadMetricsVisitor); } @@ -45,6 +55,10 @@ public function __construct( */ public function parse(string $code): array { + // First, scan for annotations to collect ignored items + $this->scanForAnnotations($code); + + // Then parse for metrics $this->traverseAbstractSyntaxTree($code); $methodMetrics = $this->cognitiveMetricsVisitor->getMethodMetrics(); @@ -56,6 +70,30 @@ public function parse(string $code): array return $methodMetrics; } + /** + * Scan the code for annotations to collect ignored items. + */ + private function scanForAnnotations(string $code): void + { + // Reset the annotation visitor state before scanning + $this->annotationVisitor->reset(); + + try { + $ast = $this->parser->parse($code); + } catch (Error $e) { + throw new CognitiveAnalysisException("Parse error: {$e->getMessage()}", 0, $e); + } + + if ($ast === null) { + throw new CognitiveAnalysisException("Could not parse the code."); + } + + // Create a temporary traverser just for annotations + $annotationTraverser = new NodeTraverser(); + $annotationTraverser->addVisitor($this->annotationVisitor); + $annotationTraverser->traverse($ast); + } + /** * @throws CognitiveAnalysisException */ @@ -82,6 +120,14 @@ 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; + } $methodMetrics[$method]['halstead'] = $metrics; } @@ -96,9 +142,47 @@ 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; + } $methodMetrics[$method]['cyclomatic_complexity'] = $complexity; } return $methodMetrics; } + + /** + * Get all ignored classes and methods. + * + * @return array> Array with 'classes' and 'methods' keys + */ + public function getIgnored(): array + { + return $this->annotationVisitor->getIgnored(); + } + + /** + * Get ignored classes. + * + * @return array Array of ignored class FQCNs + */ + public function getIgnoredClasses(): array + { + return $this->annotationVisitor->getIgnoredClasses(); + } + + /** + * Get ignored methods. + * + * @return array Array of ignored method keys (ClassName::methodName) + */ + public function getIgnoredMethods(): array + { + return $this->annotationVisitor->getIgnoredMethods(); + } } diff --git a/src/Business/MetricsFacade.php b/src/Business/MetricsFacade.php index 96ebc62..263f40a 100644 --- a/src/Business/MetricsFacade.php +++ b/src/Business/MetricsFacade.php @@ -86,6 +86,36 @@ public function getConfig(): CognitiveConfig return $this->configService->getConfig(); } + /** + * Get all ignored classes and methods from the last metrics collection. + * + * @return array> Array with 'classes' and 'methods' keys + */ + public function getIgnored(): array + { + return $this->cognitiveMetricsCollector->getIgnored(); + } + + /** + * Get ignored classes from the last metrics collection. + * + * @return array Array of ignored class FQCNs + */ + public function getIgnoredClasses(): array + { + return $this->cognitiveMetricsCollector->getIgnoredClasses(); + } + + /** + * Get ignored methods from the last metrics collection. + * + * @return array Array of ignored method keys (ClassName::methodName) + */ + public function getIgnoredMethods(): array + { + return $this->cognitiveMetricsCollector->getIgnoredMethods(); + } + /** * @param array> $classes * @throws CognitiveAnalysisException diff --git a/src/Command/CognitiveMetricsCommand.php b/src/Command/CognitiveMetricsCommand.php index 7d20e41..e754a2c 100644 --- a/src/Command/CognitiveMetricsCommand.php +++ b/src/Command/CognitiveMetricsCommand.php @@ -7,6 +7,7 @@ use Exception; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\Baseline; use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsCollection; +use Phauthentic\CognitiveCodeAnalysis\Business\Cognitive\CognitiveMetricsSorter; use Phauthentic\CognitiveCodeAnalysis\Business\MetricsFacade; use Phauthentic\CognitiveCodeAnalysis\Command\Handler\CognitiveMetricsReportHandler; use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\CognitiveMetricTextRendererInterface; @@ -30,13 +31,16 @@ class CognitiveMetricsCommand extends Command public const OPTION_REPORT_TYPE = 'report-type'; public const OPTION_REPORT_FILE = 'report-file'; public const OPTION_DEBUG = 'debug'; + public const OPTION_SORT_BY = 'sort-by'; + public const OPTION_SORT_ORDER = 'sort-order'; private const ARGUMENT_PATH = 'path'; public function __construct( readonly private MetricsFacade $metricsFacade, readonly private CognitiveMetricTextRendererInterface $renderer, readonly private Baseline $baselineService, - readonly private CognitiveMetricsReportHandler $reportHandler + readonly private CognitiveMetricsReportHandler $reportHandler, + readonly private CognitiveMetricsSorter $sorter ) { parent::__construct(); } @@ -76,6 +80,18 @@ protected function configure(): void mode: InputArgument::OPTIONAL, description: 'File to write the report to.' ) + ->addOption( + name: self::OPTION_SORT_BY, + shortcut: 's', + mode: InputArgument::OPTIONAL, + description: 'Field to sort by (e.g., score, halstead, cyclomatic, class, method, etc.).', + ) + ->addOption( + name: self::OPTION_SORT_ORDER, + mode: InputArgument::OPTIONAL, + description: 'Sort order: asc or desc (default: asc).', + default: 'asc' + ) ->addOption( name: self::OPTION_DEBUG, mode: InputArgument::OPTIONAL, @@ -105,6 +121,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->handleBaseLine($input, $metricsCollection); + // Apply sorting if specified + $sortBy = $input->getOption(self::OPTION_SORT_BY); + $sortOrder = $input->getOption(self::OPTION_SORT_ORDER); + + if ($sortBy !== null) { + try { + $metricsCollection = $this->sorter->sort($metricsCollection, $sortBy, $sortOrder); + } catch (\InvalidArgumentException $e) { + $output->writeln('Sorting error: ' . $e->getMessage() . ''); + $output->writeln('Available sort fields: ' . implode(', ', $this->sorter->getSortableFields()) . ''); + return Command::FAILURE; + } + } + $reportType = $input->getOption(self::OPTION_REPORT_TYPE); $reportFile = $input->getOption(self::OPTION_REPORT_FILE); diff --git a/src/Command/Presentation/CognitiveMetricSummaryTextRenderer.php b/src/Command/Presentation/CognitiveMetricSummaryTextRenderer.php new file mode 100644 index 0000000..04bfa84 --- /dev/null +++ b/src/Command/Presentation/CognitiveMetricSummaryTextRenderer.php @@ -0,0 +1,56 @@ +getScore() > $this->configService->getConfig()->scoreThreshold) { + $highlighted[] = $metric; + } + } + + usort( + $highlighted, + static fn (CognitiveMetrics $alpha, CognitiveMetrics $beta) => $beta->getScore() <=> $alpha->getScore() + ); + + $this->output->writeln('Most Complex Methods'); + + $table = new Table($this->output); + $table->setStyle('box'); + $table->setHeaders(['Method', 'Score']); + + foreach ($highlighted as $metric) { + $table->addRow([ + $metric->getClass() . '::' . $metric->getMethod(), + $metric->getScore() > $this->configService->getConfig()->scoreThreshold + ? '' . $metric->getScore() . '' + : $metric->getScore(), + ]); + } + + $table->render(); + $this->output->writeln(''); + } +} diff --git a/src/Command/Presentation/CognitiveMetricTextRenderer.php b/src/Command/Presentation/CognitiveMetricTextRenderer.php index 1810142..fad6c99 100644 --- a/src/Command/Presentation/CognitiveMetricTextRenderer.php +++ b/src/Command/Presentation/CognitiveMetricTextRenderer.php @@ -11,18 +11,27 @@ use Phauthentic\CognitiveCodeAnalysis\Config\ConfigService; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Output\OutputInterface; -use Phauthentic\CognitiveCodeAnalysis\Business\Halstead\HalsteadMetrics; -use Phauthentic\CognitiveCodeAnalysis\Business\Cyclomatic\CyclomaticMetrics; +use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\MetricFormatter; +use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\TableRowBuilder; +use Phauthentic\CognitiveCodeAnalysis\Command\Presentation\TableHeaderBuilder; /** * */ class CognitiveMetricTextRenderer implements CognitiveMetricTextRendererInterface { + private MetricFormatter $formatter; + private TableRowBuilder $rowBuilder; + private TableHeaderBuilder $headerBuilder; + public function __construct( private readonly OutputInterface $output, private readonly ConfigService $configService, ) { + $config = $this->configService->getConfig(); + $this->formatter = new MetricFormatter($config); + $this->rowBuilder = new TableRowBuilder($this->formatter, $config); + $this->headerBuilder = new TableHeaderBuilder($config); } private function metricExceedsThreshold(CognitiveMetrics $metric, CognitiveConfig $config): bool @@ -38,308 +47,149 @@ private function metricExceedsThreshold(CognitiveMetrics $metric, CognitiveConfi */ public function render(CognitiveMetricsCollection $metricsCollection): void { - $groupedByClass = $metricsCollection->groupBy('class'); $config = $this->configService->getConfig(); - foreach ($groupedByClass as $className => $metrics) { - if (count($metrics) === 0) { - continue; - } - - $rows = []; - $filename = ''; - - foreach ($metrics as $metric) { - if ($this->metricExceedsThreshold($metric, $config)) { - continue; - } - - $rows[] = $this->prepareTableRows($metric); - $filename = $metric->getFileName(); - } - - if (count($rows) > 0) { - $this->renderTable((string)$className, $rows, $filename); - } + if ($config->groupByClass) { + $this->renderGroupedByClass($metricsCollection, $config); + return; } - } - /** - * @param string $className - * @param array $rows - * @param string $filename - */ - private function renderTable(string $className, array $rows, string $filename): void - { - $table = new Table($this->output); - $table->setStyle('box'); - $table->setHeaders($this->getTableHeaders()); - - $this->output->writeln("Class: $className"); - $this->output->writeln("File: $filename"); - - $table->setRows($rows); - $table->render(); - - $this->output->writeln(""); + $this->renderAllMethodsInSingleTable($metricsCollection, $config); } /** - * @return string[] + * @param CognitiveMetricsCollection $metricsCollection + * @param CognitiveConfig $config + * @throws CognitiveAnalysisException */ - private function getTableHeaders(): array + private function renderGroupedByClass(CognitiveMetricsCollection $metricsCollection, CognitiveConfig $config): void { - $fields = [ - "Method Name", - "Lines", - "Arguments", - "Returns", - "Variables", - "Property\nAccesses", - "If", - "If Nesting\nLevel", - "Else", - "Cognitive\nComplexity", - ]; + $groupedByClass = $metricsCollection->groupBy('class'); - $fields = $this->addHalsteadHeaders($fields); - $fields = $this->addCyclomaticHeaders($fields); + foreach ($groupedByClass as $className => $metrics) { + if (count($metrics) === 0) { + continue; + } - return $fields; + $rows = $this->buildRowsForClass($metrics, $config); + if (count($rows) > 0) { + $filename = $this->getFilenameFromMetrics($metrics); + $this->renderTable((string)$className, $rows, $filename); + } + } } /** - * @param array $fields - * @return array + * Build rows for a specific class + * + * @return array> */ - private function addHalsteadHeaders(array $fields): array + private function buildRowsForClass(CognitiveMetricsCollection $metrics, CognitiveConfig $config): array { - if ($this->configService->getConfig()->showHalsteadComplexity) { - $fields[] = "Halstead\nVolume"; - $fields[] = "Halstead\nDifficulty"; - $fields[] = "Halstead\nEffort"; + $rows = []; + foreach ($metrics as $metric) { + if ($this->metricExceedsThreshold($metric, $config)) { + continue; + } + $rows[] = $this->rowBuilder->buildRow($metric); } - - return $fields; + return $rows; } /** - * @param array $fields - * @return array + * Get filename from the first metric in the collection */ - private function addCyclomaticHeaders(array $fields): array + private function getFilenameFromMetrics(CognitiveMetricsCollection $metrics): string { - if ($this->configService->getConfig()->showCyclomaticComplexity) { - $fields[] = "Cyclomatic\nComplexity"; + foreach ($metrics as $metric) { + return $metric->getFileName(); } - - return $fields; + return ''; } /** - * @param CognitiveMetrics $metrics - * @return array + * @param CognitiveMetricsCollection $metricsCollection + * @param CognitiveConfig $config * @throws CognitiveAnalysisException */ - private function prepareTableRows(CognitiveMetrics $metrics): array + private function renderAllMethodsInSingleTable(CognitiveMetricsCollection $metricsCollection, CognitiveConfig $config): void { - $row = $this->metricsToArray($metrics); - $keys = $this->getKeys(); - - foreach ($keys as $key) { - $row = $this->roundWeighs($key, $metrics, $row); - - $getDeltaMethod = 'get' . $key . 'WeightDelta'; - $this->assertDeltaMethodExists($metrics, $getDeltaMethod); + $rows = $this->buildRowsForSingleTable($metricsCollection, $config); + $totalMethods = count($rows); - $delta = $metrics->{$getDeltaMethod}(); - if ($delta === null || $delta->hasNotChanged()) { - continue; - } - - if ($delta->hasIncreased()) { - $row[$key] .= PHP_EOL . 'Δ +' . round($delta->getValue(), 3) . ''; - continue; - } - - $row[$key] .= PHP_EOL . 'Δ -' . $delta->getValue() . ''; + if ($totalMethods > 0) { + $this->renderSingleTable($rows, $totalMethods); } - - return $row; } /** - * @return string[] + * Build rows for single table display + * + * @return array> */ - private function getKeys(): array + private function buildRowsForSingleTable(CognitiveMetricsCollection $metricsCollection, CognitiveConfig $config): array { - return [ - 'lineCount', - 'argCount', - 'returnCount', - 'variableCount', - 'propertyCallCount', - 'ifCount', - 'ifNestingLevel', - 'elseCount', - ]; + $rows = []; + foreach ($metricsCollection as $metric) { + if ($this->metricExceedsThreshold($metric, $config)) { + continue; + } + $rows[] = $this->rowBuilder->buildRowWithClassInfo($metric); + } + return $rows; } /** - * @param CognitiveMetrics $metrics - * @return array + * @param string $className + * @param array $rows + * @param string $filename */ - private function metricsToArray(CognitiveMetrics $metrics): array + private function renderTable(string $className, array $rows, string $filename): void { - $fields = [ - 'methodName' => $metrics->getMethod(), - 'lineCount' => $metrics->getLineCount(), - 'argCount' => $metrics->getArgCount(), - 'returnCount' => $metrics->getReturnCount(), - 'variableCount' => $metrics->getVariableCount(), - 'propertyCallCount' => $metrics->getPropertyCallCount(), - 'ifCount' => $metrics->getIfCount(), - 'ifNestingLevel' => $metrics->getIfNestingLevel(), - 'elseCount' => $metrics->getElseCount(), - 'score' => $this->formatScore($metrics->getScore()), - ]; - - $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); - $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); + $table = new Table($this->output); + $table->setStyle('box'); + $table->setHeaders($this->getTableHeaders()); - return $fields; - } + $this->output->writeln("Class: $className"); + $this->output->writeln("File: $filename"); - /** - * @param array $fields - * @param HalsteadMetrics|null $halstead - * @return array - */ - private function addHalsteadFields(array $fields, ?HalsteadMetrics $halstead): array - { - if ($this->configService->getConfig()->showHalsteadComplexity) { - $fields['halsteadVolume'] = $this->formatHalsteadVolume($halstead); - $fields['halsteadDifficulty'] = $this->formatHalsteadDifficulty($halstead); - $fields['halsteadEffort'] = $this->formatHalsteadEffort($halstead); - } + $table->setRows($rows); + $table->render(); - return $fields; + $this->output->writeln(""); } /** - * @param array $fields - * @param CyclomaticMetrics|null $cyclomatic - * @return array + * @param array $rows + * @param int $totalMethods */ - private function addCyclomaticFields(array $fields, ?CyclomaticMetrics $cyclomatic): array - { - if ($this->configService->getConfig()->showCyclomaticComplexity) { - $fields['cyclomaticComplexity'] = $this->formatCyclomaticComplexity($cyclomatic); - } - - return $fields; - } - - private function formatScore(float $score): string - { - return $score > $this->configService->getConfig()->scoreThreshold - ? '' . $score . '' - : '' . $score . ''; - } - - private function formatHalsteadVolume(?HalsteadMetrics $halstead): string + private function renderSingleTable(array $rows, int $totalMethods): void { - if (!$halstead) { - return '-'; - } - - $value = round($halstead->getVolume(), 3); - - return match (true) { - $value >= 1000 => '' . $value . '', - $value >= 100 => '' . $value . '', - default => (string)$value, - }; - } - - private function formatHalsteadDifficulty(?HalsteadMetrics $halstead): string - { - if (!$halstead) { - return '-'; - } - $value = round($halstead->difficulty, 3); - - return match (true) { - $value >= 50 => '' . $value . '', - $value >= 10 => '' . $value . '', - default => (string)$value, - }; - } - - private function formatHalsteadEffort(?HalsteadMetrics $halstead): string - { - if (!$halstead) { - return '-'; - } - $value = round($halstead->effort, 3); + $table = new Table($this->output); + $table->setStyle('box'); + $table->setHeaders($this->getSingleTableHeaders()); - return match (true) { - $value >= 5000 => '' . $value . '', - $value >= 500 => '' . $value . '', - default => (string)$value, - }; - } + $this->output->writeln("All Methods ($totalMethods total)"); - private function formatCyclomaticComplexity(?CyclomaticMetrics $cyclomatic): string - { - if (!$cyclomatic) { - return '-'; - } - $complexity = $cyclomatic->complexity; - $risk = $cyclomatic->riskLevel ?? ''; - if ($risk === '') { - return (string)$complexity; - } - $riskColored = $this->colorCyclomaticRisk($risk); - return $complexity . ' (' . $riskColored . ')'; - } + $table->setRows($rows); + $table->render(); - private function colorCyclomaticRisk(string $risk): string - { - return match (strtolower($risk)) { - 'medium' => '' . $risk . '', - 'high' => '' . $risk . '', - default => $risk, - }; + $this->output->writeln(""); } /** - * @param CognitiveMetrics $metrics - * @param string $getDeltaMethod - * @return void - * @throws CognitiveAnalysisException + * @return string[] */ - private function assertDeltaMethodExists(CognitiveMetrics $metrics, string $getDeltaMethod): void + private function getTableHeaders(): array { - if (!method_exists($metrics, $getDeltaMethod)) { - throw new CognitiveAnalysisException('Method not found: ' . $getDeltaMethod); - } + return $this->headerBuilder->getGroupedTableHeaders(); } /** - * @param string $key - * @param CognitiveMetrics $metrics - * @param array $row - * @return array + * @return string[] */ - private function roundWeighs(string $key, CognitiveMetrics $metrics, array $row): array + private function getSingleTableHeaders(): array { - $getMethod = 'get' . $key; - $getMethodWeight = 'get' . $key . 'Weight'; - - $weight = $metrics->{$getMethodWeight}(); - $row[$key] = $metrics->{$getMethod}() . ' (' . round($weight, 3) . ')'; - - return $row; + return $this->headerBuilder->getSingleTableHeaders(); } } diff --git a/src/Command/Presentation/MetricFormatter.php b/src/Command/Presentation/MetricFormatter.php new file mode 100644 index 0000000..55678a0 --- /dev/null +++ b/src/Command/Presentation/MetricFormatter.php @@ -0,0 +1,93 @@ + $this->config->scoreThreshold + ? '' . $score . '' + : '' . $score . ''; + } + + public function formatHalsteadVolume(?HalsteadMetrics $halstead): string + { + if (!$halstead) { + return '-'; + } + + $value = round($halstead->getVolume(), 3); + + return match (true) { + $value >= 1000 => '' . $value . '', + $value >= 100 => '' . $value . '', + default => (string)$value, + }; + } + + public function formatHalsteadDifficulty(?HalsteadMetrics $halstead): string + { + if (!$halstead) { + return '-'; + } + $value = round($halstead->difficulty, 3); + + return match (true) { + $value >= 50 => '' . $value . '', + $value >= 10 => '' . $value . '', + default => (string)$value, + }; + } + + public function formatHalsteadEffort(?HalsteadMetrics $halstead): string + { + if (!$halstead) { + return '-'; + } + $value = round($halstead->effort, 3); + + return match (true) { + $value >= 5000 => '' . $value . '', + $value >= 500 => '' . $value . '', + default => (string)$value, + }; + } + + public function formatCyclomaticComplexity(?CyclomaticMetrics $cyclomatic): string + { + if (!$cyclomatic) { + return '-'; + } + $complexity = $cyclomatic->complexity; + $risk = $cyclomatic->riskLevel ?? ''; + if ($risk === '') { + return (string)$complexity; + } + $riskColored = $this->colorCyclomaticRisk($risk); + return $complexity . ' (' . $riskColored . ')'; + } + + private function colorCyclomaticRisk(string $risk): string + { + return match (strtolower($risk)) { + 'medium' => '' . $risk . '', + 'high' => '' . $risk . '', + default => $risk, + }; + } +} diff --git a/src/Command/Presentation/TableHeaderBuilder.php b/src/Command/Presentation/TableHeaderBuilder.php new file mode 100644 index 0000000..294ff29 --- /dev/null +++ b/src/Command/Presentation/TableHeaderBuilder.php @@ -0,0 +1,103 @@ + + */ + public function getGroupedTableHeaders(): array + { + $fields = [ + "Method Name", + "Lines", + "Arguments", + "Returns", + "Variables", + "Property\nAccesses", + "If", + "If Nesting\nLevel", + "Else", + "Cognitive\nComplexity", + ]; + + $fields = $this->addHalsteadHeaders($fields); + $fields = $this->addCyclomaticHeaders($fields); + + return $fields; + } + + /** + * Get headers for single table (with class column) + * + * @return array + */ + public function getSingleTableHeaders(): array + { + $fields = [ + "Class", + "Method Name", + "Lines", + "Arguments", + "Returns", + "Variables", + "Property\nAccesses", + "If", + "If Nesting\nLevel", + "Else", + "Cognitive\nComplexity", + ]; + + $fields = $this->addHalsteadHeaders($fields); + $fields = $this->addCyclomaticHeaders($fields); + + return $fields; + } + + /** + * Add Halstead headers to the fields array + * + * @param array $fields + * @return array + */ + private function addHalsteadHeaders(array $fields): array + { + if ($this->config->showHalsteadComplexity) { + $fields[] = "Halstead\nVolume"; + $fields[] = "Halstead\nDifficulty"; + $fields[] = "Halstead\nEffort"; + } + + return $fields; + } + + /** + * Add Cyclomatic headers to the fields array + * + * @param array $fields + * @return array + */ + private function addCyclomaticHeaders(array $fields): array + { + if ($this->config->showCyclomaticComplexity) { + $fields[] = "Cyclomatic\nComplexity"; + } + + return $fields; + } +} diff --git a/src/Command/Presentation/TableRowBuilder.php b/src/Command/Presentation/TableRowBuilder.php new file mode 100644 index 0000000..5905207 --- /dev/null +++ b/src/Command/Presentation/TableRowBuilder.php @@ -0,0 +1,217 @@ + + */ + public function buildRow(CognitiveMetrics $metrics): array + { + $row = $this->metricsToArray($metrics); + $keys = $this->getKeys(); + + foreach ($keys as $key) { + $row = $this->addWeightedValue($key, $metrics, $row); + $row = $this->addDelta($key, $metrics, $row); + } + + return $row; + } + + /** + * Build a table row from metrics with class information + * + * @return array + */ + public function buildRowWithClassInfo(CognitiveMetrics $metrics): array + { + $row = $this->metricsToArrayWithClassInfo($metrics); + $keys = $this->getKeys(); + + foreach ($keys as $key) { + $row = $this->addWeightedValue($key, $metrics, $row); + $row = $this->addDelta($key, $metrics, $row); + } + + return $row; + } + + /** + * Convert metrics to array format + * + * @return array + */ + private function metricsToArray(CognitiveMetrics $metrics): array + { + $fields = [ + 'methodName' => $metrics->getMethod(), + 'lineCount' => $metrics->getLineCount(), + 'argCount' => $metrics->getArgCount(), + 'returnCount' => $metrics->getReturnCount(), + 'variableCount' => $metrics->getVariableCount(), + 'propertyCallCount' => $metrics->getPropertyCallCount(), + 'ifCount' => $metrics->getIfCount(), + 'ifNestingLevel' => $metrics->getIfNestingLevel(), + 'elseCount' => $metrics->getElseCount(), + 'score' => $this->formatter->formatScore($metrics->getScore()), + ]; + + $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); + $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); + + return $fields; + } + + /** + * Convert metrics to array format with class information + * + * @return array + */ + private function metricsToArrayWithClassInfo(CognitiveMetrics $metrics): array + { + $fields = [ + 'className' => $metrics->getClass(), + 'methodName' => $metrics->getMethod(), + 'lineCount' => $metrics->getLineCount(), + 'argCount' => $metrics->getArgCount(), + 'returnCount' => $metrics->getReturnCount(), + 'variableCount' => $metrics->getVariableCount(), + 'propertyCallCount' => $metrics->getPropertyCallCount(), + 'ifCount' => $metrics->getIfCount(), + 'ifNestingLevel' => $metrics->getIfNestingLevel(), + 'elseCount' => $metrics->getElseCount(), + 'score' => $this->formatter->formatScore($metrics->getScore()), + ]; + + $fields = $this->addHalsteadFields($fields, $metrics->getHalstead()); + $fields = $this->addCyclomaticFields($fields, $metrics->getCyclomatic()); + + return $fields; + } + + /** + * Add Halstead fields to the array + * + * @param array $fields + * @return array + */ + private function addHalsteadFields(array $fields, ?HalsteadMetrics $halstead): array + { + if ($this->config->showHalsteadComplexity) { + $fields['halsteadVolume'] = $this->formatter->formatHalsteadVolume($halstead); + $fields['halsteadDifficulty'] = $this->formatter->formatHalsteadDifficulty($halstead); + $fields['halsteadEffort'] = $this->formatter->formatHalsteadEffort($halstead); + } + + return $fields; + } + + /** + * Add Cyclomatic fields to the array + * + * @param array $fields + * @return array + */ + private function addCyclomaticFields(array $fields, ?CyclomaticMetrics $cyclomatic): array + { + if ($this->config->showCyclomaticComplexity) { + $fields['cyclomaticComplexity'] = $this->formatter->formatCyclomaticComplexity($cyclomatic); + } + + return $fields; + } + + /** + * Add weighted value to the row + * + * @param array $row + * @return array + */ + private function addWeightedValue(string $key, CognitiveMetrics $metrics, array $row): array + { + $getMethod = 'get' . $key; + $getMethodWeight = 'get' . $key . 'Weight'; + + $weight = $metrics->{$getMethodWeight}(); + $row[$key] = $metrics->{$getMethod}() . ' (' . round($weight, 3) . ')'; + + return $row; + } + + /** + * Add delta information to the row + * + * @param array $row + * @return array + */ + private function addDelta(string $key, CognitiveMetrics $metrics, array $row): array + { + $getDeltaMethod = 'get' . $key . 'WeightDelta'; + $this->assertDeltaMethodExists($metrics, $getDeltaMethod); + + $delta = $metrics->{$getDeltaMethod}(); + if ($delta === null || $delta->hasNotChanged()) { + return $row; + } + + if ($delta->hasIncreased()) { + $row[$key] .= PHP_EOL . 'Δ +' . round($delta->getValue(), 3) . ''; + + return $row; + } + + $row[$key] .= PHP_EOL . 'Δ -' . $delta->getValue() . ''; + + return $row; + } + + /** + * Get the keys for processing + * + * @return array + */ + private function getKeys(): array + { + return [ + 'lineCount', + 'argCount', + 'returnCount', + 'variableCount', + 'propertyCallCount', + 'ifCount', + 'ifNestingLevel', + 'elseCount', + ]; + } + + /** + * Assert that a delta method exists + */ + private function assertDeltaMethodExists(CognitiveMetrics $metrics, string $getDeltaMethod): void + { + if (!method_exists($metrics, $getDeltaMethod)) { + throw new CognitiveAnalysisException('Method not found: ' . $getDeltaMethod); + } + } +} diff --git a/src/Config/CognitiveConfig.php b/src/Config/CognitiveConfig.php index 376fa3a..4f57962 100644 --- a/src/Config/CognitiveConfig.php +++ b/src/Config/CognitiveConfig.php @@ -24,6 +24,7 @@ public function __construct( public readonly float $scoreThreshold, public readonly bool $showHalsteadComplexity = false, public readonly bool $showCyclomaticComplexity = false, + public readonly bool $groupByClass = false, ) { } } diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php index d20eb60..1098df2 100644 --- a/src/Config/ConfigFactory.php +++ b/src/Config/ConfigFactory.php @@ -30,7 +30,8 @@ public function fromArray(array $config): CognitiveConfig showOnlyMethodsExceedingThreshold: $config['cognitive']['showOnlyMethodsExceedingThreshold'], scoreThreshold: $config['cognitive']['scoreThreshold'], showHalsteadComplexity: $config['cognitive']['showHalsteadComplexity'] ?? false, - showCyclomaticComplexity: $config['cognitive']['showCyclomaticComplexity'] ?? false + showCyclomaticComplexity: $config['cognitive']['showCyclomaticComplexity'] ?? false, + groupByClass: $config['cognitive']['groupByClass'] ?? true ); } } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 55b0a86..60ac471 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -102,6 +102,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('showCyclomaticComplexity') ->defaultValue(false) ->end() + ->booleanNode('groupByClass') + ->defaultValue(true) + ->end() ->arrayNode('metrics') ->useAttributeAsKey('metric') ->arrayPrototype() diff --git a/src/PhpParser/AnnotationVisitor.php b/src/PhpParser/AnnotationVisitor.php new file mode 100644 index 0000000..d5fadcc --- /dev/null +++ b/src/PhpParser/AnnotationVisitor.php @@ -0,0 +1,198 @@ + List of ignored classes with their FQCN + */ + private array $ignoredClasses = []; + + /** + * @var array List of ignored methods with their FQCN::methodName + */ + private array $ignoredMethods = []; + + private string $currentNamespace = ''; + private string $currentClassName = ''; + + /** + * Check if a node has the @cca-ignore annotation. + */ + private function hasIgnoreAnnotation(Node $node): bool + { + // Check doc comment first + $docComment = $node->getDocComment(); + if ($docComment !== null) { + if (str_contains($docComment->getText(), '@cca-ignore')) { + return true; + } + } + + // Check regular comments + $comments = $node->getComments(); + foreach ($comments as $comment) { + if (str_contains($comment->getText(), '@cca-ignore')) { + return true; + } + } + + return false; + } + + /** + * Set the current namespace context. + */ + private function setCurrentNamespace(Node $node): void + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->currentNamespace = $node->name instanceof Node\Name ? $node->name->toString() : ''; + } + } + + /** + * Set the current class context. + */ + private function setCurrentClass(Node $node): void + { + if ($node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Trait_) { + if ($node->name !== null) { + $fqcn = $this->currentNamespace . '\\' . $node->name->toString(); + $this->currentClassName = $this->normalizeFqcn($fqcn); + } + } + } + + /** + * Ensures the FQCN always starts with a backslash. + */ + private function normalizeFqcn(string $fqcn): string + { + return str_starts_with($fqcn, '\\') ? $fqcn : '\\' . $fqcn; + } + + /** + * Process class/trait nodes to check for @cca-ignore annotations. + */ + private function processClassNode(Node $node): void + { + if (!$node instanceof Node\Stmt\Class_ && !$node instanceof Node\Stmt\Trait_) { + return; + } + + if ($this->hasIgnoreAnnotation($node)) { + $this->ignoredClasses[$this->currentClassName] = $this->currentClassName; + } + } + + /** + * Process method nodes to check for @cca-ignore annotations. + */ + private function processMethodNode(Node $node): void + { + if (!$node instanceof Node\Stmt\ClassMethod) { + return; + } + + // Skip methods that don't have a class context + if (empty($this->currentClassName)) { + return; + } + + if ($this->hasIgnoreAnnotation($node)) { + $methodKey = $this->currentClassName . '::' . $node->name->toString(); + $this->ignoredMethods[$methodKey] = $methodKey; + } + } + + public function enterNode(Node $node): void + { + $this->setCurrentNamespace($node); + $this->setCurrentClass($node); + $this->processClassNode($node); + $this->processMethodNode($node); + } + + public function leaveNode(Node $node): void + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->currentNamespace = ''; + } + + if ($node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Trait_) { + $this->currentClassName = ''; + } + } + + /** + * Get all ignored classes. + * + * @return array Array of ignored class FQCNs + */ + public function getIgnoredClasses(): array + { + return $this->ignoredClasses; + } + + /** + * Get all ignored methods. + * + * @return array Array of ignored method keys (ClassName::methodName) + */ + public function getIgnoredMethods(): array + { + return $this->ignoredMethods; + } + + /** + * Get all ignored items (both classes and methods). + * + * @return array> Array with 'classes' and 'methods' keys + */ + public function getIgnored(): array + { + return [ + 'classes' => $this->ignoredClasses, + 'methods' => $this->ignoredMethods, + ]; + } + + /** + * Check if a specific class is ignored. + */ + public function isClassIgnored(string $className): bool + { + return isset($this->ignoredClasses[$className]); + } + + /** + * Check if a specific method is ignored. + */ + public function isMethodIgnored(string $methodKey): bool + { + return isset($this->ignoredMethods[$methodKey]); + } + + /** + * Reset the visitor state. + */ + public function reset(): void + { + $this->ignoredClasses = []; + $this->ignoredMethods = []; + $this->currentNamespace = ''; + $this->currentClassName = ''; + } +} diff --git a/src/PhpParser/CognitiveMetricsVisitor.php b/src/PhpParser/CognitiveMetricsVisitor.php index f54f838..d8a8ac6 100644 --- a/src/PhpParser/CognitiveMetricsVisitor.php +++ b/src/PhpParser/CognitiveMetricsVisitor.php @@ -9,6 +9,8 @@ /** * Node visitor to collect method metrics. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class CognitiveMetricsVisitor extends NodeVisitorAbstract { @@ -21,6 +23,13 @@ class CognitiveMetricsVisitor extends NodeVisitorAbstract private string $currentMethod = ''; private int $currentReturnCount = 0; + /** + * @var AnnotationVisitor|null The annotation visitor to check for ignored items + */ + private ?AnnotationVisitor $annotationVisitor = null; + + + /** * @var array */ @@ -41,6 +50,14 @@ class CognitiveMetricsVisitor extends NodeVisitorAbstract private int $elseCount = 0; private int $ifCount = 0; + /** + * Set the annotation visitor to check for ignored items. + */ + public function setAnnotationVisitor(AnnotationVisitor $annotationVisitor): void + { + $this->annotationVisitor = $annotationVisitor; + } + public function resetValues(): void { $this->currentReturnCount = 0; @@ -54,16 +71,58 @@ public function resetValues(): void $this->ifCount = 0; } + /** + * Create the initial metrics array for a method. + */ + private function createMetricsArray(Node\Stmt\ClassMethod $node): array + { + return [ + 'lineCount' => $this->calculateLineCount($node), + 'argCount' => $this->countMethodArguments($node), + 'returnCount' => 0, + 'variableCount' => 0, + 'propertyCallCount' => 0, + 'ifNestingLevel' => 0, + 'elseCount' => 0, + 'ifCount' => 0, + ]; + } + + /** + * Check if we have a valid class context for method processing. + */ + private function isValidContext(): bool + { + return !empty($this->currentClassName); + } + + /** + * Build the method key for the current class and method. + */ + private function buildMethodKey(): string + { + return "{$this->currentClassName}::{$this->currentMethod}"; + } + private function classMethodOnEnterNode(Node $node): void { - if (!$this->isClassMethodNode($node)) { + // Skip methods that don't have a class or trait context (interfaces, global functions) + if (!$this->isClassMethodNode($node) || !$this->isValidContext()) { return; } + // Check if this method should be ignored + if ($this->annotationVisitor !== null) { + $methodKey = $this->currentClassName . '::' . $node->name->toString(); + if ($this->annotationVisitor->isMethodIgnored($methodKey)) { + return; + } + } + $this->initializeMethodContext($node); $this->resetValues(); - $this->recordMethodMetrics($node); $this->trackMethodArguments($node); + // Note: recordMethodMetrics is now called in leaveNode to ensure currentMethod is set } /** @@ -88,26 +147,6 @@ private function initializeMethodContext(Node\Stmt\ClassMethod $node): void $this->currentMethod = $node->name->toString(); } - /** - * Record the initial metrics for the current method. - * - * @param Node\Stmt\ClassMethod $node The class method node. - * @return void - */ - private function recordMethodMetrics(Node\Stmt\ClassMethod $node): void - { - $this->methodMetrics["{$this->currentClassName}::{$this->currentMethod}"] = [ - 'lineCount' => $this->calculateLineCount($node), - 'argCount' => $this->countMethodArguments($node), - 'returnCount' => 0, - 'variableCount' => 0, - 'propertyCallCount' => 0, - 'ifNestingLevel' => 0, - 'elseCount' => 0, - 'ifCount' => 0, - ]; - } - /** * Track the method arguments in the current context. * @@ -163,15 +202,31 @@ private function setCurrentNamespaceOnEnterNode(Node $node): void } } + /** + * Check if the node is a class or trait declaration. + */ + private function isClassOrTraitNode(Node $node): bool + { + return $node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Trait_; + } + private function setCurrentClassOnEnterNode(Node $node): bool { - if ($node instanceof Node\Stmt\Class_) { - if ($node->name === null) { - return false; - } + if (!$this->isClassOrTraitNode($node)) { + return true; + } - $fqcn = $this->currentNamespace . '\\' . $node->name->toString(); - $this->currentClassName = $this->normalizeFqcn($fqcn); + if ($node->name === null) { + return false; + } + + $fqcn = $this->currentNamespace . '\\' . $node->name->toString(); + $this->currentClassName = $this->normalizeFqcn($fqcn); + + // Check if this class should be ignored + if ($this->annotationVisitor !== null && $this->annotationVisitor->isClassIgnored($this->currentClassName)) { + $this->currentClassName = ''; // Clear the class name if ignored + return false; } return true; @@ -247,14 +302,14 @@ private function countVariablesNotAlreadyTrackedAsArguments(Node\Expr\Variable $ private function trackPropertyFetch(Node\Expr\PropertyFetch $node): void { - if ( - $node->name instanceof Node\Expr\Variable - || !method_exists($node->name, 'toString') - ) { + // Skip if property name is a variable or doesn't have toString method + if (!$node->name instanceof Node\Identifier) { return; } $property = $node->name->toString(); + + // Only track new properties to avoid duplicates if (!isset($this->accessedProperties[$property])) { $this->accessedProperties[$property] = true; $this->propertyCalls++; @@ -283,20 +338,45 @@ private function checkNestingLevelOnLeaveNode(Node $node): void } } + + private function writeMetricsOnLeaveNode(Node $node): void { - if ($node instanceof Node\Stmt\ClassMethod) { - $method = "{$this->currentClassName}::{$this->currentMethod}"; - $this->methodMetrics[$method]['returnCount'] = $this->currentReturnCount; - $this->methodMetrics[$method]['variableCount'] = count($this->currentVariables); - $this->methodMetrics[$method]['propertyCallCount'] = $this->propertyCalls; - $this->methodMetrics[$method]['ifCount'] = $this->ifCount; - $this->methodMetrics[$method]['ifNestingLevel'] = $this->maxIfNestingLevel; - $this->methodMetrics[$method]['elseCount'] = $this->elseCount; - $this->methodMetrics[$method]['lineCount'] = $node->getEndLine() - $node->getStartLine() + 1; - $this->methodMetrics[$method]['argCount'] = count($node->getParams()); + if (!$node instanceof Node\Stmt\ClassMethod) { + return; + } + + // Skip methods that don't have a class or trait context (interfaces, global functions) + if (!$this->isValidContext()) { $this->currentMethod = ''; + return; + } + + // Check if this method should be ignored + if ($this->annotationVisitor !== null) { + $methodKey = $this->currentClassName . '::' . $node->name->toString(); + if ($this->annotationVisitor->isMethodIgnored($methodKey)) { + $this->currentMethod = ''; + return; + } + } + + // Record the method metrics if they haven't been recorded yet + $methodKey = $this->buildMethodKey(); + if (!isset($this->methodMetrics[$methodKey])) { + $this->methodMetrics[$methodKey] = $this->createMetricsArray($node); } + + // Update the metrics with the collected values + $this->methodMetrics[$methodKey]['returnCount'] = $this->currentReturnCount; + $this->methodMetrics[$methodKey]['variableCount'] = count($this->currentVariables); + $this->methodMetrics[$methodKey]['propertyCallCount'] = $this->propertyCalls; + $this->methodMetrics[$methodKey]['ifCount'] = $this->ifCount; + $this->methodMetrics[$methodKey]['ifNestingLevel'] = $this->maxIfNestingLevel; + $this->methodMetrics[$methodKey]['elseCount'] = $this->elseCount; + $this->methodMetrics[$methodKey]['lineCount'] = $node->getEndLine() - $node->getStartLine() + 1; + $this->methodMetrics[$methodKey]['argCount'] = count($node->getParams()); + $this->currentMethod = ''; } private function checkNameSpaceOnLeaveNode(Node $node): void @@ -308,7 +388,7 @@ private function checkNameSpaceOnLeaveNode(Node $node): void private function checkClassOnLeaveNode(Node $node): void { - if ($node instanceof Node\Stmt\Class_) { + if ($this->isClassOrTraitNode($node)) { $this->currentClassName = ''; } } @@ -323,6 +403,15 @@ public function leaveNode(Node $node): void public function getMethodMetrics(): array { - return $this->methodMetrics; + // Filter out any incomplete metrics that might have slipped through + $completeMetrics = []; + foreach ($this->methodMetrics as $methodKey => $metrics) { + // Ensure the method key contains a class name and method name (not just ::method or ClassName::) + if (strpos($methodKey, '::') > 0 && !str_starts_with($methodKey, '::') && !str_ends_with($methodKey, '::')) { + $completeMetrics[$methodKey] = $metrics; + } + } + + return $completeMetrics; } } diff --git a/src/PhpParser/CyclomaticComplexityVisitor.php b/src/PhpParser/CyclomaticComplexityVisitor.php index 5d7e19e..c5655dd 100644 --- a/src/PhpParser/CyclomaticComplexityVisitor.php +++ b/src/PhpParser/CyclomaticComplexityVisitor.php @@ -16,6 +16,7 @@ * - +1 for each logical operator (&&, ||, and, or, xor) * * @SuppressWarnings(TooManyFields) + * @SuppressWarnings(ExcessiveClassComplexity) */ class CyclomaticComplexityVisitor extends NodeVisitorAbstract { @@ -38,6 +39,11 @@ class CyclomaticComplexityVisitor extends NodeVisitorAbstract private string $currentClassName = ''; private string $currentMethod = ''; + /** + * @var AnnotationVisitor|null The annotation visitor to check for ignored items + */ + private ?AnnotationVisitor $annotationVisitor = null; + // Complexity counters for the current method private int $currentMethodComplexity = 1; // Base complexity private int $ifCount = 0; @@ -56,6 +62,14 @@ class CyclomaticComplexityVisitor extends NodeVisitorAbstract private int $logicalXorCount = 0; private int $ternaryCount = 0; + /** + * Set the annotation visitor to check for ignored items. + */ + public function setAnnotationVisitor(AnnotationVisitor $annotationVisitor): void + { + $this->annotationVisitor = $annotationVisitor; + } + public function resetMethodCounters(): void { $this->currentMethodComplexity = 1; // Base complexity @@ -103,11 +117,18 @@ private function setCurrentNamespaceOnEnterNode(Node $node): void private function setCurrentClassOnEnterNode(Node $node): void { - if ($node instanceof Node\Stmt\Class_) { + if ($node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Trait_) { if ($node->name !== null) { $fqcn = $this->currentNamespace . '\\' . $node->name->toString(); $this->currentClassName = $this->normalizeFqcn($fqcn); - $this->classComplexity[$this->currentClassName] = 1; // Base complexity for class + + // Check if this class should be ignored + if ($this->annotationVisitor !== null && $this->annotationVisitor->isClassIgnored($this->currentClassName)) { + $this->currentClassName = ''; // Clear the class name if ignored + return; + } + + $this->classComplexity[$this->currentClassName] = 1; // Base complexity for class or trait } } } @@ -120,6 +141,18 @@ private function normalizeFqcn(string $fqcn): string private function handleClassMethodEnter(Node $node): void { if ($node instanceof Node\Stmt\ClassMethod) { + // Skip methods that don't have a class or trait context (interfaces, global functions) + if (empty($this->currentClassName)) { + return; + } + + $methodKey = $this->currentClassName . '::' . $node->name->toString(); + + // Check if this method should be ignored + if ($this->annotationVisitor !== null && $this->annotationVisitor->isMethodIgnored($methodKey)) { + return; + } + $this->currentMethod = $node->name->toString(); $this->resetMethodCounters(); } @@ -233,6 +266,12 @@ private function countTernary(): void private function handleClassMethodLeave(Node $node): void { if ($node instanceof Node\Stmt\ClassMethod) { + // Skip methods that don't have a class context (interfaces, traits, global functions) + if (empty($this->currentClassName)) { + $this->currentMethod = ''; + return; + } + $methodKey = "{$this->currentClassName}::{$this->currentMethod}"; // Store method complexity @@ -277,7 +316,7 @@ private function checkNamespaceLeave(Node $node): void private function checkClassLeave(Node $node): void { - if ($node instanceof Node\Stmt\Class_) { + if ($node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Trait_) { $this->currentClassName = ''; } } diff --git a/src/PhpParser/HalsteadMetricsVisitor.php b/src/PhpParser/HalsteadMetricsVisitor.php index acc587e..2900655 100644 --- a/src/PhpParser/HalsteadMetricsVisitor.php +++ b/src/PhpParser/HalsteadMetricsVisitor.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\Trait_; use PhpParser\Node\Stmt\Namespace_; use PhpParser\NodeVisitorAbstract; @@ -13,6 +14,7 @@ * @SuppressWarnings(PHPMD.TooManyFields) * @SuppressWarnings(PHPMD.ShortVariable) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ class HalsteadMetricsVisitor extends NodeVisitorAbstract { @@ -26,6 +28,19 @@ class HalsteadMetricsVisitor extends NodeVisitorAbstract private array $methodOperands = []; private array $methodMetrics = []; + /** + * @var AnnotationVisitor|null The annotation visitor to check for ignored items + */ + private ?AnnotationVisitor $annotationVisitor = null; + + /** + * Set the annotation visitor to check for ignored items. + */ + public function setAnnotationVisitor(AnnotationVisitor $annotationVisitor): void + { + $this->annotationVisitor = $annotationVisitor; + } + /** * Processes each node in the abstract syntax tree (AST) as it is entered. * @@ -46,10 +61,27 @@ public function enterNode(Node $node) $node instanceof Namespace_ => function () use ($node) { $this->setCurrentNamespace($node); }, - $node instanceof Class_ => function () use ($node) { + $node instanceof Class_ || $node instanceof Trait_ => function () use ($node) { $this->setCurrentClassName($node); + + // Check if this class should be ignored + if ($this->annotationVisitor !== null && $this->annotationVisitor->isClassIgnored($this->currentClassName)) { + $this->currentClassName = null; // Clear the class name if ignored + } }, $node instanceof Node\Stmt\ClassMethod => function () use ($node) { + // Skip methods that don't have a class or trait context (interfaces, global functions) + if (empty($this->currentClassName)) { + return; + } + + $methodKey = $this->currentClassName . '::' . $node->name->toString(); + + // Check if this method should be ignored + if ($this->annotationVisitor !== null && $this->annotationVisitor->isMethodIgnored($methodKey)) { + return; + } + $this->currentMethodName = $node->name->toString(); $this->methodOperators = []; $this->methodOperands = []; @@ -79,7 +111,7 @@ private function setCurrentNamespace(Namespace_ $node): void $this->currentNamespace = $node->name instanceof \PhpParser\Node\Name ? $node->name->toString() : ''; } - private function setCurrentClassName(Class_ $node): void + private function setCurrentClassName(Node $node): void { $className = $node->name ? $node->name->toString() : ''; // Always build FQCN as "namespace\class" (even if namespace is empty) diff --git a/tests/TraitTestCode/ComplexTrait.php b/tests/TraitTestCode/ComplexTrait.php new file mode 100644 index 0000000..1b253bd --- /dev/null +++ b/tests/TraitTestCode/ComplexTrait.php @@ -0,0 +1,92 @@ + 5) { + if ($item === 8) { + $result += $item * 2; + } else { + $result += $item; + } + } else { + $result += $item / 2; + } + } else { + if ($item < 5) { + $result += $item; + } else { + $result += $item * 3; + } + } + } + + return $result; + } + + public function complexMethod2(): string + { + $result = ''; + $data = ['a', 'b', 'c', 'd', 'e']; + + for ($i = 0; $i < count($data); $i++) { + switch ($data[$i]) { + case 'a': + $result .= 'alpha'; + break; + case 'b': + $result .= 'beta'; + break; + case 'c': + $result .= 'gamma'; + break; + case 'd': + $result .= 'delta'; + break; + case 'e': + $result .= 'epsilon'; + break; + default: + $result .= 'unknown'; + } + + if ($i < count($data) - 1) { + $result .= '-'; + } + } + + return $result; + } + + public function complexMethod3(): array + { + $result = []; + $numbers = range(1, 20); + + while (!empty($numbers)) { + $current = array_shift($numbers); + + try { + if ($current % 3 === 0 && $current % 5 === 0) { + $result[] = 'fizzbuzz'; + } elseif ($current % 3 === 0) { + $result[] = 'fizz'; + } elseif ($current % 5 === 0) { + $result[] = 'buzz'; + } else { + $result[] = $current; + } + } catch (Exception $e) { + $result[] = 'error'; + } + } + + return $result; + } +} diff --git a/tests/TraitTestCode/EmptyTrait.php b/tests/TraitTestCode/EmptyTrait.php new file mode 100644 index 0000000..1d33b98 --- /dev/null +++ b/tests/TraitTestCode/EmptyTrait.php @@ -0,0 +1,6 @@ + 3) { + $result += $item; + } + } + + return $result; + } +} diff --git a/tests/Unit/Business/Cognitive/CognitiveMetricsSorterTest.php b/tests/Unit/Business/Cognitive/CognitiveMetricsSorterTest.php new file mode 100644 index 0000000..03f9132 --- /dev/null +++ b/tests/Unit/Business/Cognitive/CognitiveMetricsSorterTest.php @@ -0,0 +1,234 @@ +sorter = new CognitiveMetricsSorter(); + } + + #[Test] + public function testSortByScoreAscending(): void + { + $collection = $this->createTestCollection(); + + // Debug: Check collection size + $this->assertEquals(3, $collection->count()); + + $sorted = $this->sorter->sort($collection, 'score', 'asc'); + + $metrics = iterator_to_array($sorted, true); + $metricsArray = array_values($metrics); + $this->assertEquals(1.0, $metricsArray[0]->getScore()); + $this->assertEquals(5.0, $metricsArray[1]->getScore()); + $this->assertEquals(10.0, $metricsArray[2]->getScore()); + } + + #[Test] + public function testSortByScoreDescending(): void + { + $collection = $this->createTestCollection(); + $sorted = $this->sorter->sort($collection, 'score', 'desc'); + + $metrics = iterator_to_array($sorted, true); + $metricsArray = array_values($metrics); + $this->assertEquals(10.0, $metricsArray[0]->getScore()); + $this->assertEquals(5.0, $metricsArray[1]->getScore()); + $this->assertEquals(1.0, $metricsArray[2]->getScore()); + } + + #[Test] + public function testSortByClassAscending(): void + { + $collection = $this->createTestCollection(); + $sorted = $this->sorter->sort($collection, 'class', 'asc'); + + $metrics = iterator_to_array($sorted, true); + $metricsArray = array_values($metrics); + $this->assertEquals('ClassA', $metricsArray[0]->getClass()); + $this->assertEquals('ClassB', $metricsArray[1]->getClass()); + $this->assertEquals('ClassC', $metricsArray[2]->getClass()); + } + + #[Test] + public function testSortByMethodAscending(): void + { + $collection = $this->createTestCollection(); + $sorted = $this->sorter->sort($collection, 'method', 'asc'); + + $metrics = iterator_to_array($sorted, true); + $metricsArray = array_values($metrics); + $this->assertEquals('method1', $metricsArray[0]->getMethod()); + $this->assertEquals('method2', $metricsArray[1]->getMethod()); + $this->assertEquals('method3', $metricsArray[2]->getMethod()); + } + + #[Test] + public function testSortByHalsteadVolume(): void + { + $collection = $this->createTestCollection(); + $sorted = $this->sorter->sort($collection, 'halstead', 'asc'); + + $metrics = iterator_to_array($sorted, true); + $metricsArray = array_values($metrics); + $this->assertEquals(100.0, $metricsArray[0]->getHalstead()?->getVolume()); + $this->assertEquals(200.0, $metricsArray[1]->getHalstead()?->getVolume()); + $this->assertEquals(300.0, $metricsArray[2]->getHalstead()?->getVolume()); + } + + #[Test] + public function testSortByCyclomaticComplexity(): void + { + $collection = $this->createTestCollection(); + $sorted = $this->sorter->sort($collection, 'cyclomatic', 'asc'); + + $metrics = iterator_to_array($sorted, true); + $metricsArray = array_values($metrics); + $this->assertEquals(1, $metricsArray[0]->getCyclomatic()?->complexity); + $this->assertEquals(3, $metricsArray[1]->getCyclomatic()?->complexity); + $this->assertEquals(5, $metricsArray[2]->getCyclomatic()?->complexity); + } + + #[Test] + public function testInvalidSortField(): void + { + $collection = $this->createTestCollection(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid sort field "invalid_field"'); + + $this->sorter->sort($collection, 'invalid_field'); + } + + #[Test] + public function testInvalidSortOrder(): void + { + $collection = $this->createTestCollection(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Sort order must be "asc" or "desc"'); + + $this->sorter->sort($collection, 'score', 'invalid'); + } + + #[Test] + public function testGetSortableFields(): void + { + $fields = $this->sorter->getSortableFields(); + + $this->assertContains('score', $fields); + $this->assertContains('halstead', $fields); + $this->assertContains('cyclomatic', $fields); + $this->assertContains('class', $fields); + $this->assertContains('method', $fields); + } + + private function createTestCollection(): CognitiveMetricsCollection + { + $collection = new CognitiveMetricsCollection(); + + // Create metrics with different values for testing + $metrics1 = new CognitiveMetrics([ + 'class' => 'ClassA', + 'method' => 'method1', + 'file' => '/test/file1.php', + 'lineCount' => 10, + 'argCount' => 1, + 'returnCount' => 1, + 'variableCount' => 1, + 'propertyCallCount' => 1, + 'ifCount' => 1, + 'ifNestingLevel' => 1, + 'elseCount' => 1, + ]); + $metrics1->setScore(10.0); + + $metrics2 = new CognitiveMetrics([ + 'class' => 'ClassB', + 'method' => 'method2', + 'file' => '/test/file2.php', + 'lineCount' => 20, + 'argCount' => 2, + 'returnCount' => 2, + 'variableCount' => 2, + 'propertyCallCount' => 2, + 'ifCount' => 2, + 'ifNestingLevel' => 2, + 'elseCount' => 2, + ]); + $metrics2->setScore(5.0); + + $metrics3 = new CognitiveMetrics([ + 'class' => 'ClassC', + 'method' => 'method3', + 'file' => '/test/file3.php', + 'lineCount' => 30, + 'argCount' => 3, + 'returnCount' => 3, + 'variableCount' => 3, + 'propertyCallCount' => 3, + 'ifCount' => 3, + 'ifNestingLevel' => 3, + 'elseCount' => 3, + ]); + $metrics3->setScore(1.0); + + // Add Halstead metrics + $halstead1 = new HalsteadMetrics(['volume' => 100.0, 'n1' => 1, 'n2' => 1, 'N1' => 1, 'N2' => 1, 'programLength' => 1, 'programVocabulary' => 1, 'difficulty' => 1.0, 'effort' => 1.0, 'fqName' => 'test']); + $halstead2 = new HalsteadMetrics(['volume' => 200.0, 'n1' => 1, 'n2' => 1, 'N1' => 1, 'N2' => 1, 'programLength' => 1, 'programVocabulary' => 1, 'difficulty' => 1.0, 'effort' => 1.0, 'fqName' => 'test']); + $halstead3 = new HalsteadMetrics(['volume' => 300.0, 'n1' => 1, 'n2' => 1, 'N1' => 1, 'N2' => 1, 'programLength' => 1, 'programVocabulary' => 1, 'difficulty' => 1.0, 'effort' => 1.0, 'fqName' => 'test']); + + // Add Cyclomatic metrics + $cyclomatic1 = new CyclomaticMetrics(['complexity' => 1]); + $cyclomatic2 = new CyclomaticMetrics(['complexity' => 3]); + $cyclomatic3 = new CyclomaticMetrics(['complexity' => 5]); + + // Use reflection to set private properties for testing + $reflection = new \ReflectionClass($metrics1); + $halsteadProperty = $reflection->getProperty('halstead'); + $halsteadProperty->setAccessible(true); + $halsteadProperty->setValue($metrics1, $halstead1); + + $cyclomaticProperty = $reflection->getProperty('cyclomatic'); + $cyclomaticProperty->setAccessible(true); + $cyclomaticProperty->setValue($metrics1, $cyclomatic1); + + $reflection = new \ReflectionClass($metrics2); + $halsteadProperty = $reflection->getProperty('halstead'); + $halsteadProperty->setAccessible(true); + $halsteadProperty->setValue($metrics2, $halstead2); + + $cyclomaticProperty = $reflection->getProperty('cyclomatic'); + $cyclomaticProperty->setAccessible(true); + $cyclomaticProperty->setValue($metrics2, $cyclomatic2); + + $reflection = new \ReflectionClass($metrics3); + $halsteadProperty = $reflection->getProperty('halstead'); + $halsteadProperty->setAccessible(true); + $halsteadProperty->setValue($metrics3, $halstead3); + + $cyclomaticProperty = $reflection->getProperty('cyclomatic'); + $cyclomaticProperty->setAccessible(true); + $cyclomaticProperty->setValue($metrics3, $cyclomatic3); + + $collection->add($metrics1); + $collection->add($metrics2); + $collection->add($metrics3); + + return $collection; + } +} diff --git a/tests/Unit/Business/Cognitive/ParserTest.php b/tests/Unit/Business/Cognitive/ParserTest.php new file mode 100644 index 0000000..7319246 --- /dev/null +++ b/tests/Unit/Business/Cognitive/ParserTest.php @@ -0,0 +1,270 @@ +parser = new Parser( + new ParserFactory(), + new NodeTraverser() + ); + } + + public function testSkipsIgnoredClass(): void + { + $code = <<<'CODE' + 0) { + return $var; + } + return 0; + } + } + CODE; + + $metrics = $this->parser->parse($code); + + // The class should be ignored, so no metrics should be returned + $this->assertEmpty($metrics); + + // Check that the ignored items are tracked + $ignored = $this->parser->getIgnored(); + $this->assertContains('\\MyNamespace\\MyClass', $ignored['classes']); + } + + public function testSkipsIgnoredMethod(): void + { + $code = <<<'CODE' + 0) { + return $var; + } + return 0; + } + + public function normalMethod() { + $var = 2; + return $var; + } + } + CODE; + + $metrics = $this->parser->parse($code); + + // Only the normal method should have metrics + $this->assertCount(1, $metrics); + $this->assertArrayHasKey('\\MyNamespace\\MyClass::normalMethod', $metrics); + $this->assertArrayNotHasKey('\\MyNamespace\\MyClass::ignoredMethod', $metrics); + + // Check that the ignored method is tracked + $ignored = $this->parser->getIgnored(); + $this->assertContains('\\MyNamespace\\MyClass::ignoredMethod', $ignored['methods']); + $this->assertEmpty($ignored['classes']); + } + + public function testSkipsIgnoredClassAndMethod(): void + { + $code = <<<'CODE' + 0) { + return $var; + } + return 0; + } + + public function normalMethod() { + $var = 2; + return $var; + } + } + CODE; + + $metrics = $this->parser->parse($code); + + // Only the normal method in NormalClass should have metrics + $this->assertCount(1, $metrics); + $this->assertArrayHasKey('\\MyNamespace\\NormalClass::normalMethod', $metrics); + $this->assertArrayNotHasKey('\\MyNamespace\\NormalClass::ignoredMethod', $metrics); + $this->assertArrayNotHasKey('\\MyNamespace\\IgnoredClass::method1', $metrics); + + // Check that both ignored items are tracked + $ignored = $this->parser->getIgnored(); + $this->assertContains('\\MyNamespace\\IgnoredClass', $ignored['classes']); + $this->assertContains('\\MyNamespace\\NormalClass::ignoredMethod', $ignored['methods']); + } + + public function testNoAnnotations(): void + { + $code = <<<'CODE' + parser->parse($code); + + // The method should have metrics + $this->assertCount(1, $metrics); + $this->assertArrayHasKey('\\MyNamespace\\MyClass::myMethod', $metrics); + + // No ignored items + $ignored = $this->parser->getIgnored(); + $this->assertEmpty($ignored['classes']); + $this->assertEmpty($ignored['methods']); + } + + public function testInlineCommentAnnotations(): void + { + $code = <<<'CODE' + parser->parse($code); + + // Both class and method should be ignored + $this->assertEmpty($metrics); + + // Check that both ignored items are tracked + $ignored = $this->parser->getIgnored(); + $this->assertContains('\\MyNamespace\\MyClass', $ignored['classes']); + $this->assertContains('\\MyNamespace\\MyClass::myMethod', $ignored['methods']); + } + + public function testGetIgnoredMethods(): void + { + $code = <<<'CODE' + parser->parse($code); + + $ignoredMethods = $this->parser->getIgnoredMethods(); + $this->assertCount(2, $ignoredMethods); + $this->assertContains('\\MyNamespace\\MyClass::ignoredMethod1', $ignoredMethods); + $this->assertContains('\\MyNamespace\\MyClass::ignoredMethod2', $ignoredMethods); + $this->assertNotContains('\\MyNamespace\\MyClass::normalMethod', $ignoredMethods); + } + + public function testGetIgnoredClasses(): void + { + $code = <<<'CODE' + parser->parse($code); + + $ignoredClasses = $this->parser->getIgnoredClasses(); + $this->assertCount(2, $ignoredClasses); + $this->assertContains('\\MyNamespace\\IgnoredClass1', $ignoredClasses); + $this->assertContains('\\MyNamespace\\IgnoredClass2', $ignoredClasses); + $this->assertNotContains('\\MyNamespace\\NormalClass', $ignoredClasses); + } +} diff --git a/tests/Unit/Command/CognitiveMetricsCommandTest.php b/tests/Unit/Command/CognitiveMetricsCommandTest.php index 4e92a1d..637c24d 100644 --- a/tests/Unit/Command/CognitiveMetricsCommandTest.php +++ b/tests/Unit/Command/CognitiveMetricsCommandTest.php @@ -110,4 +110,53 @@ public static function reportDataProvider(): array ] ]; } + + #[Test] + public function testAnalyseWithSorting(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../../src', + '--sort-by' => 'score', + '--sort-order' => 'desc', + ]); + + $this->assertEquals(Command::SUCCESS, $tester->getStatusCode(), 'Command should succeed with sorting'); + } + + #[Test] + public function testAnalyseWithInvalidSortField(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../../src', + '--sort-by' => 'invalid-field', + ]); + + $this->assertEquals(Command::FAILURE, $tester->getStatusCode(), 'Command should fail with invalid sort field'); + $this->assertStringContainsString('Sorting error', $tester->getDisplay()); + } + + #[Test] + public function testAnalyseWithInvalidSortOrder(): void + { + $application = new Application(); + $command = $application->getContainer()->get(CognitiveMetricsCommand::class); + $tester = new CommandTester($command); + + $tester->execute([ + 'path' => __DIR__ . '/../../../src', + '--sort-by' => 'score', + '--sort-order' => 'invalid', + ]); + + $this->assertEquals(Command::FAILURE, $tester->getStatusCode(), 'Command should fail with invalid sort order'); + $this->assertStringContainsString('Sorting error', $tester->getDisplay()); + } } diff --git a/tests/Unit/PhpParser/AnnotationVisitorTest.php b/tests/Unit/PhpParser/AnnotationVisitorTest.php new file mode 100644 index 0000000..ce24dec --- /dev/null +++ b/tests/Unit/PhpParser/AnnotationVisitorTest.php @@ -0,0 +1,289 @@ +visitor = new AnnotationVisitor(); + $this->parser = (new ParserFactory())->createForHostVersion(); + $this->traverser = new NodeTraverser(); + $this->traverser->addVisitor($this->visitor); + } + + public function testDetectsIgnoredClass(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $ignored = $this->visitor->getIgnored(); + + $this->assertArrayHasKey('classes', $ignored); + $this->assertArrayHasKey('methods', $ignored); + $this->assertContains('\\MyNamespace\\MyClass', $ignored['classes']); + $this->assertEmpty($ignored['methods']); // Methods in ignored classes are not tracked + } + + public function testDetectsIgnoredMethod(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $ignored = $this->visitor->getIgnored(); + + $this->assertArrayHasKey('classes', $ignored); + $this->assertArrayHasKey('methods', $ignored); + $this->assertEmpty($ignored['classes']); + $this->assertContains('\\MyNamespace\\MyClass::ignoredMethod', $ignored['methods']); + $this->assertNotContains('\\MyNamespace\\MyClass::normalMethod', $ignored['methods']); + } + + public function testDetectsIgnoredClassAndMethod(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $ignored = $this->visitor->getIgnored(); + + $this->assertArrayHasKey('classes', $ignored); + $this->assertArrayHasKey('methods', $ignored); + $this->assertContains('\\MyNamespace\\IgnoredClass', $ignored['classes']); + $this->assertContains('\\MyNamespace\\NormalClass::ignoredMethod', $ignored['methods']); + $this->assertNotContains('\\MyNamespace\\NormalClass::normalMethod', $ignored['methods']); + } + + public function testDetectsInlineCommentAnnotation(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $ignored = $this->visitor->getIgnored(); + + $this->assertArrayHasKey('classes', $ignored); + $this->assertArrayHasKey('methods', $ignored); + $this->assertContains('\\MyNamespace\\MyClass', $ignored['classes']); + $this->assertContains('\\MyNamespace\\MyClass::myMethod', $ignored['methods']); + } + + public function testDetectsTraitAnnotation(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $ignored = $this->visitor->getIgnored(); + + $this->assertArrayHasKey('classes', $ignored); + $this->assertArrayHasKey('methods', $ignored); + $this->assertContains('\\MyNamespace\\MyTrait', $ignored['classes']); + $this->assertEmpty($ignored['methods']); + } + + public function testNoAnnotations(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $ignored = $this->visitor->getIgnored(); + + $this->assertArrayHasKey('classes', $ignored); + $this->assertArrayHasKey('methods', $ignored); + $this->assertEmpty($ignored['classes']); + $this->assertEmpty($ignored['methods']); + } + + public function testResetFunctionality(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + // Verify items are detected + $ignored = $this->visitor->getIgnored(); + $this->assertNotEmpty($ignored['classes']); + + // Reset and verify items are cleared + $this->visitor->reset(); + $ignored = $this->visitor->getIgnored(); + $this->assertEmpty($ignored['classes']); + $this->assertEmpty($ignored['methods']); + } + + public function testIsClassIgnored(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $this->assertTrue($this->visitor->isClassIgnored('\\MyNamespace\\MyClass')); + $this->assertFalse($this->visitor->isClassIgnored('\\MyNamespace\\OtherClass')); + } + + public function testIsMethodIgnored(): void + { + $code = <<<'CODE' + parser->parse($code); + $this->traverser->traverse($statements); + + $this->assertTrue($this->visitor->isMethodIgnored('\\MyNamespace\\MyClass::ignoredMethod')); + $this->assertFalse($this->visitor->isMethodIgnored('\\MyNamespace\\MyClass::normalMethod')); + } +} From bf87a7fd70e13e3780e6eabfbe3263bfd82fa4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Sun, 7 Sep 2025 21:09:59 +0200 Subject: [PATCH 2/2] Add sorting and filtering documentation to README and introduce grouping results by class in Configuration --- docs/Configuration.md | 14 +++++ docs/Sorting-and-Filtering.md | 101 ++++++++++++++++++++++++++++++++++ readme.md | 1 + 3 files changed, 116 insertions(+) create mode 100644 docs/Sorting-and-Filtering.md diff --git a/docs/Configuration.md b/docs/Configuration.md index 016a0eb..3654cf8 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -79,3 +79,17 @@ cognitive: showHalsteadComplexity: false showCyclomaticComplexity: false ``` + +## Grouping Results by Class + +You can control how the analysis results are displayed by setting the `groupByClass` option. + +```yaml +cognitive: + groupByClass: true +``` + +- **`true` (default)**: Results are grouped by class, showing all methods within each class together +- **`false`**: Results are displayed as a flat list without grouping + +When `groupByClass` is enabled, the output will show separate tables for each class, making it easier to understand the complexity within specific classes. When disabled, all methods are shown in a single table sorted by their complexity score. diff --git a/docs/Sorting-and-Filtering.md b/docs/Sorting-and-Filtering.md new file mode 100644 index 0000000..c9c47a6 --- /dev/null +++ b/docs/Sorting-and-Filtering.md @@ -0,0 +1,101 @@ +# Sorting and Filtering + +The cognitive code analysis tool provides powerful sorting and filtering capabilities to help you organize and focus on the most relevant results from your code analysis. + +## Sorting Results + +You can sort analysis results by various metrics to identify the most complex or problematic code areas. + +### Command Line Options + +```bash +bin/phpcca analyse --sort-by= --sort-order= +``` + +#### Available Options + +- `--sort-by, -s`: Field to sort by (optional) +- `--sort-order`: Sort order - `asc` (ascending) or `desc` (descending), default: `asc` + +### Sortable Fields + +The following fields are available for sorting: + +| Field | Description | +|-------|-------------| +| `score` | Cognitive complexity score | +| `halstead` | Halstead complexity metrics | +| `cyclomatic` | Cyclomatic complexity | +| `class` | Class name (alphabetical) | +| `method` | Method name (alphabetical) | +| `lineCount` | Number of lines of code | +| `argCount` | Number of method arguments | +| `returnCount` | Number of return statements | +| `variableCount` | Number of variables used | +| `propertyCallCount` | Number of property accesses | +| `ifCount` | Number of if statements | +| `ifNestingLevel` | Maximum nesting level of if statements | +| `elseCount` | Number of else statements | + +### Examples + +Sort by cognitive complexity score (highest first): +```bash +bin/phpcca analyse src/ --sort-by=score --sort-order=desc +``` + +Sort by method name alphabetically: +```bash +bin/phpcca analyse src/ --sort-by=method --sort-order=asc +``` + +Sort by cyclomatic complexity: +```bash +bin/phpcca analyse src/ --sort-by=cyclomatic --sort-order=desc +``` + +## Filtering and Grouping + +### Grouping by Class + +By default, results are grouped by class to make it easier to understand complexity within specific classes. This behavior can be controlled via configuration: + +```yaml +cognitive: + groupByClass: true # Default: true +``` + +- **`true`**: Results are grouped by class, showing separate tables for each class +- **`false`**: Results are displayed as a flat list without grouping + +### Excluding Classes and Methods + +You can exclude specific classes and methods from analysis using regex patterns in your configuration file: + +```yaml +cognitive: + excludePatterns: + - '(.*)::__construct' # Exclude all constructors + - '(.*)::toArray' # Exclude all toArray methods + - '(.*)Transformer::(.*)' # Exclude all methods in Transformer classes +``` + +### Excluding Files + +You can exclude entire files from analysis: + +```yaml +cognitive: + excludeFilePatterns: + - '.*Cognitive.*' # Exclude files with "Cognitive" in the name + - '(.*)Test.php' # Exclude all test files +``` + +## Error Handling + +If you specify an invalid sort field, the tool will display an error message with the list of available fields: + +```bash +bin/phpcca analyse src/ --sort-by=invalidField +# Output: Sorting error: Invalid sort field "invalidField". Available fields: score, halstead, cyclomatic, class, method, lineCount, argCount, returnCount, variableCount, propertyCallCount, ifCount, ifNestingLevel, elseCount +``` diff --git a/readme.md b/readme.md index 07e3f5e..556ad7e 100644 --- a/readme.md +++ b/readme.md @@ -51,6 +51,7 @@ bin/phpcca churn * [Metrics Collected](./docs/Cognitive-Complexity-Analysis.md#metrics-collected) * [Result Interpretation](./docs/Cognitive-Complexity-Analysis.md#result-interpretation) * [Churn - Finding Hotspots](./docs/Churn-Finding-Hotspots.md) + * [Sorting and Filtering](./docs/Sorting-and-Filtering.md) * [Configuration](./docs/Configuration.md#configuration) * [Tuning the calculation](./docs/Configuration.md#tuning-the-calculation) * [Examples](#examples)