diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index c7c3e9f8ab..72b16060ad 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -11,6 +11,7 @@ use PHPStan\Command\Symfony\SymfonyOutput; use PHPStan\Command\Symfony\SymfonyStyle; use PHPStan\File\CouldNotWriteFileException; +use PHPStan\File\FileReader; use PHPStan\File\FileWriter; use PHPStan\File\ParentDirectoryRelativePathHelper; use PHPStan\File\PathNotFoundException; @@ -32,6 +33,7 @@ use function is_array; use function is_bool; use function is_dir; +use function is_file; use function is_string; use function mkdir; use function pathinfo; @@ -279,10 +281,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $baselineFileDirectory = dirname($generateBaselineFile); $baselineErrorFormatter = new BaselineNeonErrorFormatter(new ParentDirectoryRelativePathHelper($baselineFileDirectory)); + $existingBaselineContent = is_file($generateBaselineFile) ? FileReader::read($generateBaselineFile) : ''; + $streamOutput = $this->createStreamOutput(); $errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $streamOutput); $baselineOutput = new SymfonyOutput($streamOutput, new SymfonyStyle($errorConsoleStyle)); - $baselineErrorFormatter->formatErrors($analysisResult, $baselineOutput); + $baselineErrorFormatter->formatErrors($analysisResult, $baselineOutput, $existingBaselineContent); $stream = $streamOutput->getStream(); rewind($stream); diff --git a/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php b/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php index 1c1940a383..545eeed111 100644 --- a/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php +++ b/src/Command/ErrorFormatter/BaselineNeonErrorFormatter.php @@ -4,11 +4,14 @@ use Nette\DI\Helpers; use Nette\Neon\Neon; +use Nette\Utils\Strings; use PHPStan\Command\AnalysisResult; use PHPStan\Command\Output; use PHPStan\File\RelativePathHelper; +use PHPStan\ShouldNotHappenException; use function ksort; use function preg_quote; +use function substr; use const SORT_STRING; class BaselineNeonErrorFormatter @@ -21,14 +24,11 @@ public function __construct(private RelativePathHelper $relativePathHelper) public function formatErrors( AnalysisResult $analysisResult, Output $output, + string $existingBaselineContent, ): int { if (!$analysisResult->hasErrors()) { - $output->writeRaw(Neon::encode([ - 'parameters' => [ - 'ignoreErrors' => [], - ], - ], Neon::BLOCK)); + $output->writeRaw($this->getNeon([], $existingBaselineContent)); return 0; } @@ -63,13 +63,36 @@ public function formatErrors( } } - $output->writeRaw(Neon::encode([ + $output->writeRaw($this->getNeon($errorsToOutput, $existingBaselineContent)); + + return 1; + } + + /** + * @param array $ignoreErrors + */ + private function getNeon(array $ignoreErrors, string $existingBaselineContent): string + { + $neon = Neon::encode([ 'parameters' => [ - 'ignoreErrors' => $errorsToOutput, + 'ignoreErrors' => $ignoreErrors, ], - ], Neon::BLOCK)); + ], Neon::BLOCK); - return 1; + if (substr($neon, -2) !== "\n\n") { + throw new ShouldNotHappenException(); + } + + if ($existingBaselineContent === '') { + return substr($neon, 0, -1); + } + + $existingBaselineContentEndOfFileNewlinesMatches = Strings::match($existingBaselineContent, "~(\n)+$~"); + $existingBaselineContentEndOfFileNewlines = $existingBaselineContentEndOfFileNewlinesMatches !== null + ? $existingBaselineContentEndOfFileNewlinesMatches[0] + : ''; + + return substr($neon, 0, -2) . $existingBaselineContentEndOfFileNewlines; } } diff --git a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php index 44d52fb724..52dcff2b71 100644 --- a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php +++ b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php @@ -6,11 +6,23 @@ use Nette\Neon\Neon; use PHPStan\Analyser\Error; use PHPStan\Command\AnalysisResult; +use PHPStan\Command\ErrorsConsoleStyle; +use PHPStan\Command\Symfony\SymfonyOutput; +use PHPStan\Command\Symfony\SymfonyStyle; use PHPStan\File\SimpleRelativePathHelper; +use PHPStan\ShouldNotHappenException; use PHPStan\Testing\ErrorFormatterTestCase; +use PHPUnit\Framework\Assert; +use Symfony\Component\Console\Input\StringInput; +use Symfony\Component\Console\Output\StreamOutput; +use function fopen; use function mt_srand; +use function rewind; use function shuffle; use function sprintf; +use function str_repeat; +use function stream_get_contents; +use function substr; use function trim; class BaselineNeonErrorFormatterTest extends ErrorFormatterTestCase @@ -117,6 +129,7 @@ public function testFormatErrors( $this->assertSame($exitCode, $formatter->formatErrors( $this->getAnalysisResult($numFileErrors, $numGenericErrors), $this->getOutput(), + '', ), sprintf('%s: response code do not match', $message)); $this->assertSame(trim(Neon::encode(['parameters' => ['ignoreErrors' => $expected]], Neon::BLOCK)), trim($this->getOutputContent()), sprintf('%s: output do not match', $message)); @@ -139,6 +152,7 @@ public function testFormatErrorMessagesRegexEscape(): void $formatter->formatErrors( $result, $this->getOutput(), + '', ); self::assertSame( @@ -175,6 +189,7 @@ public function testEscapeDiNeon(): void $formatter->formatErrors( $result, $this->getOutput(), + '', ); self::assertSame( trim( @@ -237,6 +252,7 @@ public function testOutputOrdering(array $errors): void $formatter->formatErrors( $result, $this->getOutput(), + '', ); self::assertSame( trim(Neon::encode([ @@ -284,4 +300,137 @@ public function testOutputOrdering(array $errors): void ); } + /** + * @return Generator}> + */ + public function endOfFileNewlinesProvider(): Generator + { + $existingBaselineContentWithoutEndNewlines = 'parameters: + ignoreErrors: + - + message: "#^Existing error$#" + count: 1 + path: TestfileA'; + + yield 'one error' => [ + 'errors' => [ + new Error('Error #1', 'TestfileA', 1), + ], + 'existingBaselineContent' => $existingBaselineContentWithoutEndNewlines . "\n", + 'expectedNewlinesCount' => 1, + ]; + + yield 'no errors' => [ + 'errors' => [], + 'existingBaselineContent' => $existingBaselineContentWithoutEndNewlines . "\n", + 'expectedNewlinesCount' => 1, + ]; + + yield 'one error with 2 newlines' => [ + 'errors' => [ + new Error('Error #1', 'TestfileA', 1), + ], + 'existingBaselineContent' => $existingBaselineContentWithoutEndNewlines . "\n\n", + 'expectedNewlinesCount' => 2, + ]; + + yield 'no errors with 2 newlines' => [ + 'errors' => [], + 'existingBaselineContent' => $existingBaselineContentWithoutEndNewlines . "\n\n", + 'expectedNewlinesCount' => 2, + ]; + + yield 'one error with 0 newlines' => [ + 'errors' => [ + new Error('Error #1', 'TestfileA', 1), + ], + 'existingBaselineContent' => $existingBaselineContentWithoutEndNewlines, + 'expectedNewlinesCount' => 0, + ]; + + yield 'one error with 3 newlines' => [ + 'errors' => [ + new Error('Error #1', 'TestfileA', 1), + ], + 'existingBaselineContent' => $existingBaselineContentWithoutEndNewlines . "\n\n\n", + 'expectedNewlinesCount' => 3, + ]; + + yield 'empty existing baseline' => [ + 'errors' => [ + new Error('Error #1', 'TestfileA', 1), + ], + 'existingBaselineContent' => '', + 'expectedNewlinesCount' => 1, + ]; + + yield 'empty existing baseline, no new errors' => [ + 'errors' => [], + 'existingBaselineContent' => '', + 'expectedNewlinesCount' => 1, + ]; + + yield 'empty existing baseline with a newline, no new errors' => [ + 'errors' => [], + 'existingBaselineContent' => "\n", + 'expectedNewlinesCount' => 1, + ]; + + yield 'empty existing baseline with 2 newlines, no new errors' => [ + 'errors' => [], + 'existingBaselineContent' => "\n\n", + 'expectedNewlinesCount' => 2, + ]; + } + + /** + * @dataProvider endOfFileNewlinesProvider + * + * @param list $errors + */ + public function testEndOfFileNewlines( + array $errors, + string $existingBaselineContent, + int $expectedNewlinesCount, + ): void + { + $formatter = new BaselineNeonErrorFormatter(new SimpleRelativePathHelper(self::DIRECTORY_PATH)); + $result = new AnalysisResult( + $errors, + [], + [], + [], + false, + null, + true, + ); + + $resource = fopen('php://memory', 'w', false); + if ($resource === false) { + throw new ShouldNotHappenException(); + } + $outputStream = new StreamOutput($resource, StreamOutput::VERBOSITY_NORMAL, false); + + $errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $outputStream); + $output = new SymfonyOutput($outputStream, new SymfonyStyle($errorConsoleStyle)); + + $formatter->formatErrors( + $result, + $output, + $existingBaselineContent, + ); + + rewind($outputStream->getStream()); + + $content = stream_get_contents($outputStream->getStream()); + if ($content === false) { + throw new ShouldNotHappenException(); + } + + if ($expectedNewlinesCount > 0) { + Assert::assertSame(str_repeat("\n", $expectedNewlinesCount), substr($content, -$expectedNewlinesCount)); + } + Assert::assertNotSame("\n", substr($content, -($expectedNewlinesCount + 1), 1)); + } + }