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

move-elevator / composer-translation-validator / 16590208832

29 Jul 2025 08:00AM UTC coverage: 96.618% (+0.009%) from 96.609%
16590208832

Pull #60

github

web-flow
Merge 47b4b0e38 into f0215bd32
Pull Request #60: fix: improve key naming convention detection for dot notation

50 of 53 new or added lines in 4 files covered. (94.34%)

1 existing line in 1 file now uncovered.

2257 of 2336 relevant lines covered (96.62%)

8.44 hits per line

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

95.41
/src/Validator/KeyNamingConventionValidator.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 InvalidArgumentException;
27
use MoveElevator\ComposerTranslationValidator\Config\TranslationValidatorConfig;
28
use MoveElevator\ComposerTranslationValidator\Enum\KeyNamingConvention;
29
use MoveElevator\ComposerTranslationValidator\Parser\JsonParser;
30
use MoveElevator\ComposerTranslationValidator\Parser\ParserInterface;
31
use MoveElevator\ComposerTranslationValidator\Parser\PhpParser;
32
use MoveElevator\ComposerTranslationValidator\Parser\XliffParser;
33
use MoveElevator\ComposerTranslationValidator\Parser\YamlParser;
34
use MoveElevator\ComposerTranslationValidator\Result\Issue;
35

36
class KeyNamingConventionValidator extends AbstractValidator implements ValidatorInterface
37
{
38
    private ?KeyNamingConvention $convention = null;
39
    private ?string $customPattern = null;
40
    private ?TranslationValidatorConfig $config = null;
41

42
    public function setConfig(?TranslationValidatorConfig $config): void
8✔
43
    {
44
        $this->config = $config;
8✔
45
        $this->loadConventionFromConfig();
8✔
46
    }
47

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

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

57
            return [];
1✔
58
        }
59

60
        $issues = [];
51✔
61

62
        // If no convention is configured, analyze keys for inconsistencies
63
        if (null === $this->convention && null === $this->customPattern) {
51✔
64
            $issueData = $this->analyzeKeyConsistency($keys, $file->getFileName());
8✔
65
        } else {
66
            // Use configured convention
67
            $issueData = [];
43✔
68
            foreach ($keys as $key) {
43✔
69
                if (!$this->validateKeyFormat($key)) {
43✔
70
                    $issueData[] = [
30✔
71
                        'key' => $key,
30✔
72
                        'file' => $file->getFileName(),
30✔
73
                        'expected_convention' => $this->convention->value ?? 'custom pattern',
30✔
74
                        'pattern' => $this->getActivePattern(),
30✔
75
                        'suggestion' => $this->suggestCorrection($key),
30✔
76
                    ];
30✔
77
                }
78
            }
79
        }
80

81
        return $issueData;
51✔
82
    }
83

84
    private function loadConventionFromConfig(): void
8✔
85
    {
86
        if (null === $this->config) {
8✔
87
            return;
1✔
88
        }
89

90
        $validatorSettings = $this->config->getValidatorSettings('KeyNamingConventionValidator');
7✔
91

92
        if (empty($validatorSettings)) {
7✔
93
            return;
2✔
94
        }
95

96
        // Load convention from config
97
        if (isset($validatorSettings['convention']) && is_string($validatorSettings['convention'])) {
5✔
98
            try {
99
                $this->setConvention($validatorSettings['convention']);
3✔
100
            } catch (InvalidArgumentException $e) {
1✔
101
                $this->logger?->warning(
1✔
102
                    'Invalid convention in config: '.$validatorSettings['convention'].'. '.$e->getMessage(),
1✔
103
                );
1✔
104
            }
105
        }
106

107
        // Load custom pattern from config (overrides convention)
108
        if (isset($validatorSettings['custom_pattern']) && is_string($validatorSettings['custom_pattern'])) {
5✔
109
            try {
110
                $this->setCustomPattern($validatorSettings['custom_pattern']);
3✔
111
            } catch (InvalidArgumentException $e) {
1✔
112
                $this->logger?->warning(
1✔
113
                    'Invalid custom pattern in config: '.$validatorSettings['custom_pattern'].'. '.$e->getMessage(),
1✔
114
                );
1✔
115
            }
116
        }
117
    }
118

119
    public function setConvention(string $convention): void
47✔
120
    {
121
        $this->convention = KeyNamingConvention::fromString($convention);
47✔
122
    }
123

124
    public function setCustomPattern(string $pattern): void
7✔
125
    {
126
        $result = @preg_match($pattern, '');
7✔
127
        if (false === $result) {
7✔
128
            throw new InvalidArgumentException('Invalid regex pattern provided');
2✔
129
        }
130

131
        $this->customPattern = $pattern;
5✔
132
        $this->convention = null; // Custom pattern overrides convention
5✔
133
    }
134

135
    private function validateKeyFormat(string $key): bool
43✔
136
    {
137
        if (null === $this->convention && null === $this->customPattern) {
43✔
138
            return true; // No validation if no pattern is set
×
139
        }
140

141
        // If custom pattern is set, use it directly
142
        if (null !== $this->customPattern) {
43✔
143
            return (bool) preg_match($this->customPattern, $key);
3✔
144
        }
145

146
        // For base conventions, validate each segment separately if key contains dots
147
        if (str_contains($key, '.')) {
40✔
148
            $segments = explode('.', $key);
8✔
149
            foreach ($segments as $segment) {
8✔
150
                if (!$this->validateSegment($segment)) {
8✔
151
                    return false;
4✔
152
                }
153
            }
154

155
            return true;
7✔
156
        }
157

158
        // Single segment, validate directly
159
        return $this->validateSegment($key);
34✔
160
    }
161

162
    private function validateSegment(string $segment): bool
40✔
163
    {
164
        if (null === $this->convention) {
40✔
165
            return true;
×
166
        }
167

168
        return $this->convention->matches($segment);
40✔
169
    }
170

171
    private function getActivePattern(): ?string
30✔
172
    {
173
        if (null !== $this->customPattern) {
30✔
174
            return $this->customPattern;
3✔
175
        }
176

177
        return $this->convention?->getPattern();
27✔
178
    }
179

180
    private function suggestCorrection(string $key): string
30✔
181
    {
182
        if (null === $this->convention) {
30✔
183
            return $key;
3✔
184
        }
185

186
        if (str_contains($key, '.')) {
27✔
187
            return $this->convertDotSeparatedKey($key);
4✔
188
        }
189

190
        return match ($this->convention) {
23✔
191
            KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($key),
23✔
192
            KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($key),
11✔
193
            KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($key),
7✔
194
            KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($key),
6✔
195
            KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($key),
23✔
196
        };
23✔
197
    }
198

199
    private function toSnakeCase(string $key): string
17✔
200
    {
201
        // Convert camelCase/PascalCase to snake_case
202
        $result = preg_replace('/([a-z])([A-Z])/', '$1_$2', $key);
17✔
203
        // Convert kebab-case and dot.notation to snake_case
204
        $result = str_replace(['-', '.'], '_', $result ?? $key);
17✔
205

206
        // Convert to lowercase
207
        return strtolower($result);
17✔
208
    }
209

210
    private function toCamelCase(string $key): string
9✔
211
    {
212
        // Handle camelCase/PascalCase first
213
        if (preg_match('/[A-Z]/', $key)) {
9✔
214
            // Convert PascalCase to camelCase
215
            return lcfirst($key);
2✔
216
        }
217

218
        // Convert snake_case, kebab-case, and dot.notation to camelCase
219
        $parts = preg_split('/[_\-.]+/', $key);
7✔
220
        if (false === $parts) {
7✔
221
            return $key;
×
222
        }
223

224
        $result = strtolower($parts[0] ?? '');
7✔
225
        for ($i = 1, $iMax = count($parts); $i < $iMax; ++$i) {
7✔
226
            $result .= ucfirst(strtolower($parts[$i]));
6✔
227
        }
228

229
        return $result;
7✔
230
    }
231

232
    private function toKebabCase(string $key): string
4✔
233
    {
234
        // Convert camelCase/PascalCase to kebab-case
235
        $result = preg_replace('/([a-z])([A-Z])/', '$1-$2', $key);
4✔
236
        // Convert snake_case and dot.notation to kebab-case
237
        $result = str_replace(['_', '.'], '-', $result ?? $key);
4✔
238

239
        return strtolower($result);
4✔
240
    }
241

242
    private function toPascalCase(string $key): string
5✔
243
    {
244
        // Handle camelCase/PascalCase first
245
        if (preg_match('/[A-Z]/', $key)) {
5✔
246
            // Already in PascalCase or camelCase, just ensure first letter is uppercase
247
            return ucfirst($key);
2✔
248
        }
249

250
        // Convert snake_case, kebab-case, and dot.notation to PascalCase
251
        $parts = preg_split('/[_\-.]+/', $key);
3✔
252
        if (false === $parts) {
3✔
253
            return ucfirst($key);
×
254
        }
255

256
        return implode('', array_map('ucfirst', array_map('strtolower', $parts)));
3✔
257
    }
258

259
    private function toDotNotation(string $key): string
2✔
260
    {
261
        // Convert camelCase/PascalCase to dot.notation
262
        $result = preg_replace('/([a-z])([A-Z])/', '$1.$2', $key);
2✔
263
        // Convert snake_case and kebab-case to dot.notation
264
        $result = str_replace(['_', '-'], '.', $result ?? $key);
2✔
265

266
        return strtolower($result);
2✔
267
    }
268

269
    private function convertDotSeparatedKey(string $key): string
4✔
270
    {
271
        $segments = explode('.', $key);
4✔
272
        $convertedSegments = [];
4✔
273

274
        foreach ($segments as $segment) {
4✔
275
            $convertedSegments[] = match ($this->convention) {
4✔
276
                KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($segment),
4✔
277
                KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($segment),
3✔
278
                KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($segment),
1✔
279
                KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($segment),
1✔
NEW
280
                KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($segment),
×
NEW
281
                null => $segment,
×
282
            };
4✔
283
        }
284

285
        return implode('.', $convertedSegments);
4✔
286
    }
287

288
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
3✔
289
    {
290
        $details = $issue->getDetails();
3✔
291
        $resultType = $this->resultTypeOnValidationFailure();
3✔
292

293
        $level = $resultType->toString();
3✔
294
        $color = $resultType->toColorString();
3✔
295

296
        $key = $details['key'] ?? 'unknown';
3✔
297

298
        // Handle different issue types
299
        if (isset($details['inconsistency_type']) && 'mixed_conventions' === $details['inconsistency_type']) {
3✔
300
            $detectedConventions = $details['detected_conventions'] ?? [];
1✔
301
            $dominantConvention = $details['dominant_convention'] ?? 'unknown';
1✔
302
            $allConventions = $details['all_conventions_found'] ?? [];
1✔
303

304
            $detectedStr = implode(', ', $detectedConventions);
1✔
305
            $allStr = implode(', ', $allConventions);
1✔
306

307
            $message = "inconsistent key naming: `{$key}` follows {$detectedStr} but file uses mixed conventions ({$allStr}). Dominant convention: {$dominantConvention}";
1✔
308
        } else {
309
            // Legacy behavior for configured conventions
310
            $convention = $details['expected_convention'] ?? 'custom pattern';
2✔
311
            $suggestion = $details['suggestion'] ?? '';
2✔
312

313
            $message = "key naming convention violation: `{$key}` does not follow {$convention} convention";
2✔
314
            if (!empty($suggestion) && $suggestion !== $key) {
2✔
315
                $message .= " (suggestion: `{$suggestion}`)";
2✔
316
            }
317
        }
318

319
        return "- <fg={$color}>{$level}</> {$prefix}{$message}";
3✔
320
    }
321

322
    /**
323
     * @return class-string<ParserInterface>[]
324
     */
325
    public function supportsParser(): array
1✔
326
    {
327
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
328
    }
329

330
    public function resultTypeOnValidationFailure(): ResultType
4✔
331
    {
332
        return ResultType::WARNING;
4✔
333
    }
334

335
    public function shouldShowDetailedOutput(): bool
1✔
336
    {
337
        return false;
1✔
338
    }
339

340
    /**
341
     * Get available naming conventions.
342
     *
343
     * @return array<string, array{pattern: string, description: string}>
344
     */
345
    public static function getAvailableConventions(): array
3✔
346
    {
347
        $conventions = [];
3✔
348
        foreach (KeyNamingConvention::cases() as $convention) {
3✔
349
            $conventions[$convention->value] = [
3✔
350
                'pattern' => $convention->getPattern(),
3✔
351
                'description' => $convention->getDescription(),
3✔
352
            ];
3✔
353
        }
354

355
        return $conventions;
3✔
356
    }
357

358
    /**
359
     * Check if validator should run based on configuration.
360
     */
361
    public function shouldRun(): bool
10✔
362
    {
363
        return true; // Always run, even without configuration
10✔
364
    }
365

366
    /**
367
     * Analyze keys for consistency when no convention is configured.
368
     *
369
     * @param array<string> $keys
370
     *
371
     * @return array<array<string, mixed>>
372
     */
373
    private function analyzeKeyConsistency(array $keys, string $fileName): array
8✔
374
    {
375
        if (empty($keys)) {
8✔
NEW
376
            return [];
×
377
        }
378

379
        $conventionCounts = [];
8✔
380
        $keyConventions = [];
8✔
381

382
        // Analyze each key to determine which conventions it matches
383
        foreach ($keys as $key) {
8✔
384
            $matchingConventions = $this->detectKeyConventions($key);
8✔
385
            $keyConventions[$key] = $matchingConventions;
8✔
386

387
            foreach ($matchingConventions as $convention) {
8✔
388
                $conventionCounts[$convention] = ($conventionCounts[$convention] ?? 0) + 1;
8✔
389
            }
390
        }
391

392
        // If all keys follow the same convention(s), no issues
393
        if (count($conventionCounts) <= 1) {
8✔
394
            return [];
2✔
395
        }
396

397
        // Find the most common convention
398
        $dominantConvention = array_key_first($conventionCounts);
6✔
399
        $maxCount = $conventionCounts[$dominantConvention];
6✔
400

401
        foreach ($conventionCounts as $convention => $count) {
6✔
402
            if ($count > $maxCount) {
6✔
403
                $dominantConvention = $convention;
×
404
                $maxCount = $count;
×
405
            }
406
        }
407

408
        $issues = [];
6✔
409
        $conventionNames = array_keys($conventionCounts);
6✔
410

411
        // Report inconsistencies
412
        foreach ($keys as $key) {
6✔
413
            $keyMatches = $keyConventions[$key];
6✔
414

415
            // If key doesn't match the dominant convention, it's an issue
416
            if (!in_array($dominantConvention, $keyMatches, true)) {
6✔
417
                $issues[] = [
5✔
418
                    'key' => $key,
5✔
419
                    'file' => $fileName,
5✔
420
                    'detected_conventions' => $keyMatches,
5✔
421
                    'dominant_convention' => $dominantConvention,
5✔
422
                    'all_conventions_found' => $conventionNames,
5✔
423
                    'inconsistency_type' => 'mixed_conventions',
5✔
424
                ];
5✔
425
            }
426
        }
427

428
        return $issues;
6✔
429
    }
430

431
    /**
432
     * Detect which conventions a key matches.
433
     *
434
     * @return array<string>
435
     */
436
    private function detectKeyConventions(string $key): array
9✔
437
    {
438
        // For keys with dots, we need to handle dot.notation specially
439
        if (str_contains($key, '.')) {
9✔
440
            $matchingConventions = [];
4✔
441

442
            // First, check if the entire key matches dot.notation
443
            if (KeyNamingConvention::DOT_NOTATION->matches($key)) {
4✔
444
                $matchingConventions[] = KeyNamingConvention::DOT_NOTATION->value;
2✔
445
            }
446

447
            // Then check if all segments follow a consistent non-dot convention
448
            $segments = explode('.', $key);
4✔
449
            $consistentConventions = null;
4✔
450

451
            // Check which conventions ALL segments support (excluding dot.notation)
452
            foreach ($segments as $segment) {
4✔
453
                $segmentMatches = $this->detectSegmentConventions($segment);
4✔
454
                // Remove dot.notation from segment matches as it doesn't apply to individual segments
455
                $segmentMatches = array_filter($segmentMatches, fn ($conv) => $conv !== KeyNamingConvention::DOT_NOTATION->value);
4✔
456

457
                if (null === $consistentConventions) {
4✔
458
                    // First segment - initialize with its conventions
459
                    $consistentConventions = $segmentMatches;
4✔
460
                } else {
461
                    // Subsequent segments - keep only conventions that ALL segments support
462
                    $consistentConventions = array_intersect($consistentConventions, $segmentMatches);
4✔
463
                }
464
            }
465

466
            // Add segment-based conventions to the result
467
            if (!empty($consistentConventions) && !in_array('unknown', $consistentConventions, true)) {
4✔
468
                $matchingConventions = array_merge($matchingConventions, array_values($consistentConventions));
4✔
469
            }
470

471
            // If no convention matches, it's mixed
472
            if (empty($matchingConventions)) {
4✔
UNCOV
473
                return ['mixed_conventions'];
×
474
            }
475

476
            return array_unique($matchingConventions);
4✔
477
        } else {
478
            // No dots, check regular conventions
479
            return $this->detectSegmentConventions($key);
6✔
480
        }
481
    }
482

483
    /**
484
     * Detect conventions for a single segment (without dots).
485
     *
486
     * @return array<string>
487
     */
488
    private function detectSegmentConventions(string $segment): array
9✔
489
    {
490
        $matchingConventions = [];
9✔
491

492
        foreach (KeyNamingConvention::cases() as $convention) {
9✔
493
            if ($convention->matches($segment)) {
9✔
494
                $matchingConventions[] = $convention->value;
9✔
495
            }
496
        }
497

498
        // If no convention matches, classify as 'unknown'
499
        if (empty($matchingConventions)) {
9✔
500
            $matchingConventions[] = 'unknown';
1✔
501
        }
502

503
        return $matchingConventions;
9✔
504
    }
505
}
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