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

dg / dresscode / 39958913551

23 Oct 2026 12:44PM UTC coverage: 96.921% (-0.9%) from 97.773%
39958913551

push

github

dg
uses the Runner of nette/command-line WIP

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

278 existing lines in 25 files now uncovered.

24711 of 25496 relevant lines covered (96.92%)

0.97 hits per line

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

97.56
/src/Engine/PassLoop.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the DressCode, a coding style and upgrade tool for PHP (https://dresscode.run)
5
 * Copyright (c) 2026 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace DressCode\Engine;
9

10
use DressCode\{
11
        Analyses, ConfigurationException, ConvergenceException, NodeRule, Rule,
12
        RuleContext, RuleException, RuleInfo, Severity, Stage, Style, Violation,
13
};
14
use DressCode\Engine\Gaps\{Fixer, Resolver};
15
use PhpSyntax\{Node, Printer, Token, Traverser, Trivia};
16
use PhpSyntax\Nodes\FileNode;
17
use function count, strlen;
18

19

20
/**
21
 * Runs the rules over a file in passes until nothing mutates: each pass is one pre-order traversal per stage,
22
 * rules are dispatched by the type of the node or token, every mutation is paired with a report.
23
 * @internal
24
 */
25
final class PassLoop
26
{
27
        private const MaxPasses = 10;
28

29
        /**
30
         * @var array<string, array<class-string, list<array{NodeRule, RuleContext, class-string<NodeRule>}>>>  stage => node class =>
31
         * rules entering it, with their contexts and names
32
         */
33
        private array $entering = [];
34

35
        /** @var array<string, array<class-string, list<array{NodeRule, RuleContext, class-string<NodeRule>}>>>  the same for `leave()` */
36
        private array $leaving = [];
37

38
        /** @var array<class-string<Rule>, RuleContext> */
39
        private array $contexts = [];
40

41
        /** the gap rules, applied together along the traversal of the formatting stage */
42
        private readonly Resolver $gaps;
43

44
        /** @var array<class-string<Rule>, true>  rules that mutated the file in the current pass */
45
        private array $mutatedRules = [];
46

47
        /** @var array<string, Violation>  by fingerprint, so that a pass repeating a report adds nothing */
48
        private array $violations = [];
49

50
        /**
51
         * @var \WeakMap<Token, string>  a token whose line the fixer opened or closed => the fingerprint of the violation
52
         * it did it for, the first of its chain
53
         */
54
        private \WeakMap $opened;
55

56
        /** @var \WeakMap<Token, string>  a token whose line a rule moved => the fingerprint of the violation the move follows from */
57
        private \WeakMap $moved;
58

59
        /** @var list<array{string, Report, Severity}>  the reports of violations of the current pass: the rule, the report, its severity */
60
        private array $reported = [];
61

62
        /** @var list<string> */
63
        private array $warnings = [];
64

65
        private Fingerprints $fingerprints;
66

67
        /** @var list<int>  byte offset of the start of each line of the original code */
68
        private array $lineOffsets = [];
69
        private string $code = '';
70

71
        private FileNode $file;
72
        private string $path = '';
73

74

75
        public function __construct(
76
                private readonly RulePlan $plan,
1 ✔
77
                private readonly Analyses\Registry $analyses,
1 ✔
78
                private readonly ReportPolicy $policy = new ReportPolicy,
1 ✔
79
                private readonly ?Profiler $profiler = null,
1 ✔
80
        ) {
81
                $this->gaps = new Resolver($plan->claims);
1 ✔
82
        }
1 ✔
83

84

85
        /**
86
         * @param  array<string, true>  $acceptedRisks  fingerprints of the occurrences whose fix that may change what the code does is allowed
87
         * @throws RuleException|ConvergenceException|ConfigurationException
88
         */
89
        public function run(FileNode $file, string $code, string $path, Style $style, string $phpVersion, array $acceptedRisks = []): PassResult
90
        {
91
                $this->file = $file;
1 ✔
92
                $this->path = $path;
1 ✔
93
                $this->code = $code;
1 ✔
94
                $split = (array) preg_split('~\r\n|\r|\n~', $code, -1, PREG_SPLIT_OFFSET_CAPTURE);
1 ✔
95
                $lines = array_column($split, 0);
1 ✔
96
                $this->lineOffsets = array_column($split, 1);
1 ✔
97
                $this->violations = $this->warnings = $this->contexts = $this->entering = $this->leaving = [];
1 ✔
98
                $this->opened = new \WeakMap;
1 ✔
99
                $this->moved = new \WeakMap;
1 ✔
100
                $this->fingerprints = new Fingerprints($lines, $path, $this->policy->baseline);
1 ✔
101
                $suppression = Suppression::fromFile($file, $this->policy->expandName, $code, $this->policy->suppressionComments);
1 ✔
102
                foreach ($suppression->getUnknownNames() as $name => $comment) {
1 ✔
103
                        $this->warnings[] = 'Comment '
×
104
                                . Violation::formatCode($comment)
×
UNCOV
105
                                . " names `$name`, which is no decision, section or rule, so it silences nothing.";
×
106
                }
107

108
                $spans = new Spans($code);
1 ✔
109
                foreach ($this->plan->rules as $rule) {
1 ✔
110
                        $ruleClass = $rule::class;
1 ✔
111
                        $this->contexts[$ruleClass] = new RuleContext(
1 ✔
112
                                $file,
1 ✔
113
                                $path,
114
                                $style,
115
                                $phpVersion,
116
                                $this->analyses,
1 ✔
117
                                $suppression,
118
                                $this->fingerprints,
1 ✔
119
                                $acceptedRisks,
120
                                $this->policy,
1 ✔
121
                                $this->plan->gates[$ruleClass],
1 ✔
122
                                RuleInfo::fromRule($rule),
1 ✔
123
                                $spans,
124
                        );
125
                }
126

127
                $seen = [hash('xxh3', $code) => true];
1 ✔
128
                $last = $code;
1 ✔
129
                $passes = 0;
1 ✔
130
                $mutated = false;
1 ✔
131
                $mutatedRules = [];
1 ✔
132
                while (true) {
1 ✔
133
                        if ($passes++ === self::MaxPasses) {
1 ✔
134
                                throw new ConvergenceException($path, array_keys($this->mutatedRules), '');
1 ✔
135
                        }
136

137
                        $this->mutatedRules = $this->reported = [];
1 ✔
138
                        $this->fingerprints->beginPass();
1 ✔
139
                        foreach ($this->contexts as $context) {
1 ✔
140
                                $context->passStorage = [];
1 ✔
141
                        }
142

143
                        $start = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
144
                        $this->analyses->beginPass($file);
1 ✔
145
                        $this->profiler?->addPhase('analyses: begin pass', hrtime(as_number: true) - $start);
1 ✔
146
                        $revision = $file->revision;
1 ✔
147
                        foreach ($this->plan->stages as $stage => $rules) {
1 ✔
148
                                $this->runStage($stage, $rules, $style);
1 ✔
149
                        }
150

151
                        $failure = $this->analyses->findCreated($file, Analyses\Types::class)?->failure;
1 ✔
152
                        if ($failure !== null) {
1 ✔
UNCOV
153
                                $warning = "PHPStan fails on the file, so the rules asking for its types see none where it stopped: $failure";
×
UNCOV
154
                                $this->warnings = array_values(array_unique([...$this->warnings, $warning]));
×
155
                        }
156

157
                        if ($file->revision === $revision) {
1 ✔
158
                                $this->checkUnfixed();
1 ✔
159
                                break;
1 ✔
160
                        }
161

162
                        $mutated = true;
1 ✔
163
                        $mutatedRules += $this->mutatedRules;
1 ✔
164
                        $start = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
165
                        $output = Printer::print($file);
1 ✔
166
                        $this->profiler?->addPhase('print', hrtime(as_number: true) - $start);
1 ✔
167
                        $hash = hash('xxh3', $output);
1 ✔
168
                        if (isset($seen[$hash])) { // a state seen before: the rules cycle
1 ✔
169
                                throw new ConvergenceException($path, array_keys($this->mutatedRules), Diff::unified($last, $output, $path));
1 ✔
170
                        }
171

172
                        $seen[$hash] = true;
1 ✔
173
                        $last = $output;
1 ✔
174
                }
175

176
                $this->analyses->endFile($file);
1 ✔
177
                return new PassResult(
1 ✔
178
                        self::sortByPosition(array_values($this->violations)),
1 ✔
179
                        $this->warnings,
1 ✔
180
                        $passes,
181
                        $mutated,
182
                        $this->fingerprints->getBaselined(),
1 ✔
183
                        array_keys($mutatedRules),
1 ✔
184
                        // what the baseline holds is decided by the lines of the fixed text, which only another run has
185
                        remaining: $mutated && $this->policy->baseline === null ? $this->collectRemaining($last) : null,
1 ✔
186
                        output: $mutated ? $last : null,
1 ✔
187
                        removedImports: array_merge(
1 ✔
188
                                ...array_values(array_map(fn(RuleContext $context) => $context->takeRemovedImports(), $this->contexts)),
1 ✔
189
                        ),
190
                );
191
        }
192

193

194
        /**
195
         * The violations of the fixed text: what the last pass, which changed nothing, reported, identified by the lines
196
         * of that text as a run over it would identify them.
197
         * @return list<Violation>
198
         */
199
        private function collectRemaining(string $text): array
200
        {
201
                $fingerprints = new Fingerprints(preg_split('~\r\n|\r|\n~', $text) ?: [], $this->path);
1 ✔
202
                /** @var \WeakMap<Token, string> $opened */
203
                $opened = new \WeakMap;
1 ✔
204
                /** @var \WeakMap<Token, string> $moved */
205
                $moved = new \WeakMap;
1 ✔
206
                $violations = [];
1 ✔
207
                $spans = new Spans;
1 ✔
208
                foreach ($this->reported as [, $report, $severity]) {
1 ✔
209
                        $token = $report->at->getFirstToken();
1 ✔
210
                        $line = self::findCurrentLine($token, $report->trivia) + $report->lineInTrivia;
1 ✔
211
                        $fingerprint = $report->construct === null
1 ✔
212
                                ? $fingerprints->create($report->decision, $report->message, $line)
1 ✔
213
                                : $fingerprints->createFor($report->construct, $report->decision, $report->message, $line);
1 ✔
214
                        $violation = self::createViolation(
1 ✔
215
                                $report,
216
                                $fingerprint,
217
                                $line,
218
                                $token?->getCurrentColumn(),
1 ✔
219
                                $spans->find($report->at, $report->trivia, $report->lineInTrivia, $report->gap, $report->follows),
1 ✔
220
                                $this->findOriginalLines($report),
1 ✔
221
                                $severity,
222
                                $opened,
223
                                $moved,
224
                        );
225
                        $violations[$fingerprint] ??= $violation;
1 ✔
226
                }
227

228
                return self::sortByPosition(array_values($violations));
1 ✔
229
        }
230

231

232
        /**
233
         * The violation of the report, derived from what opened, closed or moved its line, and the line its fix opens
234
         * or closes marked for what follows.
235
         * @param ?array{int, int} $span  the offset and the length of what was reported
236
         * @param array{int, int} $originalLines  the first and the last line of what was reported in the original file
237
         * @param \WeakMap<Token, string> $opened
238
         * @param \WeakMap<Token, string> $moved
239
         */
240
        private static function createViolation(
241
                Report $report,
242
                string $fingerprint,
243
                int $line,
244
                ?int $column,
245
                ?array $span,
246
                array $originalLines,
247
                Severity $severity,
248
                \WeakMap $opened,
249
                \WeakMap $moved,
250
        ): Violation
251
        {
252
                $derivedFrom = self::findAncestor($report, $opened, $moved);
1 ✔
253
                self::markLine($report, $derivedFrom ?? $fingerprint, $opened, $moved);
1 ✔
254
                return new Violation(
1 ✔
255
                        $report->decision,
1 ✔
256
                        $report->message,
1 ✔
257
                        $line,
258
                        $report->trivia === null ? $column : null,
1 ✔
259
                        $severity,
260
                        fingerprint: $fingerprint,
261
                        fixable: $report->fixable,
1 ✔
262
                        risk: $report->risk,
1 ✔
263
                        refused: $report->refused,
1 ✔
264
                        because: $report->because,
1 ✔
265
                        derivedFrom: $derivedFrom === $fingerprint ? null : $derivedFrom,
1 ✔
266
                        offset: $span[0] ?? null,
1 ✔
267
                        length: $span[1] ?? null,
1 ✔
268
                        originalLines: $originalLines,
269
                );
270
        }
271

272

273
        /**
274
         * The line of the token or of its trivia in the current text; the trivia is counted back from the token, whose
275
         * line the index follows.
276
         */
277
        private static function findCurrentLine(?Token $token, ?Trivia $trivia): int
278
        {
279
                $line = $token?->getCurrentLine() ?? 1;
1 ✔
280
                if ($token === null || $trivia === null) {
1 ✔
281
                        return $line;
1 ✔
282
                }
283

284
                $leading = array_search($trivia, $token->leadingTrivia, strict: true);
1 ✔
285
                if ($leading !== false) {
1 ✔
286
                        foreach (array_slice($token->leadingTrivia, $leading) as $item) {
1 ✔
287
                                $line -= substr_count($item->text, "\n");
1 ✔
288
                        }
289

290
                        return $line;
1 ✔
291
                }
292

293
                $line += substr_count($token->text, "\n");
1 ✔
294
                foreach ($token->trailingTrivia as $item) {
1 ✔
295
                        if ($item === $trivia) {
1 ✔
296
                                break;
1 ✔
297
                        }
298

299
                        $line += substr_count($item->text, "\n");
1 ✔
300
                }
301

302
                return $line;
1 ✔
303
        }
304

305

306
        /**
307
         * The passes report by rule, the reader reads by position.
308
         * @param list<Violation> $violations
309
         * @return list<Violation>
310
         */
311
        private static function sortByPosition(array $violations): array
312
        {
313
                usort($violations, fn(Violation $a, Violation $b) => [$a->line, $a->column ?? 0] <=> [$b->line, $b->column ?? 0]);
1 ✔
314
                return $violations;
1 ✔
315
        }
316

317

318
        /** @param list<NodeRule> $rules */
319
        private function runStage(string $stage, array $rules, Style $style): void
320
        {
321
                // the gap rules ride along the traversal of the formatting stage and report under their decisions;
322
                // a rule is accounted for when it reported, the revision before the traversal standing for the whole of it
323
                $gaps = $stage === Stage::Formatting->name && !$this->plan->claims->isEmpty() ? $this->gaps : null;
1 ✔
324
                if ($rules === [] && $gaps === null) {
1 ✔
325
                        return;
1 ✔
326
                }
327

328
                foreach ($rules as $rule) {
1 ✔
329
                        $this->invoke($rule, fn(RuleContext $context) => $rule->beforePass($context));
1 ✔
330
                }
331

332
                // every gap holds the closure, which therefore must not hold the loop: through it a gap would reach and keep in
333
                // cycles everything the pass made
334
                [$analyses, $file, $path, $strict] = [$this->analyses, $this->file, $this->path, $this->policy->strict];
1 ✔
335
                $gaps?->beginPass(
1 ✔
336
                        $style,
337
                        new Fixer($this->contexts, stripos($this->code, '__LINE__') !== false),
1 ✔
338
                        static function (string $class, Rule $rule) use ($analyses, $file, $path, $strict): ?object {
1 ✔
339
                                if ($strict) {
1 ✔
340
                                        try {
341
                                                RuleInfo::fromRule($rule)->checkAnalysis($class);
1 ✔
342
                                        } catch (\LogicException $e) {
1 ✔
343
                                                throw new RuleException($rule::class, $path, $e);
1 ✔
344
                                        }
345
                                }
346

347
                                return $analyses->find($file, $class, $path);
1 ✔
348
                        },
1 ✔
349
                );
350
                $before = $this->file->revision;
1 ✔
351
                $enter = function (Node|Token $node) use ($stage, $gaps): void {
1 ✔
352
                        if (($this->entering[$stage][$node::class] ?? null) !== []) {
1 ✔
353
                                $this->visit($stage, $node, enter: true);
1 ✔
354
                        }
355

356
                        // a node a rule has just taken out of the tree has no gaps to decide
357
                        if ($gaps === null || ($node->parent === null && !$node instanceof FileNode)) {
1 ✔
358
                                return;
1 ✔
359
                        }
360

361
                        $start = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
362
                        try {
363
                                $node instanceof Token ? $gaps->enterToken($node) : $gaps->enterNode($node);
1 ✔
364
                                $this->profiler?->addPhase('gaps', hrtime(as_number: true) - $start);
1 ✔
365
                        } catch (ConfigurationException|RuleException $e) { // two claims deciding one gap, or a claim of the rule named failing
1 ✔
366
                                throw $e;
1 ✔
UNCOV
367
                        } catch (\Throwable $e) {
×
UNCOV
368
                                throw new RuleException(null, $this->path, $e);
×
369
                        }
370
                };
1 ✔
371
                // the traverser leaves every node it entered, a replaced one included; a rule sees only what is still in the tree
372
                $leave = fn(Node|Token $node) => ($this->leaving[$stage][$node::class] ?? null) === []
1 ✔
373
                        || ($node->parent === null && $node !== $this->file)
1 ✔
374
                        ? null
1 ✔
375
                        : $this->visit($stage, $node, enter: false);
1 ✔
376
                Traverser::traverse($this->file, $enter, $this->plan->leaves[$stage] ? $leave : null);
1 ✔
377

378
                if ($gaps !== null) {
1 ✔
379
                        foreach ($this->contexts as $ruleClass => $context) {
1 ✔
380
                                if ($context->hasReports()) {
1 ✔
381
                                        // the whitespace of a gap is written by the engine, always after a report of its own, so a mutation
382
                                        // without a report or after a denied one belongs to another rule of the same traversal
383
                                        $this->account($ruleClass, $context, $before, checkRevisions: false);
1 ✔
384
                                }
385
                        }
386
                }
387

388
                foreach ($rules as $rule) {
1 ✔
389
                        $this->invoke($rule, fn(RuleContext $context) => $rule->afterPass($context));
1 ✔
390
                }
391
        }
1 ✔
392

393

394
        private function visit(string $stage, Node|Token $node, bool $enter): void
395
        {
396
                $class = $node::class;
1 ✔
397
                $rules = ($enter ? $this->entering : $this->leaving)[$stage][$class] ?? $this->resolveRules($stage, $node, $enter);
1 ✔
398
                if ($rules === []) {
1 ✔
399
                        return;
1 ✔
400
                }
401

402
                $parent = $node->parent;
1 ✔
403
                $file = $this->file;
1 ✔
404
                $profiler = $this->profiler;
1 ✔
405
                foreach ($rules as [$rule, $context, $ruleClass]) {
1 ✔
406
                        $before = $file->revision;
1 ✔
407
                        $start = $profiler ? hrtime(as_number: true) : 0;
1 ✔
408
                        try {
409
                                $enter ? $rule->enter($node, $context) : $rule->leave($node, $context);
1 ✔
410
                        } catch (\Throwable $e) {
1 ✔
411
                                throw new RuleException($ruleClass, $this->path, $e);
1 ✔
412
                        }
413

414
                        $profiler?->addRule($ruleClass, hrtime(as_number: true) - $start, $file->revision !== $before);
1 ✔
415
                        if ($file->revision !== $before || $context->hasReports()) {
1 ✔
416
                                $this->account($ruleClass, $context, $before);
1 ✔
417
                        }
418

419
                        if ($node->parent !== $parent) { // replaced or removed: the rest of the chain never sees it
1 ✔
420
                                return;
1 ✔
421
                        }
422
                }
423
        }
1 ✔
424

425

426
        /**
427
         * The rules the plan dispatches the class of the node to, with their contexts in this file.
428
         * @return list<array{NodeRule, RuleContext, class-string<NodeRule>}>
429
         */
430
        private function resolveRules(string $stage, Node|Token $node, bool $enter): array
431
        {
432
                $rules = [];
1 ✔
433
                foreach ($this->plan->getRulesVisiting($stage, $node::class, $enter) as $rule) {
1 ✔
434
                        $ruleClass = $rule::class;
1 ✔
435
                        $rules[] = [$rule, $this->contexts[$ruleClass], $ruleClass];
1 ✔
436
                }
437

438
                if ($enter) {
1 ✔
439
                        $this->entering[$stage][$node::class] = $rules;
1 ✔
440
                } else {
441
                        $this->leaving[$stage][$node::class] = $rules;
1 ✔
442
                }
443

444
                return $rules;
1 ✔
445
        }
446

447

448
        /**
449
         * Calls a per-file callback of a rule and accounts for what it did.
450
         * @param \Closure(RuleContext): void $callback
451
         */
452
        private function invoke(NodeRule $rule, \Closure $callback): void
453
        {
454
                $ruleClass = $rule::class;
1 ✔
455
                $context = $this->contexts[$ruleClass];
1 ✔
456
                $before = $this->file->revision;
1 ✔
457
                $start = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
458
                try {
459
                        $callback($context);
1 ✔
460
                } catch (\Throwable $e) {
1 ✔
461
                        throw new RuleException($ruleClass, $this->path, $e);
1 ✔
462
                }
463

464
                $this->profiler?->addRule($ruleClass, hrtime(as_number: true) - $start, $this->file->revision !== $before);
1 ✔
465
                $this->account($ruleClass, $context, $before);
1 ✔
466
        }
1 ✔
467

468

469
        /**
470
         * Turns the reports of a callback into violations and checks that every mutation follows a report that returned
471
         * true; what the rule wrote after a denied report, up to its next one, it wrote for that report. After the last
472
         * report the rule may be fixing any of those it was allowed earlier, so that one is judged only where none was. A
473
         * report the baseline holds is not denied, only not recorded.
474
         * @param class-string<Rule> $ruleClass
475
         * @param int $before  the revision of the file before the callback
476
         * @param bool $checkRevisions  whether the revisions are the rule's doing, which along the gap traversal they need
477
         *                              not be, because they then cover every rule
478
         */
479
        private function account(string $ruleClass, RuleContext $context, int $before, bool $checkRevisions = true): void
480
        {
481
                $after = $this->file->revision;
1 ✔
482
                $reported = $allowed = false;
1 ✔
483
                $reports = $context->takeReports();
1 ✔
484
                foreach ($reports as $i => $report) {
1 ✔
485
                        $fingerprint = $report->fingerprint;
1 ✔
486
                        $denied = !$report->fixable || $report->silenced || $report->refused || $report->left;
1 ✔
487
                        $end = $reports[$i + 1]->revision ?? ($allowed ? null : $after);
1 ✔
488
                        if ($checkRevisions && $denied && $end !== null && $end > $report->revision) {
1 ✔
489
                                $this->violateContract($ruleClass, $report->fixable
1 ✔
490
                                        ? 'it changed the file although `report()` returned `false`'
1 ✔
491
                                        : 'it changed the file after a report with `fixable: false`');
1 ✔
492
                        }
493

494
                        $allowed = $allowed || !$denied;
1 ✔
495

496
                        if (!str_ends_with($report->message, '.')) {
1 ✔
497
                                $this->violateContract(
1 ✔
498
                                        $ruleClass,
499
                                        'it reported a message not ending with a period, ' . Violation::formatCode($report->message),
1 ✔
500
                                );
501
                        }
502

503
                        if ($report->silenced || $fingerprint === null) { // and then there is no violation to record
1 ✔
504
                                continue;
1 ✔
505
                        }
506

507
                        $reported = true;
1 ✔
508
                        $severity = isset($this->policy->warningDecisions[$report->decision]) || ($report->refused && $report->risk?->isResolvable())
1 ✔
509
                                ? Severity::Warning
1 ✔
510
                                : Severity::Error;
1 ✔
511
                        $this->reported[] = [$ruleClass, $report, $severity];
1 ✔
512
                        $violation = self::createViolation(
1 ✔
513
                                $report,
1 ✔
514
                                $fingerprint,
1 ✔
515
                                $report->line,
1 ✔
516
                                $this->findOriginalColumn($report->at),
1 ✔
517
                                $report->span,
1 ✔
518
                                $this->findOriginalLines($report),
1 ✔
519
                                $severity,
1 ✔
520
                                $this->opened,
1 ✔
521
                                $this->moved,
1 ✔
522
                        );
523
                        if (!$report->baselined) { // what the baseline holds is not recorded, though the rule may have fixed it
1 ✔
524
                                $this->violations[$fingerprint] ??= $violation;
1 ✔
525
                        }
526
                }
527

528
                // the lines of code a fix wrote anew are placed by it, as the lines of a gap the fixer broke are
529
                foreach ($context->takeRewritten() as [$fingerprint, $node]) {
1 ✔
530
                        $origin = $this->violations[$fingerprint]->derivedFrom ?? $fingerprint;
1 ✔
531
                        foreach ($node->getTokens() as $i => $token) {
1 ✔
532
                                if ($i === 0 || $token->startsLine()) {
1 ✔
533
                                        $this->moved[$token] ??= $origin;
1 ✔
534
                                }
535
                        }
536
                }
537

538
                if ($after > $before) {
1 ✔
539
                        $this->mutatedRules[$ruleClass] = true;
1 ✔
540
                        if (!$reported && $checkRevisions) {
1 ✔
541
                                $this->violateContract($ruleClass, 'it changed the file without reporting a violation');
1 ✔
542
                        }
543
                }
544
        }
1 ✔
545

546

547
        /**
548
         * The violation the report follows from: the one the fixer opened or closed the line of the reported gap
549
         * for, else the one that opened, closed or moved the line the reported whitespace is counted from. A report
550
         * about the shape of the line it stands on asks who opened or closed that line, and nothing about whitespace.
551
         * @param \WeakMap<Token, string> $opened
552
         * @param \WeakMap<Token, string> $moved
553
         */
554
        private static function findAncestor(Report $report, \WeakMap $opened, \WeakMap $moved): ?string
555
        {
556
                if ($report->byLine) {
1 ✔
557
                        $token = $report->at->getFirstToken();
1 ✔
558
                        return $token === null ? null : $opened[$token] ?? null;
1 ✔
559
                }
560

561
                if ($report->gap !== null && isset($opened[$report->gap])) {
1 ✔
562
                        return $opened[$report->gap];
1 ✔
563
                }
564

565
                $follows = $report->follows;
1 ✔
566
                return $follows === null ? null : $opened[$follows] ?? $moved[$follows] ?? null;
1 ✔
567
        }
568

569

570
        /**
571
         * What is placed by the line of the reported gap follows from what opened, closed or moved it, the first of the chain.
572
         * @param \WeakMap<Token, string> $opened
573
         * @param \WeakMap<Token, string> $moved
574
         */
575
        private static function markLine(Report $report, string $origin, \WeakMap $opened, \WeakMap $moved): void
576
        {
577
                if ($report->gap === null || $report->refused || $report->left) {
1 ✔
578
                        return;
1 ✔
579
                } elseif ($report->breaks) {
1 ✔
580
                        $opened[$report->gap] ??= $origin;
1 ✔
581
                } elseif ($report->follows !== null) {
1 ✔
582
                        $moved[$report->gap] ??= $origin;
1 ✔
583
                }
584
        }
1 ✔
585

586

587
        /**
588
         * A pass that changed nothing left every occurrence it reported as it was, so the rule had no fix for one it did not
589
         * report with `fixable: false`, refused as risky, or left as the file writes it.
590
         */
591
        private function checkUnfixed(): void
592
        {
593
                $rules = [];
1 ✔
594
                foreach ($this->reported as [$ruleClass, $report]) {
1 ✔
595
                        if ($report->fixable && !$report->refused && !$report->left && $report->gap === null) { // a gap is the engine's to fix, and a comment may keep it from that
1 ✔
596
                                $rules[$ruleClass] = true;
1 ✔
597
                        }
598
                }
599

600
                foreach (array_keys($rules) as $ruleClass) {
1 ✔
601
                        $this->violateContract($ruleClass, 'it reported an occurrence it then left unfixed without saying `fixable: false`');
1 ✔
602
                }
603
        }
1 ✔
604

605

606
        /** @param  string  $breach  what the rule did, as a clause */
607
        private function violateContract(string $ruleClass, string $breach): void
608
        {
609
                if ($this->policy->strict) {
1 ✔
610
                        throw new RuleException($ruleClass, $this->path, new \LogicException(ucfirst($breach) . '.'));
1 ✔
611
                }
612

613
                $this->warnings[] = "Rule `$ruleClass` is faulty: $breach.";
1 ✔
614
        }
1 ✔
615

616

617
        /**
618
         * Column in characters of the token in the original file, from its original offset and the start of its line.
619
         */
620
        private function findOriginalColumn(Node|Token $at): ?int
621
        {
622
                $token = $at->getFirstToken();
1 ✔
623
                if ($token === null || $token->pos < 0 || $token->line < 0) {
1 ✔
624
                        return null;
1 ✔
625
                }
626

627
                $lineStart = $this->lineOffsets[$token->line - 1] ?? 0;
1 ✔
628
                $before = substr($this->code, $lineStart, $token->pos - $lineStart);
1 ✔
629
                return strlen($before) - preg_match_all('~[\x80-\xBF]~', $before) + 1;
1 ✔
630
        }
631

632

633
        /**
634
         * The first and the last line of the original file the span of the report covers, or the line of the report where
635
         * it has no span.
636
         * @return array{int, int}
637
         */
638
        private function findOriginalLines(Report $report): array
639
        {
640
                if ($report->span === null) {
1 ✔
641
                        return [$report->line, $report->line];
1 ✔
642
                }
643

644
                [$offset, $length] = $report->span;
1 ✔
645
                return [$this->findOriginalLineAt($offset), $this->findOriginalLineAt($offset + max($length - 1, 0))];
1 ✔
646
        }
647

648

649
        /** The line of the original file the byte offset stands on, from 1. */
650
        private function findOriginalLineAt(int $offset): int
651
        {
652
                [$low, $high] = [0, count($this->lineOffsets) - 1];
1 ✔
653
                while ($low < $high) {
1 ✔
654
                        $middle = intdiv($low + $high + 1, 2);
1 ✔
655
                        if ($this->lineOffsets[$middle] <= $offset) {
1 ✔
656
                                $low = $middle;
1 ✔
657
                        } else {
658
                                $high = $middle - 1;
1 ✔
659
                        }
660
                }
661

662
                return $low + 1;
1 ✔
663
        }
664
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc