• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHP-CS-Fixer / PHP-CS-Fixer / 15955191197

29 Jun 2025 12:26PM UTC coverage: 94.822% (-0.01%) from 94.832%
15955191197

push

github

web-flow
feat: `ProtectedToPrivateFixer` - add support for asymmetric visibility (#8569)

15 of 15 new or added lines in 1 file covered. (100.0%)

3 existing lines in 1 file now uncovered.

28148 of 29685 relevant lines covered (94.82%)

45.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.5
/src/Runner/Runner.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <fabien@symfony.com>
9
 *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14

15
namespace PhpCsFixer\Runner;
16

17
use Clue\React\NDJson\Decoder;
18
use Clue\React\NDJson\Encoder;
19
use PhpCsFixer\AbstractFixer;
20
use PhpCsFixer\Cache\CacheManagerInterface;
21
use PhpCsFixer\Cache\Directory;
22
use PhpCsFixer\Cache\DirectoryInterface;
23
use PhpCsFixer\Console\Command\WorkerCommand;
24
use PhpCsFixer\Differ\DifferInterface;
25
use PhpCsFixer\Error\Error;
26
use PhpCsFixer\Error\ErrorsManager;
27
use PhpCsFixer\Error\SourceExceptionFactory;
28
use PhpCsFixer\FileReader;
29
use PhpCsFixer\Fixer\FixerInterface;
30
use PhpCsFixer\Linter\LinterInterface;
31
use PhpCsFixer\Linter\LintingException;
32
use PhpCsFixer\Linter\LintingResultInterface;
33
use PhpCsFixer\Preg;
34
use PhpCsFixer\Runner\Event\AnalysisStarted;
35
use PhpCsFixer\Runner\Event\FileProcessed;
36
use PhpCsFixer\Runner\Parallel\ParallelAction;
37
use PhpCsFixer\Runner\Parallel\ParallelConfig;
38
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
39
use PhpCsFixer\Runner\Parallel\ParallelisationException;
40
use PhpCsFixer\Runner\Parallel\ProcessFactory;
41
use PhpCsFixer\Runner\Parallel\ProcessIdentifier;
42
use PhpCsFixer\Runner\Parallel\ProcessPool;
43
use PhpCsFixer\Runner\Parallel\WorkerException;
44
use PhpCsFixer\Tokenizer\Tokens;
45
use React\EventLoop\StreamSelectLoop;
46
use React\Socket\ConnectionInterface;
47
use React\Socket\TcpServer;
48
use Symfony\Component\Console\Input\InputInterface;
49
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
50
use Symfony\Component\Filesystem\Exception\IOException;
51
use Symfony\Contracts\EventDispatcher\Event;
52

53
/**
54
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
55
 * @author Greg Korba <greg@codito.dev>
56
 *
57
 * @phpstan-type _RunResult array<string, array{appliedFixers: list<string>, diff: string}>
58
 */
59
final class Runner
60
{
61
    /**
62
     * Buffer size used in the NDJSON decoder for communication between main process and workers.
63
     *
64
     * @see https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/8068
65
     */
66
    private const PARALLEL_BUFFER_SIZE = 16 * (1_024 * 1_024 /* 1MB */);
67

68
    private DifferInterface $differ;
69

70
    private DirectoryInterface $directory;
71

72
    private ?EventDispatcherInterface $eventDispatcher;
73

74
    private ErrorsManager $errorsManager;
75

76
    private CacheManagerInterface $cacheManager;
77

78
    private bool $isDryRun;
79

80
    private LinterInterface $linter;
81

82
    /**
83
     * @var null|\Traversable<array-key, \SplFileInfo>
84
     */
85
    private ?\Traversable $fileIterator = null;
86

87
    private int $fileCount;
88

89
    /**
90
     * @var list<FixerInterface>
91
     */
92
    private array $fixers;
93

94
    private bool $stopOnViolation;
95

96
    private ParallelConfig $parallelConfig;
97

98
    private ?InputInterface $input;
99

100
    private ?string $configFile;
101

102
    /**
103
     * @param null|\Traversable<array-key, \SplFileInfo> $fileIterator
104
     * @param list<FixerInterface>                       $fixers
105
     */
106
    public function __construct(
107
        ?\Traversable $fileIterator,
108
        array $fixers,
109
        DifferInterface $differ,
110
        ?EventDispatcherInterface $eventDispatcher,
111
        ErrorsManager $errorsManager,
112
        LinterInterface $linter,
113
        bool $isDryRun,
114
        CacheManagerInterface $cacheManager,
115
        ?DirectoryInterface $directory = null,
116
        bool $stopOnViolation = false,
117
        // @TODO Make these arguments required in 4.0
118
        ?ParallelConfig $parallelConfig = null,
119
        ?InputInterface $input = null,
120
        ?string $configFile = null
121
    ) {
122
        // Required only for main process (calculating workers count)
123
        $this->fileCount = null !== $fileIterator ? \count(iterator_to_array($fileIterator)) : 0;
7✔
124

125
        $this->fileIterator = $fileIterator;
7✔
126
        $this->fixers = $fixers;
7✔
127
        $this->differ = $differ;
7✔
128
        $this->eventDispatcher = $eventDispatcher;
7✔
129
        $this->errorsManager = $errorsManager;
7✔
130
        $this->linter = $linter;
7✔
131
        $this->isDryRun = $isDryRun;
7✔
132
        $this->cacheManager = $cacheManager;
7✔
133
        $this->directory = $directory ?? new Directory('');
7✔
134
        $this->stopOnViolation = $stopOnViolation;
7✔
135
        $this->parallelConfig = $parallelConfig ?? ParallelConfigFactory::sequential();
7✔
136
        $this->input = $input;
7✔
137
        $this->configFile = $configFile;
7✔
138
    }
139

140
    /**
141
     * @TODO consider to drop this method and make iterator parameter obligatory in constructor,
142
     * more in https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7777/files#r1590447581
143
     *
144
     * @param \Traversable<array-key, \SplFileInfo> $fileIterator
145
     */
146
    public function setFileIterator(iterable $fileIterator): void
147
    {
148
        $this->fileIterator = $fileIterator;
×
149

150
        // Required only for main process (calculating workers count)
151
        $this->fileCount = \count(iterator_to_array($fileIterator));
×
152
    }
153

154
    /**
155
     * @return _RunResult
156
     */
157
    public function fix(): array
158
    {
159
        if (0 === $this->fileCount) {
7✔
160
            return [];
×
161
        }
162

163
        // @TODO 4.0: Remove condition and its body, as no longer needed when param will be required in the constructor.
164
        // This is a fallback only in case someone calls `new Runner()` in a custom repo and does not provide v4-ready params in v3-codebase.
165
        if (null === $this->input) {
7✔
166
            return $this->fixSequential();
3✔
167
        }
168

169
        if (
170
            1 === $this->parallelConfig->getMaxProcesses()
4✔
171
            || $this->fileCount <= $this->parallelConfig->getFilesPerProcess()
4✔
172
        ) {
173
            return $this->fixSequential();
3✔
174
        }
175

176
        return $this->fixParallel();
1✔
177
    }
178

179
    /**
180
     * Heavily inspired by {@see https://github.com/phpstan/phpstan-src/blob/9ce425bca5337039fb52c0acf96a20a2b8ace490/src/Parallel/ParallelAnalyser.php}.
181
     *
182
     * @return _RunResult
183
     */
184
    private function fixParallel(): array
185
    {
186
        $this->dispatchEvent(AnalysisStarted::NAME, new AnalysisStarted(AnalysisStarted::MODE_PARALLEL, $this->isDryRun));
2✔
187

188
        $changed = [];
2✔
189
        $streamSelectLoop = new StreamSelectLoop();
2✔
190
        $server = new TcpServer('127.0.0.1:0', $streamSelectLoop);
2✔
191
        $serverPort = parse_url($server->getAddress() ?? '', PHP_URL_PORT);
2✔
192

193
        if (!is_numeric($serverPort)) {
2✔
194
            throw new ParallelisationException(\sprintf(
×
195
                'Unable to parse server port from "%s"',
×
196
                $server->getAddress() ?? ''
×
197
            ));
×
198
        }
199

200
        $processPool = new ProcessPool($server);
2✔
201
        $maxFilesPerProcess = $this->parallelConfig->getFilesPerProcess();
2✔
202
        $fileIterator = $this->getFilteringFileIterator();
2✔
203
        $fileIterator->rewind();
2✔
204

205
        $getFileChunk = static function () use ($fileIterator, $maxFilesPerProcess): array {
2✔
206
            $files = [];
2✔
207

208
            while (\count($files) < $maxFilesPerProcess) {
2✔
209
                $current = $fileIterator->current();
2✔
210

211
                if (null === $current) {
2✔
212
                    break;
2✔
213
                }
214

215
                $files[] = $current->getPathname();
2✔
216

217
                $fileIterator->next();
2✔
218
            }
219

220
            return $files;
2✔
221
        };
2✔
222

223
        // [REACT] Handle worker's handshake (init connection)
224
        $server->on('connection', static function (ConnectionInterface $connection) use ($processPool, $getFileChunk): void {
2✔
225
            $decoder = new Decoder(
2✔
226
                $connection,
2✔
227
                true,
2✔
228
                512,
2✔
229
                JSON_INVALID_UTF8_IGNORE,
2✔
230
                self::PARALLEL_BUFFER_SIZE
2✔
231
            );
2✔
232
            $encoder = new Encoder($connection, JSON_INVALID_UTF8_IGNORE);
2✔
233

234
            // [REACT] Bind connection when worker's process requests "hello" action (enables 2-way communication)
235
            $decoder->on('data', static function (array $data) use ($processPool, $getFileChunk, $decoder, $encoder): void {
2✔
236
                if (ParallelAction::WORKER_HELLO !== $data['action']) {
2✔
237
                    return;
2✔
238
                }
239

240
                $identifier = ProcessIdentifier::fromRaw($data['identifier']);
2✔
241
                $process = $processPool->getProcess($identifier);
2✔
242
                $process->bindConnection($decoder, $encoder);
2✔
243
                $fileChunk = $getFileChunk();
2✔
244

245
                if (0 === \count($fileChunk)) {
2✔
UNCOV
246
                    $process->request(['action' => ParallelAction::RUNNER_THANK_YOU]);
×
UNCOV
247
                    $processPool->endProcessIfKnown($identifier);
×
248

UNCOV
249
                    return;
×
250
                }
251

252
                $process->request(['action' => ParallelAction::RUNNER_REQUEST_ANALYSIS, 'files' => $fileChunk]);
2✔
253
            });
2✔
254
        });
2✔
255

256
        $processesToSpawn = min(
2✔
257
            $this->parallelConfig->getMaxProcesses(),
2✔
258
            max(
2✔
259
                1,
2✔
260
                (int) ceil($this->fileCount / $this->parallelConfig->getFilesPerProcess()),
2✔
261
            )
2✔
262
        );
2✔
263
        $processFactory = new ProcessFactory($this->input);
2✔
264

265
        for ($i = 0; $i < $processesToSpawn; ++$i) {
2✔
266
            $identifier = ProcessIdentifier::create();
2✔
267
            $process = $processFactory->create(
2✔
268
                $streamSelectLoop,
2✔
269
                new RunnerConfig(
2✔
270
                    $this->isDryRun,
2✔
271
                    $this->stopOnViolation,
2✔
272
                    $this->parallelConfig,
2✔
273
                    $this->configFile
2✔
274
                ),
2✔
275
                $identifier,
2✔
276
                $serverPort,
2✔
277
            );
2✔
278
            $processPool->addProcess($identifier, $process);
2✔
279
            $process->start(
2✔
280
                // [REACT] Handle workers' responses (multiple actions possible)
281
                function (array $workerResponse) use ($processPool, $process, $identifier, $getFileChunk, &$changed): void {
2✔
282
                    // File analysis result (we want close-to-realtime progress with frequent cache savings)
283
                    if (ParallelAction::WORKER_RESULT === $workerResponse['action']) {
2✔
284
                        // Dispatch an event for each file processed and dispatch its status (required for progress output)
285
                        $this->dispatchEvent(FileProcessed::NAME, new FileProcessed($workerResponse['status']));
2✔
286

287
                        if (isset($workerResponse['fileHash'])) {
2✔
288
                            $this->cacheManager->setFileHash($workerResponse['file'], $workerResponse['fileHash']);
2✔
289
                        }
290

291
                        foreach ($workerResponse['errors'] ?? [] as $error) {
2✔
292
                            $this->errorsManager->report(new Error(
×
293
                                $error['type'],
×
294
                                $error['filePath'],
×
295
                                null !== $error['source']
×
296
                                    ? SourceExceptionFactory::fromArray($error['source'])
×
297
                                    : null,
×
298
                                $error['appliedFixers'],
×
299
                                $error['diff']
×
300
                            ));
×
301
                        }
302

303
                        // Pass-back information about applied changes (only if there are any)
304
                        if (isset($workerResponse['fixInfo'])) {
2✔
305
                            $relativePath = $this->directory->getRelativePathTo($workerResponse['file']);
2✔
306
                            $changed[$relativePath] = $workerResponse['fixInfo'];
2✔
307

308
                            if ($this->stopOnViolation) {
2✔
309
                                $processPool->endAll();
×
310

311
                                return;
×
312
                            }
313
                        }
314

315
                        return;
2✔
316
                    }
317

318
                    if (ParallelAction::WORKER_GET_FILE_CHUNK === $workerResponse['action']) {
2✔
319
                        // Request another chunk of files, if still available
320
                        $fileChunk = $getFileChunk();
2✔
321

322
                        if (0 === \count($fileChunk)) {
2✔
323
                            $process->request(['action' => ParallelAction::RUNNER_THANK_YOU]);
2✔
324
                            $processPool->endProcessIfKnown($identifier);
2✔
325

326
                            return;
2✔
327
                        }
328

329
                        $process->request(['action' => ParallelAction::RUNNER_REQUEST_ANALYSIS, 'files' => $fileChunk]);
1✔
330

331
                        return;
1✔
332
                    }
333

334
                    if (ParallelAction::WORKER_ERROR_REPORT === $workerResponse['action']) {
×
335
                        throw WorkerException::fromRaw($workerResponse); // @phpstan-ignore-line
×
336
                    }
337

338
                    throw new ParallelisationException('Unsupported action: '.($workerResponse['action'] ?? 'n/a'));
×
339
                },
2✔
340

341
                // [REACT] Handle errors encountered during worker's execution
342
                static function (\Throwable $error) use ($processPool): void {
2✔
343
                    $processPool->endAll();
×
344

345
                    throw new ParallelisationException($error->getMessage(), $error->getCode(), $error);
×
346
                },
2✔
347

348
                // [REACT] Handle worker's shutdown
349
                static function ($exitCode, string $output) use ($processPool, $identifier): void {
2✔
350
                    $processPool->endProcessIfKnown($identifier);
2✔
351

352
                    if (0 === $exitCode || null === $exitCode) {
2✔
353
                        return;
2✔
354
                    }
355

356
                    $errorsReported = Preg::matchAll(
×
357
                        \sprintf('/^(?:%s)([^\n]+)+/m', WorkerCommand::ERROR_PREFIX),
×
358
                        $output,
×
359
                        $matches
×
360
                    );
×
361

362
                    if ($errorsReported > 0) {
×
363
                        throw WorkerException::fromRaw(json_decode($matches[1][0], true));
×
364
                    }
365
                }
2✔
366
            );
2✔
367
        }
368

369
        $streamSelectLoop->run();
2✔
370

371
        return $changed;
2✔
372
    }
373

374
    /**
375
     * @return _RunResult
376
     */
377
    private function fixSequential(): array
378
    {
379
        $this->dispatchEvent(AnalysisStarted::NAME, new AnalysisStarted(AnalysisStarted::MODE_SEQUENTIAL, $this->isDryRun));
7✔
380

381
        $changed = [];
7✔
382
        $collection = $this->getLintingFileIterator();
7✔
383

384
        foreach ($collection as $file) {
7✔
385
            $fixInfo = $this->fixFile($file, $collection->currentLintingResult());
7✔
386

387
            // we do not need Tokens to still caching just fixed file - so clear the cache
388
            Tokens::clearCache();
7✔
389

390
            if (null !== $fixInfo) {
7✔
391
                $relativePath = $this->directory->getRelativePathTo($file->__toString());
5✔
392
                $changed[$relativePath] = $fixInfo;
5✔
393

394
                if ($this->stopOnViolation) {
5✔
395
                    break;
2✔
396
                }
397
            }
398
        }
399

400
        return $changed;
7✔
401
    }
402

403
    /**
404
     * @return null|array{appliedFixers: list<string>, diff: string}
405
     */
406
    private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResult): ?array
407
    {
408
        $filePathname = $file->getPathname();
6✔
409

410
        try {
411
            $lintingResult->check();
6✔
412
        } catch (LintingException $e) {
2✔
413
            $this->dispatchEvent(
2✔
414
                FileProcessed::NAME,
2✔
415
                new FileProcessed(FileProcessed::STATUS_INVALID)
2✔
416
            );
2✔
417

418
            $this->errorsManager->report(new Error(Error::TYPE_INVALID, $filePathname, $e));
2✔
419

420
            return null;
2✔
421
        }
422

423
        $old = FileReader::createSingleton()->read($file->getRealPath());
4✔
424

425
        $tokens = Tokens::fromCode($old);
4✔
426
        $oldHash = $tokens->getCodeHash();
4✔
427

428
        $new = $old;
4✔
429
        $newHash = $oldHash;
4✔
430

431
        $appliedFixers = [];
4✔
432

433
        try {
434
            foreach ($this->fixers as $fixer) {
4✔
435
                // for custom fixers we don't know is it safe to run `->fix()` without checking `->supports()` and `->isCandidate()`,
436
                // thus we need to check it and conditionally skip fixing
437
                if (
438
                    !$fixer instanceof AbstractFixer
4✔
439
                    && (!$fixer->supports($file) || !$fixer->isCandidate($tokens))
4✔
440
                ) {
441
                    continue;
×
442
                }
443

444
                $fixer->fix($file, $tokens);
4✔
445

446
                if ($tokens->isChanged()) {
4✔
447
                    $tokens->clearEmptyTokens();
4✔
448
                    $tokens->clearChanged();
4✔
449
                    $appliedFixers[] = $fixer->getName();
4✔
450
                }
451
            }
452
        } catch (\ParseError $e) {
×
453
            $this->dispatchEvent(FileProcessed::NAME, new FileProcessed(FileProcessed::STATUS_LINT));
×
454

455
            $this->errorsManager->report(new Error(Error::TYPE_LINT, $filePathname, $e));
×
456

457
            return null;
×
458
        } catch (\Throwable $e) {
×
459
            $this->processException($filePathname, $e);
×
460

461
            return null;
×
462
        }
463

464
        $fixInfo = null;
4✔
465

466
        if ([] !== $appliedFixers) {
4✔
467
            $new = $tokens->generateCode();
4✔
468
            $newHash = $tokens->getCodeHash();
4✔
469
        }
470

471
        // We need to check if content was changed and then applied changes.
472
        // But we can't simply check $appliedFixers, because one fixer may revert
473
        // work of other and both of them will mark collection as changed.
474
        // Therefore we need to check if code hashes changed.
475
        if ($oldHash !== $newHash) {
4✔
476
            $fixInfo = [
4✔
477
                'appliedFixers' => $appliedFixers,
4✔
478
                'diff' => $this->differ->diff($old, $new, $file),
4✔
479
            ];
4✔
480

481
            try {
482
                $this->linter->lintSource($new)->check();
4✔
483
            } catch (LintingException $e) {
×
484
                $this->dispatchEvent(FileProcessed::NAME, new FileProcessed(FileProcessed::STATUS_LINT));
×
485

486
                $this->errorsManager->report(new Error(Error::TYPE_LINT, $filePathname, $e, $fixInfo['appliedFixers'], $fixInfo['diff']));
×
487

488
                return null;
×
489
            }
490

491
            if (!$this->isDryRun) {
4✔
492
                $fileRealPath = $file->getRealPath();
×
493

494
                if (!file_exists($fileRealPath)) {
×
495
                    throw new IOException(
×
496
                        \sprintf('Failed to write file "%s" (no longer) exists.', $file->getPathname()),
×
497
                        0,
×
498
                        null,
×
499
                        $file->getPathname()
×
500
                    );
×
501
                }
502

503
                if (is_dir($fileRealPath)) {
×
504
                    throw new IOException(
×
505
                        \sprintf('Cannot write file "%s" as the location exists as directory.', $fileRealPath),
×
506
                        0,
×
507
                        null,
×
508
                        $fileRealPath
×
509
                    );
×
510
                }
511

512
                if (!is_writable($fileRealPath)) {
×
513
                    throw new IOException(
×
514
                        \sprintf('Cannot write to file "%s" as it is not writable.', $fileRealPath),
×
515
                        0,
×
516
                        null,
×
517
                        $fileRealPath
×
518
                    );
×
519
                }
520

521
                if (false === @file_put_contents($fileRealPath, $new)) {
×
522
                    $error = error_get_last();
×
523

524
                    throw new IOException(
×
525
                        \sprintf('Failed to write file "%s", "%s".', $fileRealPath, null !== $error ? $error['message'] : 'no reason available'),
×
526
                        0,
×
527
                        null,
×
528
                        $fileRealPath
×
529
                    );
×
530
                }
531
            }
532
        }
533

534
        $this->cacheManager->setFileHash($filePathname, $newHash);
4✔
535

536
        $this->dispatchEvent(
4✔
537
            FileProcessed::NAME,
4✔
538
            new FileProcessed(null !== $fixInfo ? FileProcessed::STATUS_FIXED : FileProcessed::STATUS_NO_CHANGES, $filePathname, $newHash)
4✔
539
        );
4✔
540

541
        return $fixInfo;
4✔
542
    }
543

544
    /**
545
     * Process an exception that occurred.
546
     */
547
    private function processException(string $name, \Throwable $e): void
548
    {
549
        $this->dispatchEvent(FileProcessed::NAME, new FileProcessed(FileProcessed::STATUS_EXCEPTION));
×
550

551
        $this->errorsManager->report(new Error(Error::TYPE_EXCEPTION, $name, $e));
×
552
    }
553

554
    private function dispatchEvent(string $name, Event $event): void
555
    {
556
        if (null === $this->eventDispatcher) {
7✔
557
            return;
4✔
558
        }
559

560
        $this->eventDispatcher->dispatch($event, $name);
3✔
561
    }
562

563
    private function getLintingFileIterator(): LintingResultAwareFileIteratorInterface
564
    {
565
        $fileFilterIterator = $this->getFilteringFileIterator();
6✔
566

567
        return $this->linter->isAsync()
6✔
568
            ? new FileCachingLintingFileIterator($fileFilterIterator, $this->linter)
×
569
            : new LintingFileIterator($fileFilterIterator, $this->linter);
6✔
570
    }
571

572
    private function getFilteringFileIterator(): FileFilterIterator
573
    {
574
        if (null === $this->fileIterator) {
7✔
575
            throw new \RuntimeException('File iterator is not configured. Pass paths during Runner initialisation or set them after with `setFileIterator()`.');
×
576
        }
577

578
        return new FileFilterIterator(
7✔
579
            $this->fileIterator instanceof \IteratorAggregate
7✔
580
                ? $this->fileIterator->getIterator()
7✔
581
                : $this->fileIterator,
7✔
582
            $this->eventDispatcher,
7✔
583
            $this->cacheManager
7✔
584
        );
7✔
585
    }
586
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc