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

move-elevator / composer-translation-validator / 18560103885

16 Oct 2025 11:42AM UTC coverage: 95.519%. Remained the same
18560103885

Pull #73

github

jackd248
refactor: remove unnecessary type hint from MismatchValidator
Pull Request #73: build: add php-cs-fixer-preset

206 of 210 new or added lines in 16 files covered. (98.1%)

91 existing lines in 20 files now uncovered.

2345 of 2455 relevant lines covered (95.52%)

7.73 hits per line

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

98.66
/src/Validator/PlaceholderConsistencyValidator.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the "composer-translation-validator" Composer plugin.
7
 *
8
 * (c) 2025 Konrad Michalik <km@move-elevator.de>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace MoveElevator\ComposerTranslationValidator\Validator;
15

16
use MoveElevator\ComposerTranslationValidator\FileDetector\FileSet;
17
use MoveElevator\ComposerTranslationValidator\Parser\{JsonParser, ParserInterface, PhpParser, XliffParser, YamlParser};
18
use MoveElevator\ComposerTranslationValidator\Result\Issue;
19
use Symfony\Component\Console\Helper\{Table, TableStyle};
20
use Symfony\Component\Console\Output\OutputInterface;
21

22
use function count;
23
use function in_array;
24

25
/**
26
 * PlaceholderConsistencyValidator.
27
 *
28
 * @author Konrad Michalik <km@move-elevator.de>
29
 * @license GPL-3.0-or-later
30
 */
31
class PlaceholderConsistencyValidator extends AbstractValidator implements ValidatorInterface
32
{
33
    /** @var array<string, array<string, array{value: string, placeholders: array<string>}>> */
34
    protected array $keyData = [];
35

36
    public function processFile(ParserInterface $file): array
6✔
37
    {
38
        $keys = $file->extractKeys();
6✔
39

40
        if (null === $keys) {
6✔
41
            $this->logger?->error(
1✔
42
                'The source file '.$file->getFileName().' is not valid.',
1✔
43
            );
1✔
44

45
            return [];
1✔
46
        }
47

48
        foreach ($keys as $key) {
5✔
49
            $value = $file->getContentByKey($key);
5✔
50
            if (null === $value) {
5✔
UNCOV
51
                continue;
×
52
            }
53

54
            $placeholders = $this->extractPlaceholders($value);
5✔
55
            $fileKey = !empty($this->currentFilePath) ? $this->currentFilePath : $file->getFileName();
5✔
56
            $this->keyData[$key][$fileKey] = [
5✔
57
                'value' => $value,
5✔
58
                'placeholders' => $placeholders,
5✔
59
            ];
5✔
60
        }
61

62
        return [];
5✔
63
    }
64

65
    public function postProcess(): void
3✔
66
    {
67
        foreach ($this->keyData as $key => $fileData) {
3✔
68
            $placeholderInconsistencies = $this->findPlaceholderInconsistencies($fileData);
3✔
69

70
            if (!empty($placeholderInconsistencies)) {
3✔
71
                $result = [
2✔
72
                    'key' => $key,
2✔
73
                    'files' => $fileData,
2✔
74
                    'inconsistencies' => $placeholderInconsistencies,
2✔
75
                ];
2✔
76

77
                $this->addIssue(new Issue(
2✔
78
                    '',
2✔
79
                    $result,
2✔
80
                    '',
2✔
81
                    $this->getShortName(),
2✔
82
                ));
2✔
83
            }
84
        }
85
    }
86

87
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
2✔
88
    {
89
        $details = $issue->getDetails();
2✔
90
        $resultType = $this->resultTypeOnValidationFailure();
2✔
91

92
        $level = $resultType->toString();
2✔
93
        $color = $resultType->toColorString();
2✔
94

95
        $key = $details['key'] ?? 'unknown';
2✔
96
        $inconsistencies = $details['inconsistencies'] ?? [];
2✔
97

98
        $inconsistencyText = implode('; ', $inconsistencies);
2✔
99

100
        return "- <fg={$color}>{$level}</> {$prefix}placeholder inconsistency in translation key `{$key}` - {$inconsistencyText}";
2✔
101
    }
102

103
    public function distributeIssuesForDisplay(FileSet $fileSet): array
1✔
104
    {
105
        $distribution = [];
1✔
106

107
        foreach ($this->issues as $issue) {
1✔
108
            $details = $issue->getDetails();
1✔
109
            $files = $details['files'] ?? [];
1✔
110

111
            foreach ($files as $filePath => $fileInfo) {
1✔
112
                if (!empty($filePath)) {
1✔
113
                    $fileSpecificIssue = new Issue(
1✔
114
                        $filePath,
1✔
115
                        $details,
1✔
116
                        $issue->getParser(),
1✔
117
                        $issue->getValidatorType(),
1✔
118
                    );
1✔
119

120
                    $distribution[$filePath] ??= [];
1✔
121
                    $distribution[$filePath][] = $fileSpecificIssue;
1✔
122
                }
123
            }
124
        }
125

126
        return $distribution;
1✔
127
    }
128

129
    public function renderDetailedOutput(OutputInterface $output, array $issues): void
1✔
130
    {
131
        if (empty($issues)) {
1✔
UNCOV
132
            return;
×
133
        }
134

135
        $rows = [];
1✔
136
        $allKeys = [];
1✔
137
        $allFilesData = [];
1✔
138

139
        foreach ($issues as $issue) {
1✔
140
            $details = $issue->getDetails();
1✔
141
            $key = $details['key'] ?? 'unknown';
1✔
142
            $files = $details['files'] ?? [];
1✔
143

144
            if (!in_array($key, $allKeys)) {
1✔
145
                $allKeys[] = $key;
1✔
146
            }
147

148
            foreach ($files as $filePath => $fileInfo) {
1✔
149
                $fileName = basename((string) $filePath);
1✔
150
                $value = $fileInfo['value'] ?? '';
1✔
151
                if (!isset($allFilesData[$key])) {
1✔
152
                    $allFilesData[$key] = [];
1✔
153
                }
154
                $allFilesData[$key][$fileName] = $value;
1✔
155
            }
156
        }
157

158
        $firstIssue = $issues[0];
1✔
159
        $firstDetails = $firstIssue->getDetails();
1✔
160
        $firstFiles = $firstDetails['files'] ?? [];
1✔
161

162
        $fileOrder = array_map(static fn ($path) => basename((string) $path), array_keys($firstFiles));
1✔
163

164
        $header = ['Translation Key'];
1✔
165
        foreach ($fileOrder as $fileName) {
1✔
166
            $header[] = $fileName;
1✔
167
        }
168

169
        foreach ($allKeys as $key) {
1✔
170
            $row = [$key];
1✔
171
            foreach ($fileOrder as $fileName) {
1✔
172
                $value = $allFilesData[$key][$fileName] ?? '';
1✔
173
                $row[] = $this->highlightPlaceholders($value);
1✔
174
            }
175
            $rows[] = $row;
1✔
176
        }
177

178
        $table = new Table($output);
1✔
179
        $table->setHeaders($header)
1✔
180
            ->setRows($rows)
1✔
181
            ->setStyle(
1✔
182
                (new TableStyle())
1✔
183
                    ->setCellHeaderFormat('%s'),
1✔
184
            )
1✔
185
            ->render();
1✔
186
    }
187

188
    /**
189
     * @return class-string<ParserInterface>[]
190
     */
191
    public function supportsParser(): array
1✔
192
    {
193
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
194
    }
195

196
    public function resultTypeOnValidationFailure(): ResultType
3✔
197
    {
198
        return ResultType::WARNING;
3✔
199
    }
200

201
    public function shouldShowDetailedOutput(): bool
1✔
202
    {
203
        return true;
1✔
204
    }
205

206
    protected function resetState(): void
1✔
207
    {
208
        parent::resetState();
1✔
209
        $this->keyData = [];
1✔
210
    }
211

212
    /**
213
     * Extract placeholders from a translation value
214
     * Supports various placeholder syntaxes:
215
     * - %parameter% (Symfony style)
216
     * - {parameter} (ICU MessageFormat style)
217
     * - {{ parameter }} (Twig style)
218
     * - %s, %d, %1$s (printf style)
219
     * - :parameter (Laravel style).
220
     *
221
     * @return array<string>
222
     */
223
    private function extractPlaceholders(string $value): array
12✔
224
    {
225
        $placeholders = [];
12✔
226

227
        // Symfony style: %parameter%
228
        if (preg_match_all('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', $value, $matches)) {
12✔
229
            foreach ($matches[1] as $match) {
8✔
230
                $placeholders[] = "%{$match}%";
8✔
231
            }
232
        }
233

234
        // ICU MessageFormat style: {parameter}
235
        if (preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $value, $matches)) {
12✔
236
            foreach ($matches[1] as $match) {
4✔
237
                $placeholders[] = "{{$match}}";
4✔
238
            }
239
        }
240

241
        // Twig style: {{ parameter }}
242
        if (preg_match_all('/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/', $value, $matches)) {
12✔
243
            foreach ($matches[1] as $match) {
1✔
244
                $placeholders[] = "{{ {$match} }}";
1✔
245
            }
246
        }
247

248
        // Printf style: %s, %d, %1$s, etc.
249
        if (preg_match_all('/%(?:(\d+)\$)?[sdcoxXeEfFgGaA]/', $value, $matches)) {
12✔
250
            foreach ($matches[0] as $match) {
4✔
251
                $placeholders[] = $match;
4✔
252
            }
253
        }
254

255
        // Laravel style: :parameter
256
        if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $value, $matches)) {
12✔
257
            foreach ($matches[1] as $match) {
1✔
258
                $placeholders[] = ":{$match}";
1✔
259
            }
260
        }
261

262
        return array_unique($placeholders);
12✔
263
    }
264

265
    /**
266
     * @param array<string, array{value: string, placeholders: array<string>}> $fileData
267
     *
268
     * @return array<string>
269
     */
270
    private function findPlaceholderInconsistencies(array $fileData): array
4✔
271
    {
272
        if (count($fileData) < 2) {
4✔
273
            return [];
1✔
274
        }
275

276
        $inconsistencies = [];
3✔
277
        $allPlaceholders = [];
3✔
278

279
        // Collect all placeholders from all files for this key
280
        foreach ($fileData as $fileName => $data) {
3✔
281
            $allPlaceholders[$fileName] = $data['placeholders'];
3✔
282
        }
283

284
        // Compare placeholders between files
285
        $fileNames = array_keys($allPlaceholders);
3✔
286
        $referenceFile = $fileNames[0];
3✔
287
        $referencePlaceholders = $allPlaceholders[$referenceFile];
3✔
288

289
        for ($i = 1, $iMax = count($fileNames); $i < $iMax; ++$i) {
3✔
290
            $currentFile = basename($fileNames[$i]);
3✔
291
            $currentPlaceholders = $allPlaceholders[$fileNames[$i]];
3✔
292

293
            $missing = array_diff($referencePlaceholders, $currentPlaceholders);
3✔
294
            $extra = array_diff($currentPlaceholders, $referencePlaceholders);
3✔
295

296
            if (!empty($missing)) {
3✔
297
                $inconsistencies[] = "File '{$currentFile}' is missing placeholders: ".implode(', ', $missing);
2✔
298
            }
299

300
            if (!empty($extra)) {
3✔
301
                $inconsistencies[] = "File '{$currentFile}' has extra placeholders: ".implode(', ', $extra);
2✔
302
            }
303
        }
304

305
        return $inconsistencies;
3✔
306
    }
307

308
    private function highlightPlaceholders(string $value): string
1✔
309
    {
310
        $placeholders = $this->extractPlaceholders($value);
1✔
311

312
        foreach ($placeholders as $placeholder) {
1✔
313
            $value = str_replace($placeholder, "<fg=yellow>{$placeholder}</>", $value);
1✔
314
        }
315

316
        return $value;
1✔
317
    }
318
}
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

© 2025 Coveralls, Inc