diff --git a/src/Application/ApplicationFileProcessor.php b/src/Application/ApplicationFileProcessor.php index 311b23045fa..b7fff79015e 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(), $configuration->getFilters()); + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRules(), $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(), $configuration->getFilters()); + $this->changedFilesDetector->setActiveScope($configuration->getOnlyRules(), $configuration->getOnlySuffix(), $configuration->getFilters()); /** @var SystemError[] $systemErrors */ $systemErrors = []; diff --git a/src/Caching/Detector/ChangedFilesDetector.php b/src/Caching/Detector/ChangedFilesDetector.php index ebe513d0edd..385dbffb98c 100644 --- a/src/Caching/Detector/ChangedFilesDetector.php +++ b/src/Caching/Detector/ChangedFilesDetector.php @@ -33,14 +33,15 @@ public function __construct( } /** + * @param string[] $onlyRules * @param string[] $filters */ - public function setActiveScope(?string $onlyRule, ?string $onlySuffix, array $filters = []): void + public function setActiveScope(array $onlyRules, ?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 && $filters === []) + $this->scopeSuffix = ($onlyRules === [] && $onlySuffix === null && $filters === []) ? '' - : '|only:' . ($onlyRule ?? '') . '|suffix:' . ($onlySuffix ?? '') . '|filter:' . implode(',', $filters); + : '|only:' . implode(',', $onlyRules) . '|suffix:' . ($onlySuffix ?? '') . '|filter:' . implode(',', $filters); } public function cacheFile(string $filePath): void diff --git a/src/Configuration/ConfigurationFactory.php b/src/Configuration/ConfigurationFactory.php index f0bf20040e0..dff51b7d014 100644 --- a/src/Configuration/ConfigurationFactory.php +++ b/src/Configuration/ConfigurationFactory.php @@ -67,10 +67,12 @@ public function createFromInput(InputInterface $input): Configuration $fileExtensions = SimpleParameterProvider::provideArrayParameter(Option::FILE_EXTENSIONS); // filter rule and path - $onlyRule = $input->getOption(Option::ONLY); - if ($onlyRule !== null) { - $onlyRule = $this->onlyRuleResolver->resolve($onlyRule); - } + /** @var string[] $onlyRuleInputs */ + $onlyRuleInputs = (array) $input->getOption(Option::ONLY); + $onlyRules = array_map( + $this->onlyRuleResolver->resolve(...), + $onlyRuleInputs + ); $onlySuffix = $input->getOption(Option::ONLY_SUFFIX); if ($onlySuffix !== null) { @@ -84,7 +86,7 @@ public function createFromInput(InputInterface $input): Configuration // "--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 || $filters !== []) { + if ($onlyRules !== [] || $onlySuffix !== null || $filters !== []) { SimpleParameterProvider::setParameter(Option::IS_RUN_NARROWED, true); } @@ -141,7 +143,7 @@ public function createFromInput(InputInterface $input): Configuration $memoryLimit, $isDebug, $isReportingWithRealPath, - $onlyRule, + $onlyRules, $onlySuffix, $levelOverflows, $showRulesSummary, diff --git a/src/Configuration/ConfigurationRuleFilter.php b/src/Configuration/ConfigurationRuleFilter.php index 7f9ea2cf13b..798a4fa188b 100644 --- a/src/Configuration/ConfigurationRuleFilter.php +++ b/src/Configuration/ConfigurationRuleFilter.php @@ -41,9 +41,9 @@ public function filter(array $rectors): array return $rectors; } - $onlyRule = $this->configuration->getOnlyRule(); - if ($onlyRule !== null) { - return $this->filterOnlyRule($rectors, $onlyRule); + $onlyRules = $this->configuration->getOnlyRules(); + if ($onlyRules !== []) { + return $this->filterOnlyRules($rectors, $onlyRules); } if ($this->configuration->isComposerBased()) { @@ -59,14 +59,18 @@ public function filter(array $rectors): array /** * @param list $rectors + * @param string[] $onlyRules * @return list */ - public function filterOnlyRule(array $rectors, string $onlyRule): array + public function filterOnlyRules(array $rectors, array $onlyRules): array { $activeRectors = []; foreach ($rectors as $rector) { - if ($rector instanceof $onlyRule) { - $activeRectors[] = $rector; + foreach ($onlyRules as $onlyRule) { + if ($rector instanceof $onlyRule) { + $activeRectors[] = $rector; + break; + } } } diff --git a/src/Console/ProcessConfigureDecorator.php b/src/Console/ProcessConfigureDecorator.php index 5e32a27b168..800168aba89 100644 --- a/src/Console/ProcessConfigureDecorator.php +++ b/src/Console/ProcessConfigureDecorator.php @@ -57,7 +57,12 @@ public static function decorate(Command $command): void ); // filter by rule and path - $command->addOption(Option::ONLY, null, InputOption::VALUE_REQUIRED, 'Fully qualified rule class name'); + $command->addOption( + Option::ONLY, + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Fully qualified rule class name; repeat to run several rules, e.g. --only=A --only=B' + ); $command->addOption( Option::COMPOSER_BASED, diff --git a/src/Parallel/Command/WorkerCommandLineFactory.php b/src/Parallel/Command/WorkerCommandLineFactory.php index 36f44109f2a..87293be531f 100644 --- a/src/Parallel/Command/WorkerCommandLineFactory.php +++ b/src/Parallel/Command/WorkerCommandLineFactory.php @@ -127,11 +127,6 @@ public function create( $workerCommandArray[] = self::OPTION_DASHES . Option::COMPOSER_BASED; } - if ($input->getOption(Option::ONLY) !== null) { - $workerCommandArray[] = self::OPTION_DASHES . Option::ONLY; - $workerCommandArray[] = escapeshellarg((string) $input->getOption(Option::ONLY)); - } - return implode(' ', $workerCommandArray); } @@ -175,7 +170,7 @@ private function mirrorCommandOptions(InputInterface $input, array $mainCommandO continue; } - /** @var bool|string|null $optionValue */ + /** @var bool|string|string[]|null $optionValue */ $optionValue = $input->getOption($mainCommandOptionName); // skip clutter @@ -191,6 +186,16 @@ private function mirrorCommandOptions(InputInterface $input, array $mainCommandO continue; } + // array options (e.g. repeated --only) are mirrored one flag per value + if (is_array($optionValue)) { + foreach ($optionValue as $singleOptionValue) { + $workerCommandOptions[] = self::OPTION_DASHES . $mainCommandOptionName; + $workerCommandOptions[] = \escapeshellarg($singleOptionValue); + } + + continue; + } + if ($mainCommandOptionName === 'memory-limit') { // symfony/console does not accept -1 as value without assign $workerCommandOptions[] = self::OPTION_DASHES . $mainCommandOptionName . '=' . \escapeshellarg( diff --git a/src/ValueObject/Configuration.php b/src/ValueObject/Configuration.php index 3d0f2118b4b..3497baf3722 100644 --- a/src/ValueObject/Configuration.php +++ b/src/ValueObject/Configuration.php @@ -15,6 +15,7 @@ /** * @param string[] $fileExtensions * @param string[] $paths + * @param string[] $onlyRules * @param LevelOverflow[] $levelOverflows * @param string[] $filters */ @@ -32,7 +33,7 @@ public function __construct( private string|null $memoryLimit = null, private bool $isDebug = false, private bool $reportingWithRealPath = false, - private ?string $onlyRule = null, + private array $onlyRules = [], private ?string $onlySuffix = null, private array $levelOverflows = [], private bool $showRulesSummary = false, @@ -82,9 +83,12 @@ public function getFileExtensions(): array return $this->fileExtensions; } - public function getOnlyRule(): ?string + /** + * @return string[] + */ + public function getOnlyRules(): array { - return $this->onlyRule; + return $this->onlyRules; } /** diff --git a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php index c42ad956d1c..9d90a5fdd28 100644 --- a/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php +++ b/tests/Application/ApplicationFileProcessor/ApplicationFileProcessorTest.php @@ -49,15 +49,15 @@ public function testOnlyRuleRunCachesUnderOwnScopeWithoutPoisoningFullRun(): voi $this->applicationFileProcessor->processFiles([$filePath], new Configuration( isDryRun: true, - onlyRule: RemoveEmptyClassMethodRector::class + onlyRules: [RemoveEmptyClassMethodRector::class] )); // a repeated --only run hits its own scoped cache entry - $this->changedFilesDetector->setActiveScope(RemoveEmptyClassMethodRector::class, null); + $this->changedFilesDetector->setActiveScope([RemoveEmptyClassMethodRector::class], null); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); // a full run uses a different scope key, so it is not poisoned - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } @@ -70,10 +70,10 @@ public function testOnlySuffixRunCachesUnderOwnScopeWithoutPoisoningFullRun(): v onlySuffix: 'Controller.php' )); - $this->changedFilesDetector->setActiveScope(null, 'Controller.php'); + $this->changedFilesDetector->setActiveScope([], 'Controller.php'); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } diff --git a/tests/Caching/Detector/ChangedFilesDetectorTest.php b/tests/Caching/Detector/ChangedFilesDetectorTest.php index 3fcdc0d2b40..a904f939c04 100644 --- a/tests/Caching/Detector/ChangedFilesDetectorTest.php +++ b/tests/Caching/Detector/ChangedFilesDetectorTest.php @@ -68,13 +68,13 @@ public function testScopedRunReusesFullRunCache(): void $filePath = __DIR__ . '/Source/file.php'; // full run caches the file as clean - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->changedFilesDetector->addCacheableFile($filePath); $this->changedFilesDetector->cacheFile($filePath); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); // an --only run reuses the full-run cache instead of re-analysing the file - $this->changedFilesDetector->setActiveScope('Rector\\SomeRule', null); + $this->changedFilesDetector->setActiveScope(['Rector\\SomeRule'], null); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); } @@ -83,13 +83,13 @@ public function testScopedCacheDoesNotLeakToFullRun(): void $filePath = __DIR__ . '/Source/file.php'; // a scoped run only caches under its own key - $this->changedFilesDetector->setActiveScope('Rector\\SomeRule', null); + $this->changedFilesDetector->setActiveScope(['Rector\\SomeRule'], null); $this->changedFilesDetector->addCacheableFile($filePath); $this->changedFilesDetector->cacheFile($filePath); $this->assertFalse($this->changedFilesDetector->hasFileChanged($filePath)); // a full run must not treat the file as cached, as only one rule ran - $this->changedFilesDetector->setActiveScope(null, null); + $this->changedFilesDetector->setActiveScope([], null); $this->assertTrue($this->changedFilesDetector->hasFileChanged($filePath)); } diff --git a/tests/Configuration/ConfigurationRuleFilterTest.php b/tests/Configuration/ConfigurationRuleFilterTest.php index 0ac159485c6..de10056c31f 100644 --- a/tests/Configuration/ConfigurationRuleFilterTest.php +++ b/tests/Configuration/ConfigurationRuleFilterTest.php @@ -5,6 +5,7 @@ namespace Rector\Tests\Configuration; use Rector\Configuration\ConfigurationRuleFilter; +use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector; use Rector\DeadCode\Rector\If_\RemoveDeadInstanceOfRector; use Rector\Php80\Rector\Class_\StringableForToStringRector; use Rector\Testing\PHPUnit\AbstractLazyTestCase; @@ -22,6 +23,13 @@ protected function setUp(): void $this->configurationRuleFilter = $this->make(ConfigurationRuleFilter::class); } + protected function tearDown(): void + { + // the filter is a shared container service; reset it so a configured onlyRules/isPhpOnly + // does not leak into other tests in the same process + $this->configurationRuleFilter->setConfiguration(new Configuration()); + } + public function testPhpOnlyKeepsMinPhpVersionRules(): void { $stringableForToStringRector = $this->make(StringableForToStringRector::class); @@ -64,6 +72,24 @@ public function testFiltersOutDeprecatedRules(): void $this->assertSame([$removeDeadInstanceOfRector], $filteredRectors); } + public function testOnlyRulesKeepsEveryListedRule(): void + { + $stringableForToStringRector = $this->make(StringableForToStringRector::class); + $removeDeadInstanceOfRector = $this->make(RemoveDeadInstanceOfRector::class); + $removeEmptyClassMethodRector = $this->make(RemoveEmptyClassMethodRector::class); + + $this->configurationRuleFilter->setConfiguration(new Configuration(onlyRules: [ + StringableForToStringRector::class, + RemoveEmptyClassMethodRector::class, + ])); + + $filteredRectors = $this->configurationRuleFilter->filter( + [$stringableForToStringRector, $removeDeadInstanceOfRector, $removeEmptyClassMethodRector] + ); + + $this->assertSame([$stringableForToStringRector, $removeEmptyClassMethodRector], $filteredRectors); + } + private function createConfiguration(bool $isPhpOnly): Configuration { return new Configuration(isPhpOnly: $isPhpOnly);