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

move-elevator / composer-translation-validator / 16517534501

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

Pull #46

github

jackd248
fix: add newline at end of unsupported.txt
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

97.6
/src/Command/ValidateTranslationCommand.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\Command;
25

26
use Composer\Command\BaseCommand;
27
use JsonException;
28
use MoveElevator\ComposerTranslationValidator\Config\ConfigReader;
29
use MoveElevator\ComposerTranslationValidator\Config\TranslationValidatorConfig;
30
use MoveElevator\ComposerTranslationValidator\FileDetector\Collector;
31
use MoveElevator\ComposerTranslationValidator\FileDetector\DetectorInterface;
32
use MoveElevator\ComposerTranslationValidator\Result\FormatType;
33
use MoveElevator\ComposerTranslationValidator\Result\Output;
34
use MoveElevator\ComposerTranslationValidator\Result\ValidationRun;
35
use MoveElevator\ComposerTranslationValidator\Utility\ClassUtility;
36
use MoveElevator\ComposerTranslationValidator\Validator\ResultType;
37
use MoveElevator\ComposerTranslationValidator\Validator\ValidatorInterface;
38
use MoveElevator\ComposerTranslationValidator\Validator\ValidatorRegistry;
39
use Psr\Log\LoggerInterface;
40
use ReflectionException;
41
use RuntimeException;
42
use Symfony\Component\Console\Command\Command;
43
use Symfony\Component\Console\Input\InputArgument;
44
use Symfony\Component\Console\Input\InputInterface;
45
use Symfony\Component\Console\Input\InputOption;
46
use Symfony\Component\Console\Logger\ConsoleLogger;
47
use Symfony\Component\Console\Output\OutputInterface;
48
use Symfony\Component\Console\Style\SymfonyStyle;
49

50
class ValidateTranslationCommand extends BaseCommand
51
{
52
    protected ?SymfonyStyle $io = null;
53
    protected ?InputInterface $input = null;
54
    protected ?OutputInterface $output = null;
55

56
    protected LoggerInterface $logger;
57

58
    protected ResultType $resultType = ResultType::SUCCESS;
59
    protected bool $dryRun = false;
60
    protected bool $strict = false;
61

62
    protected function configure(): void
16✔
63
    {
64
        $this->setName('validate-translations')
16✔
65
            ->setAliases(['vt'])
16✔
66
            ->setDescription('Validates translation files with several validators.')
16✔
67
            ->addArgument(
16✔
68
                'path',
16✔
69
                InputArgument::IS_ARRAY | InputArgument::REQUIRED,
16✔
70
                'Paths to the folders containing translation files',
16✔
71
            )
16✔
72
            ->addOption(
16✔
73
                'dry-run',
16✔
74
                null,
16✔
75
                InputOption::VALUE_NONE,
16✔
76
                'Run the command in dry-run mode without throwing errors',
16✔
77
            )
16✔
78
            ->addOption(
16✔
79
                'strict',
16✔
80
                null,
16✔
81
                InputOption::VALUE_NONE,
16✔
82
                'Fail on warnings as errors',
16✔
83
            )
16✔
84
            ->addOption(
16✔
85
                'format',
16✔
86
                'f',
16✔
87
                InputOption::VALUE_OPTIONAL,
16✔
88
                'Output format: cli or json',
16✔
89
                FormatType::CLI->value,
16✔
90
            )
16✔
91
            ->addOption(
16✔
92
                'only',
16✔
93
                'o',
16✔
94
                InputOption::VALUE_OPTIONAL,
16✔
95
                'The specific validators to use (FQCN), comma-separated',
16✔
96
            )
16✔
97
            ->addOption(
16✔
98
                'skip',
16✔
99
                's',
16✔
100
                InputOption::VALUE_OPTIONAL,
16✔
101
                'Skip specific validators (FQCN), comma-separated',
16✔
102
            )
16✔
103
            ->addOption(
16✔
104
                'config',
16✔
105
                'c',
16✔
106
                InputOption::VALUE_OPTIONAL,
16✔
107
                'Path to the configuration file',
16✔
108
            )
16✔
109
            ->addOption(
16✔
110
                'recursive',
16✔
111
                'r',
16✔
112
                InputOption::VALUE_NONE,
16✔
113
                'Search for translation files recursively in subdirectories',
16✔
114
            )
16✔
115
            ->setHelp(
16✔
116
                <<<HELP
16✔
117
The <info>validate-translations</info> command validates translation files (XLIFF, YAML, JSON and PHP)
118
using multiple validators to ensure consistency, correctness and schema compliance.
119

120
<comment>Usage:</comment>
121
  <info>composer validate-translations <path> [options]</info>
122

123
<comment>Examples:</comment>
124
  <info>composer validate-translations translations/</info>
125
  <info>composer validate-translations translations/ --recursive</info>
126
  <info>composer validate-translations translations/ -r --format json</info>
127
  <info>composer validate-translations translations/ --dry-run</info>
128
  <info>composer validate-translations translations/ --strict</info>
129
  <info>composer validate-translations translations/ --only \</info>
130
    <info>"MoveElevator\ComposerTranslationValidator\Validator\DuplicateKeysValidator"</info>
131

132
<comment>Available Validators:</comment>
133
  • <info>MismatchValidator</info>        - Detects mismatches between source and target
134
  • <info>DuplicateKeysValidator</info>   - Finds duplicate translation keys
135
  • <info>DuplicateValuesValidator</info> - Finds duplicate translation values
136
  • <info>EmptyValuesValidator</info>     - Finds empty or whitespace-only translation values
137
  • <info>EncodingValidator</info>        - Validates file encoding and character issues
138
  • <info>KeyNamingConventionValidator</info> - Validates translation key naming conventions
139
  • <info>PlaceholderConsistencyValidator</info> - Validates placeholder consistency across files
140
  • <info>XliffSchemaValidator</info>     - Validates XLIFF schema compliance
141

142
<comment>Configuration:</comment>
143
You can configure the validator using:
144
  1. Command line options
145
  2. A configuration file (--config option)
146
  3. Settings in composer.json under "extra.translation-validator"
147
  4. Auto-detection from project structure
148

149
<comment>Output Formats:</comment>
150
  • <info>cli</info>  - Human-readable console output (default)
151
  • <info>json</info> - Machine-readable JSON output
152

153
<comment>Modes:</comment>
154
  • <info>--dry-run</info> - Run validation without failing on errors
155
  • <info>--strict</info>  - Treat warnings as errors
156
HELP
16✔
157
            );
16✔
158
    }
159

160
    protected function initialize(InputInterface $input, OutputInterface $output): void
15✔
161
    {
162
        $this->input = $input;
15✔
163
        $this->output = $output;
15✔
164
        $this->io = new SymfonyStyle($input, $output);
15✔
165
        $this->logger = new ConsoleLogger($output);
15✔
166
    }
167

168
    /**
169
     * @throws ReflectionException|JsonException
170
     */
171
    protected function execute(InputInterface $input, OutputInterface $output): int
15✔
172
    {
173
        $config = $this->loadConfiguration($input);
15✔
174

175
        $paths = $this->resolvePaths($input, $config);
15✔
176

177
        $this->dryRun = $config->getDryRun() || $input->getOption('dry-run');
15✔
178
        $this->strict = $config->getStrict() || $input->getOption('strict');
15✔
179
        $recursive = (bool) $input->getOption('recursive');
15✔
180
        $excludePatterns = $config->getExclude();
15✔
181

182
        $fileDetector = $this->resolveFileDetector($config);
15✔
183

184
        if (empty($paths)) {
15✔
185
            $this->io?->error('No paths provided.');
1✔
186

187
            return Command::FAILURE;
1✔
188
        }
189

190
        $allFiles = (new Collector($this->logger))->collectFiles(
14✔
191
            $paths,
14✔
192
            $fileDetector,
14✔
193
            $excludePatterns,
14✔
194
            $recursive,
14✔
195
        );
14✔
196
        if (empty($allFiles)) {
14✔
197
            $this->io?->warning('No files found in the specified directories.');
1✔
198

199
            return Command::SUCCESS;
1✔
200
        }
201

202
        $validators = $this->resolveValidators($input, $config);
13✔
203
        $fileSets = ValidationRun::createFileSetsFromArray($allFiles);
13✔
204

205
        $validationRun = new ValidationRun($this->logger);
13✔
206
        $validationResult = $validationRun->executeFor($fileSets, $validators, $config);
13✔
207

208
        $format = FormatType::tryFrom($input->getOption('format') ?: $config->getFormat());
12✔
209

210
        if (null === $format) {
12✔
211
            $this->io?->error('Invalid output format specified. Use "cli" or "json".');
1✔
212

213
            return Command::FAILURE;
1✔
214
        }
215

216
        if (null === $this->output || null === $this->input) {
11✔
UNCOV
217
            throw new RuntimeException('Output or Input interface not initialized');
×
218
        }
219

220
        return (new Output(
11✔
221
            $this->logger,
11✔
222
            $this->output,
11✔
223
            $this->input,
11✔
224
            $format,
11✔
225
            $validationResult,
11✔
226
            $this->dryRun,
11✔
227
            $this->strict,
11✔
228
        ))->summarize();
11✔
229
    }
230

231
    private function loadConfiguration(InputInterface $input): TranslationValidatorConfig
15✔
232
    {
233
        $configReader = new ConfigReader();
15✔
234
        $configPath = $input->getOption('config');
15✔
235

236
        if ($configPath) {
15✔
UNCOV
237
            return $configReader->read($configPath);
×
238
        }
239

240
        // Try to load from composer.json
241
        $composerJsonPath = getcwd().'/composer.json';
15✔
242
        $config = $configReader->readFromComposerJson($composerJsonPath);
15✔
243
        if ($config) {
15✔
UNCOV
244
            return $config;
×
245
        }
246

247
        // Try auto-detection
248
        $config = $configReader->autoDetect();
15✔
249
        if ($config) {
15✔
UNCOV
250
            return $config;
×
251
        }
252

253
        // Return default configuration
254
        return new TranslationValidatorConfig();
15✔
255
    }
256

257
    /**
258
     * @return string[]
259
     */
260
    private function resolvePaths(InputInterface $input, TranslationValidatorConfig $config): array
15✔
261
    {
262
        $inputPaths = $input->getArgument('path');
15✔
263
        $configPaths = $config->getPaths();
15✔
264

265
        $paths = !empty($inputPaths) ? $inputPaths : $configPaths;
15✔
266

267
        return array_map(
15✔
268
            static fn ($path) => str_starts_with((string) $path, '/')
15✔
269
                ? $path
14✔
270
                : getcwd().'/'.$path,
15✔
271
            $paths,
15✔
272
        );
15✔
273
    }
274

275
    private function resolveFileDetector(TranslationValidatorConfig $config): ?DetectorInterface
15✔
276
    {
277
        $configFileDetectors = $config->getFileDetectors();
15✔
278
        $fileDetectorClass = !empty($configFileDetectors) ? $configFileDetectors[0] : null;
15✔
279

280
        $detector = ClassUtility::instantiate(
15✔
281
            DetectorInterface::class,
15✔
282
            $this->logger,
15✔
283
            'file detector',
15✔
284
            $fileDetectorClass,
15✔
285
        );
15✔
286

287
        return $detector instanceof DetectorInterface ? $detector : null;
15✔
288
    }
289

290
    /**
291
     * @return array<int, class-string<ValidatorInterface>>
292
     */
293
    private function resolveValidators(
13✔
294
        InputInterface $input,
295
        TranslationValidatorConfig $config,
296
    ): array {
297
        $inputOnly = $this->validateClassInput(
13✔
298
            ValidatorInterface::class,
13✔
299
            'validator',
13✔
300
            $input->getOption('only'),
13✔
301
        );
13✔
302
        $inputSkip = $this->validateClassInput(
13✔
303
            ValidatorInterface::class,
13✔
304
            'validator',
13✔
305
            $input->getOption('skip'),
13✔
306
        );
13✔
307

308
        $only = !empty($inputOnly) ? $inputOnly : $config->getOnly();
13✔
309
        $skip = !empty($inputSkip) ? $inputSkip : $config->getSkip();
13✔
310

311
        /** @var array<int, class-string<ValidatorInterface>> $result */
312
        $result = match (true) {
13✔
313
            !empty($only) => $only,
13✔
314
            !empty($skip) => array_values(array_diff(ValidatorRegistry::getAvailableValidators(), $skip)),
12✔
315
            default => ValidatorRegistry::getAvailableValidators(),
12✔
316
        };
13✔
317

318
        return $result;
13✔
319
    }
320

321
    /**
322
     * @return array<int, class-string>
323
     */
324
    private function validateClassInput(
13✔
325
        string $interface,
326
        string $type,
327
        ?string $className = null,
328
    ): array {
329
        if (null === $className) {
13✔
330
            return [];
13✔
331
        }
332

333
        $classNames = str_contains($className, ',') ? explode(',', $className) : [$className];
1✔
334
        /** @var array<int, class-string> $classes */
335
        $classes = [];
1✔
336

337
        foreach ($classNames as $name) {
1✔
338
            ClassUtility::instantiate(
1✔
339
                $interface,
1✔
340
                $this->logger,
1✔
341
                $type,
1✔
342
                $name,
1✔
343
            );
1✔
344
            /** @var class-string $validatedName */
345
            $validatedName = $name;
1✔
346
            $classes[] = $validatedName;
1✔
347
        }
348

349
        return $classes;
1✔
350
    }
351
}
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