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),
);
}