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

move-elevator / composer-translation-validator / 18559927341

16 Oct 2025 11:35AM UTC coverage: 95.519%. Remained the same
18559927341

Pull #73

github

jackd248
build: add php-cs-fixer-preset
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

95.93
/src/Validator/KeyNamingConventionValidator.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 InvalidArgumentException;
17
use MoveElevator\ComposerTranslationValidator\Config\TranslationValidatorConfig;
18
use MoveElevator\ComposerTranslationValidator\Enum\KeyNamingConvention;
19
use MoveElevator\ComposerTranslationValidator\Parser\{JsonParser, ParserInterface, PhpParser, XliffParser, YamlParser};
20
use MoveElevator\ComposerTranslationValidator\Result\Issue;
21

22
use function count;
23
use function in_array;
24
use function is_string;
25

26
/**
27
 * KeyNamingConventionValidator.
28
 *
29
 * @author Konrad Michalik <km@move-elevator.de>
30
 * @license GPL-3.0-or-later
31
 */
32
class KeyNamingConventionValidator extends AbstractValidator implements ValidatorInterface
33
{
34
    private ?KeyNamingConvention $convention = null;
35
    private ?string $customPattern = null;
36
    private ?TranslationValidatorConfig $config = null;
37
    private bool $configHintShown = false;
38

39
    public function setConfig(?TranslationValidatorConfig $config): void
10✔
40
    {
41
        $this->config = $config;
10✔
42
        $this->loadConventionFromConfig();
10✔
43
    }
44

45
    public function processFile(ParserInterface $file): array
52✔
46
    {
47
        // Reset hint shown flag for each new file
48
        $this->configHintShown = false;
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());
10✔
65
        } else {
66
            // Use configured convention
67
            $issueData = [];
41✔
68
            foreach ($keys as $key) {
41✔
69
                if (!$this->validateKeyFormat($key)) {
41✔
70
                    $issueData[] = [
28✔
71
                        'key' => $key,
28✔
72
                        'file' => $file->getFileName(),
28✔
73
                        'expected_convention' => $this->convention->value ?? 'custom pattern',
28✔
74
                        'pattern' => $this->getActivePattern(),
28✔
75
                        'suggestion' => $this->suggestCorrection($key),
28✔
76
                    ];
28✔
77
                }
78
            }
79
        }
80

81
        return $issueData;
51✔
82
    }
83

84
    public function setConvention(string $convention): void
47✔
85
    {
86
        if ('dot.notation' === $convention) {
47✔
87
            throw new InvalidArgumentException('dot.notation cannot be configured explicitly. It is used internally for detection but should not be set as a configuration option.');
1✔
88
        }
89

90
        $this->convention = KeyNamingConvention::fromString($convention);
46✔
91
    }
92

93
    public function setCustomPattern(string $pattern): void
7✔
94
    {
95
        $result = @preg_match($pattern, '');
7✔
96
        if (false === $result) {
7✔
97
            throw new InvalidArgumentException('Invalid regex pattern provided');
3✔
98
        }
99

100
        $this->customPattern = $pattern;
4✔
101
        $this->convention = null; // Custom pattern overrides convention
4✔
102
    }
103

104
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
6✔
105
    {
106
        $details = $issue->getDetails();
6✔
107
        $resultType = $this->resultTypeOnValidationFailure();
6✔
108

109
        $level = $resultType->toString();
6✔
110
        $color = $resultType->toColorString();
6✔
111

112
        $key = $details['key'] ?? 'unknown';
6✔
113

114
        // Handle different issue types
115
        if (isset($details['inconsistency_type']) && 'mixed_conventions' === $details['inconsistency_type']) {
6✔
116
            $detectedConventions = $details['detected_conventions'] ?? [];
3✔
117
            $dominantConvention = $details['dominant_convention'] ?? 'unknown';
3✔
118

119
            $detectedStr = implode(', ', $detectedConventions);
3✔
120

121
            $message = "key naming inconsistency: `{$key}` uses {$detectedStr} convention";
3✔
122
            $message .= ", but this file predominantly uses {$dominantConvention}";
3✔
123

124
            if ('unknown' !== $dominantConvention && 'mixed_conventions' !== $dominantConvention) {
3✔
125
                $suggestion = $this->suggestKeyConversion($key, $dominantConvention);
3✔
126
                if ($suggestion !== $key) {
3✔
127
                    $message .= ". Consider: `{$suggestion}`";
3✔
128
                }
129
            } else {
NEW
130
                $message .= '. Consider standardizing all keys to use the same naming convention';
×
131
            }
132

133
            if ($this->isAutoDetectionMode() && !$this->configHintShown) {
3✔
134
                $message .= $this->getConfigurationHint();
3✔
135
                $this->configHintShown = true;
3✔
136
            }
137
        } else {
138
            $convention = $details['expected_convention'] ?? 'custom pattern';
3✔
139
            $suggestion = $details['suggestion'] ?? '';
3✔
140

141
            $message = "key naming convention violation: `{$key}` does not follow the configured {$convention} convention";
3✔
142
            if (!empty($suggestion) && $suggestion !== $key) {
3✔
143
                $message .= ". Suggested: `{$suggestion}`";
3✔
144
            }
145
        }
146

147
        return "- <fg={$color}>{$level}</> {$prefix}{$message}";
6✔
148
    }
149

150
    /**
151
     * @return class-string<ParserInterface>[]
152
     */
NEW
153
    public function supportsParser(): array
×
154
    {
NEW
155
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
×
156
    }
157

158
    public function resultTypeOnValidationFailure(): ResultType
6✔
159
    {
160
        return ResultType::WARNING;
6✔
161
    }
162

163
    /**
164
     * Get available naming conventions.
165
     *
166
     * @return array<string, array{pattern: string, description: string}>
167
     */
168
    public static function getAvailableConventions(): array
1✔
169
    {
170
        $conventions = [];
1✔
171

172
        foreach (KeyNamingConvention::getConfigurableConventions() as $value) {
1✔
173
            $convention = KeyNamingConvention::from($value);
1✔
174
            $conventions[$value] = [
1✔
175
                'pattern' => $convention->getPattern(),
1✔
176
                'description' => $convention->getDescription(),
1✔
177
            ];
1✔
178
        }
179

180
        return $conventions;
1✔
181
    }
182

183
    /**
184
     * Check if validator should run based on configuration.
185
     */
186
    public function shouldRun(): bool
9✔
187
    {
188
        return true; // Always run, even without configuration
9✔
189
    }
190

191
    private function loadConventionFromConfig(): void
10✔
192
    {
193
        if (null === $this->config) {
10✔
194
            return;
1✔
195
        }
196

197
        $validatorSettings = $this->config->getValidatorSettings('KeyNamingConventionValidator');
9✔
198

199
        if (empty($validatorSettings)) {
9✔
200
            return;
2✔
201
        }
202

203
        // Load convention from config
204
        if (isset($validatorSettings['convention']) && is_string($validatorSettings['convention'])) {
7✔
205
            try {
206
                $this->setConvention($validatorSettings['convention']);
4✔
207
            } catch (InvalidArgumentException $e) {
2✔
208
                $this->logger?->warning(
2✔
209
                    'Invalid convention in config: '.$validatorSettings['convention'].'. '.$e->getMessage(),
2✔
210
                );
2✔
211
            }
212
        }
213

214
        // Load custom pattern from config (overrides convention)
215
        if (isset($validatorSettings['custom_pattern']) && is_string($validatorSettings['custom_pattern'])) {
7✔
216
            try {
217
                $this->setCustomPattern($validatorSettings['custom_pattern']);
4✔
218
            } catch (InvalidArgumentException $e) {
2✔
219
                $this->logger?->warning(
2✔
220
                    'Invalid custom pattern in config: '.$validatorSettings['custom_pattern'].'. '.$e->getMessage(),
2✔
221
                );
2✔
222
            }
223
        }
224
    }
225

226
    private function validateKeyFormat(string $key): bool
42✔
227
    {
228
        if (null === $this->convention && null === $this->customPattern) {
42✔
229
            return true; // No validation if no pattern is set
1✔
230
        }
231

232
        // If custom pattern is set, use it directly
233
        if (null !== $this->customPattern) {
41✔
234
            return (bool) preg_match($this->customPattern, $key);
3✔
235
        }
236

237
        // For base conventions, validate each segment separately if key contains dots
238
        if (str_contains($key, '.')) {
38✔
239
            $segments = explode('.', $key);
7✔
240
            foreach ($segments as $segment) {
7✔
241
                if (!$this->validateSegment($segment)) {
7✔
242
                    return false;
4✔
243
                }
244
            }
245

246
            return true;
6✔
247
        }
248

249
        // Single segment, validate directly
250
        return $this->validateSegment($key);
32✔
251
    }
252

253
    private function validateSegment(string $segment): bool
39✔
254
    {
255
        if (null === $this->convention) {
39✔
256
            return true;
1✔
257
        }
258

259
        return $this->convention->matches($segment);
38✔
260
    }
261

262
    private function getActivePattern(): ?string
28✔
263
    {
264
        if (null !== $this->customPattern) {
28✔
265
            return $this->customPattern;
3✔
266
        }
267

268
        return $this->convention?->getPattern();
25✔
269
    }
270

271
    private function suggestCorrection(string $key): string
28✔
272
    {
273
        if (null === $this->convention) {
28✔
274
            return $key;
3✔
275
        }
276

277
        if (str_contains($key, '.')) {
25✔
278
            return $this->convertDotSeparatedKey($key, $this->convention);
4✔
279
        }
280

281
        return match ($this->convention) {
21✔
282
            KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($key),
21✔
283
            KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($key),
9✔
284
            KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($key),
5✔
285
            KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($key),
4✔
286
            KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($key),
21✔
287
        };
21✔
288
    }
289

290
    private function toSnakeCase(string $key): string
19✔
291
    {
292
        // Convert camelCase/PascalCase to snake_case
293
        $result = preg_replace('/([a-z])([A-Z])/', '$1_$2', $key);
19✔
294
        // Convert kebab-case and dot.notation to snake_case
295
        $result = str_replace(['-', '.'], '_', $result ?? $key);
19✔
296

297
        // Convert to lowercase
298
        return strtolower($result);
19✔
299
    }
300

301
    private function toCamelCase(string $key): string
11✔
302
    {
303
        // Handle camelCase/PascalCase first
304
        if (preg_match('/[A-Z]/', $key)) {
11✔
305
            // Convert PascalCase to camelCase
306
            return lcfirst($key);
2✔
307
        }
308

309
        // Convert snake_case, kebab-case, and dot.notation to camelCase
310
        $parts = preg_split('/[_\-.]+/', $key);
9✔
311
        if (false === $parts) {
9✔
UNCOV
312
            return $key;
×
313
        }
314

315
        $result = strtolower($parts[0] ?? '');
9✔
316
        for ($i = 1, $iMax = count($parts); $i < $iMax; ++$i) {
9✔
317
            $result .= ucfirst(strtolower($parts[$i]));
7✔
318
        }
319

320
        return $result;
9✔
321
    }
322

323
    private function toKebabCase(string $key): string
4✔
324
    {
325
        // Convert camelCase/PascalCase to kebab-case
326
        $result = preg_replace('/([a-z])([A-Z])/', '$1-$2', $key);
4✔
327
        // Convert snake_case and dot.notation to kebab-case
328
        $result = str_replace(['_', '.'], '-', $result ?? $key);
4✔
329

330
        return strtolower($result);
4✔
331
    }
332

333
    private function toPascalCase(string $key): string
5✔
334
    {
335
        // Handle camelCase/PascalCase first
336
        if (preg_match('/[A-Z]/', $key)) {
5✔
337
            // Already in PascalCase or camelCase, just ensure first letter is uppercase
338
            return ucfirst($key);
2✔
339
        }
340

341
        // Convert snake_case, kebab-case, and dot.notation to PascalCase
342
        $parts = preg_split('/[_\-.]+/', $key);
3✔
343
        if (false === $parts) {
3✔
UNCOV
344
            return ucfirst($key);
×
345
        }
346

347
        return implode('', array_map('ucfirst', array_map('strtolower', $parts)));
3✔
348
    }
349

350
    private function toDotNotation(string $key): string
2✔
351
    {
352
        // Convert camelCase/PascalCase to dot.notation
353
        $result = preg_replace('/([a-z])([A-Z])/', '$1.$2', $key);
2✔
354
        // Convert snake_case and kebab-case to dot.notation
355
        $result = str_replace(['_', '-'], '.', $result ?? $key);
2✔
356

357
        return strtolower($result);
2✔
358
    }
359

360
    private function convertDotSeparatedKey(string $key, ?KeyNamingConvention $convention): string
6✔
361
    {
362
        if (null === $convention) {
6✔
363
            return $key;
1✔
364
        }
365

366
        $segments = explode('.', $key);
5✔
367
        $convertedSegments = [];
5✔
368

369
        foreach ($segments as $segment) {
5✔
370
            $convertedSegments[] = match ($convention) {
5✔
371
                KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($segment),
5✔
372
                KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($segment),
4✔
373
                KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($segment),
2✔
374
                KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($segment),
2✔
375
                KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($segment),
1✔
376
            };
5✔
377
        }
378

379
        return implode('.', $convertedSegments);
5✔
380
    }
381

382
    /**
383
     * Check if the validator is in auto-detection mode (no explicit configuration).
384
     */
385
    private function isAutoDetectionMode(): bool
3✔
386
    {
387
        return null === $this->convention && null === $this->customPattern;
3✔
388
    }
389

390
    /**
391
     * Get a helpful configuration hint for users.
392
     */
393
    private function getConfigurationHint(): string
3✔
394
    {
395
        // Use only configurable conventions (excludes dot.notation)
396
        $availableConventions = KeyNamingConvention::getConfigurableConventions();
3✔
397
        $conventionsList = implode(', ', $availableConventions);
3✔
398

399
        return "\n  Tip: Configure a specific naming convention in a configuration file to avoid inconsistencies. "
3✔
400
            ."Available conventions: {$conventionsList}. ";
3✔
401
    }
402

403
    /**
404
     * Suggest a key conversion to match the dominant convention.
405
     */
406
    private function suggestKeyConversion(string $key, string $targetConvention): string
4✔
407
    {
408
        try {
409
            $convention = KeyNamingConvention::fromString($targetConvention);
4✔
410

411
            if (str_contains($key, '.')) {
3✔
UNCOV
412
                return $this->convertDotSeparatedKey($key, $convention);
×
413
            }
414

415
            return match ($convention) {
416
                KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($key),
3✔
417
                KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($key),
1✔
418
                KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($key),
×
UNCOV
419
                KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($key),
×
420
                KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($key),
3✔
421
            };
422
        } catch (InvalidArgumentException) {
1✔
423
            return $key;
1✔
424
        }
425
    }
426

427
    /**
428
     * Analyze keys for consistency when no convention is configured.
429
     *
430
     * @param array<string> $keys
431
     *
432
     * @return array<array<string, mixed>>
433
     */
434
    private function analyzeKeyConsistency(array $keys, string $fileName): array
10✔
435
    {
436
        if (empty($keys)) {
10✔
437
            return [];
1✔
438
        }
439

440
        $conventionCounts = [];
9✔
441
        $keyConventions = [];
9✔
442

443
        // Analyze each key to determine which conventions it matches
444
        foreach ($keys as $key) {
9✔
445
            $matchingConventions = $this->detectKeyConventions($key);
9✔
446
            $keyConventions[$key] = $matchingConventions;
9✔
447

448
            foreach ($matchingConventions as $convention) {
9✔
449
                $conventionCounts[$convention] = ($conventionCounts[$convention] ?? 0) + 1;
9✔
450
            }
451
        }
452

453
        // If all keys follow the same convention(s), no issues
454
        if (count($conventionCounts) <= 1) {
9✔
455
            return [];
1✔
456
        }
457

458
        // Find the most common convention
459
        $dominantConvention = array_key_first($conventionCounts);
8✔
460
        $maxCount = $conventionCounts[$dominantConvention];
8✔
461

462
        foreach ($conventionCounts as $convention => $count) {
8✔
463
            if ($count > $maxCount) {
8✔
UNCOV
464
                $dominantConvention = $convention;
×
UNCOV
465
                $maxCount = $count;
×
466
            }
467
        }
468

469
        $issues = [];
8✔
470
        $conventionNames = array_keys($conventionCounts);
8✔
471

472
        // Report inconsistencies
473
        foreach ($keys as $key) {
8✔
474
            $keyMatches = $keyConventions[$key];
8✔
475

476
            // If key doesn't match the dominant convention, it's an issue
477
            if (!in_array($dominantConvention, $keyMatches, true)) {
8✔
478
                $issues[] = [
7✔
479
                    'key' => $key,
7✔
480
                    'file' => $fileName,
7✔
481
                    'detected_conventions' => $keyMatches,
7✔
482
                    'dominant_convention' => $dominantConvention,
7✔
483
                    'all_conventions_found' => $conventionNames,
7✔
484
                    'inconsistency_type' => 'mixed_conventions',
7✔
485
                ];
7✔
486
            }
487
        }
488

489
        return $issues;
8✔
490
    }
491

492
    /**
493
     * Detect which conventions a key matches.
494
     *
495
     * @return array<string>
496
     */
497
    private function detectKeyConventions(string $key): array
11✔
498
    {
499
        // For keys with dots, we need to handle dot.notation specially
500
        if (str_contains($key, '.')) {
11✔
501
            $matchingConventions = [];
5✔
502

503
            // First, check if the entire key matches dot.notation
504
            if (KeyNamingConvention::DOT_NOTATION->matches($key)) {
5✔
505
                $matchingConventions[] = KeyNamingConvention::DOT_NOTATION->value;
2✔
506
            }
507

508
            // Then check if all segments follow a consistent non-dot convention
509
            $segments = explode('.', $key);
5✔
510
            $consistentConventions = null;
5✔
511

512
            // Check which conventions ALL segments support (excluding dot.notation)
513
            foreach ($segments as $segment) {
5✔
514
                $segmentMatches = $this->detectSegmentConventions($segment);
5✔
515
                // Remove dot.notation from segment matches as it doesn't apply to individual segments
516
                $segmentMatches = array_filter($segmentMatches, fn ($conv) => $conv !== KeyNamingConvention::DOT_NOTATION->value);
5✔
517

518
                if (null === $consistentConventions) {
5✔
519
                    // First segment - initialize with its conventions
520
                    $consistentConventions = $segmentMatches;
5✔
521
                } else {
522
                    // Subsequent segments - keep only conventions that ALL segments support
523
                    $consistentConventions = array_intersect($consistentConventions, $segmentMatches);
5✔
524
                }
525
            }
526

527
            // Add segment-based conventions to the result
528
            if (!empty($consistentConventions) && !in_array('unknown', $consistentConventions, true)) {
5✔
529
                $matchingConventions = array_merge($matchingConventions, array_values($consistentConventions));
4✔
530
            }
531

532
            // If no convention matches, it's mixed
533
            if (empty($matchingConventions)) {
5✔
534
                return ['mixed_conventions'];
1✔
535
            }
536

537
            return array_unique($matchingConventions);
4✔
538
        } else {
539
            // No dots, check regular conventions
540
            return $this->detectSegmentConventions($key);
7✔
541
        }
542
    }
543

544
    /**
545
     * Detect conventions for a single segment (without dots).
546
     *
547
     * @return array<string>
548
     */
549
    private function detectSegmentConventions(string $segment): array
12✔
550
    {
551
        $matchingConventions = [];
12✔
552

553
        foreach (KeyNamingConvention::cases() as $convention) {
12✔
554
            if ($convention->matches($segment)) {
12✔
555
                $matchingConventions[] = $convention->value;
10✔
556
            }
557
        }
558

559
        // If no convention matches, classify as 'unknown'
560
        if (empty($matchingConventions)) {
12✔
561
            $matchingConventions[] = 'unknown';
4✔
562
        }
563

564
        return $matchingConventions;
12✔
565
    }
566
}
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