From 7309cebd1879ea3107dfecc0d44029f0bb3020cf Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sun, 30 Aug 2026 19:08:53 +0200 Subject: [PATCH 1/3] [FileSystem] Add --filter option to keep only files matching all given patterns --- src/Application/ApplicationFileProcessor.php | 4 +- src/Caching/Detector/ChangedFilesDetector.php | 11 ++- src/Configuration/ConfigurationFactory.php | 10 ++- src/Configuration/Option.php | 5 ++ src/Console/ProcessConfigureDecorator.php | 7 ++ src/FileSystem/FilePathFilter.php | 81 +++++++++++++++++++ src/FileSystem/FilesFinder.php | 8 ++ src/ValueObject/Configuration.php | 10 +++ .../FilePathFilter/FilePathFilterTest.php | 77 ++++++++++++++++++ 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/FileSystem/FilePathFilter.php create mode 100644 tests/FileSystem/FilePathFilter/FilePathFilterTest.php diff --git a/src/Application/ApplicationFileProcessor.php b/src/Application/ApplicationFileProcessor.php index eeb05d5a297..dc3556ceb52 100644 --- a/src/Application/ApplicationFileProcessor.php +++ b/src/Application/ApplicationFileProcessor.php @@ -57,7 +57,7 @@ public function __construct( public function run(Configuration $configuration, InputInterface $input): ProcessResult { // scope the cache to this run's --only / --only-suffix selection before any cache read/write - $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix()); + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix(), $configuration->getFilters()); $filePaths = $this->filesFinder->findFilesInPaths($configuration->getPaths(), $configuration); @@ -125,7 +125,7 @@ public function processFiles( ?callable $postFileCallback = null ): ProcessResult { // also set here: parallel workers reach processFiles() via WorkerCommand, bypassing run() - $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix()); + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRule(), $configuration->getOnlySuffix(), $configuration->getFilters()); /** @var SystemError[] $systemErrors */ $systemErrors = []; diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index cf9afb54807..ebe513d0edd 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -22,7 +22,7 @@ final class ChangedFilesDetector */ private array $cacheableFiles = []; - // scopes the per-file cache key to the active --only / --only-suffix selection (empty = full run) + // scopes the per-file cache key to the active --only / --only-suffix / --filter selection (empty = full run) private string $scopeSuffix = ''; public function __construct( @@ -32,12 +32,15 @@ public function __construct( ) { } - public function setActiveScope(?string $onlyRule, ?string $onlySuffix): void + /** + * @param string[] $filters + */ + public function setActiveScope(?string $onlyRule, ?string $onlySuffix, array $filters = []): void { // each selection gets its own cache key, so --only and full runs coexist without clearing or poisoning - $this->scopeSuffix = ($onlyRule === null && $onlySuffix === null) + $this->scopeSuffix = ($onlyRule === null && $onlySuffix === null && $filters === []) ? '' - : '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? ''); + : '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? '') . '|filter:' . implode(',', $filters); } public function cacheFile(string $filePath): void diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index 4f81642eb0b..3c7b70063e7 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -6,6 +6,7 @@ use Rector\ChangesReporting\Output\ConsoleOutputFormatter; use Rector\Configuration\Parameter\SimpleParameterProvider; +use Rector\FileSystem\FilePathFilter; use Rector\ValueObject\Configuration; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -18,6 +19,7 @@ public function __construct( private SymfonyStyle $symfonyStyle, private OnlyRuleResolver $onlyRuleResolver, + private FilePathFilter $filePathFilter, ) { } @@ -71,9 +73,12 @@ public function createFromInput(InputInterface $input): Configuration $onlySuffix = $input->getOption(Option::ONLY_SUFFIX); - // "--only"/"--only-suffix" narrow the run, so skips outside the scope look falsely unused; + $rawFilter = $input->getOption(Option::FILTER); + $filters = $rawFilter !== null ? $this->filePathFilter->parsePatterns((string) $rawFilter) : []; + + // "--only"/"--only-suffix"/"--filter" narrow the run, so skips outside the scope look falsely unused; // mark the run as narrowed to disable unused skip reporting and avoid false positives - if ($onlyRule !== null || $onlySuffix !== null) { + if ($onlyRule !== null || $onlySuffix !== null || $filters !== []) { SimpleParameterProvider::setParameter(Option::IS_RUN_NARROWED, true); } @@ -129,6 +134,7 @@ public function createFromInput(InputInterface $input): Configuration $showRulesSummary, $isComposerBased, $isPhpOnly, + $filters, ); } diff --git a/src/Configuration/Option.php b/src/Configuration/Option.php index 7f3db083793..aa10ad2c447 100644 --- a/src/Configuration/Option.php +++ b/src/Configuration/Option.php @@ -302,6 +302,11 @@ final class Option */ public const string ONLY_SUFFIX = 'only-suffix'; + /** + * @internal To keep only files matching all given patterns + */ + public const string FILTER = 'filter'; + /** * @internal To report overflow levels in ->with*Level() methods */ diff --git a/src/Console/ProcessConfigureDecorator.php b/src/Console/ProcessConfigureDecorator.php index ce3673e499a..1f83cccedd6 100644 --- a/src/Console/ProcessConfigureDecorator.php +++ b/src/Console/ProcessConfigureDecorator.php @@ -80,6 +80,13 @@ public static function decorate(Command $command): void 'Filter only files with specific suffix in name, e.g. "Controller"' ); + $command->addOption( + Option::FILTER, + null, + InputOption::VALUE_REQUIRED, + 'Keep only files matching all comma-separated patterns: "/Controller/" (path substring), "*Repository.php" (basename glob), "tests" (Test.php and TestCase.php files)' + ); + $command->addOption(Option::DEBUG, null, InputOption::VALUE_NONE, 'Display debug output.'); $command->addOption(Option::MEMORY_LIMIT, null, InputOption::VALUE_REQUIRED, 'Memory limit for process'); $command->addOption(Option::CLEAR_CACHE, null, InputOption::VALUE_NONE, 'Clear unchanged files cache'); diff --git a/src/FileSystem/FilePathFilter.php b/src/FileSystem/FilePathFilter.php new file mode 100644 index 00000000000..5145f85ab16 --- /dev/null +++ b/src/FileSystem/FilePathFilter.php @@ -0,0 +1,81 @@ + $this->matchesAllPatterns($filePath, $patterns) + )); + } + + /** + * @param string[] $patterns + */ + private function matchesAllPatterns(string $filePath, array $patterns): bool + { + return array_all($patterns, fn (string $pattern): bool => $this->matchesPattern($filePath, $pattern)); + } + + /** + * Three kinds of pattern are recognised: + * - "tests" the basename ends in Test.php or TestCase.php + * - contains "*" glob matched against the basename, e.g. *Repository.php + * - anything else substring matched anywhere in the full path, e.g. /Controller/ + */ + private function matchesPattern(string $filePath, string $pattern): bool + { + $basename = basename($filePath); + + if ($pattern === self::TESTS_KEYWORD) { + return str_ends_with($basename, 'Test.php') || str_ends_with($basename, 'TestCase.php'); + } + + if (str_contains($pattern, '*')) { + return fnmatch($pattern, $basename); + } + + return str_contains($filePath, $pattern); + } +} diff --git a/src/FileSystem/FilesFinder.php b/src/FileSystem/FilesFinder.php index 8a8a5871f7b..a7f4ce50790 100644 --- a/src/FileSystem/FilesFinder.php +++ b/src/FileSystem/FilesFinder.php @@ -25,12 +25,14 @@ public function __construct( private PathSkipper $pathSkipper, private FilePathHelper $filePathHelper, private ChangedFilesDetector $changedFilesDetector, + private FilePathFilter $filePathFilter, ) { } /** * @param string[] $source * @param string[] $suffixes + * @param string[] $filters * @return string[] */ public function findInDirectoriesAndFiles( @@ -38,6 +40,7 @@ public function findInDirectoriesAndFiles( array $suffixes = [], bool $sortByName = true, ?string $onlySuffix = null, + array $filters = [], ): array { $filesAndDirectories = $this->filesystemTweaker->resolveWithFnmatch($source); @@ -102,6 +105,10 @@ function (string $file): bool { ); $filePaths = [...$filteredFilePaths, ...$filteredFilePathsInDirectories]; + + // keep only files matching all --filter patterns + $filePaths = $this->filePathFilter->filter($filePaths, $filters); + return $this->unchangedFilesFilter->filterFilePaths($filePaths); } @@ -120,6 +127,7 @@ public function findFilesInPaths(array $paths, Configuration $configuration): ar $configuration->getFileExtensions(), true, $configuration->getOnlySuffix(), + $configuration->getFilters(), ); } diff --git a/src/ValueObject/Configuration.php b/src/ValueObject/Configuration.php index 19c22edc5b6..f043209ce7d 100644 --- a/src/ValueObject/Configuration.php +++ b/src/ValueObject/Configuration.php @@ -16,6 +16,7 @@ * @param string[] $fileExtensions * @param string[] $paths * @param LevelOverflow[] $levelOverflows + * @param string[] $filters */ public function __construct( private bool $isDryRun = false, @@ -37,6 +38,7 @@ public function __construct( private bool $showRulesSummary = false, private bool $isComposerBased = false, private bool $isPhpOnly = false, + private array $filters = [], ) { } @@ -132,6 +134,14 @@ public function getOnlySuffix(): ?string return $this->onlySuffix; } + /** + * @return string[] + */ + public function getFilters(): array + { + return $this->filters; + } + /** * @return LevelOverflow[] */ diff --git a/tests/FileSystem/FilePathFilter/FilePathFilterTest.php b/tests/FileSystem/FilePathFilter/FilePathFilterTest.php new file mode 100644 index 00000000000..a08e6041347 --- /dev/null +++ b/tests/FileSystem/FilePathFilter/FilePathFilterTest.php @@ -0,0 +1,77 @@ +filePathFilter = new FilePathFilter(); + } + + /** + * @param string[] $patterns + * @param string[] $expectedFilePaths + */ + #[DataProvider('provideData')] + public function test(array $patterns, array $expectedFilePaths): void + { + $filePaths = [ + '/project/src/Controller/HomeController.php', + '/project/src/Repository/UserRepository.php', + '/project/tests/Unit/SomeTest.php', + '/project/tests/AbstractTestCase.php', + ]; + + $this->assertSame($expectedFilePaths, $this->filePathFilter->filter($filePaths, $patterns)); + } + + public static function provideData(): Iterator + { + yield 'no patterns keeps everything' => [[], [ + '/project/src/Controller/HomeController.php', + '/project/src/Repository/UserRepository.php', + '/project/tests/Unit/SomeTest.php', + '/project/tests/AbstractTestCase.php', + ]]; + + yield 'path substring' => [['/Controller/'], ['/project/src/Controller/HomeController.php']]; + + yield 'basename glob' => [['*Repository.php'], ['/project/src/Repository/UserRepository.php']]; + + yield 'tests keyword' => [['tests'], [ + '/project/tests/Unit/SomeTest.php', + '/project/tests/AbstractTestCase.php', + ]]; + + yield 'patterns combine with AND' => [['/tests/', '*Test.php'], ['/project/tests/Unit/SomeTest.php']]; + + yield 'no match yields empty' => [['*Missing.php'], []]; + } + + /** + * @param string[] $expectedPatterns + */ + #[DataProvider('provideParseData')] + public function testParsePatterns(string $rawFilter, array $expectedPatterns): void + { + $this->assertSame($expectedPatterns, $this->filePathFilter->parsePatterns($rawFilter)); + } + + public static function provideParseData(): Iterator + { + yield 'empty string yields no patterns' => ['', []]; + yield 'single pattern' => ['/Controller/', ['/Controller/']]; + yield 'comma separated, trimmed' => [' /Controller/ , *Repository.php ', ['/Controller/', '*Repository.php']]; + yield 'blank parts dropped' => ['tests,,', ['tests']]; + } +} From 5d86589dc06ca2d1e34f225b9515e007ec01b010 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Tue, 1 Sep 2026 23:46:30 +0200 Subject: [PATCH 2/3] Match glob patterns with a slash against the full path Makes --filter=*/Controller/* behave like --filter=/Controller/, so a glob containing a "/" is matched against the full path instead of the basename. Claude-Session: https://claude.ai/code/session_01J73DBF9SQvcgFnsBhr71cF --- src/FileSystem/FilePathFilter.php | 8 ++++---- tests/FileSystem/FilePathFilter/FilePathFilterTest.php | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/FileSystem/FilePathFilter.php b/src/FileSystem/FilePathFilter.php index 5145f85ab16..694ce324022 100644 --- a/src/FileSystem/FilePathFilter.php +++ b/src/FileSystem/FilePathFilter.php @@ -61,19 +61,19 @@ private function matchesAllPatterns(string $filePath, array $patterns): bool /** * Three kinds of pattern are recognised: * - "tests" the basename ends in Test.php or TestCase.php - * - contains "*" glob matched against the basename, e.g. *Repository.php + * - contains "*" glob matched against the full path when it has a "/", else against the basename * - anything else substring matched anywhere in the full path, e.g. /Controller/ */ private function matchesPattern(string $filePath, string $pattern): bool { - $basename = basename($filePath); - if ($pattern === self::TESTS_KEYWORD) { + $basename = basename($filePath); return str_ends_with($basename, 'Test.php') || str_ends_with($basename, 'TestCase.php'); } if (str_contains($pattern, '*')) { - return fnmatch($pattern, $basename); + $subject = str_contains($pattern, '/') ? $filePath : basename($filePath); + return fnmatch($pattern, $subject); } return str_contains($filePath, $pattern); diff --git a/tests/FileSystem/FilePathFilter/FilePathFilterTest.php b/tests/FileSystem/FilePathFilter/FilePathFilterTest.php index a08e6041347..1478c1751c5 100644 --- a/tests/FileSystem/FilePathFilter/FilePathFilterTest.php +++ b/tests/FileSystem/FilePathFilter/FilePathFilterTest.php @@ -46,6 +46,8 @@ public static function provideData(): Iterator yield 'path substring' => [['/Controller/'], ['/project/src/Controller/HomeController.php']]; + yield 'path glob matches same as substring' => [['*/Controller/*'], ['/project/src/Controller/HomeController.php']]; + yield 'basename glob' => [['*Repository.php'], ['/project/src/Repository/UserRepository.php']]; yield 'tests keyword' => [['tests'], [ From 9c8daa5ae25da214b8c0faf9a4299ae93fa989b7 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Tue, 1 Sep 2026 23:54:13 +0200 Subject: [PATCH 3/3] Deprecate --only-suffix in favor of --filter Emits a deprecation warning when --only-suffix is used and points users to --filter, which covers the same case via --filter="*Controller.php". Claude-Session: https://claude.ai/code/session_01J73DBF9SQvcgFnsBhr71cF --- src/Configuration/ConfigurationFactory.php | 5 +++++ src/Console/ProcessConfigureDecorator.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index 3c7b70063e7..c79f434f5d1 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -72,6 +72,11 @@ public function createFromInput(InputInterface $input): Configuration } $onlySuffix = $input->getOption(Option::ONLY_SUFFIX); + if ($onlySuffix !== null) { + $this->symfonyStyle->warning( + 'The "--only-suffix" option is deprecated and will be removed. Use "--filter" instead, e.g. --filter="*Controller.php"' + ); + } $rawFilter = $input->getOption(Option::FILTER); $filters = $rawFilter !== null ? $this->filePathFilter->parsePatterns((string) $rawFilter) : []; diff --git a/src/Console/ProcessConfigureDecorator.php b/src/Console/ProcessConfigureDecorator.php index 1f83cccedd6..05b92d1b2ad 100644 --- a/src/Console/ProcessConfigureDecorator.php +++ b/src/Console/ProcessConfigureDecorator.php @@ -77,7 +77,7 @@ public static function decorate(Command $command): void Option::ONLY_SUFFIX, null, InputOption::VALUE_REQUIRED, - 'Filter only files with specific suffix in name, e.g. "Controller"' + 'Deprecated, use "--filter" instead. Filter only files with specific suffix in name, e.g. "Controller"' ); $command->addOption(