Skip to content

Commit 1e7a50d

Browse files
author
Konrad Michalik
authored
Merge pull request #37 from move-elevator/recursive
feat: add recursive option for validating translation files
2 parents 97a205a + 03fe7bb commit 1e7a50d

16 files changed

Lines changed: 553 additions & 27 deletions

README.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,20 @@ composer validate-translations ./translations
4242
The command `validate-translations` can be used to validate translation files in your project. It will automatically detect the translation files based on the supported formats and run the configured validators.
4343

4444
```bash
45-
composer validate-translations [<path>] [--dry-run] [--strict] [-f|--format cli|json] [-s|--skip VALIDATOR] [-o|--only VALIDATOR] [-v|--verbose]
45+
composer validate-translations [<path>...] [--dry-run] [--strict] [--format|-f <cli|json>] [--skip|-s <VALIDATOR>...] [--only|-o <VALIDATOR>...] [--recursive|-r] [--verbose|-v] [--config|-c <CONFIG>]```
4646
```
4747

48-
| Argument / Option | Shortcut | Description |
49-
|--------------------------|----------|---------------------------------------------------------------------------------------------------|
50-
| `<path>` | | (Optional) Path to the translation files or directories to validate (can be used multiple times). |
51-
| `--format` | `-f` | Sets the output format (`cli`, `json`). |
52-
| `--skip` | `-s` | Skips specific validators (can be used multiple times). |
53-
| `--only` | `-o` | Runs only the specified validators (can be used multiple times). |
54-
| `--verbose` | `-v` | Shows additional output for detailed information. |
55-
| `--strict` | | Enables strict mode, treating warnings as errors. |
56-
| `--dry-run` | | Runs the validation in test mode without saving changes. |
57-
| `--config` | `-c` | Path to a configuration file (e.g. `translation-validator.yaml`). |
48+
| Argument / Option | Shortcut | Description |
49+
|-------------------|----------|---------------------------------------------------------------------------------------------------|
50+
| `<path>` | | (Optional) Path to the translation files or directories to validate (can be used multiple times). |
51+
| `--format` | `-f` | Sets the output format (`cli`, `json`). |
52+
| `--skip` | `-s` | Skips specific validators (can be used multiple times). |
53+
| `--only` | `-o` | Runs only the specified validators (can be used multiple times). |
54+
| `--recursive` | `-r` | Search for translation files recursively in subdirectories |
55+
| `--verbose` | `-v` | Shows additional output for detailed information. |
56+
| `--strict` | | Enables strict mode, treating warnings as errors. |
57+
| `--dry-run` | | Runs the validation in test mode without saving changes. |
58+
| `--config` | `-c` | Path to a configuration file (e.g. `translation-validator.yaml`). |
5859

5960
Find more information about store a [config file](docs/config-file.md).
6061

phpunit.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
44
displayDetailsOnTestsThatTriggerWarnings="true"
55
displayDetailsOnTestsThatTriggerNotices="true"
6+
displayDetailsOnSkippedTests="true"
67
bootstrap="vendor/autoload.php"
78
colors="true"
89
>

src/Command/ValidateTranslationCommand.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ protected function configure(): void
8484
InputOption::VALUE_OPTIONAL,
8585
'Path to the configuration file'
8686
)
87+
->addOption(
88+
'recursive',
89+
'r',
90+
InputOption::VALUE_NONE,
91+
'Search for translation files recursively in subdirectories'
92+
)
8793
->setHelp(
8894
<<<HELP
8995
The <info>validate-translations</info> command validates translation files (XLIFF, YAML, JSON and PHP)
@@ -94,7 +100,8 @@ protected function configure(): void
94100
95101
<comment>Examples:</comment>
96102
<info>composer validate-translations translations/</info>
97-
<info>composer validate-translations translations/ --format json</info>
103+
<info>composer validate-translations translations/ --recursive</info>
104+
<info>composer validate-translations translations/ -r --format json</info>
98105
<info>composer validate-translations translations/ --dry-run</info>
99106
<info>composer validate-translations translations/ --strict</info>
100107
<info>composer validate-translations translations/ --only \</info>
@@ -145,6 +152,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
145152

146153
$this->dryRun = $config->getDryRun() || $input->getOption('dry-run');
147154
$this->strict = $config->getStrict() || $input->getOption('strict');
155+
$recursive = (bool) $input->getOption('recursive');
148156
$excludePatterns = $config->getExclude();
149157

150158
$fileDetector = $this->resolveFileDetector($config);
@@ -158,7 +166,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
158166
$allFiles = (new Collector($this->logger))->collectFiles(
159167
$paths,
160168
$fileDetector,
161-
$excludePatterns
169+
$excludePatterns,
170+
$recursive
162171
);
163172
if (empty($allFiles)) {
164173
$this->io->warning('No files found in the specified directories.');

src/FileDetector/Collector.php

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,41 +26,34 @@ public function collectFiles(
2626
array $paths,
2727
?DetectorInterface $detector = null,
2828
?array $excludePatterns = null,
29+
bool $recursive = false,
2930
): array {
3031
$allFiles = [];
3132
foreach ($paths as $path) {
3233
if (!(new Filesystem())->exists($path)) {
33-
$this->logger->error('The provided path "'.$path.'" is not a valid directory.');
34+
$this->logger?->error('The provided path "'.$path.'" is not a valid directory.');
3435
continue;
3536
}
3637

3738
foreach (ParserRegistry::getAvailableParsers() as $parserClass) {
38-
$globFiles = glob($path.'/*');
39-
if (false === $globFiles) {
39+
$files = $this->findFiles($path, $parserClass::getSupportedFileExtensions(), $recursive);
40+
if (empty($files)) {
41+
$this->logger?->debug('No files found for parser class "'.$parserClass.'" in path "'.$path.'".');
4042
continue;
4143
}
4244

43-
$files = array_filter(
44-
$globFiles,
45-
static fn ($file) => in_array(
46-
pathinfo($file, PATHINFO_EXTENSION),
47-
$parserClass::getSupportedFileExtensions(),
48-
true
49-
)
50-
);
51-
5245
if ($excludePatterns) {
5346
$files = array_filter(
5447
$files,
5548
static fn ($file) => !array_filter(
5649
$excludePatterns,
57-
static fn ($pattern) => fnmatch($pattern, basename($file))
50+
static fn ($pattern) => fnmatch($pattern, basename((string) $file))
5851
)
5952
);
6053
}
6154

6255
if (empty($files)) {
63-
$this->logger->debug('No files found for parser class "'.$parserClass.'" in path "'.$path.'".');
56+
$this->logger?->debug('No files found for parser class "'.$parserClass.'" in path "'.$path.'".');
6457
continue;
6558
}
6659

@@ -80,4 +73,92 @@ public function collectFiles(
8073

8174
return $allFiles;
8275
}
76+
77+
/**
78+
* Find files in a directory, optionally recursively.
79+
*
80+
* @param string[] $supportedExtensions
81+
*
82+
* @return string[]
83+
*/
84+
private function findFiles(string $path, array $supportedExtensions, bool $recursive): array
85+
{
86+
if (!$recursive) {
87+
$globFiles = glob($path.'/*');
88+
if (false === $globFiles) {
89+
$this->logger?->warning('Failed to glob files in path: '.$path);
90+
91+
return [];
92+
}
93+
94+
return array_filter(
95+
$globFiles,
96+
static fn ($file) => in_array(
97+
pathinfo($file, PATHINFO_EXTENSION),
98+
$supportedExtensions,
99+
true
100+
)
101+
);
102+
}
103+
104+
$normalizedPath = $this->normalizePath($path);
105+
if (!$this->isPathSafe($normalizedPath)) {
106+
$this->logger?->warning('Skipping potentially unsafe path: '.$path);
107+
108+
return [];
109+
}
110+
111+
$files = [];
112+
113+
try {
114+
$iterator = new \RecursiveIteratorIterator(
115+
new \RecursiveDirectoryIterator($normalizedPath, \RecursiveDirectoryIterator::SKIP_DOTS),
116+
\RecursiveIteratorIterator::LEAVES_ONLY
117+
);
118+
119+
foreach ($iterator as $file) {
120+
$filePath = $file->getPathname();
121+
$extension = pathinfo((string) $filePath, PATHINFO_EXTENSION);
122+
123+
if (in_array($extension, $supportedExtensions, true) && is_file($filePath)) {
124+
$files[] = $filePath;
125+
}
126+
}
127+
} catch (\Exception $e) {
128+
$this->logger?->error('Error during recursive file search: '.$e->getMessage());
129+
130+
return [];
131+
}
132+
133+
return $files;
134+
}
135+
136+
/**
137+
* Normalize a file path to prevent path traversal attacks.
138+
*/
139+
private function normalizePath(string $path): string
140+
{
141+
$resolved = realpath($path);
142+
if (false !== $resolved) {
143+
return $resolved;
144+
}
145+
146+
return rtrim($path, '/\\');
147+
}
148+
149+
/**
150+
* Basic path safety check to prevent obvious security issues.
151+
*/
152+
private function isPathSafe(string $path): bool
153+
{
154+
$dangerousPaths = ['/etc', '/usr', '/bin', '/sbin', '/proc', '/sys', '/private/etc'];
155+
156+
foreach ($dangerousPaths as $dangerousPath) {
157+
if (str_starts_with($path, $dangerousPath)) {
158+
return false;
159+
}
160+
}
161+
162+
return substr_count($path, '/') + substr_count($path, '\\') <= 20;
163+
}
83164
}

tests/src/Command/ValidateTranslationCommandTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,79 @@ public function testExecuteWithNullValidator(): void
218218
$this->assertStringContainsString('Language validation succeeded.', $commandTester->getDisplay());
219219
$this->assertSame(0, $commandTester->getStatusCode());
220220
}
221+
222+
public function testExecuteWithRecursiveOption(): void
223+
{
224+
$application = new Application();
225+
$application->add(new ValidateTranslationCommand());
226+
227+
$command = $application->find('validate-translations');
228+
$commandTester = new CommandTester($command);
229+
230+
$commandTester->execute([
231+
'path' => [__DIR__.'/../Fixtures/translations/xliff/success'],
232+
'--recursive' => true,
233+
'--dry-run' => true,
234+
]);
235+
236+
$output = $commandTester->getDisplay();
237+
$this->assertStringContainsString('Language validation', $output);
238+
$this->assertSame(0, $commandTester->getStatusCode());
239+
}
240+
241+
public function testExecuteWithRecursiveShortOption(): void
242+
{
243+
$application = new Application();
244+
$application->add(new ValidateTranslationCommand());
245+
246+
$command = $application->find('validate-translations');
247+
$commandTester = new CommandTester($command);
248+
249+
$commandTester->execute([
250+
'path' => [__DIR__.'/../Fixtures/translations/xliff/success'],
251+
'-r' => true,
252+
'--dry-run' => true,
253+
]);
254+
255+
$output = $commandTester->getDisplay();
256+
$this->assertStringContainsString('Language validation', $output);
257+
$this->assertSame(0, $commandTester->getStatusCode());
258+
}
259+
260+
public function testExecuteWithRecursiveAndJsonFormat(): void
261+
{
262+
$application = new Application();
263+
$application->add(new ValidateTranslationCommand());
264+
265+
$command = $application->find('validate-translations');
266+
$commandTester = new CommandTester($command);
267+
268+
$commandTester->execute([
269+
'path' => [__DIR__.'/../Fixtures/translations/xliff/success'],
270+
'--recursive' => true,
271+
'--format' => 'json',
272+
]);
273+
274+
$output = json_decode($commandTester->getDisplay(), true);
275+
$this->assertIsArray($output);
276+
$this->assertArrayHasKey('status', $output);
277+
}
278+
279+
public function testRecursiveOptionCanBeUsedWithOtherOptions(): void
280+
{
281+
$application = new Application();
282+
$application->add(new ValidateTranslationCommand());
283+
284+
$command = $application->find('validate-translations');
285+
$commandTester = new CommandTester($command);
286+
287+
$commandTester->execute([
288+
'path' => [__DIR__.'/../Fixtures/translations/xliff/success'],
289+
'--recursive' => true,
290+
'--dry-run' => true,
291+
'--strict' => true,
292+
]);
293+
294+
$this->assertSame(0, $commandTester->getStatusCode());
295+
}
221296
}

tests/src/Config/ConfigFileReaderTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,61 @@ public function testReadPhpFileWithInvalidReturn(): void
189189

190190
$this->reader->readAsConfig($configPath);
191191
}
192+
193+
public function testReadPhpConfigWithInvalidPath(): void
194+
{
195+
// We need to test readAsConfig() directly for PHP files to reach the realpath() check
196+
$configPath = $this->tempDir.'/config.php';
197+
$brokenLink = $this->tempDir.'/broken_link.php';
198+
199+
file_put_contents($configPath, '<?php return "test";');
200+
symlink($configPath, $brokenLink);
201+
unlink($configPath); // Break the symlink
202+
203+
$this->expectException(\RuntimeException::class);
204+
$this->expectExceptionMessage('Invalid configuration file path');
205+
206+
$this->reader->readAsConfig($brokenLink);
207+
}
208+
209+
public function testReadJsonConfigWithNonArrayContent(): void
210+
{
211+
$configPath = $this->tempDir.'/config.json';
212+
file_put_contents($configPath, '"string-content"');
213+
214+
$this->expectException(\RuntimeException::class);
215+
$this->expectExceptionMessage('Invalid JSON configuration file');
216+
217+
$this->reader->readFile($configPath);
218+
}
219+
220+
public function testReadYamlConfigWithNonArrayContent(): void
221+
{
222+
$configPath = $this->tempDir.'/config.yaml';
223+
file_put_contents($configPath, '"string-content"');
224+
225+
$this->expectException(\RuntimeException::class);
226+
$this->expectExceptionMessage('Invalid YAML configuration file');
227+
228+
$this->reader->readFile($configPath);
229+
}
230+
231+
public function testReadAsConfigUsesDirectPhpConfigPath(): void
232+
{
233+
// Test that readAsConfig() uses the direct PHP config path
234+
$configPath = $this->tempDir.'/config.php';
235+
$phpContent = '<?php
236+
use MoveElevator\ComposerTranslationValidator\Config\TranslationValidatorConfig;
237+
238+
$config = new TranslationValidatorConfig();
239+
$config->setPaths([\'direct-php-test\']);
240+
return $config;
241+
';
242+
243+
file_put_contents($configPath, $phpContent);
244+
245+
$config = $this->reader->readAsConfig($configPath);
246+
247+
$this->assertSame(['direct-php-test'], $config->getPaths());
248+
}
192249
}

0 commit comments

Comments
 (0)