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

move-elevator / composer-translation-validator / 16126465319

07 Jul 2025 07:50PM UTC coverage: 81.058% (-13.5%) from 94.526%
16126465319

push

github

jackd248
refactor: enhance output rendering by introducing compact and verbose modes for validation results

105 of 255 new or added lines in 2 files covered. (41.18%)

2 existing lines in 1 file now uncovered.

843 of 1040 relevant lines covered (81.06%)

4.31 hits per line

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

45.29
/src/Result/ValidationResultCliRenderer.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace MoveElevator\ComposerTranslationValidator\Result;
6

7
use MoveElevator\ComposerTranslationValidator\Validator\ResultType;
8
use MoveElevator\ComposerTranslationValidator\Validator\ValidatorInterface;
9
use Symfony\Component\Console\Input\InputInterface;
10
use Symfony\Component\Console\Output\OutputInterface;
11
use Symfony\Component\Console\Style\SymfonyStyle;
12

13
class ValidationResultCliRenderer
14
{
15
    private readonly SymfonyStyle $io;
16

17
    public function __construct(
12✔
18
        private readonly OutputInterface $output,
19
        private readonly InputInterface $input,
20
        private readonly bool $dryRun = false,
21
        private readonly bool $strict = false,
22
    ) {
23
        $this->io = new SymfonyStyle($this->input, $this->output);
12✔
24
    }
25

26
    public function render(ValidationResult $validationResult): int
12✔
27
    {
28
        if ($this->output->isVerbose()) {
12✔
29
            $this->renderVerboseOutput($validationResult);
3✔
30
        } else {
31
            $this->renderCompactOutput($validationResult);
10✔
32
        }
33

34
        $this->renderSummary($validationResult->getOverallResult());
12✔
35

36
        return $validationResult->getOverallResult()->resolveErrorToCommandExitCode($this->dryRun, $this->strict);
12✔
37
    }
38

39
    private function renderCompactOutput(ValidationResult $validationResult): void
10✔
40
    {
41
        $validatorPairs = $validationResult->getValidatorFileSetPairs();
10✔
42

43
        if (empty($validatorPairs)) {
10✔
44
            return;
3✔
45
        }
46

47
        // Group by file path
48
        $groupedByFile = [];
7✔
49
        foreach ($validatorPairs as $pair) {
7✔
50
            $validator = $pair['validator'];
7✔
51
            $fileSet = $pair['fileSet'];
7✔
52

53
            if (!$validator->hasIssues()) {
7✔
54
                continue;
×
55
            }
56

57
            // Special handling for MismatchValidator - assign issues to affected files
58
            if (str_contains($validator::class, 'MismatchValidator')) {
7✔
NEW
59
                $this->handleMismatchValidatorIssues($validator, $fileSet, $groupedByFile);
×
60
            } else {
61
                foreach ($validator->getIssues() as $issue) {
7✔
62
                    $fileName = $issue->getFile();
4✔
63
                    // Build full path from fileSet and filename for consistency
64
                    $filePath = empty($fileName) ? '' : $fileSet->getPath().'/'.$fileName;
4✔
65

66
                    if (!empty($filePath) && !isset($groupedByFile[$filePath])) {
4✔
67
                        $groupedByFile[$filePath] = [];
4✔
68
                    }
69

70
                    if (!empty($filePath)) {
4✔
71
                        $groupedByFile[$filePath][] = [
4✔
72
                            'validator' => $validator,
4✔
73
                            'issue' => $issue,
4✔
74
                        ];
4✔
75
                    }
76
                }
77
            }
78
        }
79

80
        foreach ($groupedByFile as $filePath => $fileIssues) {
7✔
81
            $relativePath = $this->getRelativePath($filePath);
4✔
82
            $this->io->writeln("<fg=cyan>$relativePath</>");
4✔
83
            $this->io->newLine();
4✔
84

85
            // Sort issues by severity (errors first, warnings second)
86
            $sortedIssues = $this->sortIssuesBySeverity($fileIssues);
4✔
87

88
            foreach ($sortedIssues as $fileIssue) {
4✔
89
                $validator = $fileIssue['validator'];
4✔
90
                $issue = $fileIssue['issue'];
4✔
91
                $validatorName = $this->getValidatorShortName($validator::class);
4✔
92

93
                $message = $this->formatIssueMessage($validator, $issue, $validatorName);
4✔
94
                // Handle multiple lines from formatIssueMessage
95
                $lines = explode("\n", $message);
4✔
96
                foreach ($lines as $line) {
4✔
97
                    if (!empty(trim($line))) {
4✔
98
                        $this->io->writeln($line);
4✔
99
                    }
100
                }
101
            }
102

103
            $this->io->newLine();
4✔
104
        }
105
    }
106

107
    private function renderVerboseOutput(ValidationResult $validationResult): void
3✔
108
    {
109
        $validatorPairs = $validationResult->getValidatorFileSetPairs();
3✔
110

111
        if (empty($validatorPairs)) {
3✔
112
            return;
1✔
113
        }
114

115
        // Group by file path, then by validator
116
        $groupedByFile = [];
2✔
117
        foreach ($validatorPairs as $pair) {
2✔
118
            $validator = $pair['validator'];
2✔
119
            $fileSet = $pair['fileSet'];
2✔
120

121
            if (!$validator->hasIssues()) {
2✔
NEW
122
                continue;
×
123
            }
124

125
            // Special handling for MismatchValidator - assign issues to affected files
126
            if (str_contains($validator::class, 'MismatchValidator')) {
2✔
NEW
127
                $this->handleMismatchValidatorIssuesVerbose($validator, $fileSet, $groupedByFile);
×
128
            } else {
129
                foreach ($validator->getIssues() as $issue) {
2✔
130
                    $fileName = $issue->getFile();
2✔
131
                    $validatorClass = $validator::class;
2✔
132
                    // Build full path from fileSet and filename for consistency
133
                    $filePath = empty($fileName) ? '' : $fileSet->getPath().'/'.$fileName;
2✔
134

135
                    if (!empty($filePath)) {
2✔
136
                        if (!isset($groupedByFile[$filePath])) {
2✔
137
                            $groupedByFile[$filePath] = [];
2✔
138
                        }
139
                        if (!isset($groupedByFile[$filePath][$validatorClass])) {
2✔
140
                            $groupedByFile[$filePath][$validatorClass] = [
2✔
141
                                'validator' => $validator,
2✔
142
                                'issues' => [],
2✔
143
                            ];
2✔
144
                        }
145

146
                        $groupedByFile[$filePath][$validatorClass]['issues'][] = $issue;
2✔
147
                    }
148
                }
149
            }
150
        }
151

152
        foreach ($groupedByFile as $filePath => $validatorGroups) {
2✔
153
            $relativePath = $this->getRelativePath($filePath);
2✔
154
            $this->io->writeln("<fg=cyan>$relativePath</>");
2✔
155
            $this->io->newLine();
2✔
156

157
            // Sort validator groups by severity (errors first, warnings second)
158
            $sortedValidatorGroups = $this->sortValidatorGroupsBySeverity($validatorGroups);
2✔
159

160
            foreach ($sortedValidatorGroups as $validatorClass => $data) {
2✔
161
                $validator = $data['validator'];
2✔
162
                $issues = $data['issues'];
2✔
163
                $validatorName = $this->getValidatorShortName($validatorClass);
2✔
164

165
                $this->io->writeln("  <fg=cyan;options=bold>$validatorName</>");
2✔
166

167
                foreach ($issues as $issue) {
2✔
168
                    $message = $this->formatIssueMessage($validator, $issue, '', true);
2✔
169
                    // Handle multiple lines from formatIssueMessage
170
                    $lines = explode("\n", $message);
2✔
171
                    foreach ($lines as $line) {
2✔
172
                        if (!empty(trim($line))) {
2✔
173
                            $this->io->writeln("    $line");
2✔
174
                        }
175
                    }
176
                }
177

178
                // Show detailed tables for certain validators in verbose mode
179
                if ($this->shouldShowDetailedOutput($validator)) {
2✔
NEW
180
                    $this->renderDetailedValidatorOutput($validator, $issues);
×
181
                }
182

183
                $this->io->newLine();
2✔
184
            }
185
        }
186
    }
187

188
    private function getValidatorShortName(string $validatorClass): string
5✔
189
    {
190
        $parts = explode('\\', $validatorClass);
5✔
191

192
        return end($parts);
5✔
193
    }
194

195
    private function getRelativePath(string $filePath): string
5✔
196
    {
197
        $cwd = getcwd();
5✔
198
        if ($cwd && str_starts_with($filePath, $cwd)) {
5✔
NEW
199
            return '.'.substr($filePath, strlen($cwd));
×
200
        }
201

202
        return $filePath;
5✔
203
    }
204

205
    /**
206
     * @param array<array{validator: ValidatorInterface, issue: Issue}> $fileIssues
207
     *
208
     * @return array<array{validator: ValidatorInterface, issue: Issue}>
209
     */
210
    private function sortIssuesBySeverity(array $fileIssues): array
4✔
211
    {
212
        usort($fileIssues, function ($a, $b) {
4✔
NEW
213
            $severityA = $this->getIssueSeverity($a['validator']);
×
NEW
214
            $severityB = $this->getIssueSeverity($b['validator']);
×
215

216
            // Errors (1) come before warnings (2)
NEW
217
            return $severityA <=> $severityB;
×
218
        });
4✔
219

220
        return $fileIssues;
4✔
221
    }
222

223
    /**
224
     * @param array<string, array{validator: ValidatorInterface, issues: array<Issue>}> $validatorGroups
225
     *
226
     * @return array<string, array{validator: ValidatorInterface, issues: array<Issue>}>
227
     */
228
    private function sortValidatorGroupsBySeverity(array $validatorGroups): array
2✔
229
    {
230
        uksort($validatorGroups, function ($validatorClassA, $validatorClassB) {
2✔
NEW
231
            $severityA = $this->getValidatorSeverity($validatorClassA);
×
NEW
232
            $severityB = $this->getValidatorSeverity($validatorClassB);
×
233

234
            // Errors (1) come before warnings (2)
NEW
235
            return $severityA <=> $severityB;
×
236
        });
2✔
237

238
        return $validatorGroups;
2✔
239
    }
240

NEW
241
    private function getIssueSeverity(ValidatorInterface $validator): int
×
242
    {
NEW
243
        return $this->getValidatorSeverity($validator::class);
×
244
    }
245

NEW
246
    private function getValidatorSeverity(string $validatorClass): int
×
247
    {
248
        // MismatchValidator and DuplicateValuesValidator produce warnings
NEW
249
        if (str_contains($validatorClass, 'MismatchValidator')
×
NEW
250
            || str_contains($validatorClass, 'DuplicateValuesValidator')) {
×
NEW
251
            return 2; // Warning
×
252
        }
253

254
        // All other validators produce errors
NEW
255
        return 1; // Error
×
256
    }
257

258
    /**
259
     * @param \MoveElevator\ComposerTranslationValidator\FileDetector\FileSet               $fileSet
260
     * @param array<string, array<int, array{validator: ValidatorInterface, issue: Issue}>> $groupedByFile
261
     */
NEW
262
    private function handleMismatchValidatorIssues(ValidatorInterface $validator, $fileSet, array &$groupedByFile): void
×
263
    {
NEW
264
        foreach ($validator->getIssues() as $issue) {
×
NEW
265
            $details = $issue->getDetails();
×
NEW
266
            $files = $details['files'] ?? [];
×
267

268
            // Add the issue to each affected file
NEW
269
            foreach ($files as $fileInfo) {
×
NEW
270
                $fileName = $fileInfo['file'] ?? '';
×
NEW
271
                if (!empty($fileName)) {
×
272
                    // Construct full file path from fileSet
NEW
273
                    $filePath = $fileSet->getPath().'/'.$fileName;
×
274

275
                    // Create a new issue specific to this file
NEW
276
                    $fileSpecificIssue = new Issue(
×
NEW
277
                        $filePath,
×
NEW
278
                        $details,
×
NEW
279
                        $issue->getParser(),
×
NEW
280
                        $issue->getValidatorType()
×
NEW
281
                    );
×
282

NEW
283
                    if (!isset($groupedByFile[$filePath])) {
×
NEW
284
                        $groupedByFile[$filePath] = [];
×
285
                    }
286

NEW
287
                    $groupedByFile[$filePath][] = [
×
NEW
288
                        'validator' => $validator,
×
NEW
289
                        'issue' => $fileSpecificIssue,
×
NEW
290
                    ];
×
291
                }
292
            }
293
        }
294
    }
295

296
    /**
297
     * @param \MoveElevator\ComposerTranslationValidator\FileDetector\FileSet                          $fileSet
298
     * @param array<string, array<string, array{validator: ValidatorInterface, issues: array<Issue>}>> $groupedByFile
299
     */
NEW
300
    private function handleMismatchValidatorIssuesVerbose(ValidatorInterface $validator, $fileSet, array &$groupedByFile): void
×
301
    {
NEW
302
        foreach ($validator->getIssues() as $issue) {
×
NEW
303
            $details = $issue->getDetails();
×
NEW
304
            $files = $details['files'] ?? [];
×
NEW
305
            $validatorClass = $validator::class;
×
306

307
            // Add the issue to each affected file
NEW
308
            foreach ($files as $fileInfo) {
×
NEW
309
                $fileName = $fileInfo['file'] ?? '';
×
NEW
310
                if (!empty($fileName)) {
×
311
                    // Construct full file path from fileSet
NEW
312
                    $filePath = $fileSet->getPath().'/'.$fileName;
×
313

314
                    // Create a new issue specific to this file
NEW
315
                    $fileSpecificIssue = new Issue(
×
NEW
316
                        $filePath,
×
NEW
317
                        $details,
×
NEW
318
                        $issue->getParser(),
×
NEW
319
                        $issue->getValidatorType()
×
NEW
320
                    );
×
321

NEW
322
                    if (!isset($groupedByFile[$filePath])) {
×
NEW
323
                        $groupedByFile[$filePath] = [];
×
324
                    }
NEW
325
                    if (!isset($groupedByFile[$filePath][$validatorClass])) {
×
NEW
326
                        $groupedByFile[$filePath][$validatorClass] = [
×
NEW
327
                            'validator' => $validator,
×
NEW
328
                            'issues' => [],
×
NEW
329
                        ];
×
330
                    }
331

NEW
332
                    $groupedByFile[$filePath][$validatorClass]['issues'][] = $fileSpecificIssue;
×
333
                }
334
            }
335
        }
336
    }
337

338
    private function formatIssueMessage(ValidatorInterface $validator, Issue $issue, string $validatorName = '', bool $isVerbose = false): string
5✔
339
    {
340
        $details = $issue->getDetails();
5✔
341
        $prefix = $isVerbose ? '' : "($validatorName) ";
5✔
342

343
        // Handle different validator types
344
        $validatorClass = $validator::class;
5✔
345

346
        if (str_contains($validatorClass, 'DuplicateKeysValidator')) {
5✔
347
            // Details contains duplicate keys with their counts
NEW
348
            $messages = [];
×
NEW
349
            foreach ($details as $key => $count) {
×
NEW
350
                if (is_string($key) && is_int($count)) {
×
NEW
351
                    $messages[] = "- <fg=red>ERROR</> {$prefix}the translation key `$key` occurs multiple times ({$count}x)";
×
352
                }
353
            }
354

NEW
355
            return implode("\n", $messages);
×
356
        }
357

358
        if (str_contains($validatorClass, 'DuplicateValuesValidator')) {
5✔
359
            // Details contains duplicate values with their keys
NEW
360
            $messages = [];
×
NEW
361
            foreach ($details as $value => $keys) {
×
NEW
362
                if (is_string($value) && is_array($keys)) {
×
NEW
363
                    $keyList = implode('`, `', $keys);
×
NEW
364
                    $messages[] = "- <fg=yellow>WARNING</> {$prefix}the translation value `$value` occurs in multiple keys (`$keyList`)";
×
365
                }
366
            }
367

NEW
368
            return implode("\n", $messages);
×
369
        }
370

371
        if (str_contains($validatorClass, 'SchemaValidator')) {
5✔
372
            // Details contains schema validation errors from XliffUtils::validateSchema()
NEW
373
            $messages = [];
×
374

375
            // The details array directly contains the validation errors
NEW
376
            foreach ($details as $error) {
×
NEW
377
                if (is_array($error)) {
×
NEW
378
                    $message = $error['message'] ?? 'Schema validation error';
×
NEW
379
                    $line = isset($error['line']) ? " (Line: {$error['line']})" : '';
×
NEW
380
                    $code = isset($error['code']) ? " (Code: {$error['code']})" : '';
×
NEW
381
                    $level = $error['level'] ?? 'ERROR';
×
382

NEW
383
                    $color = 'ERROR' === strtoupper($level) ? 'red' : 'yellow';
×
NEW
384
                    $levelText = strtoupper($level);
×
385

NEW
386
                    $messages[] = "- <fg=$color>$levelText</> {$prefix}$message$line$code";
×
387
                }
388
            }
389

NEW
390
            if (empty($messages)) {
×
NEW
391
                $messages[] = "- <fg=red>ERROR</> {$prefix}Schema validation error";
×
392
            }
393

NEW
394
            return implode("\n", $messages);
×
395
        }
396

397
        if (str_contains($validatorClass, 'MismatchValidator')) {
5✔
398
            // Details contains key mismatch information
NEW
399
            $key = $details['key'] ?? 'unknown';
×
NEW
400
            $files = $details['files'] ?? [];
×
NEW
401
            $currentFile = basename($issue->getFile());
×
NEW
402
            $otherFiles = [];
×
NEW
403
            $currentFileHasValue = false;
×
404

NEW
405
            foreach ($files as $fileInfo) {
×
NEW
406
                $fileName = $fileInfo['file'] ?? 'unknown';
×
NEW
407
                if ($fileName === $currentFile) {
×
NEW
408
                    $currentFileHasValue = null !== $fileInfo['value'];
×
409
                } else {
NEW
410
                    $otherFiles[] = $fileName;
×
411
                }
412
            }
413

NEW
414
            if ($currentFileHasValue) {
×
NEW
415
                $action = 'missing from';
×
416
            } else {
NEW
417
                $action = 'present in';
×
418
            }
419

NEW
420
            $otherFilesList = !empty($otherFiles) ? implode('`, `', $otherFiles) : 'other files';
×
421

NEW
422
            return "- <fg=yellow>WARNING</> {$prefix}translation key `$key` is $action other translation files (`$otherFilesList`)";
×
423
        }
424

425
        // Fallback for other validators
426
        $message = $details['message'] ?? 'Validation error';
5✔
427

428
        return "- <fg=red>ERROR</> {$prefix}$message";
5✔
429
    }
430

431
    private function shouldShowDetailedOutput(ValidatorInterface $validator): bool
2✔
432
    {
433
        $validatorClass = $validator::class;
2✔
434

435
        return str_contains($validatorClass, 'MismatchValidator');
2✔
436
    }
437

438
    /**
439
     * @param array<Issue> $issues
440
     */
NEW
441
    private function renderDetailedValidatorOutput(ValidatorInterface $validator, array $issues): void
×
442
    {
443
        // For MismatchValidator, show the detailed table
NEW
444
        if (str_contains($validator::class, 'MismatchValidator')) {
×
UNCOV
445
            $this->io->newLine();
×
NEW
446
            $this->renderMismatchTable($issues);
×
447
        }
448
    }
449

450
    /**
451
     * @param array<Issue> $issues
452
     */
NEW
453
    private function renderMismatchTable(array $issues): void
×
454
    {
NEW
455
        if (empty($issues)) {
×
NEW
456
            return;
×
457
        }
458

NEW
459
        $rows = [];
×
NEW
460
        $allKeys = [];
×
NEW
461
        $allFilesData = [];
×
462

463
        // Collect all data
UNCOV
464
        foreach ($issues as $issue) {
×
NEW
465
            $details = $issue->getDetails();
×
NEW
466
            $key = $details['key'] ?? 'unknown';
×
NEW
467
            $files = $details['files'] ?? [];
×
NEW
468
            $currentFile = basename($issue->getFile());
×
469

NEW
470
            if (!in_array($key, $allKeys)) {
×
NEW
471
                $allKeys[] = $key;
×
472
            }
473

NEW
474
            foreach ($files as $fileInfo) {
×
NEW
475
                $fileName = $fileInfo['file'] ?? '';
×
NEW
476
                $value = $fileInfo['value'];
×
477

NEW
478
                if (!isset($allFilesData[$key])) {
×
NEW
479
                    $allFilesData[$key] = [];
×
480
                }
NEW
481
                $allFilesData[$key][$fileName] = $value;
×
482
            }
483
        }
484

485
        // Get first issue to determine current file and file order
NEW
486
        $firstIssue = $issues[0];
×
NEW
487
        $currentFile = basename($firstIssue->getFile());
×
NEW
488
        $firstDetails = $firstIssue->getDetails();
×
NEW
489
        $firstFiles = $firstDetails['files'] ?? [];
×
490

491
        // Order files: current file first, then others
NEW
492
        $fileOrder = [$currentFile];
×
NEW
493
        foreach ($firstFiles as $fileInfo) {
×
NEW
494
            $fileName = $fileInfo['file'] ?? '';
×
NEW
495
            if ($fileName !== $currentFile && !in_array($fileName, $fileOrder)) {
×
NEW
496
                $fileOrder[] = $fileName;
×
497
            }
498
        }
499

NEW
500
        $header = ['Translation Key', $currentFile];
×
NEW
501
        foreach ($fileOrder as $fileName) {
×
NEW
502
            if ($fileName !== $currentFile) {
×
NEW
503
                $header[] = $fileName;
×
504
            }
505
        }
506

507
        // Build rows
NEW
508
        foreach ($allKeys as $key) {
×
NEW
509
            $row = [$key];
×
NEW
510
            foreach ($fileOrder as $fileName) {
×
NEW
511
                $value = $allFilesData[$key][$fileName] ?? null;
×
NEW
512
                $row[] = $value ?? '';  // Empty string instead of <missing>
×
513
            }
NEW
514
            $rows[] = $row;
×
515
        }
516

NEW
517
        $table = new \Symfony\Component\Console\Helper\Table($this->output);
×
NEW
518
        $table->setHeaders($header)
×
NEW
519
              ->setRows($rows)
×
NEW
520
              ->setStyle(
×
NEW
521
                  (new \Symfony\Component\Console\Helper\TableStyle())
×
NEW
522
                      ->setCellHeaderFormat('%s')
×
NEW
523
              )
×
NEW
524
              ->render();
×
525
    }
526

527
    private function renderSummary(ResultType $resultType): void
12✔
528
    {
529
        if ($resultType->notFullySuccessful()) {
12✔
530
            $this->io->newLine();
9✔
531
            $message = $this->dryRun
9✔
532
                ? 'Language validation failed and completed in dry-run mode.'
1✔
533
                : 'Language validation failed.';
8✔
534

535
            if (!$this->output->isVerbose()) {
9✔
536
                $message .= ' See more details with the `-v` verbose option.';
8✔
537
            }
538

539
            $this->io->{$this->dryRun || ResultType::WARNING === $resultType ? 'warning' : 'error'}($message);
9✔
540
        } else {
541
            $message = 'Language validation succeeded.';
3✔
542
            $this->output->isVerbose()
3✔
543
                ? $this->io->success($message)
1✔
544
                : $this->output->writeln('<fg=green>'.$message.'</>');
2✔
545
        }
546
    }
547
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc