• 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

98.72
/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};
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

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

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

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

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

120
                                $remaining = $result->remaining;
1 ✔
121
                                break;
1 ✔
122
                        }
123
                }
124

125
                return new FileResult(
1 ✔
126
                        $path,
1 ✔
127
                        $code,
128
                        $text,
129
                        $first->violations,
1 ✔
130
                        $first->warnings,
1 ✔
131
                        passes: $passes,
132
                        baselined: $first->baselined,
1 ✔
133
                        remaining: $remaining ?? $result->violations, // the last round is a check of the text the fix writes
1 ✔
134
                );
135
        }
136

137

138
        /** A syntax error of the file in the first round, a failure of the fixer in a later one, over the text it wrote. */
139
        private static function createSyntaxError(string $path, string $code, ParseException $e, int $round): FileResult
140
        {
141
                return $round === 1
1 ✔
142
                        ? new FileResult($path, $code, output: null, syntaxError: $e->getMessage(), syntaxErrorLine: $e->sourceLine)
1 ✔
143
                        : self::createParseFailure($path, $code, $e);
1 ✔
144
        }
145

146

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

163
                        foreach ($statement->items->getItems() as $item) {
1 ✔
164
                                $name = $item->alias->text ?? $item->name->shortName;
1 ✔
165
                                $key = $item->symbolKind->name . ($item->symbolKind === SymbolKind::Constant ? $name : strtolower($name));
1 ✔
166
                                if (isset($names[$key])) {
1 ✔
167
                                        throw new ParseException(
1 ✔
168
                                                "Cannot use $item->fullName as $name because the name is already in use",
1 ✔
169
                                                $item->getFirstToken()->line,
1 ✔
170
                                        );
171
                                }
172

173
                                $names[$key] = true;
1 ✔
174
                        }
175
                }
176
        }
1 ✔
177

178

179
        private static function createParseFailure(string $path, string $code, ParseException $e): FileResult
180
        {
181
                return new FileResult(
1 ✔
182
                        $path,
183
                        $code,
184
                        output: null,
1 ✔
185
                        failure: "The fixed code no longer parses: {$e->getMessage()} on line $e->sourceLine.",
1 ✔
186
                );
187
        }
188
}
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