• 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.3
/src/Reporters/ConsoleReporter.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\Reporters;
9

10
use DressCode\Console\Markup;
11
use DressCode\Engine\{Diff, FileSummary, RunInfo, RunResult, TypeAnalysisStatus};
12
use DressCode\{FileResult, Reporter, Risk, Severity, Violation};
13
use Nette\CommandLine\{Ansi, Console};
14
use Nette\Utils\FileSystem;
15
use function count, sprintf, strlen;
16

17

18
/**
19
 * Human-readable listing: what the user has to deal with, grouped by file and optionally with a diff of the
20
 * fixes, then the verdict. A fix run lists what the fixed text still violates and says of a file it changed
21
 * that it rewrote it.
22
 * @internal
23
 */
24
final class ConsoleReporter implements Reporter
25
{
26
        /** the message column follows the longest message but never grows past this */
27
        private const MessageWidth = 100;
28

29
        /** a run shorter than this is not worth timing */
30
        private const LongRun = 1.0;
31

32
        private bool $fix = false;
33
        private int $fileCount = 0;
34

35
        private TypeAnalysisStatus $typeAnalysis = TypeAnalysisStatus::Unavailable;
36

37
        /** the configuration lists functions or constants the namespaces declare */
38
        private bool $namespacesListed = false;
39
        private float $started = 0.0;
40

41
        /** some file was listed, so the verdict needs a blank line above it */
42
        private bool $listed = false;
43

44
        /** the file before this one printed lines under its name and wants a blank line after it */
45
        private bool $separate = false;
46

47

48
        public function __construct(
49
                private readonly Console $console,
1 ✔
50
                private readonly bool $diff = false,
1 ✔
51
                /** the paths of the results are relative to it */
52
                private readonly string $root = '',
1 ✔
53
                /** a file under it is reported relative to it, the others absolutely */
54
                private readonly string $cwd = '',
1 ✔
55
                /** @var ?\Closure(string): ?string  the path of a decision => the address of its page */
56
                private readonly ?\Closure $findDecisionUrl = null,
1 ✔
57
                /** the command whose `--ask-risky` decides the risky fixes left */
58
                private readonly string $command = 'fix',
1 ✔
59
        ) {
60
        }
1 ✔
61

62

63
        public function start(RunInfo $run): void
64
        {
65
                $this->fix = $run->fix;
1 ✔
66
                $this->fileCount = $run->fileCount;
1 ✔
67
                $this->typeAnalysis = $run->typeAnalysis;
1 ✔
68
                $this->namespacesListed = $run->namespacesListed;
1 ✔
69
                $this->started = microtime(as_float: true);
1 ✔
70
                $this->listed = $this->separate = false;
1 ✔
71
        }
1 ✔
72

73

74
        public function reportFile(FileResult $result): void
75
        {
76
                $violations = $this->fix ? $result->remaining : $result->violations;
1 ✔
77
                $rewritten = $this->fix && $result->changed;
1 ✔
78
                if (
79
                        !$violations
1 ✔
80
                        && !$rewritten
1 ✔
81
                        && !$result->warnings
1 ✔
82
                        && $result->syntaxError === null
1 ✔
83
                        && $result->failure === null
1 ✔
84
                ) {
85
                        return;
1 ✔
86
                }
87

88
                if ($this->separate) {
1 ✔
89
                        $this->write("\n");
1 ✔
90
                }
91

92
                $this->listed = true;
1 ✔
93
                $this->write($this->console->color('white', $this->formatPath($result->path))
1 ✔
94
                        . ($rewritten ? $this->console->color('gray', '  rewritten') : '') . "\n");
1 ✔
95
                if ($result->failure !== null) {
1 ✔
96
                        [$message, $diff] = explode("\n", $result->failure, 2) + [1 => ''];
1 ✔
97
                        $this->write('  ' . Markup::highlightCode($this->console, $message, 'red') . "\n" . Markup::highlightDiff($this->console, $diff));
1 ✔
98
                        if ($result->failureDocs !== null) {
1 ✔
99
                                $this->write('  ' . Markup::formatDocsLink($this->console, $result->failureDocs) . "\n");
1 ✔
100
                        }
101
                }
102

103
                if ($result->syntaxError !== null) {
1 ✔
104
                        $position = $result->syntaxErrorLine === null ? '' : "$result->syntaxErrorLine  ";
1 ✔
105
                        $this->write('  ' . Markup::highlightCode($this->console, $position . 'Syntax error: ' . $result->syntaxError, 'red') . "\n");
1 ✔
106
                }
107

108
                $this->writeViolations($violations);
1 ✔
109
                foreach ($result->warnings as $warning) {
1 ✔
110
                        $this->write('  ' . Markup::highlightCode($this->console, $warning, 'yellow') . "\n");
1 ✔
111
                }
112

113
                if ($this->diff && $result->changed) {
1 ✔
114
                        $this->write(Markup::highlightDiff($this->console, Diff::unified($result->code, (string) $result->output, $result->path)));
1 ✔
115
                }
116

117
                // a file with nothing below its name stays a single line, the others are set apart
118
                $this->separate = $violations || $result->warnings || $result->syntaxError !== null
1 ✔
119
                        || $result->failure !== null || ($this->diff && $result->changed);
1 ✔
120
        }
1 ✔
121

122

123
        /**
124
         * The positions belong to the text the violations were found in: the file as it was read in a check, the
125
         * fixed text in a fix. A violation that follows from another one is a note under it rather than a line of its
126
         * own, unless the two differ in state, when it must not hide behind a state that is not its own.
127
         * @param  list<Violation>  $violations
128
         */
129
        private function writeViolations(array $violations): void
130
        {
131
                if (!$violations) {
1 ✔
132
                        return;
1 ✔
133
                }
134

135
                $derived = [];
1 ✔
136
                $listed = [];
1 ✔
137
                $byFingerprint = array_column($violations, null, 'fingerprint');
1 ✔
138
                foreach ($violations as $violation) {
1 ✔
139
                        $ancestor = $byFingerprint[$violation->derivedFrom ?? ''] ?? null;
1 ✔
140
                        if ($ancestor !== null && self::formatState($ancestor) === self::formatState($violation)) {
1 ✔
141
                                $derived[$ancestor->fingerprint][] = $violation;
1 ✔
142
                        } else {
143
                                $listed[] = $violation;
1 ✔
144
                        }
145
                }
146

147
                $notes = [];
1 ✔
148
                foreach ($derived as $fingerprint => $followers) {
1 ✔
149
                        $notes[$fingerprint] = self::describeDerived($byFingerprint[$fingerprint], $followers);
1 ✔
150
                }
151

152
                $show = fn(Violation $v) => Markup::highlightCode($this->console, $v->message);
1 ✔
153
                $showNote = fn(string $text) => Markup::highlightCode($this->console, $text, 'gray');
1 ✔
154
                $widths = [0, ...array_map(fn(Violation $v) => Ansi::measure($show($v)), $listed)];
1 ✔
155
                foreach ($notes as $rows) {
1 ✔
156
                        foreach ($rows as [$text]) {
1 ✔
157
                                $widths[] = Ansi::measure($showNote($text));
1 ✔
158
                        }
159
                }
160

161
                $positionWidth = max(array_map(fn(Violation $v) => Ansi::measure(self::formatPosition($v)), $violations));
1 ✔
162
                $messageWidth = min(self::MessageWidth, max($widths));
1 ✔
163
                $stateWidth = max(array_map(fn(Violation $v) => Ansi::measure(self::formatState($v)), $violations));
1 ✔
164
                $formatDecision = fn(string $path) => $this->console->color('gray', Markup::formatDecision(
1 ✔
165
                        $this->console,
1 ✔
166
                        $path,
1 ✔
167
                        $this->findDecisionUrl === null ? null : ($this->findDecisionUrl)($path),
1 ✔
168
                ));
169

170
                foreach ($listed as $violation) {
1 ✔
171
                        $state = self::formatState($violation);
1 ✔
172
                        $this->write(sprintf(
1 ✔
173
                                "  %s  %s  %s  %s\n",
174
                                $this->console->color($violation->severity === Severity::Warning ? 'olive' : 'maroon', Ansi::pad($state, $stateWidth)),
1 ✔
175
                                $this->console->color('gray', Ansi::pad(self::formatPosition($violation), $positionWidth, STR_PAD_LEFT)),
1 ✔
176
                                Ansi::pad($show($violation), $messageWidth),
1 ✔
177
                                $formatDecision($violation->decision),
1 ✔
178
                        ));
179
                        $indent = str_repeat(' ', $stateWidth + $positionWidth + 6);
1 ✔
180
                        if ($violation->because !== null) {
1 ✔
181
                                $this->write($indent . $showNote("Risky because $violation->because.") . "\n");
1 ✔
182
                        }
183

184
                        foreach ($notes[$violation->fingerprint] ?? [] as [$text, $decision]) {
1 ✔
185
                                $this->write($indent . Ansi::pad($showNote($text), $messageWidth) . '  ' . $formatDecision($decision) . "\n");
1 ✔
186
                        }
187
                }
188
        }
1 ✔
189

190

191
        /**
192
         * What fixing a violation brings, a message per row with its decision, and the lines where they are not the line of
193
         * the violation: `Then also, on lines 37, 38, 39: Expected the statement indented by 2 tabs, 1 tab found.`
194
         * @param  list<Violation>  $followers
195
         * @return list<array{string, string}>
196
         */
197
        private static function describeDerived(Violation $ancestor, array $followers): array
198
        {
199
                $lines = [];
1 ✔
200
                foreach ($followers as $violation) {
1 ✔
201
                        $lines[$violation->decision][$violation->message][$violation->line] = true;
1 ✔
202
                }
203

204
                $rows = [];
1 ✔
205
                foreach ($lines as $decision => $messages) {
1 ✔
206
                        foreach ($messages as $message => $messageLines) {
1 ✔
207
                                $numbers = array_keys($messageLines);
1 ✔
208
                                sort($numbers);
1 ✔
209
                                $where = match (true) {
1 ✔
210
                                        $numbers === [$ancestor->line] => '',
1 ✔
211
                                        count($numbers) === 1 => ", on line $numbers[0]",
1 ✔
212
                                        count($numbers) <= 4 => ', on lines ' . implode(', ', $numbers),
1 ✔
213
                                        default => sprintf(', on %d lines from %d to %d', count($numbers), $numbers[0], end($numbers)),
1 ✔
214
                                };
215
                                $rows[] = ["Then also$where: $message", $decision];
1 ✔
216
                        }
217
                }
218

219
                return $rows;
1 ✔
220
        }
221

222

223
        public function finish(RunResult $result): void
224
        {
225
                if ($this->fileCount === 0) { // an empty scope is the header's business, not a verdict
1 ✔
UNCOV
226
                        return;
×
227
                }
228

229
                if ($this->listed) {
1 ✔
230
                        $this->write("\n");
1 ✔
231
                }
232

233
                foreach ($result->warnings as $warning) {
1 ✔
UNCOV
234
                        $this->write(Markup::highlightCode($this->console, "Warning: $warning", 'yellow') . "\n\n");
×
235
                }
236

237
                // the fixes left for want of consent, by what would decide them, each with how its risk is taken away
238
                $risks = array_filter(Risk::cases(), fn(Risk $risk) => $result->countRefusedBy($risk) > 0);
1 ✔
239
                foreach ($risks as $risk) {
1 ✔
240
                        $this->write($this->formatRefused($risk, $result) . "\n");
1 ✔
241
                }
242

243
                // in a check, what no fix takes away, which nothing in the configuration changes
244
                $unfixable = $this->fix ? 0 : $result->countUnfixable();
1 ✔
245
                if ($unfixable) {
1 ✔
246
                        $this->write(($unfixable === 1 ? '1 violation has' : "$unfixable violations have") . " no fix, the rules only report them.\n");
1 ✔
247
                }
248

249
                if ($risks || $unfixable) {
1 ✔
250
                        $this->write("\n");
1 ✔
251
                }
252

253
                $this->write($this->console->color(
1 ✔
254
                        match (true) {
255
                                $result->getExitCode() !== 0 => 'white/red',
1 ✔
256
                                $this->fix && $result->countChangedFiles() > 0 => 'white/blue',
1 ✔
257
                                default => 'white/green',
1 ✔
258
                        },
259
                        $this->formatVerdict($result),
1 ✔
260
                ) . "\n");
1 ✔
261
        }
1 ✔
262

263

264
        /**
265
         * The line of the summary for the fixes the run refused for the risk: how many, why, how the risk is taken away,
266
         * and the page of the manual that says so.
267
         */
268
        private function formatRefused(Risk $risk, RunResult $result): string
269
        {
270
                $count = $result->countRefusedBy($risk);
1 ✔
271
                [$wait, $them, $decisions] = $count === 1
1 ✔
272
                        ? ['1 risky fix waits', 'it', 'its decision']
1 ✔
273
                        : ["$count risky fixes wait", 'them', 'their decisions'];
1 ✔
274
                $text = match ($risk) {
1 ✔
275
                        Risk::NameUncertain => "$wait, a name may reach a function of the namespace: " . ($this->namespacesListed
1 ✔
276
                                ? 'set `nameResolution: certain`'
1 ✔
277
                                : 'run `dresscode init`, which lists what the namespaces declare, or set `nameResolution: certain` if they declare nothing'),
1 ✔
278
                        Risk::TypeUnknown => match ($this->typeAnalysis) {
1 ✔
279
                                TypeAnalysisStatus::Available => "$wait, the type is unknown: set `typeAnalysis: phpstan`",
1 ✔
280
                                TypeAnalysisStatus::Enabled => "$wait, not even the types tell: check $them with `$this->command --ask-risky`",
1 ✔
281
                                TypeAnalysisStatus::Unavailable => "$wait, the type is unknown: check $them with `$this->command --ask-risky`",
1 ✔
282
                        },
UNCOV
283
                        Risk::BehaviorChanges => ($count === 1 ? '1 risky fix changes' : "$count risky fixes change")
×
UNCOV
284
                                . " what the code does: decide $them with `$this->command --ask-risky`, or accept $decisions in `fixRisky`",
×
285
                };
286
                $docs = match ($risk) {
1 ✔
287
                        Risk::TypeUnknown => 'types#enable',
1 ✔
288
                        Risk::NameUncertain => 'namespaces#name-resolution',
1 ✔
UNCOV
289
                        Risk::BehaviorChanges => 'configuration#risky-fixes',
×
290
                };
291
                return Markup::highlightCode($this->console, "$text.") . ' ' . Markup::formatDocsLink($this->console, $docs);
1 ✔
292
        }
293

294

295
        /**
296
         * The state of the run, then every count with the noun it counts, then the scope it all happened in. A fix
297
         * says how many violations it found and how many the fixed text still has, which are the two numbers the
298
         * text can tell; a check says how many a fix would leave. A run with nothing to count names only the scope.
299
         */
300
        private function formatVerdict(RunResult $result): string
301
        {
302
                $remaining = $result->countReported(Severity::Error);
1 ✔
303
                $warnings = $result->countReported(Severity::Warning);
1 ✔
304
                $found = $result->countViolations(Severity::Error);
1 ✔
305
                $left = $result->countRemaining(Severity::Error);
1 ✔
306
                $changed = $this->fix ? $result->countChangedFiles() : 0;
1 ✔
307
                $failures = $result->countFailures();
1 ✔
308
                $affected = count(array_filter(
1 ✔
309
                        $result->files,
1 ✔
310
                        fn(FileSummary $f) => $f->violations || $f->syntaxError !== null || $f->failure !== null,
1 ✔
311
                ));
312

313
                $derived = $result->countDerived(Severity::Error);
1 ✔
314
                $parts = array_filter([
1 ✔
315
                        match (true) {
1 ✔
316
                                $this->fix && ($found || $remaining) => Markup::formatCount($found, 'violation')
1 ✔
317
                                        . ' found, '
1 ✔
318
                                        . ($remaining ?: 'none')
1 ✔
319
                                        . ' remaining',
1 ✔
320
                                !$this->fix && $remaining > 0 => Markup::formatCount($remaining, 'violation'),
1 ✔
321
                                default => null,
1 ✔
322
                        },
323
                        $derived ? "$derived of them following from others" : null,
1 ✔
324
                        $warnings ? Markup::formatCount($warnings, 'warning') : null,
1 ✔
325
                        !$this->fix && $left !== $found ? 'a fix leaves ' . ($left ?: 'none') : null,
1 ✔
326
                        $result->countSyntaxErrors() ? Markup::formatCount($result->countSyntaxErrors(), 'syntax error') : null,
1 ✔
327
                        $failures ? Markup::formatCount($failures, 'failed file') : null,
1 ✔
328
                        $result->baselined ? Markup::formatCount($result->baselined, 'violation') . ' in the baseline' : null,
1 ✔
329
                ]);
330

331
                $state = match (true) {
1 ✔
332
                        $failures > 0 => 'FAILED',
1 ✔
333
                        $changed > 0 => 'FIXED',
1 ✔
334
                        $remaining > 0 || $warnings > 0 || $result->countSyntaxErrors() > 0 => 'FOUND',
1 ✔
335
                        default => 'OK',
1 ✔
336
                };
337
                $scope = $affected > 0 && $affected < $this->fileCount
1 ✔
338
                        ? sprintf('%d of %s', $affected, Markup::formatCount($this->fileCount, 'file'))
1 ✔
339
                        : Markup::formatCount($this->fileCount, 'file');
1 ✔
340
                $elapsed = microtime(as_float: true) - $this->started;
1 ✔
341

342
                $summary = $parts
1 ✔
343
                        ? implode(', ', $parts) . " in $scope"
1 ✔
344
                        : Markup::formatCount($this->fileCount, 'file') . ', ' . ($this->fileCount > 1 ? 'all ' : '') . 'up to the dress code';
1 ✔
345
                return "$state  $summary" . ($elapsed < self::LongRun ? '' : sprintf(', %.1f s', $elapsed));
1 ✔
346
        }
347

348

349
        /**
350
         * Relative to the working directory when the file lies under it, absolute otherwise, so that the path
351
         * can be clicked and copied wherever the run was started from.
352
         */
353
        private function formatPath(string $path): string
354
        {
355
                // a path outside the root is absolute already
356
                $path = $this->root === '' || FileSystem::isAbsolute($path) ? $path : "$this->root/$path";
1 ✔
357
                if ($this->cwd !== '' && str_starts_with($path, "$this->cwd/")) {
1 ✔
358
                        $path = substr($path, strlen($this->cwd) + 1);
1 ✔
359
                }
360

361
                return FileSystem::platformSlashes($path);
1 ✔
362
        }
363

364

365
        private static function formatState(Violation $violation): string
366
        {
367
                return match (true) {
368
                        $violation->refused => 'risky',
1 ✔
369
                        $violation->severity === Severity::Warning => 'warning',
1 ✔
370
                        default => 'error',
1 ✔
371
                };
372
        }
373

374

375
        private static function formatPosition(Violation $violation): string
376
        {
377
                return $violation->line . ($violation->column === null ? '' : ":$violation->column");
1 ✔
378
        }
379

380

381
        private function write(string $text): void
382
        {
383
                $this->console->write($text);
1 ✔
384
        }
1 ✔
385
}
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