• 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

85.98
/src/Engine/PhpFileProcessor.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\{Analyses, ConfigurationException, ConvergenceException, FileResult, Rule, RuleException, Style, Violation};
11
use PhpSyntax\Nodes\FileNode;
12
use PhpSyntax\Nodes\Statement\{NamespaceNode, UseNode};
13
use PhpSyntax\{ParseException, Parser, SymbolKind};
14
use function strlen;
15

16

17
/**
18
 * Processes one PHP file: parse, passes of the rules, print. A pure function of the path and the text.
19
 * @internal
20
 */
21
final readonly class PhpFileProcessor implements FileProcessor
22
{
23
        private const MaxRounds = 5;
24

25
        private Parser $parser;
26
        private RulePlan $plan;
27

28

29
        /** @throws ConfigurationException when the rules do not fit the tree or each other */
30
        public function __construct(
31
                /** @var list<Rule> in configuration order */
32
                public array $rules,
1 ✔
33
                private Analyses\Registry $analyses,
1 ✔
34
                /** the version the checked code is written for; it has no default, only the configuration knows it */
35
                private string $phpVersion,
1 ✔
36
                private Style $style = new Style,
1 ✔
37
                /** the line ending of the style follows the prevailing one of each file */
38
                private bool $detectLineEnding = true,
1 ✔
39
                private ReportPolicy $policy = new ReportPolicy,
1 ✔
40
                private ?Profiler $profiler = null,
1 ✔
41
                /** @var array<class-string, Gate>  the class of a rule => the gate of its reports */
42
                private array $gates = [],
1 ✔
43
        ) {
44
                $this->parser = new Parser;
1 ✔
45
                $this->plan = new RulePlan($this->rules, $this->profiler, $this->gates);
1 ✔
46
        }
1 ✔
47

48

49
        /**
50
         * The decisions the rules report in this run.
51
         * @return list<string>
52
         */
53
        public function getReportedDecisions(): array
54
        {
55
                $decisions = [];
1 ✔
56
                foreach ($this->rules as $rule) {
1 ✔
UNCOV
57
                        array_push($decisions, ...$this->plan->gates[$rule::class]->getAdmitted());
×
58
                }
59

60
                return $decisions;
1 ✔
61
        }
62

63

64
        /**
65
         * @param  array<string, true>  $acceptedRisks  fingerprints of the occurrences whose risky fix is allowed on top of
66
         *   what the configuration allows
67
         * @throws RuleException|ConvergenceException|ConfigurationException
68
         */
69
        public function process(string $path, string $code, array $acceptedRisks = []): FileResult
70
        {
71
                $style = $this->detectLineEnding ? $this->style->withLineEnding(Style::detectLineEnding($code)) : $this->style;
1 ✔
72
                $text = $code;
1 ✔
73
                $first = null;
1 ✔
74
                $passes = 0;
1 ✔
75
                $seen = [];
1 ✔
76
                $settled = false;
1 ✔
77
                $remaining = null;
1 ✔
78
                $removedImports = $mutatedRules = [];
1 ✔
79
                $start = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
80
                $times = ['parse' => 0, 'passes' => 0];
1 ✔
81

82
                // a mutated tree is not the tree the parser would build from the printed text, so a rule can miss what another
83
                // one has just written; the strict run makes the text settle in rounds, the others take what the last pass
84
                // of the tree left for what a round over the text would report, which spares a pass; they only parse the printed
85
                // text, so that a broken rule never writes code PHP refuses
86
                $passLoop = new PassLoop($this->plan, $this->analyses, $this->policy, $this->profiler);
1 ✔
87
                $rounds = 0;
1 ✔
88
                while (!$settled) {
1 ✔
89
                        $rounds++;
1 ✔
90
                        $lap = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
91
                        try {
92
                                $file = $this->parser->parse($text);
1 ✔
93
                                self::checkImports($file);
1 ✔
94
                        } catch (ParseException $e) {
1 ✔
95
                                return self::createSyntaxError($path, $code, $e, $rounds);
1 ✔
96
                        }
97

98
                        if ($this->profiler) {
1 ✔
99
                                $times['parse'] += hrtime(as_number: true) - $lap;
×
UNCOV
100
                                $lap = hrtime(as_number: true);
×
101
                        }
102

103
                        try {
104
                                $result = $passLoop->run($file, $text, $path, $style, $this->phpVersion, $acceptedRisks);
1 ✔
105
                        } catch (RuleException $e) {
1 ✔
106
                                // the types refuse code PHP would not compile, whichever rule asks for them first
107
                                $previous = $e->getPrevious();
1 ✔
108
                                return $previous instanceof Analyses\UncompilableCodeException
1 ✔
UNCOV
109
                                        ? self::createSyntaxError($path, $code, new ParseException($previous->getMessage()), $rounds)
×
110
                                        : throw $e;
1 ✔
111
                        }
112

113
                        $first ??= $result;
1 ✔
114
                        $passes += $result->passes;
1 ✔
115
                        array_push($removedImports, ...$result->removedImports);
1 ✔
116
                        array_push($mutatedRules, ...$result->mutatedRules);
1 ✔
117
                        if ($this->profiler) {
1 ✔
UNCOV
118
                                $times['passes'] += hrtime(as_number: true) - $lap;
×
119
                        }
120

121
                        $printed = $result->output ?? $text;
1 ✔
122
                        $settled = $printed === $text;
1 ✔
123
                        $seen[hash('xxh3', $text)] = true;
1 ✔
124
                        // a text seen before is a cycle, and one still changing in the last round is a broken rule too
125
                        if (!$settled && ($rounds === self::MaxRounds || isset($seen[hash('xxh3', $printed)]))) {
1 ✔
126
                                throw new ConvergenceException($path, $result->mutatedRules, Diff::unified($text, $printed, $path));
1 ✔
127
                        }
128

129
                        $text = $printed;
1 ✔
130
                        if (!$settled && !$this->policy->strict && $result->remaining !== null) {
1 ✔
131
                                $lap = $this->profiler ? hrtime(as_number: true) : 0;
1 ✔
132
                                try {
133
                                        self::checkImports($this->parser->parse($text));
1 ✔
134
                                } catch (ParseException $e) {
1 ✔
135
                                        return self::createParseFailure($path, $code, $e);
1 ✔
136
                                }
137

138
                                if ($this->profiler) {
1 ✔
UNCOV
139
                                        $times['parse'] += hrtime(as_number: true) - $lap;
×
140
                                }
141

142
                                $remaining = $result->remaining;
1 ✔
143
                                $rounds++; // the parse of the printed text stands for the round over it
1 ✔
144
                                break;
1 ✔
145
                        }
146
                }
147

148
                if ($this->profiler) {
1 ✔
UNCOV
149
                        foreach ($times as $phase => $time) {
×
UNCOV
150
                                $this->profiler->addPhase($phase, $time, $rounds);
×
151
                        }
152

UNCOV
153
                        $this->profiler->addFile([
×
UNCOV
154
                                'path' => $path,
×
UNCOV
155
                                'size' => strlen($code),
×
UNCOV
156
                                'time' => hrtime(as_number: true) - $start,
×
UNCOV
157
                                ...$times,
×
UNCOV
158
                                'rounds' => $rounds,
×
UNCOV
159
                                'passCount' => $passes,
×
160
                        ]);
161
                }
162

163
                // the last round is a check of the text the fix writes; a round after the first parsed that text, not the file as read
164
                $remaining ??= $result === $first
1 ✔
165
                        ? $result->violations
1 ✔
166
                        : array_map(fn(Violation $v) => new Violation(...[...get_object_vars($v), 'originalLines' => null]), $result->violations);
1 ✔
167
                return new FileResult(
1 ✔
168
                        $path,
1 ✔
169
                        $code,
170
                        $text,
171
                        $first->violations,
1 ✔
172
                        $first->warnings,
1 ✔
173
                        passes: $passes,
174
                        baselined: $first->baselined,
1 ✔
175
                        remaining: $remaining,
176
                        removedImports: $removedImports,
177
                        mutatedRules: array_values(array_unique($mutatedRules)),
1 ✔
178
                );
179
        }
180

181

182
        /** A syntax error of the file in the first round, a failure of the fixer in a later one, over the text it wrote. */
183
        private static function createSyntaxError(string $path, string $code, ParseException $e, int $round): FileResult
184
        {
185
                return $round === 1
1 ✔
186
                        ? new FileResult($path, $code, output: null, syntaxError: $e->getMessage(), syntaxErrorLine: $e->sourceLine)
1 ✔
187
                        : self::createParseFailure($path, $code, $e);
1 ✔
188
        }
189

190

191
        /**
192
         * Refuses two imports of one kind under one name in a scope, the name of a constant in its letter case and the others
193
         * in any, which PHP refuses to compile as the parser of PhpSyntax does not.
194
         * @throws ParseException
195
         */
196
        private static function checkImports(FileNode|NamespaceNode $scope): void
197
        {
198
                $names = [];
1 ✔
199
                foreach ($scope->statements->getItems() as $statement) {
1 ✔
200
                        if ($statement instanceof NamespaceNode) {
1 ✔
201
                                self::checkImports($statement);
1 ✔
202
                                continue;
1 ✔
203
                        } elseif (!$statement instanceof UseNode) {
1 ✔
204
                                continue;
1 ✔
205
                        }
206

207
                        foreach ($statement->items->getItems() as $item) {
1 ✔
208
                                $name = $item->alias->text ?? $item->name->shortName;
1 ✔
209
                                $key = $item->symbolKind->name . ($item->symbolKind === SymbolKind::Constant ? $name : strtolower($name));
1 ✔
210
                                if (isset($names[$key])) {
1 ✔
211
                                        throw new ParseException(
1 ✔
212
                                                "Cannot use $item->fullName as $name because the name is already in use",
1 ✔
213
                                                $item->getFirstToken()->line,
1 ✔
214
                                        );
215
                                }
216

217
                                $names[$key] = true;
1 ✔
218
                        }
219
                }
220
        }
1 ✔
221

222

223
        private static function createParseFailure(string $path, string $code, ParseException $e): FileResult
224
        {
225
                return new FileResult(
1 ✔
226
                        $path,
227
                        $code,
228
                        output: null,
1 ✔
229
                        failure: "The fixed code no longer parses: {$e->getMessage()} on line $e->sourceLine.",
1 ✔
230
                );
231
        }
232
}
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