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

move-elevator / composer-translation-validator / 28714660122

04 Jul 2026 05:51PM UTC coverage: 99.96% (-0.04%) from 100.0%
28714660122

Pull #154

github

konradmichalik
perf: memoize placeholder extraction per value
Pull Request #154: perf: memoize placeholder extraction per value

3 of 4 new or added lines in 1 file covered. (75.0%)

2494 of 2495 relevant lines covered (99.96%)

9.86 hits per line

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

99.29
/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-2026 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\Parser\{JsonParser, ParserInterface, PhpParser, XliffParser, YamlParser};
17
use MoveElevator\ComposerTranslationValidator\Result\Issue;
18
use MoveElevator\ComposerTranslationValidator\Validator\Trait\DistributesIssuesForDisplayTrait;
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
    use DistributesIssuesForDisplayTrait;
34

35
    /** @var array<string, array<string, array{value: string, placeholders: array<string>}>> */
36
    protected array $keyData = [];
37

38
    /**
39
     * Cache of extracted placeholders per value. The same value is processed
40
     * during analysis and again while rendering; memoizing avoids re-running
41
     * the placeholder regexes for it.
42
     *
43
     * @var array<string, array<string>>
44
     */
45
    private array $placeholderCache = [];
46

47
    public function processFile(ParserInterface $file): array
7✔
48
    {
49
        $keys = $file->extractKeys();
7✔
50

51
        if (null === $keys) {
7✔
52
            $this->logger?->error(
1✔
53
                'The source file '.$file->getFileName().' is not valid.',
1✔
54
            );
1✔
55

56
            return [];
1✔
57
        }
58

59
        foreach ($keys as $key) {
6✔
60
            $value = $file->getContentByKey($key);
6✔
61
            if (null === $value) {
6✔
62
                continue;
1✔
63
            }
64

65
            $placeholders = $this->extractPlaceholders($value);
6✔
66
            $fileKey = !empty($this->currentFilePath) ? $this->currentFilePath : $file->getFileName();
6✔
67
            $this->keyData[$key][$fileKey] = [
6✔
68
                'value' => $value,
6✔
69
                'placeholders' => $placeholders,
6✔
70
            ];
6✔
71
        }
72

73
        return [];
6✔
74
    }
75

76
    public function postProcess(): void
3✔
77
    {
78
        foreach ($this->keyData as $key => $fileData) {
3✔
79
            $placeholderInconsistencies = $this->findPlaceholderInconsistencies($fileData);
3✔
80

81
            if (!empty($placeholderInconsistencies)) {
3✔
82
                $result = [
2✔
83
                    'key' => $key,
2✔
84
                    'files' => $fileData,
2✔
85
                    'inconsistencies' => $placeholderInconsistencies,
2✔
86
                ];
2✔
87

88
                $this->addIssue(new Issue(
2✔
89
                    '',
2✔
90
                    $result,
2✔
91
                    '',
2✔
92
                    $this->getShortName(),
2✔
93
                ));
2✔
94
            }
95
        }
96
    }
97

98
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
1✔
99
    {
100
        $details = $issue->getDetails();
1✔
101
        $resultType = $this->resultTypeOnValidationFailure();
1✔
102

103
        $level = $resultType->toString();
1✔
104
        $color = $resultType->toColorString();
1✔
105

106
        $key = $details['key'] ?? 'unknown';
1✔
107
        $inconsistencies = $details['inconsistencies'] ?? [];
1✔
108

109
        $inconsistencyText = implode('; ', $inconsistencies);
1✔
110

111
        return "- <fg={$color}>{$level}</> {$prefix}placeholder inconsistency in translation key `{$key}` - {$inconsistencyText}";
1✔
112
    }
113

114
    public function renderDetailedOutput(OutputInterface $output, array $issues): void
2✔
115
    {
116
        if (empty($issues)) {
2✔
117
            return;
1✔
118
        }
119

120
        $rows = [];
1✔
121
        $allKeys = [];
1✔
122
        $allFilesData = [];
1✔
123

124
        foreach ($issues as $issue) {
1✔
125
            $details = $issue->getDetails();
1✔
126
            $key = $details['key'] ?? 'unknown';
1✔
127
            $files = $details['files'] ?? [];
1✔
128

129
            if (!in_array($key, $allKeys)) {
1✔
130
                $allKeys[] = $key;
1✔
131
            }
132

133
            foreach ($files as $filePath => $fileInfo) {
1✔
134
                $fileName = basename((string) $filePath);
1✔
135
                $value = $fileInfo['value'] ?? '';
1✔
136
                if (!isset($allFilesData[$key])) {
1✔
137
                    $allFilesData[$key] = [];
1✔
138
                }
139
                $allFilesData[$key][$fileName] = $value;
1✔
140
            }
141
        }
142

143
        $firstIssue = $issues[0];
1✔
144
        $firstDetails = $firstIssue->getDetails();
1✔
145
        $firstFiles = $firstDetails['files'] ?? [];
1✔
146

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

149
        $header = ['Translation Key'];
1✔
150
        foreach ($fileOrder as $fileName) {
1✔
151
            $header[] = $fileName;
1✔
152
        }
153

154
        foreach ($allKeys as $key) {
1✔
155
            $row = [$key];
1✔
156
            foreach ($fileOrder as $fileName) {
1✔
157
                $value = $allFilesData[$key][$fileName] ?? '';
1✔
158
                $row[] = $this->highlightPlaceholders($value);
1✔
159
            }
160
            $rows[] = $row;
1✔
161
        }
162

163
        $table = new Table($output);
1✔
164
        $table->setHeaders($header)
1✔
165
            ->setRows($rows)
1✔
166
            ->setStyle(
1✔
167
                (new TableStyle())
1✔
168
                    ->setCellHeaderFormat('%s'),
1✔
169
            )
1✔
170
            ->render();
1✔
171
    }
172

173
    /**
174
     * @return class-string<ParserInterface>[]
175
     */
176
    public function supportsParser(): array
1✔
177
    {
178
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
179
    }
180

181
    public function resultTypeOnValidationFailure(): ResultType
2✔
182
    {
183
        return ResultType::WARNING;
2✔
184
    }
185

186
    public function shouldShowDetailedOutput(): bool
1✔
187
    {
188
        return true;
1✔
189
    }
190

191
    /**
192
     * @return array<string>
193
     */
194
    protected function extractFilePathsFromIssue(Issue $issue): array
1✔
195
    {
196
        $details = $issue->getDetails();
1✔
197
        $files = $details['files'] ?? [];
1✔
198

199
        return array_map(static fn (int|string $key): string => (string) $key, array_keys($files));
1✔
200
    }
201

202
    protected function resetState(): void
1✔
203
    {
204
        parent::resetState();
1✔
205
        $this->keyData = [];
1✔
206
        $this->placeholderCache = [];
1✔
207
    }
208

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

226
        $placeholders = [];
13✔
227

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

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

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

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

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

263
        return $this->placeholderCache[$value] = array_unique($placeholders);
13✔
264
    }
265

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

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

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

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

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

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

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

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

306
        return $inconsistencies;
3✔
307
    }
308

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

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

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