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

dg / dresscode / 39958301154

09 Oct 2026 03:22PM UTC coverage: 97.773% (+0.004%) from 97.769%
39958301154

push

github

dg
released version 1.3.0

22739 of 23257 relevant lines covered (97.77%)

0.98 hits per line

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

98.81
/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

15

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

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

27

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

46

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

58
                return $decisions;
1 ✔
59
        }
60

61

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

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

93
                        try {
94
                                $result = $passLoop->run($file, $text, $path, $style, $this->phpVersion, $acceptedRisks);
1 ✔
95
                        } catch (RuleException $e) {
1 ✔
96
                                // the types refuse code PHP would not compile, whichever rule asks for them first
97
                                $previous = $e->getPrevious();
1 ✔
98
                                return $previous instanceof Analyses\UncompilableCodeException
1 ✔
99
                                        ? self::createSyntaxError($path, $code, new ParseException($previous->getMessage()), $rounds)
×
100
                                        : throw $e;
1 ✔
101
                        }
102

103
                        $first ??= $result;
1 ✔
104
                        $passes += $result->passes;
1 ✔
105
                        array_push($removedImports, ...$result->removedImports);
1 ✔
106
                        array_push($mutatedRules, ...$result->mutatedRules);
1 ✔
107
                        $printed = $result->output ?? $text;
1 ✔
108
                        $settled = $printed === $text;
1 ✔
109
                        $seen[hash('xxh3', $text)] = true;
1 ✔
110
                        // a text seen before is a cycle, and one still changing in the last round is a broken rule too
111
                        if (!$settled && ($rounds === self::MaxRounds || isset($seen[hash('xxh3', $printed)]))) {
1 ✔
112
                                throw new ConvergenceException($path, $result->mutatedRules, Diff::unified($text, $printed, $path));
1 ✔
113
                        }
114

115
                        $text = $printed;
1 ✔
116
                        if (!$settled && !$this->policy->strict && $result->remaining !== null) {
1 ✔
117
                                try {
118
                                        self::checkImports($this->parser->parse($text));
1 ✔
119
                                } catch (ParseException $e) {
1 ✔
120
                                        return self::createParseFailure($path, $code, $e);
1 ✔
121
                                }
122

123
                                $remaining = $result->remaining;
1 ✔
124
                                break;
1 ✔
125
                        }
126
                }
127

128
                // 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
129
                $remaining ??= $result === $first
1 ✔
130
                        ? $result->violations
1 ✔
131
                        : array_map(fn(Violation $v) => new Violation(...[...get_object_vars($v), 'originalLines' => null]), $result->violations);
1 ✔
132
                return new FileResult(
1 ✔
133
                        $path,
1 ✔
134
                        $code,
135
                        $text,
136
                        $first->violations,
1 ✔
137
                        $first->warnings,
1 ✔
138
                        passes: $passes,
139
                        baselined: $first->baselined,
1 ✔
140
                        remaining: $remaining,
141
                        removedImports: $removedImports,
142
                        mutatedRules: array_values(array_unique($mutatedRules)),
1 ✔
143
                );
144
        }
145

146

147
        /** A syntax error of the file in the first round, a failure of the fixer in a later one, over the text it wrote. */
148
        private static function createSyntaxError(string $path, string $code, ParseException $e, int $round): FileResult
149
        {
150
                return $round === 1
1 ✔
151
                        ? new FileResult($path, $code, output: null, syntaxError: $e->getMessage(), syntaxErrorLine: $e->sourceLine)
1 ✔
152
                        : self::createParseFailure($path, $code, $e);
1 ✔
153
        }
154

155

156
        /**
157
         * Refuses two imports of one kind under one name in a scope, the name of a constant in its letter case and the others
158
         * in any, which PHP refuses to compile as the parser of PhpSyntax does not.
159
         * @throws ParseException
160
         */
161
        private static function checkImports(FileNode|NamespaceNode $scope): void
162
        {
163
                $names = [];
1 ✔
164
                foreach ($scope->statements->getItems() as $statement) {
1 ✔
165
                        if ($statement instanceof NamespaceNode) {
1 ✔
166
                                self::checkImports($statement);
1 ✔
167
                                continue;
1 ✔
168
                        } elseif (!$statement instanceof UseNode) {
1 ✔
169
                                continue;
1 ✔
170
                        }
171

172
                        foreach ($statement->items->getItems() as $item) {
1 ✔
173
                                $name = $item->alias->text ?? $item->name->shortName;
1 ✔
174
                                $key = $item->symbolKind->name . ($item->symbolKind === SymbolKind::Constant ? $name : strtolower($name));
1 ✔
175
                                if (isset($names[$key])) {
1 ✔
176
                                        throw new ParseException(
1 ✔
177
                                                "Cannot use $item->fullName as $name because the name is already in use",
1 ✔
178
                                                $item->getFirstToken()->line,
1 ✔
179
                                        );
180
                                }
181

182
                                $names[$key] = true;
1 ✔
183
                        }
184
                }
185
        }
1 ✔
186

187

188
        private static function createParseFailure(string $path, string $code, ParseException $e): FileResult
189
        {
190
                return new FileResult(
1 ✔
191
                        $path,
192
                        $code,
193
                        output: null,
1 ✔
194
                        failure: "The fixed code no longer parses: {$e->getMessage()} on line $e->sourceLine.",
1 ✔
195
                );
196
        }
197
}
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