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

move-elevator / composer-translation-validator / 24133345533

08 Apr 2026 11:38AM UTC coverage: 95.573% (+0.1%) from 95.451%
24133345533

push

github

web-flow
Merge pull request #111 from move-elevator/refactor/split-key-naming-convention-validator

refactor: split KeyNamingConventionValidator into KeyConverter and ConventionDetector

119 of 121 new or added lines in 3 files covered. (98.35%)

2 existing lines in 1 file now uncovered.

2375 of 2485 relevant lines covered (95.57%)

8.62 hits per line

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

96.24
/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-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 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 is_string;
23

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

39
    public function __construct(?\Psr\Log\LoggerInterface $logger = null)
71✔
40
    {
41
        parent::__construct($logger);
71✔
42
        $this->keyConverter = new KeyConverter();
71✔
43
        $this->conventionDetector = new ConventionDetector();
71✔
44
    }
45

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

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

57
        $keys = $file->extractKeys();
52✔
58

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

64
            return [];
1✔
65
        }
66

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

86
        return $issueData;
51✔
87
    }
88

89
    public function setConvention(string $convention): void
47✔
90
    {
91
        if ('dot.notation' === $convention) {
47✔
92
            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✔
93
        }
94

95
        $this->convention = KeyNamingConvention::fromString($convention);
46✔
96
    }
97

98
    public function setCustomPattern(string $pattern): void
7✔
99
    {
100
        $result = @preg_match($pattern, '');
7✔
101
        if (false === $result) {
7✔
102
            throw new InvalidArgumentException('Invalid regex pattern provided');
3✔
103
        }
104

105
        $this->customPattern = $pattern;
4✔
106
        $this->convention = null; // Custom pattern overrides convention
4✔
107
    }
108

109
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
7✔
110
    {
111
        $details = $issue->getDetails();
7✔
112
        $resultType = $this->resultTypeOnValidationFailure();
7✔
113

114
        $level = $resultType->toString();
7✔
115
        $color = $resultType->toColorString();
7✔
116

117
        $key = $details['key'] ?? 'unknown';
7✔
118

119
        // Handle different issue types
120
        if (isset($details['inconsistency_type']) && 'mixed_conventions' === $details['inconsistency_type']) {
7✔
121
            $detectedConventions = $details['detected_conventions'] ?? [];
4✔
122
            $dominantConvention = $details['dominant_convention'] ?? 'unknown';
4✔
123

124
            $detectedStr = implode(', ', $detectedConventions);
4✔
125

126
            $message = "key naming inconsistency: `{$key}` uses {$detectedStr} convention";
4✔
127
            $message .= ", but this file predominantly uses {$dominantConvention}";
4✔
128

129
            if ('unknown' !== $dominantConvention && 'mixed_conventions' !== $dominantConvention) {
4✔
130
                $suggestion = $this->suggestKeyConversion($key, $dominantConvention);
3✔
131
                if ($suggestion !== $key) {
3✔
132
                    $message .= ". Consider: `{$suggestion}`";
3✔
133
                }
134
            } else {
135
                $message .= '. Consider standardizing all keys to use the same naming convention';
1✔
136
            }
137

138
            if ($this->isAutoDetectionMode() && !$this->configHintShown) {
4✔
139
                $message .= $this->getConfigurationHint();
4✔
140
                $this->configHintShown = true;
4✔
141
            }
142
        } else {
143
            $convention = $details['expected_convention'] ?? 'custom pattern';
3✔
144
            $suggestion = $details['suggestion'] ?? '';
3✔
145

146
            $message = "key naming convention violation: `{$key}` does not follow the configured {$convention} convention";
3✔
147
            if (!empty($suggestion) && $suggestion !== $key) {
3✔
148
                $message .= ". Suggested: `{$suggestion}`";
3✔
149
            }
150
        }
151

152
        return "- <fg={$color}>{$level}</> {$prefix}{$message}";
7✔
153
    }
154

155
    /**
156
     * @return class-string<ParserInterface>[]
157
     */
158
    public function supportsParser(): array
×
159
    {
160
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
×
161
    }
162

163
    public function resultTypeOnValidationFailure(): ResultType
7✔
164
    {
165
        return ResultType::WARNING;
7✔
166
    }
167

168
    /**
169
     * Get available naming conventions.
170
     *
171
     * @return array<string, array{pattern: string, description: string}>
172
     */
173
    public static function getAvailableConventions(): array
1✔
174
    {
175
        $conventions = [];
1✔
176

177
        foreach (KeyNamingConvention::getConfigurableConventions() as $value) {
1✔
178
            $convention = KeyNamingConvention::from($value);
1✔
179
            $conventions[$value] = [
1✔
180
                'pattern' => $convention->getPattern(),
1✔
181
                'description' => $convention->getDescription(),
1✔
182
            ];
1✔
183
        }
184

185
        return $conventions;
1✔
186
    }
187

188
    /**
189
     * Check if validator should run based on configuration.
190
     */
191
    public function shouldRun(): bool
9✔
192
    {
193
        return true; // Always run, even without configuration
9✔
194
    }
195

196
    private function loadConventionFromConfig(): void
10✔
197
    {
198
        if (null === $this->config) {
10✔
199
            return;
1✔
200
        }
201

202
        $validatorSettings = $this->config->getValidatorSettings('KeyNamingConventionValidator');
9✔
203

204
        if (empty($validatorSettings)) {
9✔
205
            return;
2✔
206
        }
207

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

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

231
    private function validateKeyFormat(string $key): bool
41✔
232
    {
233
        if (null === $this->convention && null === $this->customPattern) {
41✔
UNCOV
234
            return true; // No validation if no pattern is set
×
235
        }
236

237
        // If custom pattern is set, use it directly
238
        if (null !== $this->customPattern) {
41✔
239
            return (bool) preg_match($this->customPattern, $key);
3✔
240
        }
241

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

251
            return true;
6✔
252
        }
253

254
        // Single segment, validate directly
255
        return $this->validateSegment($key);
32✔
256
    }
257

258
    private function validateSegment(string $segment): bool
39✔
259
    {
260
        if (null === $this->convention) {
39✔
261
            return true;
1✔
262
        }
263

264
        return $this->convention->matches($segment);
38✔
265
    }
266

267
    private function getActivePattern(): ?string
28✔
268
    {
269
        if (null !== $this->customPattern) {
28✔
270
            return $this->customPattern;
3✔
271
        }
272

273
        return $this->convention?->getPattern();
25✔
274
    }
275

276
    private function suggestCorrection(string $key): string
28✔
277
    {
278
        if (null === $this->convention) {
28✔
279
            return $key;
3✔
280
        }
281

282
        return $this->keyConverter->convertKey($key, $this->convention);
25✔
283
    }
284

285
    /**
286
     * Check if the validator is in auto-detection mode (no explicit configuration).
287
     */
288
    private function isAutoDetectionMode(): bool
4✔
289
    {
290
        return null === $this->convention && null === $this->customPattern;
4✔
291
    }
292

293
    /**
294
     * Get a helpful configuration hint for users.
295
     */
296
    private function getConfigurationHint(): string
4✔
297
    {
298
        // Use only configurable conventions (excludes dot.notation)
299
        $availableConventions = KeyNamingConvention::getConfigurableConventions();
4✔
300
        $conventionsList = implode(', ', $availableConventions);
4✔
301

302
        return "\n  Tip: Configure a specific naming convention in a configuration file to avoid inconsistencies. "
4✔
303
            ."Available conventions: {$conventionsList}. ";
4✔
304
    }
305

306
    /**
307
     * Suggest a key conversion to match the dominant convention.
308
     */
309
    private function suggestKeyConversion(string $key, string $targetConvention): string
3✔
310
    {
311
        try {
312
            $convention = KeyNamingConvention::fromString($targetConvention);
3✔
313

314
            return $this->keyConverter->convertKey($key, $convention);
3✔
315
        } catch (InvalidArgumentException) {
×
UNCOV
316
            return $key;
×
317
        }
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