diff --git a/composer.json b/composer.json index a072ae9c7a..2d4e1b54d6 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,8 @@ "sebastian/object-enumerator": "^7.0.0", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", - "staabm/side-effects-detector": "^1.0.5" + "staabm/side-effects-detector": "^1.0.5", + "webmozart/glob": "^4.7" }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index 047822b72c..4c6cf2238e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "69e5dc7fcb02f196f536d57ba935d9a9", + "content-hash": "b7a05a5262b8f42094ffb2d07feee977", "packages": [ { "name": "myclabs/deep-copy", @@ -1574,6 +1574,55 @@ } ], "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "webmozart/glob", + "version": "4.7.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/glob.git", + "reference": "8a2842112d6916e61e0e15e316465b611f3abc17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/glob/zipball/8a2842112d6916e61e0e15e316465b611f3abc17", + "reference": "8a2842112d6916e61e0e15e316465b611f3abc17", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "symfony/filesystem": "^5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Glob\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "A PHP implementation of Ant's glob.", + "support": { + "issues": "https://github.com/webmozarts/glob/issues", + "source": "https://github.com/webmozarts/glob/tree/4.7.0" + }, + "time": "2024-03-07T20:33:40+00:00" } ], "packages-dev": [], diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index 845a9b3763..eb9f6d7fc0 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,6 +9,16 @@ */ namespace PHPUnit\TextUI\Configuration; +use function array_map; +use function basename; +use function dirname; +use function preg_match; +use function rtrim; +use function sprintf; +use function str_ends_with; +use function str_starts_with; +use Webmozart\Glob\Glob; + /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * @@ -17,35 +27,98 @@ final class SourceFilter { private static ?self $instance = null; + private Source $source; + + /** + * @var list + */ + private array $includeDirectoryRegexes; /** - * @var array + * @var list */ - private readonly array $map; + private array $excludeDirectoryRegexes; public static function instance(): self { if (self::$instance === null) { - self::$instance = new self( - (new SourceMapper)->map( - Registry::get()->source(), - ), - ); + $source = Registry::get()->source(); + self::$instance = new self($source); + + return self::$instance; } return self::$instance; } /** - * @param array $map + * Convert the directory filter to a glob. + * + * To ensure that `foo/**` will match `foo/bar.php` we match both the + * globstar and the wildcard. */ - public function __construct(array $map) + public static function toGlob(FilterDirectory $directory): string + { + $path = rtrim($directory->path(), '/'); + + return sprintf( + '{(%s)|(%s)}', + Glob::toRegEx(sprintf('%s/**/*', $path), 0, ''), + Glob::toRegEx(sprintf('%s/*', $path), 0, ''), + ); + } + + public function __construct(Source $source) { - $this->map = $map; + $this->source = $source; + $this->includeDirectoryRegexes = array_map(static function (FilterDirectory $directory) + { + return [$directory, self::toGlob($directory)]; + }, $source->includeDirectories()->asArray()); + $this->excludeDirectoryRegexes = array_map(static function (FilterDirectory $directory) + { + return [$directory, self::toGlob($directory)]; + }, $source->excludeDirectories()->asArray()); } + /** + * @see https://docs.phpunit.de/en/12.4/configuration.html#the-include-element + */ public function includes(string $path): bool { - return isset($this->map[$path]); + $included = false; + $dirPath = rtrim(dirname($path), '/') . '/'; + $filename = basename($path); + + foreach ($this->source->includeFiles() as $file) { + if ($file->path() === $path) { + $included = true; + } + } + + foreach ($this->includeDirectoryRegexes as [$directory, $directoryRegex]) { + if (preg_match($directoryRegex, $dirPath) && self::filenameMatches($directory, $filename)) { + $included = true; + } + } + + foreach ($this->source->excludeFiles() as $file) { + if ($file->path() === $path) { + $included = false; + } + } + + foreach ($this->excludeDirectoryRegexes as [$directory, $directoryRegex]) { + if (preg_match($directoryRegex, $dirPath) && self::filenameMatches($directory, $filename)) { + $included = false; + } + } + + return $included; + } + + private static function filenameMatches(FilterDirectory $directory, string $filename): bool + { + return str_starts_with($filename, $directory->prefix()) && str_ends_with($filename, $directory->suffix()); } } diff --git a/tests/unit/TextUI/SourceFilterTest.php b/tests/unit/TextUI/SourceFilterTest.php index 4b2d4fb9bc..3c64cdf91a 100644 --- a/tests/unit/TextUI/SourceFilterTest.php +++ b/tests/unit/TextUI/SourceFilterTest.php @@ -9,8 +9,6 @@ */ namespace PHPUnit\TextUI\Configuration; -use function json_encode; -use function sprintf; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Small; @@ -80,6 +78,18 @@ public static function provider(): array ), ), ], + 'file included using directory with trailing slash' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => true, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath() . '/', '', '.php'), + ], + ), + ), + ], 'file included using directory, but excluded using file' => [ [ self::fixturePath('a/PrefixSuffix.php') => false, @@ -311,24 +321,6 @@ public static function provider(): array ), ), ], - 'globstar with any single char prefix includes sibling files' => [ - [ - self::fixturePath('a/PrefixSuffix.php') => false, - self::fixturePath('a/c/PrefixSuffix.php') => true, - self::fixturePath('a/c/d/PrefixSuffix.php') => true, - ], - self::createSource( - includeDirectories: FilterDirectoryCollection::fromArray( - [ - new FilterDirectory( - self::fixturePath('a/c/Z**'), - '', - '.php', - ), - ], - ), - ), - ], 'globstar with any more than a single char prefix does not include sibling files' => [ [ self::fixturePath('a/PrefixSuffix.php') => false, @@ -418,13 +410,14 @@ public static function provider(): array #[DataProvider('provider')] public function testDeterminesWhetherFileIsIncluded(array $expectations, Source $source): void { + $expected = []; + $actual = []; + foreach ($expectations as $file => $shouldInclude) { $this->assertFileExists($file); - $this->assertSame( - $shouldInclude, - new SourceFilter((new SourceMapper)->map($source))->includes($file), - sprintf('expected match to return %s for: %s', json_encode($shouldInclude), $file), - ); + $expected[$file] = $shouldInclude; + $actual[$file] = new SourceFilter($source)->includes($file); } + $this->assertEquals($expected, $actual); } }