Skip to content
Draft
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
51 changes: 50 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 84 additions & 11 deletions src/TextUI/Configuration/SourceFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -17,35 +27,98 @@
final class SourceFilter
{
private static ?self $instance = null;
private Source $source;

/**
* @var list<array{FilterDirectory,string}>
*/
private array $includeDirectoryRegexes;

/**
* @var array<non-empty-string, true>
* @var list<array{FilterDirectory,string}>
*/
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<non-empty-string, true> $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());
}
}
43 changes: 18 additions & 25 deletions tests/unit/TextUI/SourceFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
),
],
),
),
],
Copy link
Contributor Author

@dantleech dantleech Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test, that passes on main, seems more like a bug, e.g. the directory path a/c/Z** wil include a/c/PrefixSuffix.php

'globstar with any more than a single char prefix does not include sibling files' => [
[
self::fixturePath('a/PrefixSuffix.php') => false,
Expand Down Expand Up @@ -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);
}
}
Loading