Skip to content

Commit 0c3d4f4

Browse files
authored
[FileSystem] Add --filter option to keep only files matching all given patterns (#8419)
1 parent 832aaf1 commit 0c3d4f4

9 files changed

Lines changed: 213 additions & 9 deletions

File tree

src/Application/ApplicationFileProcessor.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public function __construct(
5757
public function run(Configuration $configuration, InputInterface $input): ProcessResult
5858
{
5959
// scope the cache to this run's --only / --only-suffix selection before any cache read/write
60-
$this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix());
60+
$this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix(), $configuration->getFilters());
6161

6262
$filePaths = $this->filesFinder->findFilesInPaths($configuration->getPaths(), $configuration);
6363

@@ -125,7 +125,7 @@ public function processFiles(
125125
?callable $postFileCallback = null
126126
): ProcessResult {
127127
// also set here: parallel workers reach processFiles() via WorkerCommand, bypassing run()
128-
$this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix());
128+
$this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix(), $configuration->getFilters());
129129

130130
/** @var SystemError[] $systemErrors */
131131
$systemErrors = [];

src/Caching/Detector/ChangedFilesDetector.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ final class ChangedFilesDetector
2222
*/
2323
private array $cacheableFiles = [];
2424

25-
// scopes the per-file cache key to the active --only / --only-suffix selection (empty = full run)
25+
// scopes the per-file cache key to the active --only / --only-suffix / --filter selection (empty = full run)
2626
private string $scopeSuffix = '';
2727

2828
public function __construct(
@@ -32,12 +32,15 @@ public function __construct(
3232
) {
3333
}
3434

35-
public function setActiveScope(?string $onlyRule, ?string $onlySuffix): void
35+
/**
36+
* @param string[] $filters
37+
*/
38+
public function setActiveScope(?string $onlyRule, ?string $onlySuffix, array $filters = []): void
3639
{
3740
// each selection gets its own cache key, so --only and full runs coexist without clearing or poisoning
38-
$this->scopeSuffix = ($onlyRule === null && $onlySuffix === null)
41+
$this->scopeSuffix = ($onlyRule === null && $onlySuffix === null && $filters === [])
3942
? ''
40-
: '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? '');
43+
: '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? '') . '|filter:' . implode(',', $filters);
4144
}
4245

4346
public function cacheFile(string $filePath): void

src/Configuration/ConfigurationFactory.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Rector\ChangesReporting\Output\ConsoleOutputFormatter;
88
use Rector\Configuration\Parameter\SimpleParameterProvider;
9+
use Rector\FileSystem\FilePathFilter;
910
use Rector\ValueObject\Configuration;
1011
use Symfony\Component\Console\Input\InputInterface;
1112
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -18,6 +19,7 @@
1819
public function __construct(
1920
private SymfonyStyle $symfonyStyle,
2021
private OnlyRuleResolver $onlyRuleResolver,
22+
private FilePathFilter $filePathFilter,
2123
) {
2224
}
2325

@@ -70,10 +72,18 @@ public function createFromInput(InputInterface $input): Configuration
7072
}
7173

7274
$onlySuffix = $input->getOption(Option::ONLY_SUFFIX);
75+
if ($onlySuffix !== null) {
76+
$this->symfonyStyle->warning(
77+
'The "--only-suffix" option is deprecated and will be removed. Use "--filter" instead, e.g. --filter="*Controller.php"'
78+
);
79+
}
80+
81+
$rawFilter = $input->getOption(Option::FILTER);
82+
$filters = $rawFilter !== null ? $this->filePathFilter->parsePatterns((string) $rawFilter) : [];
7383

74-
// "--only"/"--only-suffix" narrow the run, so skips outside the scope look falsely unused;
84+
// "--only"/"--only-suffix"/"--filter" narrow the run, so skips outside the scope look falsely unused;
7585
// mark the run as narrowed to disable unused skip reporting and avoid false positives
76-
if ($onlyRule !== null || $onlySuffix !== null) {
86+
if ($onlyRule !== null || $onlySuffix !== null || $filters !== []) {
7787
SimpleParameterProvider::setParameter(Option::IS_RUN_NARROWED, true);
7888
}
7989

@@ -129,6 +139,7 @@ public function createFromInput(InputInterface $input): Configuration
129139
$showRulesSummary,
130140
$isComposerBased,
131141
$isPhpOnly,
142+
$filters,
132143
);
133144
}
134145

src/Configuration/Option.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ final class Option
302302
*/
303303
public const string ONLY_SUFFIX = 'only-suffix';
304304

305+
/**
306+
* @internal To keep only files matching all given patterns
307+
*/
308+
public const string FILTER = 'filter';
309+
305310
/**
306311
* @internal To report overflow levels in ->with*Level() methods
307312
*/

src/Console/ProcessConfigureDecorator.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,14 @@ public static function decorate(Command $command): void
7777
Option::ONLY_SUFFIX,
7878
null,
7979
InputOption::VALUE_REQUIRED,
80-
'Filter only files with specific suffix in name, e.g. "Controller"'
80+
'Deprecated, use "--filter" instead. Filter only files with specific suffix in name, e.g. "Controller"'
81+
);
82+
83+
$command->addOption(
84+
Option::FILTER,
85+
null,
86+
InputOption::VALUE_REQUIRED,
87+
'Keep only files matching all comma-separated patterns: "/Controller/" (path substring), "*Repository.php" (basename glob), "tests" (Test.php and TestCase.php files)'
8188
);
8289

8390
$command->addOption(Option::DEBUG, null, InputOption::VALUE_NONE, 'Display debug output.');

src/FileSystem/FilePathFilter.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\FileSystem;
6+
7+
/**
8+
* Keeps only files matching all given --filter patterns.
9+
*
10+
* @see \Rector\Tests\FileSystem\FilePathFilter\FilePathFilterTest
11+
*/
12+
final class FilePathFilter
13+
{
14+
private const string TESTS_KEYWORD = 'tests';
15+
16+
/**
17+
* Splits a comma-separated --filter value into individual patterns, trimming blanks.
18+
*
19+
* @return string[]
20+
*/
21+
public function parsePatterns(string $rawFilter): array
22+
{
23+
$patterns = [];
24+
foreach (explode(',', $rawFilter) as $pattern) {
25+
$pattern = trim($pattern);
26+
if ($pattern !== '') {
27+
$patterns[] = $pattern;
28+
}
29+
}
30+
31+
return $patterns;
32+
}
33+
34+
/**
35+
* Keeps only files that match every pattern (AND). With no patterns the input is returned unchanged.
36+
*
37+
* @param string[] $filePaths
38+
* @param string[] $patterns
39+
* @return string[]
40+
*/
41+
public function filter(array $filePaths, array $patterns): array
42+
{
43+
if ($patterns === []) {
44+
return $filePaths;
45+
}
46+
47+
return array_values(array_filter(
48+
$filePaths,
49+
fn (string $filePath): bool => $this->matchesAllPatterns($filePath, $patterns)
50+
));
51+
}
52+
53+
/**
54+
* @param string[] $patterns
55+
*/
56+
private function matchesAllPatterns(string $filePath, array $patterns): bool
57+
{
58+
return array_all($patterns, fn (string $pattern): bool => $this->matchesPattern($filePath, $pattern));
59+
}
60+
61+
/**
62+
* Three kinds of pattern are recognised:
63+
* - "tests" the basename ends in Test.php or TestCase.php
64+
* - contains "*" glob matched against the full path when it has a "/", else against the basename
65+
* - anything else substring matched anywhere in the full path, e.g. /Controller/
66+
*/
67+
private function matchesPattern(string $filePath, string $pattern): bool
68+
{
69+
if ($pattern === self::TESTS_KEYWORD) {
70+
$basename = basename($filePath);
71+
return str_ends_with($basename, 'Test.php') || str_ends_with($basename, 'TestCase.php');
72+
}
73+
74+
if (str_contains($pattern, '*')) {
75+
$subject = str_contains($pattern, '/') ? $filePath : basename($filePath);
76+
return fnmatch($pattern, $subject);
77+
}
78+
79+
return str_contains($filePath, $pattern);
80+
}
81+
}

src/FileSystem/FilesFinder.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,22 @@ public function __construct(
2525
private PathSkipper $pathSkipper,
2626
private FilePathHelper $filePathHelper,
2727
private ChangedFilesDetector $changedFilesDetector,
28+
private FilePathFilter $filePathFilter,
2829
) {
2930
}
3031

3132
/**
3233
* @param string[] $source
3334
* @param string[] $suffixes
35+
* @param string[] $filters
3436
* @return string[]
3537
*/
3638
public function findInDirectoriesAndFiles(
3739
array $source,
3840
array $suffixes = [],
3941
bool $sortByName = true,
4042
?string $onlySuffix = null,
43+
array $filters = [],
4144
): array {
4245
$filesAndDirectories = $this->filesystemTweaker->resolveWithFnmatch($source);
4346

@@ -102,6 +105,10 @@ function (string $file): bool {
102105
);
103106

104107
$filePaths = [...$filteredFilePaths, ...$filteredFilePathsInDirectories];
108+
109+
// keep only files matching all --filter patterns
110+
$filePaths = $this->filePathFilter->filter($filePaths, $filters);
111+
105112
return $this->unchangedFilesFilter->filterFilePaths($filePaths);
106113
}
107114

@@ -120,6 +127,7 @@ public function findFilesInPaths(array $paths, Configuration $configuration): ar
120127
$configuration->getFileExtensions(),
121128
true,
122129
$configuration->getOnlySuffix(),
130+
$configuration->getFilters(),
123131
);
124132
}
125133

src/ValueObject/Configuration.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* @param string[] $fileExtensions
1717
* @param string[] $paths
1818
* @param LevelOverflow[] $levelOverflows
19+
* @param string[] $filters
1920
*/
2021
public function __construct(
2122
private bool $isDryRun = false,
@@ -37,6 +38,7 @@ public function __construct(
3738
private bool $showRulesSummary = false,
3839
private bool $isComposerBased = false,
3940
private bool $isPhpOnly = false,
41+
private array $filters = [],
4042
) {
4143
}
4244

@@ -132,6 +134,14 @@ public function getOnlySuffix(): ?string
132134
return $this->onlySuffix;
133135
}
134136

137+
/**
138+
* @return string[]
139+
*/
140+
public function getFilters(): array
141+
{
142+
return $this->filters;
143+
}
144+
135145
/**
136146
* @return LevelOverflow[]
137147
*/
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Tests\FileSystem\FilePathFilter;
6+
7+
use Iterator;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
use Rector\FileSystem\FilePathFilter;
11+
12+
final class FilePathFilterTest extends TestCase
13+
{
14+
private FilePathFilter $filePathFilter;
15+
16+
protected function setUp(): void
17+
{
18+
$this->filePathFilter = new FilePathFilter();
19+
}
20+
21+
/**
22+
* @param string[] $patterns
23+
* @param string[] $expectedFilePaths
24+
*/
25+
#[DataProvider('provideData')]
26+
public function test(array $patterns, array $expectedFilePaths): void
27+
{
28+
$filePaths = [
29+
'/project/src/Controller/HomeController.php',
30+
'/project/src/Repository/UserRepository.php',
31+
'/project/tests/Unit/SomeTest.php',
32+
'/project/tests/AbstractTestCase.php',
33+
];
34+
35+
$this->assertSame($expectedFilePaths, $this->filePathFilter->filter($filePaths, $patterns));
36+
}
37+
38+
public static function provideData(): Iterator
39+
{
40+
yield 'no patterns keeps everything' => [[], [
41+
'/project/src/Controller/HomeController.php',
42+
'/project/src/Repository/UserRepository.php',
43+
'/project/tests/Unit/SomeTest.php',
44+
'/project/tests/AbstractTestCase.php',
45+
]];
46+
47+
yield 'path substring' => [['/Controller/'], ['/project/src/Controller/HomeController.php']];
48+
49+
yield 'path glob matches same as substring' => [['*/Controller/*'], ['/project/src/Controller/HomeController.php']];
50+
51+
yield 'basename glob' => [['*Repository.php'], ['/project/src/Repository/UserRepository.php']];
52+
53+
yield 'tests keyword' => [['tests'], [
54+
'/project/tests/Unit/SomeTest.php',
55+
'/project/tests/AbstractTestCase.php',
56+
]];
57+
58+
yield 'patterns combine with AND' => [['/tests/', '*Test.php'], ['/project/tests/Unit/SomeTest.php']];
59+
60+
yield 'no match yields empty' => [['*Missing.php'], []];
61+
}
62+
63+
/**
64+
* @param string[] $expectedPatterns
65+
*/
66+
#[DataProvider('provideParseData')]
67+
public function testParsePatterns(string $rawFilter, array $expectedPatterns): void
68+
{
69+
$this->assertSame($expectedPatterns, $this->filePathFilter->parsePatterns($rawFilter));
70+
}
71+
72+
public static function provideParseData(): Iterator
73+
{
74+
yield 'empty string yields no patterns' => ['', []];
75+
yield 'single pattern' => ['/Controller/', ['/Controller/']];
76+
yield 'comma separated, trimmed' => [' /Controller/ , *Repository.php ', ['/Controller/', '*Repository.php']];
77+
yield 'blank parts dropped' => ['tests,,', ['tests']];
78+
}
79+
}

0 commit comments

Comments
 (0)