diff --git a/build.xml b/build.xml index 644d1129c4..e0d754a49a 100644 --- a/build.xml +++ b/build.xml @@ -233,6 +233,13 @@ + + + + + + + diff --git a/composer.json b/composer.json index 893ca3d104..56040dd73e 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "sebastian/diff": "^8.0.0", "sebastian/environment": "^9.1.0", "sebastian/exporter": "^8.0.0", + "sebastian/file-filter": "^1.0@dev", "sebastian/git-state": "^1.0", "sebastian/global-state": "^9.0.0", "sebastian/object-enumerator": "^8.0.0", diff --git a/composer.lock b/composer.lock index 65070dcbaf..486f91a0f2 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": "4e03413523e4a9056ff6e82325cd79f2", + "content-hash": "c1f6741374ff60d8b56e56d8be91d41b", "packages": [ { "name": "myclabs/deep-copy", @@ -1102,6 +1102,76 @@ ], "time": "2026-02-06T04:44:28+00:00" }, + { + "name": "sebastian/file-filter", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "8ce2998bec022bc562600e45f9eb3efc675a4687" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/8ce2998bec022bc562600e45f9eb3efc675a4687", + "reference": "8ce2998bec022bc562600e45f9eb3efc675a4687", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/main" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-03-08T07:29:22+00:00" + }, { "name": "sebastian/git-state", "version": "dev-main", @@ -1771,7 +1841,9 @@ "packages-dev": [], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "sebastian/file-filter": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/TextUI/Configuration/FileFilterMapper.php b/src/TextUI/Configuration/FileFilterMapper.php new file mode 100644 index 0000000000..20e0be4d4f --- /dev/null +++ b/src/TextUI/Configuration/FileFilterMapper.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TextUI\Configuration; + +use function realpath; +use SebastianBergmann\FileFilter\Builder as FilterBuilder; +use SebastianBergmann\FileFilter\Filter as FileFilter; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class FileFilterMapper +{ + public function map(Source $source): FileFilter + { + return (new FilterBuilder)->build( + $this->directories($source->includeDirectories()), + $this->files($source->includeFiles()), + $this->directories($source->excludeDirectories()), + $this->files($source->excludeFiles()), + ); + } + + /** + * @return list + */ + private function directories(FilterDirectoryCollection $directories): array + { + $result = []; + + foreach ($directories as $directory) { + $path = realpath($directory->path()); + + $result[] = [ + 'path' => $path !== false ? $path : $directory->path(), + 'prefix' => $directory->prefix(), + 'suffix' => $directory->suffix(), + ]; + } + + return $result; + } + + /** + * @return list + */ + private function files(FilterFileCollection $files): array + { + $result = []; + + foreach ($files as $file) { + $path = realpath($file->path()); + + $result[] = $path !== false ? $path : $file->path(); + } + + return $result; + } +} diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index c967475066..62d318d831 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,6 +9,8 @@ */ namespace PHPUnit\TextUI\Configuration; +use SebastianBergmann\FileFilter\Filter; + /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * @@ -17,31 +19,26 @@ final class SourceFilter { private static ?self $instance = null; - - /** - * @var array - */ - private readonly array $map; + private readonly Filter $filter; public static function instance(): self { - if (self::$instance === null) { - self::$instance = new self( - (new SourceMapper)->map( - Registry::get()->source(), - ), - ); + if (self::$instance !== null) { + return self::$instance; } + self::$instance = new self( + (new FileFilterMapper)->map( + Registry::get()->source(), + ), + ); + return self::$instance; } - /** - * @param array $map - */ - public function __construct(array $map) + public function __construct(Filter $filter) { - $this->map = $map; + $this->filter = $filter; } /** @@ -49,6 +46,6 @@ public function __construct(array $map) */ public function includes(string $path): bool { - return isset($this->map[$path]); + return $this->filter->accepts($path); } } diff --git a/tests/unit/TextUI/SourceFilterTest.php b/tests/unit/TextUI/SourceFilterTest.php index 0840ced781..64832dc79b 100644 --- a/tests/unit/TextUI/SourceFilterTest.php +++ b/tests/unit/TextUI/SourceFilterTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\Small; #[CoversClass(SourceFilter::class)] +#[CoversClass(FileFilterMapper::class)] #[Small] #[Group('textui')] #[Group('textui/configuration')] @@ -513,6 +514,62 @@ public static function provider(): array ), ), ], + 'file included using directory with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => true, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath('/b/../a'), '', '.php'), + ], + ), + ), + ], + 'file included using file with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => true, + ], + self::createSource(includeFiles: FilterFileCollection::fromArray( + [ + new FilterFile(self::fixturePath('/b/../a/PrefixSuffix.php')), + ], + )), + ], + 'file excluded using directory with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => false, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath(), '', '.php'), + ], + ), + excludeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath('/b/../a'), '', '.php'), + ], + ), + ), + ], + 'file excluded using file with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => false, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath(), '', '.php'), + ], + ), + excludeFiles: FilterFileCollection::fromArray( + [ + new FilterFile(self::fixturePath('/b/../a/PrefixSuffix.php')), + ], + ), + ), + ], 'files included using same directory and different prefixes' => [ [ self::fixturePath('a/c/Suffix.php') => true, @@ -561,7 +618,7 @@ public function testDeterminesWhetherFileIsIncluded(array $expectations, Source $this->assertFileExists($file); $this->assertSame( $shouldInclude, - new SourceFilter((new SourceMapper)->map($source))->includes($file), + new SourceFilter((new FileFilterMapper)->map($source))->includes($file), sprintf('expected match to return %s for: %s', json_encode($shouldInclude), $file), ); }