diff --git a/conf/config.neon b/conf/config.neon index 2f436f5cd9..9379d755e0 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -2262,6 +2262,13 @@ services: editorUrl: %editorUrl% editorUrlTitle: %editorUrlTitle% + errorFormatter.grouped: + class: PHPStan\Command\ErrorFormatter\GroupedErrorFormatter + arguments: + relativePathHelper: @simpleRelativePathHelper + editorUrl: %editorUrl% + editorUrlTitle: %editorUrlTitle% + errorFormatter.checkstyle: class: PHPStan\Command\ErrorFormatter\CheckstyleErrorFormatter arguments: diff --git a/src/Command/ErrorFormatter/GroupedErrorFormatter.php b/src/Command/ErrorFormatter/GroupedErrorFormatter.php new file mode 100644 index 0000000000..8e57ccf8dd --- /dev/null +++ b/src/Command/ErrorFormatter/GroupedErrorFormatter.php @@ -0,0 +1,112 @@ +getStyle(); + + if (!$analysisResult->hasErrors() && !$analysisResult->hasWarnings()) { + $style->success('No errors'); + + return 0; + } + + /** @var array $groupedErrors */ + $groupedErrors = []; + + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + $identifier = $fileSpecificError->getIdentifier() ?? self::NO_IDENTIFIER; + + $groupedErrors[$identifier][] = $fileSpecificError; + } + + uasort($groupedErrors, static fn ($errorsA, $errorsB) => count($errorsB) <=> count($errorsA)); + + foreach ($groupedErrors as $identifier => $errors) { + $count = count($errors); + $output->writeRaw(sprintf('[%s] (%dx):', $identifier, $count)); + $output->writeLineFormatted(''); + + foreach ($errors as $error) { + $file = $error->getTraitFilePath() ?? $error->getFilePath(); + $relFile = $this->relativePathHelper->getRelativePath($file); + $line = (string) $error->getLine(); + $message = $error->getMessage(); + + if (is_string($this->editorUrl)) { + $url = str_replace( + ['%file%', '%relFile%', '%line%'], + [$file, $relFile, $line], + $this->editorUrl, + ); + + if (is_string($this->editorUrlTitle)) { + $title = str_replace( + ['%file%', '%relFile%', '%line%'], + [$file, $relFile, $line], + $this->editorUrlTitle, + ); + } else { + $title = sprintf('%s:%s', $file, $line); + } + + $fileStr = '' . $title . ''; + } else { + $fileStr = sprintf('%s:%s', $file, $line); + } + + $output->writeLineFormatted(sprintf("\t- %s: %s", $fileStr, $message)); + } + $output->writeLineFormatted(''); + } + + foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { + $output->writeRaw(sprintf('?:?:%s', $notFileSpecificError)); + $output->writeLineFormatted(''); + } + + foreach ($analysisResult->getWarnings() as $warning) { + $output->writeRaw(sprintf('?:?:%s', $warning)); + $output->writeLineFormatted(''); + } + + $totalErrorsCount = $analysisResult->getTotalErrorsCount(); + $warningsCount = count($analysisResult->getWarnings()); + + $finalMessage = sprintf($totalErrorsCount === 1 ? 'Found %d error' : 'Found %d errors', $totalErrorsCount); + + if ($analysisResult->hasWarnings()) { + $finalMessage .= sprintf($warningsCount === 1 ? ' and %d warning' : ' and %d warnings', $warningsCount); + } + + if ($analysisResult->hasErrors()) { + $style->error($finalMessage); + } else { + $style->warning($finalMessage); + } + + return $analysisResult->hasErrors() ? 1 : 0; + } + +} diff --git a/tests/PHPStan/Command/ErrorFormatter/GroupedErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/GroupedErrorFormatterTest.php new file mode 100644 index 0000000000..27a8f7fa97 --- /dev/null +++ b/tests/PHPStan/Command/ErrorFormatter/GroupedErrorFormatterTest.php @@ -0,0 +1,153 @@ + 'No errors', + 'exitCode' => 0, + 'numFileErrors' => 0, + 'numGenericErrors' => 0, + 'expected' => ' + [OK] No errors + +', + ]; + + yield [ + 'message' => 'One file error', + 'exitCode' => 1, + 'numFileErrors' => 1, + 'numGenericErrors' => 0, + 'expected' => '[without identifier] (1x): + - /data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php:4: Foo + + + [ERROR] Found 1 error + +', + ]; + + yield [ + 'message' => 'One generic error', + 'exitCode' => 1, + 'numFileErrors' => 0, + 'numGenericErrors' => 1, + 'expected' => '?:?:first generic error + + [ERROR] Found 1 error + +', + ]; + + yield [ + 'message' => 'Multiple file errors', + 'exitCode' => 1, + 'numFileErrors' => 4, + 'numGenericErrors' => 0, + 'expected' => '[without identifier] (4x): + - /data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php:2: Bar +Bar2 + - /data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php:4: Foo + - /data/folder/with space/and unicode 😃/project/foo.php:1: Foo + - /data/folder/with space/and unicode 😃/project/foo.php:5: Bar +Bar2 + + + [ERROR] Found 4 errors + +', + ]; + + yield [ + 'message' => 'Multiple generic errors', + 'exitCode' => 1, + 'numFileErrors' => 0, + 'numGenericErrors' => 2, + 'expected' => '?:?:first generic error +?:?:second generic + + [ERROR] Found 2 errors + +', + ]; + + yield [ + 'message' => 'Multiple file, multiple generic errors', + 'exitCode' => 1, + 'numFileErrors' => 4, + 'numGenericErrors' => 2, + 'expected' => '[without identifier] (4x): + - /data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php:2: Bar +Bar2 + - /data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php:4: Foo + - /data/folder/with space/and unicode 😃/project/foo.php:1: Foo + - /data/folder/with space/and unicode 😃/project/foo.php:5: Bar +Bar2 + +?:?:first generic error +?:?:second generic + + [ERROR] Found 6 errors + +', + ]; + + yield [ + 'message' => 'One file error with identifier', + 'exitCode' => 1, + 'numFileErrors' => [5, 6], + 'numGenericErrors' => 0, + 'expected' => '[foobar.buz] (1x): + - /data/folder/with space/and unicode 😃/project/foo.php:5: Foobar\Buz + + + [ERROR] Found 1 error + +', + ]; + } + + /** + * @dataProvider dataFormatterOutputProvider + * @param array{int, int}|int $numFileErrors + */ + public function testFormatErrors( + string $message, + int $exitCode, + array|int $numFileErrors, + int $numGenericErrors, + string $expected, + ): void + { + $formatter = $this->createErrorFormatter(null); + + $this->assertSame($exitCode, $formatter->formatErrors( + $this->getAnalysisResult($numFileErrors, $numGenericErrors), + $this->getOutput(), + ), sprintf('%s: response code do not match', $message)); + + $this->assertEquals($expected, $this->getOutputContent(), sprintf('%s: output do not match', $message)); + } + + private function createErrorFormatter(?string $editorUrl, ?string $editorUrlTitle = null): GroupedErrorFormatter + { + $relativePathHelper = new FuzzyRelativePathHelper(new NullRelativePathHelper(), self::DIRECTORY_PATH, [], '/'); + + return new GroupedErrorFormatter( + $relativePathHelper, + $editorUrl, + $editorUrlTitle, + ); + } + +}