Просмотр исходного кода

feat: Ability to run Fixer with parallel runner 🎉 (#7777)

Co-authored-by: Dariusz Rumiński <dariusz.ruminski@gmail.com>
Co-authored-by: Julien Falque <julien.falque@gmail.com>
Greg Korba 10 месяцев назад
Родитель
Сommit
5c902243f4

+ 2 - 0
.php-cs-fixer.dist.php

@@ -14,8 +14,10 @@ declare(strict_types=1);
 
 use PhpCsFixer\Config;
 use PhpCsFixer\Finder;
+use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
 
 return (new Config())
+    ->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
     ->setRiskyAllowed(true)
     ->setRules([
         '@PHP74Migration' => true,

+ 12 - 2
composer.json

@@ -24,8 +24,15 @@
         "ext-filter": "*",
         "ext-json": "*",
         "ext-tokenizer": "*",
+        "clue/ndjson-react": "^1.0",
         "composer/semver": "^3.4",
         "composer/xdebug-handler": "^3.0.3",
+        "fidry/cpu-core-counter": "^1.0",
+        "react/child-process": "^0.6.5",
+        "react/event-loop": "^1.0",
+        "react/promise": "^2.0 || ^3.0",
+        "react/socket": "^1.0",
+        "react/stream": "^1.0",
         "sebastian/diff": "^4.0 || ^5.0 || ^6.0",
         "symfony/console": "^5.4 || ^6.0 || ^7.0",
         "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0",
@@ -87,7 +94,10 @@
         ],
         "cs:check": "@php php-cs-fixer check --verbose --diff",
         "cs:fix": "@php php-cs-fixer fix",
-        "cs:fix:parallel": "echo '🔍 Will run in batches of 50 files.'; if [[ -f .php-cs-fixer.php ]]; then FIXER_CONFIG=.php-cs-fixer.php; else FIXER_CONFIG=.php-cs-fixer.dist.php; fi; php php-cs-fixer list-files --config=$FIXER_CONFIG | xargs -n 50 -P 8 php php-cs-fixer fix --config=$FIXER_CONFIG --path-mode intersection 2> /dev/null",
+        "cs:fix:parallel": [
+            "echo '⚠️ This script is deprecated! Utilise built-in parallelisation instead.';",
+            "@cs:fix"
+        ],
         "docs": "@php dev-tools/doc.php",
         "infection": "@test:mutation",
         "install-tools": "@composer --working-dir=dev-tools install",
@@ -156,7 +166,7 @@
         "auto-review": "Execute Auto-review",
         "cs:check": "Check coding standards",
         "cs:fix": "Fix coding standards",
-        "cs:fix:parallel": "Fix coding standards in naive parallel mode (using xargs)",
+        "cs:fix:parallel": "⚠️DEPRECATED! Use cs:fix with proper parallel config",
         "docs": "Regenerate docs",
         "infection": "Alias for 'test:mutation'",
         "install-tools": "Install DEV tools",

+ 19 - 3
doc/usage.rst

@@ -21,11 +21,25 @@ If you do not have config file, you can run following command to fix non-hidden,
 
     php php-cs-fixer.phar fix .
 
-With some magic of tools provided by your OS, you can also fix files in parallel:
+You can also fix files in parallel, utilising more CPU cores. You can do this by using config class that implements ``PhpCsFixer\Runner\Parallel\ParallelConfig\ParallelAwareConfigInterface``, and use ``setParallelConfig()`` method. Recommended way is to utilise auto-detecting parallel configuration:
 
-.. code-block:: console
+.. code-block:: php
 
-    php php-cs-fixer.phar list-files --config=.php-cs-fixer.dist.php | xargs -n 50 -P 8 php php-cs-fixer.phar fix --config=.php-cs-fixer.dist.php --path-mode intersection -v
+    <?php
+
+    return (new PhpCsFixer\Config())
+        ->setParallelConfig(ParallelConfigFactory::detect())
+    ;
+
+However, in some case you may want to fine-tune parallelisation with explicit values (e.g. in environments where auto-detection does not work properly and suggests more cores than it should):
+
+.. code-block:: php
+
+    <?php
+
+    return (new PhpCsFixer\Config())
+        ->setParallelConfig(new ParallelConfig(4, 20))
+    ;
 
 You can limit process to given file or files in a given directory and its subdirectories:
 
@@ -98,6 +112,8 @@ Complete configuration for rules can be supplied using a ``json`` formatted stri
 
 The ``--dry-run`` flag will run the fixer without making changes to your files (implicitly set when you use ``check`` command).
 
+The ``--sequential`` flag will enforce sequential analysis even if parallel config is provided.
+
 The ``--diff`` flag can be used to let the fixer output all the changes it makes in ``udiff`` format.
 
 The ``--allow-risky`` option (pass ``yes`` or ``no``) allows you to set whether risky rules may run. Default value is taken from config file.

+ 24 - 1
src/Config.php

@@ -15,13 +15,15 @@ declare(strict_types=1);
 namespace PhpCsFixer;
 
 use PhpCsFixer\Fixer\FixerInterface;
+use PhpCsFixer\Runner\Parallel\ParallelConfig;
+use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
 
 /**
  * @author Fabien Potencier <fabien@symfony.com>
  * @author Katsuhiro Ogawa <ko.fivestar@gmail.com>
  * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
  */
-class Config implements ConfigInterface
+class Config implements ConfigInterface, ParallelAwareConfigInterface
 {
     private string $cacheFile = '.php-cs-fixer.cache';
 
@@ -47,6 +49,8 @@ class Config implements ConfigInterface
 
     private string $name;
 
+    private ParallelConfig $parallelConfig;
+
     /**
      * @var null|string
      */
@@ -71,6 +75,13 @@ class Config implements ConfigInterface
             $this->name = $name;
             $this->rules = ['@PSR12' => true];
         }
+
+        // @TODO 4.0 cleanup
+        if (Utils::isFutureModeEnabled() || filter_var(getenv('PHP_CS_FIXER_PARALLEL'), FILTER_VALIDATE_BOOL)) {
+            $this->parallelConfig = ParallelConfigFactory::detect();
+        } else {
+            $this->parallelConfig = ParallelConfigFactory::sequential();
+        }
     }
 
     public function getCacheFile(): string
@@ -118,6 +129,11 @@ class Config implements ConfigInterface
         return $this->name;
     }
 
+    public function getParallelConfig(): ParallelConfig
+    {
+        return $this->parallelConfig;
+    }
+
     public function getPhpExecutable(): ?string
     {
         return $this->phpExecutable;
@@ -189,6 +205,13 @@ class Config implements ConfigInterface
         return $this;
     }
 
+    public function setParallelConfig(ParallelConfig $config): ConfigInterface
+    {
+        $this->parallelConfig = $config;
+
+        return $this;
+    }
+
     public function setPhpExecutable(?string $phpExecutable): ConfigInterface
     {
         $this->phpExecutable = $phpExecutable;

+ 46 - 0
src/Console/Application.php

@@ -21,12 +21,15 @@ use PhpCsFixer\Console\Command\HelpCommand;
 use PhpCsFixer\Console\Command\ListFilesCommand;
 use PhpCsFixer\Console\Command\ListSetsCommand;
 use PhpCsFixer\Console\Command\SelfUpdateCommand;
+use PhpCsFixer\Console\Command\WorkerCommand;
 use PhpCsFixer\Console\SelfUpdate\GithubClient;
 use PhpCsFixer\Console\SelfUpdate\NewVersionChecker;
 use PhpCsFixer\PharChecker;
+use PhpCsFixer\Runner\Parallel\WorkerException;
 use PhpCsFixer\ToolInfo;
 use PhpCsFixer\Utils;
 use Symfony\Component\Console\Application as BaseApplication;
+use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Command\ListCommand;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\ConsoleOutputInterface;
@@ -45,6 +48,7 @@ final class Application extends BaseApplication
     public const VERSION_CODENAME = '15 Keys Accelerate';
 
     private ToolInfo $toolInfo;
+    private ?Command $executedCommand = null;
 
     public function __construct()
     {
@@ -63,6 +67,7 @@ final class Application extends BaseApplication
             $this->toolInfo,
             new PharChecker()
         ));
+        $this->add(new WorkerCommand($this->toolInfo));
     }
 
     public static function getMajorVersion(): int
@@ -160,4 +165,45 @@ final class Application extends BaseApplication
     {
         return [new HelpCommand(), new ListCommand()];
     }
+
+    /**
+     * @throws \Throwable
+     */
+    protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int
+    {
+        $this->executedCommand = $command;
+
+        return parent::doRunCommand($command, $input, $output);
+    }
+
+    protected function doRenderThrowable(\Throwable $e, OutputInterface $output): void
+    {
+        // Since parallel analysis utilises child processes, and they have their own output,
+        // we need to capture the output of the child process to determine it there was an exception.
+        // Default render format is not machine-friendly, so we need to override it for `worker` command,
+        // in order to be able to easily parse exception data for further displaying on main process' side.
+        if ($this->executedCommand instanceof WorkerCommand) {
+            $output->writeln(WorkerCommand::ERROR_PREFIX.json_encode(
+                [
+                    'class' => \get_class($e),
+                    'message' => $e->getMessage(),
+                    'file' => $e->getFile(),
+                    'line' => $e->getLine(),
+                    'code' => $e->getCode(),
+                    'trace' => $e->getTraceAsString(),
+                ]
+            ));
+
+            return;
+        }
+
+        parent::doRenderThrowable($e, $output);
+
+        if ($output->isVeryVerbose() && $e instanceof WorkerException) {
+            $output->writeln('<comment>Original trace from worker:</comment>');
+            $output->writeln('');
+            $output->writeln($e->getOriginalTraceAsString());
+            $output->writeln('');
+        }
+    }
 }

+ 35 - 2
src/Console/Command/FixCommand.php

@@ -30,6 +30,7 @@ use PhpCsFixer\Runner\Runner;
 use PhpCsFixer\ToolInfoInterface;
 use Symfony\Component\Console\Attribute\AsCommand;
 use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Formatter\OutputFormatter;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
@@ -150,6 +151,8 @@ use Symfony\Component\Stopwatch\Stopwatch;
 
             The <comment>--dry-run</comment> flag will run the fixer without making changes to your files.
 
+            The <comment>--sequential</comment> flag will enforce sequential analysis even if parallel config is provided.
+
             The <comment>--diff</comment> flag can be used to let the fixer output all the changes it makes.
 
             The <comment>--allow-risky</comment> option (pass `yes` or `no`) allows you to set whether risky rules may run. Default value is taken from config file.
@@ -206,12 +209,13 @@ use Symfony\Component\Stopwatch\Stopwatch;
                 new InputOption('config', '', InputOption::VALUE_REQUIRED, 'The path to a config file.'),
                 new InputOption('dry-run', '', InputOption::VALUE_NONE, 'Only shows which files would have been modified.'),
                 new InputOption('rules', '', InputOption::VALUE_REQUIRED, 'List of rules that should be run against configured paths.'),
-                new InputOption('using-cache', '', InputOption::VALUE_REQUIRED, 'Does cache should be used (can be `yes` or `no`).'),
+                new InputOption('using-cache', '', InputOption::VALUE_REQUIRED, 'Should cache be used (can be `yes` or `no`).'),
                 new InputOption('cache-file', '', InputOption::VALUE_REQUIRED, 'The path to the cache file.'),
                 new InputOption('diff', '', InputOption::VALUE_NONE, 'Prints diff for each file.'),
                 new InputOption('format', '', InputOption::VALUE_REQUIRED, 'To output results in other formats.'),
                 new InputOption('stop-on-violation', '', InputOption::VALUE_NONE, 'Stop execution on first violation.'),
                 new InputOption('show-progress', '', InputOption::VALUE_REQUIRED, 'Type of progress indicator (none, dots).'),
+                new InputOption('sequential', '', InputOption::VALUE_NONE, 'Enforce sequential analysis.'),
             ]
         );
     }
@@ -243,6 +247,7 @@ use Symfony\Component\Stopwatch\Stopwatch;
                 'stop-on-violation' => $input->getOption('stop-on-violation'),
                 'verbosity' => $verbosity,
                 'show-progress' => $input->getOption('show-progress'),
+                'sequential' => $input->getOption('sequential'),
             ],
             getcwd(),
             $this->toolInfo
@@ -256,6 +261,31 @@ use Symfony\Component\Stopwatch\Stopwatch;
 
         if (null !== $stdErr) {
             $stdErr->writeln(Application::getAboutWithRuntime(true));
+            $isParallel = $resolver->getParallelConfig()->getMaxProcesses() > 1;
+
+            $stdErr->writeln(sprintf(
+                'Running analysis on %d core%s.',
+                $resolver->getParallelConfig()->getMaxProcesses(),
+                $isParallel ? sprintf(
+                    's with %d file%s per process',
+                    $resolver->getParallelConfig()->getFilesPerProcess(),
+                    $resolver->getParallelConfig()->getFilesPerProcess() > 1 ? 's' : ''
+                ) : ' sequentially'
+            ));
+
+            /** @TODO v4 remove warnings related to parallel runner */
+            $usageDocs = 'https://cs.symfony.com/doc/usage.html';
+            $stdErr->writeln(sprintf(
+                $stdErr->isDecorated() ? '<bg=yellow;fg=black;>%s</>' : '%s',
+                $isParallel
+                    ? 'Parallel runner is an experimental feature and may be unstable, use it at your own risk. Feedback highly appreciated!'
+                    : sprintf(
+                        'You can enable parallel runner and speed up the analysis! Please see %s for more information.',
+                        $stdErr->isDecorated()
+                            ? sprintf('<href=%s;bg=yellow;fg=red;bold>usage docs</>', OutputFormatter::escape($usageDocs))
+                            : $usageDocs
+                    )
+            ));
 
             $configFile = $resolver->getConfigFile();
             $stdErr->writeln(sprintf('Loaded config <comment>%s</comment>%s.', $resolver->getConfig()->getName(), null === $configFile ? '' : ' from "'.$configFile.'"'));
@@ -297,7 +327,10 @@ use Symfony\Component\Stopwatch\Stopwatch;
             $resolver->isDryRun(),
             $resolver->getCacheManager(),
             $resolver->getDirectory(),
-            $resolver->shouldStopOnViolation()
+            $resolver->shouldStopOnViolation(),
+            $resolver->getParallelConfig(),
+            $input,
+            $resolver->getConfigFile()
         );
 
         $this->eventDispatcher->addListener(FixerFileProcessedEvent::NAME, [$progressOutput, 'onFixerFileProcessed']);

+ 7 - 2
src/Console/Command/FixCommandExitStatusCalculator.php

@@ -28,8 +28,13 @@ final class FixCommandExitStatusCalculator
     public const EXIT_STATUS_FLAG_HAS_INVALID_FIXER_CONFIG = 32;
     public const EXIT_STATUS_FLAG_EXCEPTION_IN_APP = 64;
 
-    public function calculate(bool $isDryRun, bool $hasChangedFiles, bool $hasInvalidErrors, bool $hasExceptionErrors, bool $hasLintErrorsAfterFixing): int
-    {
+    public function calculate(
+        bool $isDryRun,
+        bool $hasChangedFiles,
+        bool $hasInvalidErrors,
+        bool $hasExceptionErrors,
+        bool $hasLintErrorsAfterFixing
+    ): int {
         $exitStatus = 0;
 
         if ($isDryRun) {

+ 245 - 0
src/Console/Command/WorkerCommand.php

@@ -0,0 +1,245 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of PHP CS Fixer.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace PhpCsFixer\Console\Command;
+
+use Clue\React\NDJson\Decoder;
+use Clue\React\NDJson\Encoder;
+use PhpCsFixer\Cache\NullCacheManager;
+use PhpCsFixer\Config;
+use PhpCsFixer\Console\ConfigurationResolver;
+use PhpCsFixer\Error\ErrorsManager;
+use PhpCsFixer\FixerFileProcessedEvent;
+use PhpCsFixer\Runner\Parallel\ParallelAction;
+use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
+use PhpCsFixer\Runner\Parallel\ParallelisationException;
+use PhpCsFixer\Runner\Runner;
+use PhpCsFixer\ToolInfoInterface;
+use React\EventLoop\StreamSelectLoop;
+use React\Socket\ConnectionInterface;
+use React\Socket\TcpConnector;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\ConsoleOutputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * @author Greg Korba <greg@codito.dev>
+ *
+ * @internal
+ */
+#[AsCommand(name: 'worker', description: 'Internal command for running fixers in parallel', hidden: true)]
+final class WorkerCommand extends Command
+{
+    /** @var string Prefix used before JSON-encoded error printed in the worker's process */
+    public const ERROR_PREFIX = 'WORKER_ERROR::';
+
+    /** @var string */
+    protected static $defaultName = 'worker';
+
+    /** @var string */
+    protected static $defaultDescription = 'Internal command for running fixers in parallel';
+
+    private ToolInfoInterface $toolInfo;
+    private ConfigurationResolver $configurationResolver;
+    private ErrorsManager $errorsManager;
+    private EventDispatcherInterface $eventDispatcher;
+
+    /** @var list<FixerFileProcessedEvent> */
+    private array $events;
+
+    public function __construct(ToolInfoInterface $toolInfo)
+    {
+        parent::__construct();
+
+        $this->setHidden(true);
+        $this->toolInfo = $toolInfo;
+        $this->errorsManager = new ErrorsManager();
+        $this->eventDispatcher = new EventDispatcher();
+    }
+
+    protected function configure(): void
+    {
+        $this->setDefinition(
+            [
+                new InputOption('port', null, InputOption::VALUE_REQUIRED, 'Specifies parallelisation server\'s port.'),
+                new InputOption('identifier', null, InputOption::VALUE_REQUIRED, 'Specifies parallelisation process\' identifier.'),
+                new InputOption('allow-risky', '', InputOption::VALUE_REQUIRED, 'Are risky fixers allowed (can be `yes` or `no`).'),
+                new InputOption('config', '', InputOption::VALUE_REQUIRED, 'The path to a config file.'),
+                new InputOption('dry-run', '', InputOption::VALUE_NONE, 'Only shows which files would have been modified.'),
+                new InputOption('rules', '', InputOption::VALUE_REQUIRED, 'List of rules that should be run against configured paths.'),
+                new InputOption('using-cache', '', InputOption::VALUE_REQUIRED, 'Should cache be used (can be `yes` or `no`).'),
+                new InputOption('cache-file', '', InputOption::VALUE_REQUIRED, 'The path to the cache file.'),
+                new InputOption('diff', '', InputOption::VALUE_NONE, 'Prints diff for each file.'),
+                new InputOption('stop-on-violation', '', InputOption::VALUE_NONE, 'Stop execution on first violation.'),
+            ]
+        );
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
+        $identifier = $input->getOption('identifier');
+        $port = $input->getOption('port');
+
+        if (null === $identifier || !is_numeric($port)) {
+            throw new ParallelisationException('Missing parallelisation options');
+        }
+
+        try {
+            $runner = $this->createRunner($input);
+        } catch (\Throwable $e) {
+            throw new ParallelisationException('Unable to create runner: '.$e->getMessage(), 0, $e);
+        }
+
+        $loop = new StreamSelectLoop();
+        $tcpConnector = new TcpConnector($loop);
+        $tcpConnector
+            ->connect(sprintf('127.0.0.1:%d', $port))
+            ->then(
+                /** @codeCoverageIgnore */
+                function (ConnectionInterface $connection) use ($loop, $runner, $identifier): void {
+                    $jsonInvalidUtf8Ignore = \defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0;
+                    $out = new Encoder($connection, $jsonInvalidUtf8Ignore);
+                    $in = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore);
+
+                    // [REACT] Initialise connection with the parallelisation operator
+                    $out->write(['action' => ParallelAction::WORKER_HELLO, 'identifier' => $identifier]);
+
+                    $handleError = static function (\Throwable $error) use ($out): void {
+                        $out->write([
+                            'action' => ParallelAction::WORKER_ERROR_REPORT,
+                            'class' => \get_class($error),
+                            'message' => $error->getMessage(),
+                            'file' => $error->getFile(),
+                            'line' => $error->getLine(),
+                            'code' => $error->getCode(),
+                            'trace' => $error->getTraceAsString(),
+                        ]);
+                    };
+                    $out->on('error', $handleError);
+                    $in->on('error', $handleError);
+
+                    // [REACT] Listen for messages from the parallelisation operator (analysis requests)
+                    $in->on('data', function (array $json) use ($loop, $runner, $out): void {
+                        $action = $json['action'] ?? null;
+
+                        // Parallelisation operator does not have more to do, let's close the connection
+                        if (ParallelAction::RUNNER_THANK_YOU === $action) {
+                            $loop->stop();
+
+                            return;
+                        }
+
+                        if (ParallelAction::RUNNER_REQUEST_ANALYSIS !== $action) {
+                            // At this point we only expect analysis requests, if any other action happen, we need to fix the code.
+                            throw new \LogicException(sprintf('Unexpected action ParallelAction::%s.', $action));
+                        }
+
+                        /** @var iterable<int, string> $files */
+                        $files = $json['files'];
+
+                        foreach ($files as $absolutePath) {
+                            // Reset events because we want to collect only those coming from analysed files chunk
+                            $this->events = [];
+                            $runner->setFileIterator(new \ArrayIterator([new \SplFileInfo($absolutePath)]));
+                            $analysisResult = $runner->fix();
+
+                            if (1 !== \count($this->events)) {
+                                throw new ParallelisationException('Runner did not report a fixing event or reported too many.');
+                            }
+
+                            if (1 < \count($analysisResult)) {
+                                throw new ParallelisationException('Runner returned more analysis results than expected.');
+                            }
+
+                            $out->write([
+                                'action' => ParallelAction::WORKER_RESULT,
+                                'file' => $absolutePath,
+                                'fileHash' => $this->events[0]->getFileHash(),
+                                'status' => $this->events[0]->getStatus(),
+                                'fixInfo' => array_pop($analysisResult),
+                                'errors' => $this->errorsManager->forPath($absolutePath),
+                            ]);
+                        }
+
+                        // Request another file chunk (if available, the parallelisation operator will request new "run" action)
+                        $out->write(['action' => ParallelAction::WORKER_GET_FILE_CHUNK]);
+                    });
+                },
+                static function (\Throwable $error) use ($errorOutput): void {
+                    // @TODO Verify onRejected behaviour → https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7777#discussion_r1590399285
+                    $errorOutput->writeln($error->getMessage());
+                }
+            )
+        ;
+
+        $loop->run();
+
+        return Command::SUCCESS;
+    }
+
+    private function createRunner(InputInterface $input): Runner
+    {
+        $passedConfig = $input->getOption('config');
+        $passedRules = $input->getOption('rules');
+
+        if (null !== $passedConfig && null !== $passedRules) {
+            throw new \RuntimeException('Passing both `--config` and `--rules` options is not allowed');
+        }
+
+        // There's no one single source of truth when it comes to fixing single file, we need to collect statuses from events.
+        $this->eventDispatcher->addListener(FixerFileProcessedEvent::NAME, function (FixerFileProcessedEvent $event): void {
+            $this->events[] = $event;
+        });
+
+        $this->configurationResolver = new ConfigurationResolver(
+            new Config(),
+            [
+                'allow-risky' => $input->getOption('allow-risky'),
+                'config' => $passedConfig,
+                'dry-run' => $input->getOption('dry-run'),
+                'rules' => $passedRules,
+                'path' => [],
+                'path-mode' => ConfigurationResolver::PATH_MODE_OVERRIDE, // IMPORTANT! WorkerCommand is called with file that already passed filtering, so here we can rely on PATH_MODE_OVERRIDE.
+                'using-cache' => $input->getOption('using-cache'),
+                'cache-file' => $input->getOption('cache-file'),
+                'diff' => $input->getOption('diff'),
+                'stop-on-violation' => $input->getOption('stop-on-violation'),
+            ],
+            getcwd(), // @phpstan-ignore-line
+            $this->toolInfo
+        );
+
+        return new Runner(
+            null, // Paths are known when parallelisation server requests new chunk, not now
+            $this->configurationResolver->getFixers(),
+            $this->configurationResolver->getDiffer(),
+            $this->eventDispatcher,
+            $this->errorsManager,
+            $this->configurationResolver->getLinter(),
+            $this->configurationResolver->isDryRun(),
+            new NullCacheManager(), // IMPORTANT! We pass null cache, as cache is read&write in main process and we do not need to do it again.
+            $this->configurationResolver->getDirectory(),
+            $this->configurationResolver->shouldStopOnViolation(),
+            ParallelConfigFactory::sequential(), // IMPORTANT! Worker must run in sequential mode.
+            null,
+            $this->configurationResolver->getConfigFile()
+        );
+    }
+}

+ 13 - 0
src/Console/ConfigurationResolver.php

@@ -35,8 +35,11 @@ use PhpCsFixer\Fixer\FixerInterface;
 use PhpCsFixer\FixerFactory;
 use PhpCsFixer\Linter\Linter;
 use PhpCsFixer\Linter\LinterInterface;
+use PhpCsFixer\ParallelAwareConfigInterface;
 use PhpCsFixer\RuleSet\RuleSet;
 use PhpCsFixer\RuleSet\RuleSetInterface;
+use PhpCsFixer\Runner\Parallel\ParallelConfig;
+use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
 use PhpCsFixer\StdinFileInfo;
 use PhpCsFixer\ToolInfoInterface;
 use PhpCsFixer\Utils;
@@ -118,6 +121,7 @@ final class ConfigurationResolver
         'path' => [],
         'path-mode' => self::PATH_MODE_OVERRIDE,
         'rules' => null,
+        'sequential' => null,
         'show-progress' => null,
         'stop-on-violation' => null,
         'using-cache' => null,
@@ -274,6 +278,15 @@ final class ConfigurationResolver
         return $this->config;
     }
 
+    public function getParallelConfig(): ParallelConfig
+    {
+        $config = $this->getConfig();
+
+        return true !== $this->options['sequential'] && $config instanceof ParallelAwareConfigInterface
+            ? $config->getParallelConfig()
+            : ParallelConfigFactory::sequential();
+    }
+
     public function getConfigFile(): ?string
     {
         if (null === $this->configFile) {

+ 31 - 1
src/Error/Error.php

@@ -21,7 +21,7 @@ namespace PhpCsFixer\Error;
  *
  * @internal
  */
-final class Error
+final class Error implements \JsonSerializable
 {
     /**
      * Error which has occurred in linting phase, before applying any fixers.
@@ -38,6 +38,7 @@ final class Error
      */
     public const TYPE_LINT = 3;
 
+    /** @var self::TYPE_* */
     private int $type;
 
     private string $filePath;
@@ -52,6 +53,7 @@ final class Error
     private ?string $diff;
 
     /**
+     * @param self::TYPE_* $type
      * @param list<string> $appliedFixers
      */
     public function __construct(int $type, string $filePath, ?\Throwable $source = null, array $appliedFixers = [], ?string $diff = null)
@@ -90,4 +92,32 @@ final class Error
     {
         return $this->diff;
     }
+
+    /**
+     * @return array{
+     *     type: self::TYPE_*,
+     *     filePath: string,
+     *     source: null|array{class: class-string, message: string, code: int, file: string, line: int},
+     *     appliedFixers: list<string>,
+     *     diff: null|string
+     * }
+     */
+    public function jsonSerialize(): array
+    {
+        return [
+            'type' => $this->type,
+            'filePath' => $this->filePath,
+            'source' => null !== $this->source
+                ? [
+                    'class' => \get_class($this->source),
+                    'message' => $this->source->getMessage(),
+                    'code' => $this->source->getCode(),
+                    'file' => $this->source->getFile(),
+                    'line' => $this->source->getLine(),
+                ]
+                : null,
+            'appliedFixers' => $this->appliedFixers,
+            'diff' => $this->diff,
+        ];
+    }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов