Skip to content

Commit 8b72cc4

Browse files
committed
[FileSystem] Add --filter option to keep only files matching all given patterns
1 parent ffe4c32 commit 8b72cc4

9 files changed

Lines changed: 205 additions & 8 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: 8 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

@@ -71,9 +73,12 @@ public function createFromInput(InputInterface $input): Configuration
7173

7274
$onlySuffix = $input->getOption(Option::ONLY_SUFFIX);
7375

74-
// "--only"/"--only-suffix" narrow the run, so skips outside the scope look falsely unused;
76+
$rawFilter = $input->getOption(Option::FILTER);
77+
$filters = $rawFilter !== null ? $this->filePathFilter->parsePatterns((string) $rawFilter) : [];
78+
79+
// "--only"/"--only-suffix"/"--filter" narrow the run, so skips outside the scope look falsely unused;
7580
// mark the run as narrowed to disable unused skip reporting and avoid false positives
76-
if ($onlyRule !== null || $onlySuffix !== null) {
81+
if ($onlyRule !== null || $onlySuffix !== null || $filters !== []) {
7782
SimpleParameterProvider::setParameter(Option::IS_RUN_NARROWED, true);
7883
}
7984

@@ -129,6 +134,7 @@ public function createFromInput(InputInterface $input): Configuration
129134
$showRulesSummary,
130135
$isComposerBased,
131136
$isPhpOnly,
137+
$filters,
132138
);
133139
}
134140

‎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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ public static function decorate(Command $command): void
8080
'Filter only files with specific suffix in name, e.g. "Controller"'
8181
);
8282

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)'
88+
);
89+
8390
$command->addOption(Option::DEBUG, null, InputOption::VALUE_NONE, 'Display debug output.');
8491
$command->addOption(Option::MEMORY_LIMIT, null, InputOption::VALUE_REQUIRED, 'Memory limit for process');
8592
$command->addOption(Option::CLEAR_CACHE, null, InputOption::VALUE_NONE, 'Clear unchanged files cache');

‎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 basename, e.g. *Repository.php
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+
$basename = basename($filePath);
70+
71+
if ($pattern === self::TESTS_KEYWORD) {
72+
return str_ends_with($basename, 'Test.php') || str_ends_with($basename, 'TestCase.php');
73+
}
74+
75+
if (str_contains($pattern, '*')) {
76+
return fnmatch($pattern, $basename);
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 'basename glob' => [['*Repository.php'], ['/project/src/Repository/UserRepository.php']];
50+
51+
yield 'tests keyword' => [['tests'], [
52+
'/project/tests/Unit/SomeTest.php',
53+
'/project/tests/AbstractTestCase.php',
54+
]];
55+
56+
yield 'patterns combine with AND' => [['/tests/', '*Test.php'], ['/project/tests/Unit/SomeTest.php']];
57+
58+
yield 'no match yields empty' => [['*Missing.php'], []];
59+
}
60+
61+
/**
62+
* @param string[] $expectedPatterns
63+
*/
64+
#[DataProvider('provideParseData')]
65+
public function testParsePatterns(string $rawFilter, array $expectedPatterns): void
66+
{
67+
$this->assertSame($expectedPatterns, $this->filePathFilter->parsePatterns($rawFilter));
68+
}
69+
70+
public static function provideParseData(): Iterator
71+
{
72+
yield 'empty string yields no patterns' => ['', []];
73+
yield 'single pattern' => ['/Controller/', ['/Controller/']];
74+
yield 'comma separated, trimmed' => [' /Controller/ , *Repository.php ', ['/Controller/', '*Repository.php']];
75+
yield 'blank parts dropped' => ['tests,,', ['tests']];
76+
}
77+
}

0 commit comments

Comments
 (0)