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

dg / dresscode / 39714674839

17 Sep 2026 12:34PM UTC coverage: 97.639% (-0.2%) from 97.877%
39714674839

push

github

dg
released version 1.1.0

18571 of 19020 relevant lines covered (97.64%)

0.98 hits per line

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

97.31
/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 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, string}>>>  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, string}>>>  the same for `leave()` */
36
        private array $leaving = [];
37

38
        /** @var array<string, 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<string, 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
        ) {
80
                $this->gaps = new Resolver($plan->claims);
1 ✔
81
        }
1 ✔
82

83

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

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

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

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

142
                        $this->analyses->beginPass($file);
1 ✔
143
                        $revision = $file->revision;
1 ✔
144
                        foreach ($this->plan->stages as $stage => $rules) {
1 ✔
145
                                $this->runStage($stage, $rules, $style);
1 ✔
146
                        }
147

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

154
                        if ($file->revision === $revision) {
1 ✔
155
                                $this->checkUnfixed();
1 ✔
156
                                break;
1 ✔
157
                        }
158

159
                        $mutated = true;
1 ✔
160
                        $mutatedRules += $this->mutatedRules;
1 ✔
161
                        $output = Printer::print($file);
1 ✔
162
                        $hash = hash('xxh3', $output);
1 ✔
163
                        if (isset($seen[$hash])) { // a state seen before: the rules cycle
1 ✔
164
                                throw new ConvergenceException($path, array_keys($this->mutatedRules), Diff::unified($last, $output, $path));
1 ✔
165
                        }
166

167
                        $seen[$hash] = true;
1 ✔
168
                        $last = $output;
1 ✔
169
                }
170

171
                $this->analyses->endFile($file);
1 ✔
172
                return new PassResult(
1 ✔
173
                        self::sortByPosition(array_values($this->violations)),
1 ✔
174
                        $this->warnings,
1 ✔
175
                        $passes,
176
                        $mutated,
177
                        $this->fingerprints->getBaselined(),
1 ✔
178
                        array_keys($mutatedRules),
1 ✔
179
                        // what the baseline holds is decided by the lines of the fixed text, which only another run has
180
                        remaining: $mutated && $this->policy->baseline === null ? $this->collectRemaining($last) : null,
1 ✔
181
                        output: $mutated ? $last : null,
1 ✔
182
                );
183
        }
184

185

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

219
                return self::sortByPosition(array_values($violations));
1 ✔
220
        }
221

222

223
        /**
224
         * The violation of the report, derived from what opened, closed or moved its line, and the line its fix opens
225
         * or closes marked for what follows.
226
         * @param ?array{int, int} $span  the offset and the length of what was reported
227
         * @param \WeakMap<Token, string> $opened
228
         * @param \WeakMap<Token, string> $moved
229
         */
230
        private static function createViolation(
231
                Report $report,
232
                string $fingerprint,
233
                int $line,
234
                ?int $column,
235
                ?array $span,
236
                Severity $severity,
237
                \WeakMap $opened,
238
                \WeakMap $moved,
239
        ): Violation
240
        {
241
                $derivedFrom = self::findAncestor($report, $opened, $moved);
1 ✔
242
                self::markLine($report, $derivedFrom ?? $fingerprint, $opened, $moved);
1 ✔
243
                return new Violation(
1 ✔
244
                        $report->decision,
1 ✔
245
                        $report->message,
1 ✔
246
                        $line,
247
                        $report->trivia === null ? $column : null,
1 ✔
248
                        $severity,
249
                        fingerprint: $fingerprint,
250
                        fixable: $report->fixable,
1 ✔
251
                        risk: $report->risk,
1 ✔
252
                        refused: $report->refused,
1 ✔
253
                        because: $report->because,
1 ✔
254
                        derivedFrom: $derivedFrom === $fingerprint ? null : $derivedFrom,
1 ✔
255
                        offset: $span[0] ?? null,
1 ✔
256
                        length: $span[1] ?? null,
1 ✔
257
                );
258
        }
259

260

261
        /**
262
         * The line of the token or of its trivia in the current text; the trivia is counted back from the token, whose
263
         * line the index follows.
264
         */
265
        private static function findCurrentLine(?Token $token, ?Trivia $trivia): int
266
        {
267
                $line = $token?->getCurrentLine() ?? 1;
1 ✔
268
                if ($token === null || $trivia === null) {
1 ✔
269
                        return $line;
1 ✔
270
                }
271

272
                $leading = array_search($trivia, $token->leadingTrivia, strict: true);
1 ✔
273
                if ($leading !== false) {
1 ✔
274
                        foreach (array_slice($token->leadingTrivia, $leading) as $item) {
1 ✔
275
                                $line -= substr_count($item->text, "\n");
1 ✔
276
                        }
277

278
                        return $line;
1 ✔
279
                }
280

281
                $line += substr_count($token->text, "\n");
1 ✔
282
                foreach ($token->trailingTrivia as $item) {
1 ✔
283
                        if ($item === $trivia) {
1 ✔
284
                                break;
1 ✔
285
                        }
286

287
                        $line += substr_count($item->text, "\n");
1 ✔
288
                }
289

290
                return $line;
1 ✔
291
        }
292

293

294
        /**
295
         * The passes report by rule, the reader reads by position.
296
         * @param list<Violation> $violations
297
         * @return list<Violation>
298
         */
299
        private static function sortByPosition(array $violations): array
300
        {
301
                usort($violations, fn(Violation $a, Violation $b) => [$a->line, $a->column ?? 0] <=> [$b->line, $b->column ?? 0]);
1 ✔
302
                return $violations;
1 ✔
303
        }
304

305

306
        /** @param list<NodeRule> $rules */
307
        private function runStage(string $stage, array $rules, Style $style): void
308
        {
309
                // the gap rules ride along the traversal of the formatting stage and report under their decisions;
310
                // a rule is accounted for when it reported, the revision before the traversal standing for the whole of it
311
                $gaps = $stage === Stage::Formatting->name && !$this->plan->claims->isEmpty() ? $this->gaps : null;
1 ✔
312
                if ($rules === [] && $gaps === null) {
1 ✔
313
                        return;
1 ✔
314
                }
315

316
                foreach ($rules as $rule) {
1 ✔
317
                        $this->invoke($rule, fn(RuleContext $context) => $rule->beforePass($context));
1 ✔
318
                }
319

320
                // every gap holds the closure, which therefore must not hold the loop: through it a gap would reach and keep in
321
                // cycles everything the pass made
322
                [$analyses, $file, $path, $strict] = [$this->analyses, $this->file, $this->path, $this->policy->strict];
1 ✔
323
                $gaps?->beginPass(
1 ✔
324
                        $style,
325
                        new Fixer($this->contexts, stripos($this->code, '__LINE__') !== false),
1 ✔
326
                        static function (string $class, Rule $rule) use ($analyses, $file, $path, $strict): ?object {
1 ✔
327
                                if ($strict) {
1 ✔
328
                                        try {
329
                                                RuleInfo::fromRule($rule)->checkAnalysis($class);
1 ✔
330
                                        } catch (\LogicException $e) {
1 ✔
331
                                                throw new RuleException($rule::class, $path, $e);
1 ✔
332
                                        }
333
                                }
334

335
                                return $analyses->find($file, $class, $path);
1 ✔
336
                        },
1 ✔
337
                );
338
                $before = $this->file->revision;
1 ✔
339
                $enter = function (Node|Token $node) use ($stage, $gaps): void {
1 ✔
340
                        if (($this->entering[$stage][$node::class] ?? null) !== []) {
1 ✔
341
                                $this->visit($stage, $node, enter: true);
1 ✔
342
                        }
343

344
                        // a node a rule has just taken out of the tree has no gaps to decide
345
                        if ($gaps === null || ($node->parent === null && !$node instanceof FileNode)) {
1 ✔
346
                                return;
1 ✔
347
                        }
348

349
                        try {
350
                                $node instanceof Token ? $gaps->enterToken($node) : $gaps->enterNode($node);
1 ✔
351
                        } catch (ConfigurationException|RuleException $e) { // two claims deciding one gap, or a claim of the rule named failing
1 ✔
352
                                throw $e;
1 ✔
353
                        } catch (\Throwable $e) {
×
354
                                throw new RuleException(null, $this->path, $e);
×
355
                        }
356
                };
1 ✔
357
                // the traverser leaves every node it entered, a replaced one included; a rule sees only what is still in the tree
358
                $leave = fn(Node|Token $node) => ($this->leaving[$stage][$node::class] ?? null) === []
1 ✔
359
                        || ($node->parent === null && $node !== $this->file)
1 ✔
360
                        ? null
1 ✔
361
                        : $this->visit($stage, $node, enter: false);
1 ✔
362
                Traverser::traverse($this->file, $enter, $this->plan->leaves[$stage] ? $leave : null);
1 ✔
363

364
                if ($gaps !== null) {
1 ✔
365
                        foreach ($this->contexts as $ruleClass => $context) {
1 ✔
366
                                if ($context->hasReports()) {
1 ✔
367
                                        // the whitespace of a gap is written by the engine, always after a report of its own, so a mutation
368
                                        // without a report or after a denied one belongs to another rule of the same traversal
369
                                        $this->account($ruleClass, $context, $before, checkRevisions: false);
1 ✔
370
                                }
371
                        }
372
                }
373

374
                foreach ($rules as $rule) {
1 ✔
375
                        $this->invoke($rule, fn(RuleContext $context) => $rule->afterPass($context));
1 ✔
376
                }
377
        }
1 ✔
378

379

380
        private function visit(string $stage, Node|Token $node, bool $enter): void
381
        {
382
                $class = $node::class;
1 ✔
383
                $rules = ($enter ? $this->entering : $this->leaving)[$stage][$class] ?? $this->resolveRules($stage, $node, $enter);
1 ✔
384
                if ($rules === []) {
1 ✔
385
                        return;
1 ✔
386
                }
387

388
                $parent = $node->parent;
1 ✔
389
                $file = $this->file;
1 ✔
390
                foreach ($rules as [$rule, $context, $ruleClass]) {
1 ✔
391
                        $before = $file->revision;
1 ✔
392
                        try {
393
                                $enter ? $rule->enter($node, $context) : $rule->leave($node, $context);
1 ✔
394
                        } catch (\Throwable $e) {
1 ✔
395
                                throw new RuleException($ruleClass, $this->path, $e);
1 ✔
396
                        }
397

398
                        if ($file->revision !== $before || $context->hasReports()) {
1 ✔
399
                                $this->account($ruleClass, $context, $before);
1 ✔
400
                        }
401

402
                        if ($node->parent !== $parent) { // replaced or removed: the rest of the chain never sees it
1 ✔
403
                                return;
1 ✔
404
                        }
405
                }
406
        }
1 ✔
407

408

409
        /**
410
         * The rules the plan dispatches the class of the node to, with their contexts in this file.
411
         * @return list<array{NodeRule, RuleContext, string}>
412
         */
413
        private function resolveRules(string $stage, Node|Token $node, bool $enter): array
414
        {
415
                $rules = [];
1 ✔
416
                foreach ($this->plan->getRulesVisiting($stage, $node::class, $enter) as $rule) {
1 ✔
417
                        $ruleClass = $rule::class;
1 ✔
418
                        $rules[] = [$rule, $this->contexts[$ruleClass], $ruleClass];
1 ✔
419
                }
420

421
                if ($enter) {
1 ✔
422
                        $this->entering[$stage][$node::class] = $rules;
1 ✔
423
                } else {
424
                        $this->leaving[$stage][$node::class] = $rules;
1 ✔
425
                }
426

427
                return $rules;
1 ✔
428
        }
429

430

431
        /**
432
         * Calls a per-file callback of a rule and accounts for what it did.
433
         * @param \Closure(RuleContext): void $callback
434
         */
435
        private function invoke(NodeRule $rule, \Closure $callback): void
436
        {
437
                $ruleClass = $rule::class;
1 ✔
438
                $context = $this->contexts[$ruleClass];
1 ✔
439
                $before = $this->file->revision;
1 ✔
440
                try {
441
                        $callback($context);
1 ✔
442
                } catch (\Throwable $e) {
1 ✔
443
                        throw new RuleException($ruleClass, $this->path, $e);
1 ✔
444
                }
445

446
                $this->account($ruleClass, $context, $before);
1 ✔
447
        }
1 ✔
448

449

450
        /**
451
         * Turns the reports of a callback into violations and checks that every mutation follows a report that returned
452
         * true; what the rule wrote after a denied report, up to its next one, it wrote for that report. After the last
453
         * report the rule may be fixing any of those it was allowed earlier, so that one is judged only where none was. A
454
         * report the baseline holds is not denied, only not recorded.
455
         * @param int $before  the revision of the file before the callback
456
         * @param bool $checkRevisions  whether the revisions are the rule's doing, which along the gap traversal they need
457
         *                              not be, because they then cover every rule
458
         */
459
        private function account(string $ruleClass, RuleContext $context, int $before, bool $checkRevisions = true): void
460
        {
461
                $after = $this->file->revision;
1 ✔
462
                $reported = $allowed = false;
1 ✔
463
                $reports = $context->takeReports();
1 ✔
464
                foreach ($reports as $i => $report) {
1 ✔
465
                        $fingerprint = $report->fingerprint;
1 ✔
466
                        $denied = !$report->fixable || $report->silenced || $report->refused;
1 ✔
467
                        $end = $reports[$i + 1]->revision ?? ($allowed ? null : $after);
1 ✔
468
                        if ($checkRevisions && $denied && $end !== null && $end > $report->revision) {
1 ✔
469
                                $this->violateContract($ruleClass, $report->fixable
1 ✔
470
                                        ? 'it changed the file although `report()` returned `false`'
1 ✔
471
                                        : 'it changed the file after a report with `fixable: false`');
1 ✔
472
                        }
473

474
                        $allowed = $allowed || !$denied;
1 ✔
475

476
                        if (!str_ends_with($report->message, '.')) {
1 ✔
477
                                $this->violateContract(
1 ✔
478
                                        $ruleClass,
479
                                        'it reported a message not ending with a period, ' . Violation::formatCode($report->message),
1 ✔
480
                                );
481
                        }
482

483
                        if ($report->silenced || $fingerprint === null) { // and then there is no violation to record
1 ✔
484
                                continue;
1 ✔
485
                        }
486

487
                        $reported = true;
1 ✔
488
                        $severity = isset($this->policy->warningDecisions[$report->decision]) || ($report->refused && $report->risk?->isResolvable())
1 ✔
489
                                ? Severity::Warning
1 ✔
490
                                : Severity::Error;
1 ✔
491
                        $this->reported[] = [$ruleClass, $report, $severity];
1 ✔
492
                        $violation = self::createViolation(
1 ✔
493
                                $report,
1 ✔
494
                                $fingerprint,
1 ✔
495
                                $report->line,
1 ✔
496
                                $this->findOriginalColumn($report->at),
1 ✔
497
                                $report->span,
1 ✔
498
                                $severity,
1 ✔
499
                                $this->opened,
1 ✔
500
                                $this->moved,
1 ✔
501
                        );
502
                        if (!$report->baselined) { // what the baseline holds is not recorded, though the rule may have fixed it
1 ✔
503
                                $this->violations[$fingerprint] ??= $violation;
1 ✔
504
                        }
505
                }
506

507
                // the lines of code a fix wrote anew are placed by it, as the lines of a gap the fixer broke are
508
                foreach ($context->takeRewritten() as [$fingerprint, $node]) {
1 ✔
509
                        $origin = $this->violations[$fingerprint]->derivedFrom ?? $fingerprint;
1 ✔
510
                        foreach ($node->getTokens() as $i => $token) {
1 ✔
511
                                if ($i === 0 || $token->startsLine()) {
1 ✔
512
                                        $this->moved[$token] ??= $origin;
1 ✔
513
                                }
514
                        }
515
                }
516

517
                if ($after > $before) {
1 ✔
518
                        $this->mutatedRules[$ruleClass] = true;
1 ✔
519
                        if (!$reported && $checkRevisions) {
1 ✔
520
                                $this->violateContract($ruleClass, 'it changed the file without reporting a violation');
1 ✔
521
                        }
522
                }
523
        }
1 ✔
524

525

526
        /**
527
         * The violation the report follows from: the one the fixer opened or closed the line of the reported gap
528
         * for, else the one that opened, closed or moved the line the reported whitespace is counted from. A report
529
         * about the shape of the line it stands on asks who opened or closed that line, and nothing about whitespace.
530
         * @param \WeakMap<Token, string> $opened
531
         * @param \WeakMap<Token, string> $moved
532
         */
533
        private static function findAncestor(Report $report, \WeakMap $opened, \WeakMap $moved): ?string
534
        {
535
                if ($report->byLine) {
1 ✔
536
                        $token = $report->at->getFirstToken();
1 ✔
537
                        return $token === null ? null : $opened[$token] ?? null;
1 ✔
538
                }
539

540
                if ($report->gap !== null && isset($opened[$report->gap])) {
1 ✔
541
                        return $opened[$report->gap];
1 ✔
542
                }
543

544
                $follows = $report->follows;
1 ✔
545
                return $follows === null ? null : $opened[$follows] ?? $moved[$follows] ?? null;
1 ✔
546
        }
547

548

549
        /**
550
         * What is placed by the line of the reported gap follows from what opened, closed or moved it, the first of the chain.
551
         * @param \WeakMap<Token, string> $opened
552
         * @param \WeakMap<Token, string> $moved
553
         */
554
        private static function markLine(Report $report, string $origin, \WeakMap $opened, \WeakMap $moved): void
555
        {
556
                if ($report->gap === null || $report->refused) {
1 ✔
557
                        return;
1 ✔
558
                } elseif ($report->breaks) {
1 ✔
559
                        $opened[$report->gap] ??= $origin;
1 ✔
560
                } elseif ($report->follows !== null) {
1 ✔
561
                        $moved[$report->gap] ??= $origin;
1 ✔
562
                }
563
        }
1 ✔
564

565

566
        /**
567
         * A pass that changed nothing left every occurrence it reported as it was, so the rule had no fix for one it did not
568
         * report with `fixable: false`, or refused as risky.
569
         */
570
        private function checkUnfixed(): void
571
        {
572
                $rules = [];
1 ✔
573
                foreach ($this->reported as [$ruleClass, $report]) {
1 ✔
574
                        if ($report->fixable && !$report->refused && $report->gap === null) { // a gap is the engine's to fix, and a comment may keep it from that
1 ✔
575
                                $rules[$ruleClass] = true;
1 ✔
576
                        }
577
                }
578

579
                foreach (array_keys($rules) as $ruleClass) {
1 ✔
580
                        $this->violateContract($ruleClass, 'it reported an occurrence it then left unfixed without saying `fixable: false`');
1 ✔
581
                }
582
        }
1 ✔
583

584

585
        /** @param  string  $breach  what the rule did, as a clause */
586
        private function violateContract(string $ruleClass, string $breach): void
587
        {
588
                if ($this->policy->strict) {
1 ✔
589
                        throw new RuleException($ruleClass, $this->path, new \LogicException(ucfirst($breach) . '.'));
1 ✔
590
                }
591

592
                $this->warnings[] = "Rule `$ruleClass` is faulty: $breach.";
1 ✔
593
        }
1 ✔
594

595

596
        /**
597
         * Column in characters of the token in the original file, from its original offset and the start of its line.
598
         */
599
        private function findOriginalColumn(Node|Token $at): ?int
600
        {
601
                $token = $at->getFirstToken();
1 ✔
602
                if ($token === null || $token->pos < 0 || $token->line < 0) {
1 ✔
603
                        return null;
1 ✔
604
                }
605

606
                $lineStart = $this->lineOffsets[$token->line - 1] ?? 0;
1 ✔
607
                $before = substr($this->code, $lineStart, $token->pos - $lineStart);
1 ✔
608
                return strlen($before) - preg_match_all('~[\x80-\xBF]~', $before) + 1;
1 ✔
609
        }
610
}
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