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

move-elevator / composer-translation-validator / 16518498621

25 Jul 2025 09:18AM UTC coverage: 96.457% (+0.001%) from 96.456%
16518498621

Pull #46

github

jackd248
Merge remote-tracking branch 'origin/main' into key-naming-validator

# Conflicts:
#	README.md
#	src/Command/ValidateTranslationCommand.php
#	src/Validator/ValidatorRegistry.php
#	tests/src/Validator/ValidatorRegistryTest.php
Pull Request #46: feat: add KeyNamingConventionValidator with configurable naming conventions

139 of 144 new or added lines in 6 files covered. (96.53%)

22 existing lines in 6 files now uncovered.

2042 of 2117 relevant lines covered (96.46%)

8.42 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 plugin "composer-translation-validator".
7
 *
8
 * Copyright (C) 2025 Konrad Michalik <km@move-elevator.de>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 3 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace MoveElevator\ComposerTranslationValidator\Validator;
25

26
use MoveElevator\ComposerTranslationValidator\FileDetector\FileSet;
27
use MoveElevator\ComposerTranslationValidator\Parser\JsonParser;
28
use MoveElevator\ComposerTranslationValidator\Parser\ParserInterface;
29
use MoveElevator\ComposerTranslationValidator\Parser\PhpParser;
30
use MoveElevator\ComposerTranslationValidator\Parser\XliffParser;
31
use MoveElevator\ComposerTranslationValidator\Parser\YamlParser;
32
use MoveElevator\ComposerTranslationValidator\Result\Issue;
33
use Symfony\Component\Console\Helper\Table;
34
use Symfony\Component\Console\Helper\TableStyle;
35
use Symfony\Component\Console\Output\OutputInterface;
36

37
class PlaceholderConsistencyValidator extends AbstractValidator implements ValidatorInterface
38
{
39
    /** @var array<string, array<string, array{value: string, placeholders: array<string>}>> */
40
    protected array $keyData = [];
41

42
    public function processFile(ParserInterface $file): array
6✔
43
    {
44
        $keys = $file->extractKeys();
6✔
45

46
        if (null === $keys) {
6✔
47
            $this->logger?->error(
1✔
48
                'The source file '.$file->getFileName().' is not valid.',
1✔
49
            );
1✔
50

51
            return [];
1✔
52
        }
53

54
        foreach ($keys as $key) {
5✔
55
            $value = $file->getContentByKey($key);
5✔
56
            if (null === $value) {
5✔
57
                continue;
×
58
            }
59

60
            $placeholders = $this->extractPlaceholders($value);
5✔
61
            $this->keyData[$key][$file->getFileName()] = [
5✔
62
                'value' => $value,
5✔
63
                'placeholders' => $placeholders,
5✔
64
            ];
5✔
65
        }
66

67
        return [];
5✔
68
    }
69

70
    public function postProcess(): void
3✔
71
    {
72
        foreach ($this->keyData as $key => $fileData) {
3✔
73
            $placeholderInconsistencies = $this->findPlaceholderInconsistencies($fileData);
3✔
74

75
            if (!empty($placeholderInconsistencies)) {
3✔
76
                $result = [
2✔
77
                    'key' => $key,
2✔
78
                    'files' => $fileData,
2✔
79
                    'inconsistencies' => $placeholderInconsistencies,
2✔
80
                ];
2✔
81

82
                $this->addIssue(new Issue(
2✔
83
                    '',
2✔
84
                    $result,
2✔
85
                    '',
2✔
86
                    $this->getShortName(),
2✔
87
                ));
2✔
88
            }
89
        }
90
    }
91

92
    /**
93
     * Extract placeholders from a translation value
94
     * Supports various placeholder syntaxes:
95
     * - %parameter% (Symfony style)
96
     * - {parameter} (ICU MessageFormat style)
97
     * - {{ parameter }} (Twig style)
98
     * - %s, %d, %1$s (printf style)
99
     * - :parameter (Laravel style).
100
     *
101
     * @return array<string>
102
     */
103
    private function extractPlaceholders(string $value): array
12✔
104
    {
105
        $placeholders = [];
12✔
106

107
        // Symfony style: %parameter%
108
        if (preg_match_all('/%([a-zA-Z_][a-zA-Z0-9_]*)%/', $value, $matches)) {
12✔
109
            foreach ($matches[1] as $match) {
8✔
110
                $placeholders[] = "%{$match}%";
8✔
111
            }
112
        }
113

114
        // ICU MessageFormat style: {parameter}
115
        if (preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $value, $matches)) {
12✔
116
            foreach ($matches[1] as $match) {
4✔
117
                $placeholders[] = "{{$match}}";
4✔
118
            }
119
        }
120

121
        // Twig style: {{ parameter }}
122
        if (preg_match_all('/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/', $value, $matches)) {
12✔
123
            foreach ($matches[1] as $match) {
1✔
124
                $placeholders[] = "{{ {$match} }}";
1✔
125
            }
126
        }
127

128
        // Printf style: %s, %d, %1$s, etc.
129
        if (preg_match_all('/%(?:(\d+)\$)?[sdcoxXeEfFgGaA]/', $value, $matches)) {
12✔
130
            foreach ($matches[0] as $match) {
4✔
131
                $placeholders[] = $match;
4✔
132
            }
133
        }
134

135
        // Laravel style: :parameter
136
        if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $value, $matches)) {
12✔
137
            foreach ($matches[1] as $match) {
1✔
138
                $placeholders[] = ":{$match}";
1✔
139
            }
140
        }
141

142
        return array_unique($placeholders);
12✔
143
    }
144

145
    /**
146
     * @param array<string, array{value: string, placeholders: array<string>}> $fileData
147
     *
148
     * @return array<string>
149
     */
150
    private function findPlaceholderInconsistencies(array $fileData): array
4✔
151
    {
152
        if (count($fileData) < 2) {
4✔
153
            return [];
1✔
154
        }
155

156
        $inconsistencies = [];
3✔
157
        $allPlaceholders = [];
3✔
158

159
        // Collect all placeholders from all files for this key
160
        foreach ($fileData as $fileName => $data) {
3✔
161
            $allPlaceholders[$fileName] = $data['placeholders'];
3✔
162
        }
163

164
        // Compare placeholders between files
165
        $fileNames = array_keys($allPlaceholders);
3✔
166
        $referenceFile = $fileNames[0];
3✔
167
        $referencePlaceholders = $allPlaceholders[$referenceFile];
3✔
168

169
        for ($i = 1, $iMax = count($fileNames); $i < $iMax; ++$i) {
3✔
170
            $currentFile = $fileNames[$i];
3✔
171
            $currentPlaceholders = $allPlaceholders[$currentFile];
3✔
172

173
            $missing = array_diff($referencePlaceholders, $currentPlaceholders);
3✔
174
            $extra = array_diff($currentPlaceholders, $referencePlaceholders);
3✔
175

176
            if (!empty($missing)) {
3✔
177
                $inconsistencies[] = "File '{$currentFile}' is missing placeholders: ".implode(', ', $missing);
2✔
178
            }
179

180
            if (!empty($extra)) {
3✔
181
                $inconsistencies[] = "File '{$currentFile}' has extra placeholders: ".implode(', ', $extra);
2✔
182
            }
183
        }
184

185
        return $inconsistencies;
3✔
186
    }
187

188
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
2✔
189
    {
190
        $details = $issue->getDetails();
2✔
191
        $resultType = $this->resultTypeOnValidationFailure();
2✔
192

193
        $level = $resultType->toString();
2✔
194
        $color = $resultType->toColorString();
2✔
195

196
        $key = $details['key'] ?? 'unknown';
2✔
197
        $inconsistencies = $details['inconsistencies'] ?? [];
2✔
198

199
        $inconsistencyText = implode('; ', $inconsistencies);
2✔
200

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

204
    public function distributeIssuesForDisplay(FileSet $fileSet): array
1✔
205
    {
206
        $distribution = [];
1✔
207

208
        foreach ($this->issues as $issue) {
1✔
209
            $details = $issue->getDetails();
1✔
210
            $files = $details['files'] ?? [];
1✔
211

212
            foreach ($files as $fileName => $fileInfo) {
1✔
213
                if (!empty($fileName)) {
1✔
214
                    $basePath = rtrim($fileSet->getPath(), '/');
1✔
215
                    $filePath = $basePath.'/'.$fileName;
1✔
216

217
                    $fileSpecificIssue = new Issue(
1✔
218
                        $filePath,
1✔
219
                        $details,
1✔
220
                        $issue->getParser(),
1✔
221
                        $issue->getValidatorType(),
1✔
222
                    );
1✔
223

224
                    $distribution[$filePath] ??= [];
1✔
225
                    $distribution[$filePath][] = $fileSpecificIssue;
1✔
226
                }
227
            }
228
        }
229

230
        return $distribution;
1✔
231
    }
232

233
    public function renderDetailedOutput(OutputInterface $output, array $issues): void
1✔
234
    {
235
        if (empty($issues)) {
1✔
UNCOV
236
            return;
×
237
        }
238

239
        $rows = [];
1✔
240
        $allKeys = [];
1✔
241
        $allFilesData = [];
1✔
242

243
        foreach ($issues as $issue) {
1✔
244
            $details = $issue->getDetails();
1✔
245
            $key = $details['key'] ?? 'unknown';
1✔
246
            $files = $details['files'] ?? [];
1✔
247

248
            if (!in_array($key, $allKeys)) {
1✔
249
                $allKeys[] = $key;
1✔
250
            }
251

252
            foreach ($files as $fileName => $fileInfo) {
1✔
253
                $value = $fileInfo['value'] ?? '';
1✔
254
                if (!isset($allFilesData[$key])) {
1✔
255
                    $allFilesData[$key] = [];
1✔
256
                }
257
                $allFilesData[$key][$fileName] = $value;
1✔
258
            }
259
        }
260

261
        $firstIssue = $issues[0];
1✔
262
        $firstDetails = $firstIssue->getDetails();
1✔
263
        $firstFiles = $firstDetails['files'] ?? [];
1✔
264

265
        $fileOrder = array_keys($firstFiles);
1✔
266

267
        $header = ['Translation Key'];
1✔
268
        foreach ($fileOrder as $fileName) {
1✔
269
            $header[] = $fileName;
1✔
270
        }
271

272
        foreach ($allKeys as $key) {
1✔
273
            $row = [$key];
1✔
274
            foreach ($fileOrder as $fileName) {
1✔
275
                $value = $allFilesData[$key][$fileName] ?? '';
1✔
276
                $row[] = $this->highlightPlaceholders($value);
1✔
277
            }
278
            $rows[] = $row;
1✔
279
        }
280

281
        $table = new Table($output);
1✔
282
        $table->setHeaders($header)
1✔
283
            ->setRows($rows)
1✔
284
            ->setStyle(
1✔
285
                (new TableStyle())
1✔
286
                    ->setCellHeaderFormat('%s'),
1✔
287
            )
1✔
288
            ->render();
1✔
289
    }
290

291
    private function highlightPlaceholders(string $value): string
1✔
292
    {
293
        $placeholders = $this->extractPlaceholders($value);
1✔
294

295
        foreach ($placeholders as $placeholder) {
1✔
296
            $value = str_replace($placeholder, "<fg=yellow>{$placeholder}</>", $value);
1✔
297
        }
298

299
        return $value;
1✔
300
    }
301

302
    /**
303
     * @return class-string<ParserInterface>[]
304
     */
305
    public function supportsParser(): array
1✔
306
    {
307
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
308
    }
309

310
    protected function resetState(): void
1✔
311
    {
312
        parent::resetState();
1✔
313
        $this->keyData = [];
1✔
314
    }
315

316
    public function resultTypeOnValidationFailure(): ResultType
3✔
317
    {
318
        return ResultType::WARNING;
3✔
319
    }
320

321
    public function shouldShowDetailedOutput(): bool
1✔
322
    {
323
        return true;
1✔
324
    }
325
}
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