diff --git a/src/Application.php b/src/Application.php index 1b3f72b..45a0a0a 100644 --- a/src/Application.php +++ b/src/Application.php @@ -107,7 +107,12 @@ public function run(): int return 2; } - $reporter = $this->createReporter($options['report'], $options['colors']); + $reporter = $this->createReporter( + $options['report'], + $options['colors'], + $options['perf'] + ); + $this->write($reporter->generate($report)); return (int) $report->hasViolations(); @@ -144,6 +149,7 @@ private function resolveOverridePaths(array $paths): array * quiet: bool, * paths: list, * diff: string|null, + * perf: bool, * } */ private function parseArgv(): array @@ -157,6 +163,7 @@ private function parseArgv(): array 'quiet' => false, 'paths' => [], 'diff' => null, + 'perf' => false, ]; $args = array_slice($this->argv, 1); // skip script name @@ -235,6 +242,12 @@ private function parseArgv(): array continue; } + if ($arg === '--perf') { + $result['perf'] = true; + $i++; + continue; + } + // Anything else is a path to scan. if (!str_starts_with($arg, '-')) { $result['paths'][] = $arg; @@ -302,12 +315,12 @@ private function loadConfig(string $configPath): ConfigData return $parser->parseFile($configPath); } - private function createReporter(string $format, bool $colors): ReporterInterface + private function createReporter(string $format, bool $colors, bool $perf): ReporterInterface { return match ($format) { 'checkstyle' => new CheckstyleReporter(), 'json' => new JsonReporter(), - default => new ConsoleReporter($colors), + default => new ConsoleReporter($colors, $perf), }; } diff --git a/src/Report/Report.php b/src/Report/Report.php index 4723809..37ccbe5 100644 --- a/src/Report/Report.php +++ b/src/Report/Report.php @@ -11,6 +11,11 @@ final class Report private int $filesScanned = 0; + private float $totalTime = 0.0; + + /** @var array */ + private array $sniffTimes = []; + public function addFileReport(FileReport $fileReport): void { $this->fileReports[$fileReport->filePath] = $fileReport; @@ -79,4 +84,29 @@ public function getAllViolations(): array return $all; } + + public function setTotalTime(float $time): void + { + $this->totalTime = $time; + } + + public function addSniffTime(string $sniffClass, float $time): void + { + if (!isset($this->sniffTimes[$sniffClass])) { + $this->sniffTimes[$sniffClass] = 0.0; + } + + $this->sniffTimes[$sniffClass] += $time; + } + + public function getTotalTime(): float + { + return $this->totalTime; + } + + /** @return array */ + public function getSniffTimes(): array + { + return $this->sniffTimes; + } } diff --git a/src/Report/Reporter/CheckstyleReporter.php b/src/Report/Reporter/CheckstyleReporter.php index bcba3f9..6076f2c 100644 --- a/src/Report/Reporter/CheckstyleReporter.php +++ b/src/Report/Reporter/CheckstyleReporter.php @@ -17,6 +17,11 @@ public function generate(Report $report): string $root->setAttribute('version', '3.0'); $dom->appendChild($root); + $comment = $dom->createComment( + sprintf(' total runtime: %.3fs ', $report->getTotalTime()) + ); + $root->appendChild($comment); + foreach ($report->getFileReports() as $fileReport) { if (!$fileReport->hasViolations()) { continue; diff --git a/src/Report/Reporter/ConsoleReporter.php b/src/Report/Reporter/ConsoleReporter.php index b91d81e..6a81eb1 100644 --- a/src/Report/Reporter/ConsoleReporter.php +++ b/src/Report/Reporter/ConsoleReporter.php @@ -10,10 +10,12 @@ final class ConsoleReporter implements ReporterInterface { private bool $useColors; + private bool $showPerformance; - public function __construct(bool $useColors = true) + public function __construct(bool $useColors = true, bool $showPerformance = false) { $this->useColors = $useColors; + $this->showPerformance = $showPerformance; } public function generate(Report $report): string @@ -45,6 +47,11 @@ public function generate(Report $report): string $output .= PHP_EOL; $output .= $this->buildSummary($report) . PHP_EOL; + if ($this->showPerformance) { + $output .= PHP_EOL; + $output .= $this->buildPerformance($report) . PHP_EOL; + } + return $output; } @@ -54,6 +61,9 @@ private function buildSummary(Report $report): string $errors = $report->getTotalErrors(); $warnings = $report->getTotalWarnings(); $total = $report->getTotalViolations(); + $time = $report->getTotalTime(); + + $timeLine = sprintf('Total runtime: %.3fs', $time); if ($total === 0) { return $this->green( @@ -61,7 +71,7 @@ private function buildSummary(Report $report): string 'OK -- %d file(s) scanned, no violations found.', $files, ) - ); + ) . PHP_EOL . $this->dim($timeLine); } return $this->red( @@ -72,7 +82,41 @@ private function buildSummary(Report $report): string $warnings, count($report->getFileReports()), ) - ); + ) . PHP_EOL . $this->dim($timeLine); + } + + private function buildPerformance(Report $report): string + { + $totalTime = $report->getTotalTime(); + $sniffTimes = $report->getSniffTimes(); + + if ($totalTime <= 0.0 || $sniffTimes === []) { + return $this->dim('No performance data available.'); + } + + // Sort slowest first + arsort($sniffTimes); + + $output = $this->bold('PERFORMANCE') . PHP_EOL; + $output .= str_repeat('-', 40) . PHP_EOL; + + $output .= sprintf( + ' Total runtime: %.3fs', + $totalTime + ) . PHP_EOL . PHP_EOL; + + foreach ($sniffTimes as $sniff => $time) { + $percent = ($time / $totalTime) * 100; + + $output .= sprintf( + ' %-40s %6.3fs (%5.1f%%)', + $sniff, + $time, + $percent, + ) . PHP_EOL; + } + + return $output; } private function formatSeverity(Severity $severity): string diff --git a/src/Report/Reporter/JsonReporter.php b/src/Report/Reporter/JsonReporter.php index e413167..0e52b0f 100644 --- a/src/Report/Reporter/JsonReporter.php +++ b/src/Report/Reporter/JsonReporter.php @@ -18,6 +18,9 @@ public function generate(Report $report): string 'warnings' => $report->getTotalWarnings(), ], 'files' => [], + 'performance' => [ + 'total_runtime_seconds' => $report->getTotalTime(), + ], ]; foreach ($report->getFileReports() as $fileReport) { diff --git a/src/Runner/SniffRunner.php b/src/Runner/SniffRunner.php index 902ae79..73fdfbc 100644 --- a/src/Runner/SniffRunner.php +++ b/src/Runner/SniffRunner.php @@ -30,6 +30,8 @@ public function __construct(?ProgressInterface $progress = null) */ public function run(ConfigData $config, ?array $overridePaths = null, ?array $diffLines = null): Report { + $startTime = microtime(true); + $sniffs = $this->instantiateSniffs($config->getSniffs()); $matcher = new PathMatcher($config->getExcludePatterns()); @@ -43,10 +45,10 @@ public function run(ConfigData $config, ?array $overridePaths = null, ?array $di $files = $this->filterByDiff($files, array_keys($diffLines)); } + $report = new Report(); $preprocessor = new EntityPreprocessor(); - $processor = new XmlFileProcessor($sniffs, $preprocessor); + $processor = new XmlFileProcessor($sniffs, $preprocessor, $report); - $report = new Report(); $total = count($files); $this->progress->start($total); @@ -54,8 +56,15 @@ public function run(ConfigData $config, ?array $overridePaths = null, ?array $di foreach ($files as $index => $file) { $report->incrementFilesScanned(); - $changedLines = $diffLines !== null ? $this->getChangedLinesForFile($file, $diffLines) : []; - $fileReport = $processor->processFile($file, $changedLines, $this->makeRelative($file)); + $changedLines = $diffLines !== null + ? $this->getChangedLinesForFile($file, $diffLines) + : []; + + $fileReport = $processor->processFile( + $file, + $changedLines, + $this->makeRelative($file), + ); $violationCount = $fileReport->getViolationCount(); @@ -68,6 +77,8 @@ public function run(ConfigData $config, ?array $overridePaths = null, ?array $di $this->progress->finish(); + $report->setTotalTime(microtime(true) - $startTime); + return $report; } @@ -150,8 +161,10 @@ private function makeRelative(string $absolutePath): string if ($cwd === false) { return $absolutePath; // @codeCoverageIgnore } + $prefix = rtrim(str_replace('\\', '/', $cwd), '/') . '/'; $normalized = str_replace('\\', '/', $absolutePath); + if (str_starts_with($normalized, $prefix)) { return substr($normalized, strlen($prefix)); } diff --git a/src/Runner/XmlFileProcessor.php b/src/Runner/XmlFileProcessor.php index 8c436f4..2ab2f29 100644 --- a/src/Runner/XmlFileProcessor.php +++ b/src/Runner/XmlFileProcessor.php @@ -5,6 +5,7 @@ namespace DocbookCS\Runner; use DocbookCS\Report\FileReport; +use DocbookCS\Report\Report; use DocbookCS\Report\Severity; use DocbookCS\Report\Violation; use DocbookCS\Sniff\SniffInterface; @@ -16,16 +17,17 @@ final class XmlFileProcessor private EntityPreprocessor $preprocessor; - /** - * @param list $sniffs - * @param EntityPreprocessor|null $preprocessor - */ + private Report $report; + + /** @param list $sniffs */ public function __construct( array $sniffs, ?EntityPreprocessor $preprocessor = null, + ?Report $report = null, ) { $this->sniffs = $sniffs; $this->preprocessor = $preprocessor ?? new EntityPreprocessor(); + $this->report = $report ?? new Report(); } /** @param list $changedLines */ @@ -76,9 +78,13 @@ private function processContent( $violations = []; foreach ($this->sniffs as $sniff) { + $start = microtime(true); + foreach ($sniff->process($document, $content, $filePath) as $violation) { $violations[] = $violation; } + + $this->report->addSniffTime($sniff->getCode(), microtime(true) - $start); } if ($changedLines !== []) { diff --git a/tests/Unit/ApplicationTest.php b/tests/Unit/ApplicationTest.php index fd58b6c..22c8664 100644 --- a/tests/Unit/ApplicationTest.php +++ b/tests/Unit/ApplicationTest.php @@ -424,4 +424,47 @@ public function itSuppressesProgressForStructuredReportFormats(): void self::assertSame('', $this->readStream($stderr), "stderr should be empty for --report={$format}"); } } + + #[Test] + public function itShowsPerformanceWhenPerfFlagIsEnabled(): void + { + $app = new Application( + [ + 'docbook-cs', + '--config=' . self::VALID_CONFIG, + '--perf', + self::SCAN_FILE, + ], + $this->stdout, + $this->stderr, + ); + + $exitCode = $app->run(); + + self::assertNotSame(2, $exitCode); + + $output = $this->readStream($this->stdout); + + self::assertStringContainsString('PERFORMANCE', $output); + } + + #[Test] + public function itDoesNotShowPerformanceByDefault(): void + { + $app = new Application( + [ + 'docbook-cs', + '--config=' . self::VALID_CONFIG, + self::SCAN_FILE, + ], + $this->stdout, + $this->stderr, + ); + + $app->run(); + + $output = $this->readStream($this->stdout); + + self::assertStringNotContainsString('PERFORMANCE', $output); + } } diff --git a/tests/Unit/Report/Reporter/ConsoleReporterTest.php b/tests/Unit/Report/Reporter/ConsoleReporterTest.php index 4e65736..1dd96b8 100644 --- a/tests/Unit/Report/Reporter/ConsoleReporterTest.php +++ b/tests/Unit/Report/Reporter/ConsoleReporterTest.php @@ -360,4 +360,85 @@ public function itSeparatesFieldsWithPipes(): void self::assertNotEmpty($violationLine); self::assertSame(3, substr_count($violationLine, '|')); } + + #[Test] + public function itShowsNoPerformanceDataWhenEmpty(): void + { + $reporter = new ConsoleReporter(useColors: false, showPerformance: true); + + $report = new Report(); + $report->incrementFilesScanned(); + + $output = $reporter->generate($report); + + self::assertStringContainsString('No performance data available.', $output); + } + + #[Test] + public function itShowsPerformanceSectionWithHeader(): void + { + $reporter = new ConsoleReporter(useColors: false, showPerformance: true); + + $report = new Report(); + $report->incrementFilesScanned(); + + $report->setTotalTime(2.0); + $report->addSniffTime('SniffA', 1.0); + + $output = $reporter->generate($report); + + self::assertStringContainsString('PERFORMANCE', $output); + self::assertStringContainsString('Total runtime: 2.000s', $output); + } + + #[Test] + public function itSortsSniffTimesBySlowestFirst(): void + { + $reporter = new ConsoleReporter(useColors: false, showPerformance: true); + + $report = new Report(); + $report->setTotalTime(3.0); + + $report->addSniffTime('FastSniff', 0.5); + $report->addSniffTime('SlowSniff', 2.0); + $report->addSniffTime('MediumSniff', 1.0); + + $output = $reporter->generate($report); + + $slowPos = strpos($output, 'SlowSniff'); + $mediumPos = strpos($output, 'MediumSniff'); + $fastPos = strpos($output, 'FastSniff'); + + self::assertTrue($slowPos < $mediumPos); + self::assertTrue($mediumPos < $fastPos); + } + + #[Test] + public function itDisplaysTimeAndPercentagePerSniff(): void + { + $reporter = new ConsoleReporter(useColors: false, showPerformance: true); + + $report = new Report(); + $report->setTotalTime(2.0); + + $report->addSniffTime('SniffA', 1.0); // 50% + + $output = $reporter->generate($report); + + self::assertStringContainsString('1.000s ( 50.0%)', $output); + } + + #[Test] + public function itDoesNotShowPerformanceWhenDisabled(): void + { + $reporter = new ConsoleReporter(useColors: false, showPerformance: false); + + $report = new Report(); + $report->setTotalTime(2.0); + $report->addSniffTime('SniffA', 1.0); + + $output = $reporter->generate($report); + + self::assertStringNotContainsString('PERFORMANCE', $output); + } } diff --git a/tests/Unit/Runner/XmlFileProcessorTest.php b/tests/Unit/Runner/XmlFileProcessorTest.php index a1fc762..f35e3c4 100644 --- a/tests/Unit/Runner/XmlFileProcessorTest.php +++ b/tests/Unit/Runner/XmlFileProcessorTest.php @@ -5,6 +5,7 @@ namespace DocbookCS\Tests\Unit\Runner; use DocbookCS\Report\FileReport; +use DocbookCS\Report\Report; use DocbookCS\Report\Severity; use DocbookCS\Report\Violation; use DocbookCS\Runner\XmlFileProcessor; @@ -18,6 +19,7 @@ #[CoversClass(FileReport::class)] #[CoversClass(Violation::class)] #[CoversClass(XmlFileProcessor::class)] +#[CoversClass(Report::class)] final class XmlFileProcessorTest extends TestCase { #[Test]