diff --git a/doc/tasks/phpstan.md b/doc/tasks/phpstan.md index 7d6909fc..9470bae8 100644 --- a/doc/tasks/phpstan.md +++ b/doc/tasks/phpstan.md @@ -75,3 +75,15 @@ With this parameter you can specify the memory limit. Since there is no `--changed-files` flag [in PhpStan yet](https://github.com/phpstan/phpstan/issues/934#issuecomment-383002766), this flags allows you to change what files will be validated. You can choose to use the paths detected by GrumPHP, or you can choose to fall back on the PhpStan configuration. + +## Auto-fixing + +Starting with PHPStan 2.3.5, PHPStan marks errors it can fix automatically (🔧) and suggests to run it again with `--fix`. +When PHPStan reports such errors, GrumPHP offers to fix them by running the same `phpstan analyse` command with the `--fix` option. +The fix is applied to the same files that were analysed. +Errors that cannot be fixed automatically are left untouched and still need to be fixed by hand. + +Keep in mind that the `--fix` option is experimental in PHPStan. +Besides fixing code, it also removes unmatched `ignoreErrors` entries from your configuration file and baseline, and unmatched `@phpstan-ignore` comments. + +Whether GrumPHP asks to fix the errors or fixes them by default can be configured with the [`fixer`](../parameters.md) parameters. diff --git a/src/Task/PhpStan.php b/src/Task/PhpStan.php index 0fa048bd..47a12d99 100644 --- a/src/Task/PhpStan.php +++ b/src/Task/PhpStan.php @@ -4,6 +4,7 @@ namespace GrumPHP\Task; +use GrumPHP\Fixer\Provider\FixableProcessResultProvider; use GrumPHP\Formatter\ProcessFormatterInterface; use GrumPHP\Runner\TaskResult; use GrumPHP\Runner\TaskResultInterface; @@ -12,6 +13,7 @@ use GrumPHP\Task\Context\GitPreCommitContext; use GrumPHP\Task\Context\RunContext; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Process\Process; /** * @extends AbstractExternalTask @@ -104,9 +106,36 @@ public function run(ContextInterface $context): TaskResultInterface $process->run(); if (!$process->isSuccessful()) { - return TaskResult::createFailed($this, $context, $this->formatter->format($process)); + $failedResult = TaskResult::createFailed($this, $context, $this->formatter->format($process)); + + if (!$this->hasFixableErrors($process)) { + return $failedResult; + } + + return FixableProcessResultProvider::provide( + $failedResult, + function () use ($arguments): Process { + $arguments->add('--fix'); + + return $this->processBuilder->buildProcess($arguments); + } + ); } return TaskResult::createPassed($this, $context); } + + /** + * PHPStan (2.3.5+) ends its report with "Run PHPStan again with --fix" when at least one + * reported error can be fixed automatically. Without fixable errors, "phpstan analyse --fix" + * fails with "No fixable errors found", so the fixer is only offered when PHPStan suggests it. + * The hint for ignored errors ("--fix-ignored") is not taken into account. + */ + private function hasFixableErrors(Process $process): bool + { + return 1 === preg_match( + '/Run PHPStan again with --fix(?![\w-])/', + $process->getOutput().PHP_EOL.$process->getErrorOutput() + ); + } } diff --git a/test/Unit/Task/PhpStanTest.php b/test/Unit/Task/PhpStanTest.php index 093b01d4..e19df388 100644 --- a/test/Unit/Task/PhpStanTest.php +++ b/test/Unit/Task/PhpStanTest.php @@ -4,14 +4,30 @@ namespace GrumPHPTest\Unit\Task; +use GrumPHP\Collection\ProcessArgumentsCollection; +use GrumPHP\Runner\FixableTaskResult; use GrumPHP\Task\Context\GitPreCommitContext; use GrumPHP\Task\Context\RunContext; use GrumPHP\Task\PhpStan; use GrumPHP\Task\TaskInterface; use GrumPHP\Test\Task\AbstractExternalTaskTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; class PhpStanTest extends AbstractExternalTaskTestCase { + private const FIXABLE_OUTPUT = <<<'TXT' + [ERROR] Found 2 errors + +🔧 1 error can be fixed automatically. Run PHPStan again with --fix. +TXT; + + private const FIXABLE_IGNORED_OUTPUT = <<<'TXT' + [ERROR] Found 1 error + +🔧 1 ignored error can be fixed automatically. Run PHPStan again with --fix-ignored. +TXT; + protected function provideTask(): TaskInterface { return new PhpStan( @@ -66,6 +82,25 @@ function () { }, 'nope' ]; + yield 'exitCode1-with-fixable-errors' => [ + [], + self::mockContext(RunContext::class, ['hello.php']), + function () { + $this->mockProcessBuilder('phpstan', $process = self::mockProcess(1, self::FIXABLE_OUTPUT)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope', + FixableTaskResult::class + ]; + yield 'exitCode1-with-only-fixable-ignored-errors' => [ + [], + self::mockContext(RunContext::class, ['hello.php']), + function () { + $this->mockProcessBuilder('phpstan', $process = self::mockProcess(1, self::FIXABLE_IGNORED_OUTPUT)); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope' + ]; } public static function providePassesOnStuff(): iterable @@ -210,4 +245,79 @@ public static function provideExternalTaskRuns(): iterable ] ]; } + + /** + * When PHPStan reports auto-fixable errors, GrumPHP offers the same command with --fix added. + * The harness only asserts the analyse command, so the fix command is verified here + * by inspecting the mutated arguments collection. + */ + #[Test] + #[DataProvider('provideFixCommands')] + public function it_builds_the_fix_command( + array $config, + string $output, + string $errorOutput, + array $expectedCommand + ): void { + $this->processBuilder->createArgumentsForCommand('phpstan')->willReturn( + $arguments = new ProcessArgumentsCollection() + ); + $this->processBuilder->buildProcess($arguments)->willReturn( + $process = self::mockProcess(1, $output, $errorOutput) + ); + $this->formatter->format($process)->willReturn('failed'); + + $this->configureTask($config)->run(self::mockContext(RunContext::class, ['hello.php'])); + + self::assertSame($expectedCommand, $arguments->getValues()); + } + + public static function provideFixCommands(): iterable + { + yield 'fixable' => [ + [], + self::FIXABLE_OUTPUT, + '', + ['analyse', '--no-ansi', '--no-interaction', '--no-progress', 'hello.php', '--fix'], + ]; + yield 'fixable-keeps-options' => [ + ['configuration' => 'phpstan.neon', 'level' => 'max'], + self::FIXABLE_OUTPUT, + '', + [ + 'analyse', + '--configuration=phpstan.neon', + '--level=max', + '--no-ansi', + '--no-interaction', + '--no-progress', + 'hello.php', + '--fix', + ], + ]; + yield 'fixable-without-grumphp-paths' => [ + ['use_grumphp_paths' => false], + self::FIXABLE_OUTPUT, + '', + ['analyse', '--no-ansi', '--no-interaction', '--no-progress', '--fix'], + ]; + yield 'fixable-hint-on-error-output' => [ + [], + '', + '1 of the errors can be fixed automatically. Run PHPStan again with --fix to fix them first, then fix the rest.', + ['analyse', '--no-ansi', '--no-interaction', '--no-progress', 'hello.php', '--fix'], + ]; + yield 'not-fixable' => [ + [], + ' [ERROR] Found 1 error', + '', + ['analyse', '--no-ansi', '--no-interaction', '--no-progress', 'hello.php'], + ]; + yield 'only-fixable-ignored-errors' => [ + [], + self::FIXABLE_IGNORED_OUTPUT, + '', + ['analyse', '--no-ansi', '--no-interaction', '--no-progress', 'hello.php'], + ]; + } }