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

move-elevator / composer-translation-validator / 16598337426

29 Jul 2025 02:00PM UTC coverage: 95.366% (-1.4%) from 96.758%
16598337426

Pull #61

github

web-flow
Merge b1bb980ce into e3eff1aea
Pull Request #61: refactor: clean up tests by removing redundant cases and improving validation logic

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

33 existing lines in 3 files now uncovered.

2264 of 2374 relevant lines covered (95.37%)

7.52 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 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
    private bool $configHintShown = false;
42

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

49
    public function processFile(ParserInterface $file): array
52✔
50
    {
51
        // Reset hint shown flag for each new file
52
        $this->configHintShown = false;
52✔
53

54
        $keys = $file->extractKeys();
52✔
55

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

61
            return [];
1✔
62
        }
63

64
        $issues = [];
51✔
65

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

85
        return $issueData;
51✔
86
    }
87

88
    private function loadConventionFromConfig(): void
10✔
89
    {
90
        if (null === $this->config) {
10✔
91
            return;
1✔
92
        }
93

94
        $validatorSettings = $this->config->getValidatorSettings('KeyNamingConventionValidator');
9✔
95

96
        if (empty($validatorSettings)) {
9✔
97
            return;
2✔
98
        }
99

100
        // Load convention from config
101
        if (isset($validatorSettings['convention']) && is_string($validatorSettings['convention'])) {
7✔
102
            try {
103
                $this->setConvention($validatorSettings['convention']);
4✔
104
            } catch (InvalidArgumentException $e) {
2✔
105
                $this->logger?->warning(
2✔
106
                    'Invalid convention in config: '.$validatorSettings['convention'].'. '.$e->getMessage(),
2✔
107
                );
2✔
108
            }
109
        }
110

111
        // Load custom pattern from config (overrides convention)
112
        if (isset($validatorSettings['custom_pattern']) && is_string($validatorSettings['custom_pattern'])) {
7✔
113
            try {
114
                $this->setCustomPattern($validatorSettings['custom_pattern']);
4✔
115
            } catch (InvalidArgumentException $e) {
2✔
116
                $this->logger?->warning(
2✔
117
                    'Invalid custom pattern in config: '.$validatorSettings['custom_pattern'].'. '.$e->getMessage(),
2✔
118
                );
2✔
119
            }
120
        }
121
    }
122

123
    public function setConvention(string $convention): void
47✔
124
    {
125
        if ('dot.notation' === $convention) {
47✔
126
            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✔
127
        }
128

129
        $this->convention = KeyNamingConvention::fromString($convention);
46✔
130
    }
131

132
    public function setCustomPattern(string $pattern): void
7✔
133
    {
134
        $result = @preg_match($pattern, '');
7✔
135
        if (false === $result) {
7✔
136
            throw new InvalidArgumentException('Invalid regex pattern provided');
3✔
137
        }
138

139
        $this->customPattern = $pattern;
4✔
140
        $this->convention = null; // Custom pattern overrides convention
4✔
141
    }
142

143
    private function validateKeyFormat(string $key): bool
42✔
144
    {
145
        if (null === $this->convention && null === $this->customPattern) {
42✔
146
            return true; // No validation if no pattern is set
1✔
147
        }
148

149
        // If custom pattern is set, use it directly
150
        if (null !== $this->customPattern) {
41✔
151
            return (bool) preg_match($this->customPattern, $key);
3✔
152
        }
153

154
        // For base conventions, validate each segment separately if key contains dots
155
        if (str_contains($key, '.')) {
38✔
156
            $segments = explode('.', $key);
7✔
157
            foreach ($segments as $segment) {
7✔
158
                if (!$this->validateSegment($segment)) {
7✔
159
                    return false;
4✔
160
                }
161
            }
162

163
            return true;
6✔
164
        }
165

166
        // Single segment, validate directly
167
        return $this->validateSegment($key);
32✔
168
    }
169

170
    private function validateSegment(string $segment): bool
39✔
171
    {
172
        if (null === $this->convention) {
39✔
173
            return true;
1✔
174
        }
175

176
        return $this->convention->matches($segment);
38✔
177
    }
178

179
    private function getActivePattern(): ?string
28✔
180
    {
181
        if (null !== $this->customPattern) {
28✔
182
            return $this->customPattern;
3✔
183
        }
184

185
        return $this->convention?->getPattern();
25✔
186
    }
187

188
    private function suggestCorrection(string $key): string
28✔
189
    {
190
        if (null === $this->convention) {
28✔
191
            return $key;
3✔
192
        }
193

194
        if (str_contains($key, '.')) {
25✔
195
            return $this->convertDotSeparatedKey($key, $this->convention);
4✔
196
        }
197

198
        return match ($this->convention) {
21✔
199
            KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($key),
21✔
200
            KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($key),
9✔
201
            KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($key),
5✔
202
            KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($key),
4✔
203
            KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($key),
21✔
204
        };
21✔
205
    }
206

207
    private function toSnakeCase(string $key): string
19✔
208
    {
209
        // Convert camelCase/PascalCase to snake_case
210
        $result = preg_replace('/([a-z])([A-Z])/', '$1_$2', $key);
19✔
211
        // Convert kebab-case and dot.notation to snake_case
212
        $result = str_replace(['-', '.'], '_', $result ?? $key);
19✔
213

214
        // Convert to lowercase
215
        return strtolower($result);
19✔
216
    }
217

218
    private function toCamelCase(string $key): string
11✔
219
    {
220
        // Handle camelCase/PascalCase first
221
        if (preg_match('/[A-Z]/', $key)) {
11✔
222
            // Convert PascalCase to camelCase
223
            return lcfirst($key);
2✔
224
        }
225

226
        // Convert snake_case, kebab-case, and dot.notation to camelCase
227
        $parts = preg_split('/[_\-.]+/', $key);
9✔
228
        if (false === $parts) {
9✔
229
            return $key;
×
230
        }
231

232
        $result = strtolower($parts[0] ?? '');
9✔
233
        for ($i = 1, $iMax = count($parts); $i < $iMax; ++$i) {
9✔
234
            $result .= ucfirst(strtolower($parts[$i]));
7✔
235
        }
236

237
        return $result;
9✔
238
    }
239

240
    private function toKebabCase(string $key): string
4✔
241
    {
242
        // Convert camelCase/PascalCase to kebab-case
243
        $result = preg_replace('/([a-z])([A-Z])/', '$1-$2', $key);
4✔
244
        // Convert snake_case and dot.notation to kebab-case
245
        $result = str_replace(['_', '.'], '-', $result ?? $key);
4✔
246

247
        return strtolower($result);
4✔
248
    }
249

250
    private function toPascalCase(string $key): string
5✔
251
    {
252
        // Handle camelCase/PascalCase first
253
        if (preg_match('/[A-Z]/', $key)) {
5✔
254
            // Already in PascalCase or camelCase, just ensure first letter is uppercase
255
            return ucfirst($key);
2✔
256
        }
257

258
        // Convert snake_case, kebab-case, and dot.notation to PascalCase
259
        $parts = preg_split('/[_\-.]+/', $key);
3✔
260
        if (false === $parts) {
3✔
261
            return ucfirst($key);
×
262
        }
263

264
        return implode('', array_map('ucfirst', array_map('strtolower', $parts)));
3✔
265
    }
266

267
    private function toDotNotation(string $key): string
2✔
268
    {
269
        // Convert camelCase/PascalCase to dot.notation
270
        $result = preg_replace('/([a-z])([A-Z])/', '$1.$2', $key);
2✔
271
        // Convert snake_case and kebab-case to dot.notation
272
        $result = str_replace(['_', '-'], '.', $result ?? $key);
2✔
273

274
        return strtolower($result);
2✔
275
    }
276

277
    private function convertDotSeparatedKey(string $key, ?KeyNamingConvention $convention): string
6✔
278
    {
279
        if (null === $convention) {
6✔
280
            return $key;
1✔
281
        }
282

283
        $segments = explode('.', $key);
5✔
284
        $convertedSegments = [];
5✔
285

286
        foreach ($segments as $segment) {
5✔
287
            $convertedSegments[] = match ($convention) {
5✔
288
                KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($segment),
5✔
289
                KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($segment),
4✔
290
                KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($segment),
2✔
291
                KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($segment),
2✔
292
                KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($segment),
1✔
293
            };
5✔
294
        }
295

296
        return implode('.', $convertedSegments);
5✔
297
    }
298

299
    /**
300
     * Check if the validator is in auto-detection mode (no explicit configuration).
301
     */
302
    private function isAutoDetectionMode(): bool
3✔
303
    {
304
        return null === $this->convention && null === $this->customPattern;
3✔
305
    }
306

307
    /**
308
     * Get a helpful configuration hint for users.
309
     */
310
    private function getConfigurationHint(): string
3✔
311
    {
312
        // Use only configurable conventions (excludes dot.notation)
313
        $availableConventions = KeyNamingConvention::getConfigurableConventions();
3✔
314
        $conventionsList = implode(', ', $availableConventions);
3✔
315

316
        return "\n  Tip: Configure a specific naming convention in a configuration file to avoid inconsistencies. "
3✔
317
            ."Available conventions: {$conventionsList}. ";
3✔
318
    }
319

320
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
6✔
321
    {
322
        $details = $issue->getDetails();
6✔
323
        $resultType = $this->resultTypeOnValidationFailure();
6✔
324

325
        $level = $resultType->toString();
6✔
326
        $color = $resultType->toColorString();
6✔
327

328
        $key = $details['key'] ?? 'unknown';
6✔
329

330
        // Handle different issue types
331
        if (isset($details['inconsistency_type']) && 'mixed_conventions' === $details['inconsistency_type']) {
6✔
332
            $detectedConventions = $details['detected_conventions'] ?? [];
3✔
333
            $dominantConvention = $details['dominant_convention'] ?? 'unknown';
3✔
334

335
            $detectedStr = implode(', ', $detectedConventions);
3✔
336

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

340
            if ('unknown' !== $dominantConvention && 'mixed_conventions' !== $dominantConvention) {
3✔
341
                $suggestion = $this->suggestKeyConversion($key, $dominantConvention);
3✔
342
                if ($suggestion !== $key) {
3✔
343
                    $message .= ". Consider: `{$suggestion}`";
3✔
344
                }
345
            } else {
346
                $message .= '. Consider standardizing all keys to use the same naming convention';
×
347
            }
348

349
            if ($this->isAutoDetectionMode() && !$this->configHintShown) {
3✔
350
                $message .= $this->getConfigurationHint();
3✔
351
                $this->configHintShown = true;
3✔
352
            }
353
        } else {
354
            $convention = $details['expected_convention'] ?? 'custom pattern';
3✔
355
            $suggestion = $details['suggestion'] ?? '';
3✔
356

357
            $message = "key naming convention violation: `{$key}` does not follow the configured {$convention} convention";
3✔
358
            if (!empty($suggestion) && $suggestion !== $key) {
3✔
359
                $message .= ". Suggested: `{$suggestion}`";
3✔
360
            }
361
        }
362

363
        return "- <fg={$color}>{$level}</> {$prefix}{$message}";
6✔
364
    }
365

366
    /**
367
     * Suggest a key conversion to match the dominant convention.
368
     */
369
    private function suggestKeyConversion(string $key, string $targetConvention): string
4✔
370
    {
371
        try {
372
            $convention = KeyNamingConvention::fromString($targetConvention);
4✔
373

374
            if (str_contains($key, '.')) {
3✔
375
                return $this->convertDotSeparatedKey($key, $convention);
×
376
            }
377

378
            return match ($convention) {
379
                KeyNamingConvention::SNAKE_CASE => $this->toSnakeCase($key),
3✔
380
                KeyNamingConvention::CAMEL_CASE => $this->toCamelCase($key),
1✔
381
                KeyNamingConvention::KEBAB_CASE => $this->toKebabCase($key),
×
382
                KeyNamingConvention::PASCAL_CASE => $this->toPascalCase($key),
×
383
                KeyNamingConvention::DOT_NOTATION => $this->toDotNotation($key),
3✔
384
            };
385
        } catch (InvalidArgumentException) {
1✔
386
            return $key;
1✔
387
        }
388
    }
389

390
    /**
391
     * @return class-string<ParserInterface>[]
392
     */
UNCOV
393
    public function supportsParser(): array
×
394
    {
UNCOV
395
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
×
396
    }
397

398
    public function resultTypeOnValidationFailure(): ResultType
6✔
399
    {
400
        return ResultType::WARNING;
6✔
401
    }
402

403
    /**
404
     * Get available naming conventions.
405
     *
406
     * @return array<string, array{pattern: string, description: string}>
407
     */
408
    public static function getAvailableConventions(): array
1✔
409
    {
410
        $conventions = [];
1✔
411

412
        foreach (KeyNamingConvention::getConfigurableConventions() as $value) {
1✔
413
            $convention = KeyNamingConvention::from($value);
1✔
414
            $conventions[$value] = [
1✔
415
                'pattern' => $convention->getPattern(),
1✔
416
                'description' => $convention->getDescription(),
1✔
417
            ];
1✔
418
        }
419

420
        return $conventions;
1✔
421
    }
422

423
    /**
424
     * Check if validator should run based on configuration.
425
     */
426
    public function shouldRun(): bool
9✔
427
    {
428
        return true; // Always run, even without configuration
9✔
429
    }
430

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

444
        $conventionCounts = [];
9✔
445
        $keyConventions = [];
9✔
446

447
        // Analyze each key to determine which conventions it matches
448
        foreach ($keys as $key) {
9✔
449
            $matchingConventions = $this->detectKeyConventions($key);
9✔
450
            $keyConventions[$key] = $matchingConventions;
9✔
451

452
            foreach ($matchingConventions as $convention) {
9✔
453
                $conventionCounts[$convention] = ($conventionCounts[$convention] ?? 0) + 1;
9✔
454
            }
455
        }
456

457
        // If all keys follow the same convention(s), no issues
458
        if (count($conventionCounts) <= 1) {
9✔
459
            return [];
1✔
460
        }
461

462
        // Find the most common convention
463
        $dominantConvention = array_key_first($conventionCounts);
8✔
464
        $maxCount = $conventionCounts[$dominantConvention];
8✔
465

466
        foreach ($conventionCounts as $convention => $count) {
8✔
467
            if ($count > $maxCount) {
8✔
468
                $dominantConvention = $convention;
×
469
                $maxCount = $count;
×
470
            }
471
        }
472

473
        $issues = [];
8✔
474
        $conventionNames = array_keys($conventionCounts);
8✔
475

476
        // Report inconsistencies
477
        foreach ($keys as $key) {
8✔
478
            $keyMatches = $keyConventions[$key];
8✔
479

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

493
        return $issues;
8✔
494
    }
495

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

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

512
            // Then check if all segments follow a consistent non-dot convention
513
            $segments = explode('.', $key);
5✔
514
            $consistentConventions = null;
5✔
515

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

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

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

536
            // If no convention matches, it's mixed
537
            if (empty($matchingConventions)) {
5✔
538
                return ['mixed_conventions'];
1✔
539
            }
540

541
            return array_unique($matchingConventions);
4✔
542
        } else {
543
            // No dots, check regular conventions
544
            return $this->detectSegmentConventions($key);
7✔
545
        }
546
    }
547

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

557
        foreach (KeyNamingConvention::cases() as $convention) {
12✔
558
            if ($convention->matches($segment)) {
12✔
559
                $matchingConventions[] = $convention->value;
10✔
560
            }
561
        }
562

563
        // If no convention matches, classify as 'unknown'
564
        if (empty($matchingConventions)) {
12✔
565
            $matchingConventions[] = 'unknown';
4✔
566
        }
567

568
        return $matchingConventions;
12✔
569
    }
570
}
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