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

move-elevator / composer-translation-validator / 16517612540

25 Jul 2025 08:33AM UTC coverage: 96.5% (-0.002%) from 96.502%
16517612540

Pull #46

github

jackd248
test: add phpstan ignore for assertion in ValidatorSettingsConfigTest
Pull Request #46: feat: add KeyNamingConventionValidator with configurable naming conventions

139 of 144 new or added lines in 6 files covered. (96.53%)

22 existing lines in 6 files now uncovered.

1875 of 1943 relevant lines covered (96.5%)

8.95 hits per line

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

96.77
/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\Parser\JsonParser;
29
use MoveElevator\ComposerTranslationValidator\Parser\ParserInterface;
30
use MoveElevator\ComposerTranslationValidator\Parser\PhpParser;
31
use MoveElevator\ComposerTranslationValidator\Parser\XliffParser;
32
use MoveElevator\ComposerTranslationValidator\Parser\YamlParser;
33
use MoveElevator\ComposerTranslationValidator\Result\Issue;
34

35
class KeyNamingConventionValidator extends AbstractValidator implements ValidatorInterface
36
{
37
    private const CONVENTIONS = [
38
        'snake_case' => [
39
            'pattern' => '/^[a-z]([a-z0-9]|_[a-z0-9])*$/',
40
            'description' => 'snake_case (lowercase with underscores)',
41
        ],
42
        'camelCase' => [
43
            'pattern' => '/^[a-z][a-zA-Z0-9]*$/',
44
            'description' => 'camelCase (first letter lowercase)',
45
        ],
46
        'dot.notation' => [
47
            'pattern' => '/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/',
48
            'description' => 'dot.notation (lowercase with dots)',
49
        ],
50
        'kebab-case' => [
51
            'pattern' => '/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/',
52
            'description' => 'kebab-case (lowercase with hyphens)',
53
        ],
54
        'PascalCase' => [
55
            'pattern' => '/^[A-Z][a-zA-Z0-9]*$/',
56
            'description' => 'PascalCase (first letter uppercase)',
57
        ],
58
    ];
59

60
    private ?string $convention = null;
61
    private ?string $customPattern = null;
62
    private ?TranslationValidatorConfig $config = null;
63

64
    public function setConfig(?TranslationValidatorConfig $config): void
8✔
65
    {
66
        $this->config = $config;
8✔
67
        $this->loadConventionFromConfig();
8✔
68
    }
69

70
    public function processFile(ParserInterface $file): array
52✔
71
    {
72
        // Skip validation if no convention is configured
73
        if (null === $this->convention && null === $this->customPattern) {
52✔
74
            return [];
1✔
75
        }
76

77
        $keys = $file->extractKeys();
51✔
78

79
        if (null === $keys) {
51✔
80
            $this->logger?->error(
1✔
81
                'The source file '.$file->getFileName().' is not valid.',
1✔
82
            );
1✔
83

84
            return [];
1✔
85
        }
86

87
        $issues = [];
50✔
88

89
        foreach ($keys as $key) {
50✔
90
            if (!$this->validateKeyFormat($key)) {
50✔
91
                $issues[] = [
35✔
92
                    'key' => $key,
35✔
93
                    'file' => $file->getFileName(),
35✔
94
                    'expected_convention' => $this->convention,
35✔
95
                    'pattern' => $this->getActivePattern(),
35✔
96
                    'suggestion' => $this->suggestCorrection($key),
35✔
97
                ];
35✔
98
            }
99
        }
100

101
        return $issues;
50✔
102
    }
103

104
    private function loadConventionFromConfig(): void
8✔
105
    {
106
        if (null === $this->config) {
8✔
107
            return;
1✔
108
        }
109

110
        $validatorSettings = $this->config->getValidatorSettings('KeyNamingConventionValidator');
7✔
111

112
        if (empty($validatorSettings)) {
7✔
113
            return;
2✔
114
        }
115

116
        // Load convention from config
117
        if (isset($validatorSettings['convention']) && is_string($validatorSettings['convention'])) {
5✔
118
            try {
119
                $this->setConvention($validatorSettings['convention']);
3✔
120
            } catch (InvalidArgumentException $e) {
1✔
121
                $this->logger?->warning(
1✔
122
                    'Invalid convention in config: '.$validatorSettings['convention'].'. '.$e->getMessage(),
1✔
123
                );
1✔
124
            }
125
        }
126

127
        // Load custom pattern from config (overrides convention)
128
        if (isset($validatorSettings['custom_pattern']) && is_string($validatorSettings['custom_pattern'])) {
5✔
129
            try {
130
                $this->setCustomPattern($validatorSettings['custom_pattern']);
3✔
131
            } catch (InvalidArgumentException $e) {
1✔
132
                $this->logger?->warning(
1✔
133
                    'Invalid custom pattern in config: '.$validatorSettings['custom_pattern'].'. '.$e->getMessage(),
1✔
134
                );
1✔
135
            }
136
        }
137
    }
138

139
    public function setConvention(string $convention): void
53✔
140
    {
141
        if (!array_key_exists($convention, self::CONVENTIONS)) {
53✔
142
            throw new InvalidArgumentException(sprintf('Unknown convention "%s". Available conventions: %s', $convention, implode(', ', array_keys(self::CONVENTIONS))));
2✔
143
        }
144

145
        $this->convention = $convention;
51✔
146
    }
147

148
    public function setCustomPattern(string $pattern): void
7✔
149
    {
150
        $result = @preg_match($pattern, '');
7✔
151
        if (false === $result) {
7✔
152
            throw new InvalidArgumentException('Invalid regex pattern provided');
2✔
153
        }
154

155
        $this->customPattern = $pattern;
5✔
156
        $this->convention = null; // Custom pattern overrides convention
5✔
157
    }
158

159
    private function validateKeyFormat(string $key): bool
50✔
160
    {
161
        $pattern = $this->getActivePattern();
50✔
162
        if (null === $pattern) {
50✔
NEW
163
            return true; // No validation if no pattern is set
×
164
        }
165

166
        return (bool) preg_match($pattern, $key);
50✔
167
    }
168

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

175
        if (null !== $this->convention && isset(self::CONVENTIONS[$this->convention])) {
47✔
176
            return self::CONVENTIONS[$this->convention]['pattern'];
47✔
177
        }
178

NEW
179
        return null;
×
180
    }
181

182
    private function suggestCorrection(string $key): string
35✔
183
    {
184
        if (null === $this->convention) {
35✔
185
            return $key; // No suggestion for custom patterns
3✔
186
        }
187

188
        return match ($this->convention) {
32✔
189
            'snake_case' => $this->toSnakeCase($key),
18✔
190
            'camelCase' => $this->toCamelCase($key),
9✔
191
            'dot.notation' => $this->toDotNotation($key),
13✔
192
            'kebab-case' => $this->toKebabCase($key),
5✔
193
            'PascalCase' => $this->toPascalCase($key),
5✔
194
            default => $key,
32✔
195
        };
32✔
196
    }
197

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

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

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

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

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

228
        return $result;
7✔
229
    }
230

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

238
        return strtolower($result ?? $key);
13✔
239
    }
240

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

248
        return strtolower($result);
5✔
249
    }
250

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

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

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

268
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
1✔
269
    {
270
        $details = $issue->getDetails();
1✔
271
        $resultType = $this->resultTypeOnValidationFailure();
1✔
272

273
        $level = $resultType->toString();
1✔
274
        $color = $resultType->toColorString();
1✔
275

276
        $key = $details['key'] ?? 'unknown';
1✔
277
        $convention = $details['expected_convention'] ?? 'custom pattern';
1✔
278
        $suggestion = $details['suggestion'] ?? '';
1✔
279

280
        $message = "key naming convention violation: `{$key}` does not follow {$convention} convention";
1✔
281
        if (!empty($suggestion) && $suggestion !== $key) {
1✔
282
            $message .= " (suggestion: `{$suggestion}`)";
1✔
283
        }
284

285
        return "- <fg={$color}>{$level}</> {$prefix}{$message}";
1✔
286
    }
287

288
    /**
289
     * @return class-string<ParserInterface>[]
290
     */
291
    public function supportsParser(): array
1✔
292
    {
293
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
294
    }
295

296
    public function resultTypeOnValidationFailure(): ResultType
2✔
297
    {
298
        return ResultType::WARNING;
2✔
299
    }
300

301
    public function shouldShowDetailedOutput(): bool
1✔
302
    {
303
        return false;
1✔
304
    }
305

306
    /**
307
     * Get available naming conventions.
308
     *
309
     * @return array<string, array{pattern: string, description: string}>
310
     */
311
    public static function getAvailableConventions(): array
2✔
312
    {
313
        return self::CONVENTIONS;
2✔
314
    }
315

316
    /**
317
     * Check if validator should run based on configuration.
318
     */
319
    public function shouldRun(): bool
10✔
320
    {
321
        return null !== $this->convention || null !== $this->customPattern;
10✔
322
    }
323
}
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