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

move-elevator / composer-translation-validator / 16493569800

24 Jul 2025 09:48AM UTC coverage: 96.502% (-0.05%) from 96.554%
16493569800

push

github

web-flow
Merge pull request #44 from move-elevator/phpstan-8

build: raise phpstan to level 8

6 of 7 new or added lines in 2 files covered. (85.71%)

1738 of 1801 relevant lines covered (96.5%)

8.05 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>PlaceholderConsistencyValidator</info> - Validates placeholder consistency across files
138
  • <info>XliffSchemaValidator</info>     - Validates XLIFF schema compliance
139

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

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

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

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

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

173
        $paths = $this->resolvePaths($input, $config);
15✔
174

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

180
        $fileDetector = $this->resolveFileDetector($config);
15✔
181

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

185
            return Command::FAILURE;
1✔
186
        }
187

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

197
            return Command::SUCCESS;
1✔
198
        }
199

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

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

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

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

211
            return Command::FAILURE;
1✔
212
        }
213

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

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

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

234
        if ($configPath) {
15✔
235
            return $configReader->read($configPath);
×
236
        }
237

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

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

251
        // Return default configuration
252
        return new TranslationValidatorConfig();
15✔
253
    }
254

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

263
        $paths = !empty($inputPaths) ? $inputPaths : $configPaths;
15✔
264

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

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

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

285
        return $detector instanceof DetectorInterface ? $detector : null;
15✔
286
    }
287

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

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

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

316
        return $result;
13✔
317
    }
318

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

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

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

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