Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Application/ApplicationFileProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 = [];
Expand Down
11 changes: 7 additions & 4 deletions src/Caching/Detector/ChangedFilesDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/Configuration/ConfigurationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@
public function __construct(
private SymfonyStyle $symfonyStyle,
private OnlyRuleResolver $onlyRuleResolver,
private FilePathFilter $filePathFilter,
) {
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -129,6 +134,7 @@ public function createFromInput(InputInterface $input): Configuration
$showRulesSummary,
$isComposerBased,
$isPhpOnly,
$filters,
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/Configuration/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
7 changes: 7 additions & 0 deletions src/Console/ProcessConfigureDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
81 changes: 81 additions & 0 deletions src/FileSystem/FilePathFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Rector\FileSystem;

/**
* Keeps only files matching all given --filter patterns.
*
* @see \Rector\Tests\FileSystem\FilePathFilter\FilePathFilterTest
*/
final class FilePathFilter
{
private const string TESTS_KEYWORD = 'tests';

/**
* Splits a comma-separated --filter value into individual patterns, trimming blanks.
*
* @return string[]
*/
public function parsePatterns(string $rawFilter): array
{
$patterns = [];
foreach (explode(',', $rawFilter) as $pattern) {
$pattern = trim($pattern);
if ($pattern !== '') {
$patterns[] = $pattern;
}
}

return $patterns;
}

/**
* Keeps only files that match every pattern (AND). With no patterns the input is returned unchanged.
*
* @param string[] $filePaths
* @param string[] $patterns
* @return string[]
*/
public function filter(array $filePaths, array $patterns): array
{
if ($patterns === []) {
return $filePaths;
}

return array_values(array_filter(
$filePaths,
fn (string $filePath): bool => $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);
}
}
8 changes: 8 additions & 0 deletions src/FileSystem/FilesFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,22 @@ 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(
array $source,
array $suffixes = [],
bool $sortByName = true,
?string $onlySuffix = null,
array $filters = [],
): array {
$filesAndDirectories = $this->filesystemTweaker->resolveWithFnmatch($source);

Expand Down Expand Up @@ -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);
}

Expand All @@ -120,6 +127,7 @@ public function findFilesInPaths(array $paths, Configuration $configuration): ar
$configuration->getFileExtensions(),
true,
$configuration->getOnlySuffix(),
$configuration->getFilters(),
);
}

Expand Down
10 changes: 10 additions & 0 deletions src/ValueObject/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* @param string[] $fileExtensions
* @param string[] $paths
* @param LevelOverflow[] $levelOverflows
* @param string[] $filters
*/
public function __construct(
private bool $isDryRun = false,
Expand All @@ -37,6 +38,7 @@ public function __construct(
private bool $showRulesSummary = false,
private bool $isComposerBased = false,
private bool $isPhpOnly = false,
private array $filters = [],
) {
}

Expand Down Expand Up @@ -132,6 +134,14 @@ public function getOnlySuffix(): ?string
return $this->onlySuffix;
}

/**
* @return string[]
*/
public function getFilters(): array
{
return $this->filters;
}

/**
* @return LevelOverflow[]
*/
Expand Down
77 changes: 77 additions & 0 deletions tests/FileSystem/FilePathFilter/FilePathFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Rector\Tests\FileSystem\FilePathFilter;

use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Rector\FileSystem\FilePathFilter;

final class FilePathFilterTest extends TestCase
{
private FilePathFilter $filePathFilter;

protected function setUp(): void
{
$this->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']];
}
}
Loading