Skip to content
6 changes: 4 additions & 2 deletions core/Application/Config/FinderConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
public array $excludes;

/**
* @param iterable<non-empty-string|Path> $include Include directories or files to the scope
* @param iterable<non-empty-string|Path> $exclude Exclude directories or files from the scope
* @param iterable<non-empty-string|Path> $include Include directories or files to the scope.
* A file listed here is kept even when an exclude covers it.
* @param iterable<non-empty-string|Path> $exclude Exclude directories or files from the scope.
* Drops everything beneath each path, even a directory above an include.
*
* @note Glob and regex patterns are not supported
*/
Expand Down
32 changes: 13 additions & 19 deletions core/Tokenizer/Finder.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +33,31 @@ public function __construct(FinderConfig $config)
$this->finder->in($iDirs);
$this->finder->append($files);

# Exclude
$eDirs = $files = [];
# Exclude: excluded paths only feed the filter, they are never searched or appended.
# Found entries come as real paths, so excludes are resolved through symlinks to meet them.
$excludes = [];
foreach ($config->excludes as $path) {
$path->isDir()
? $eDirs[] = (string) $path
: $files[] = (string) $path;
$excludes[] = Path::create(\realpath((string) $path) ?: $path);
}
$this->finder->in($eDirs);
$this->finder->append($files);

$eDirs === [] && $files === [] or $this->finder->filter(
static function (\SplFileInfo $file) use ($iDirs, $eDirs, $files): bool {
$path = Path::create($file->getRealPath())->absolute();

# Files in excluded files
if ($path->isFile() && \in_array((string) $path, $files, true)) {
$excludes === [] or $this->finder->filter(
static function (\SplFileInfo $file) use ($excludes): bool {
$realPath = $file->getRealPath();
if ($realPath === false) {
return false;
}

# Directories in excluded dirs
$target = (string) $path;
while (!\in_array($target, $iDirs, true)) {
if (\in_array($target, $eDirs, true)) {
$path = Path::create($realPath);
foreach ($excludes as $excluded) {
if ($path->isWithin($excluded)) {
return false;
}

$target = \dirname($target);
}

return true;
},
# Prune; symfony/finder 6.4 takes this flag positionally only
true,
);
}

Expand Down
2 changes: 2 additions & 0 deletions skills/testo-configure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ new FinderConfig(
);
```

An exclude drops everything beneath it, wherever it sits: an excluded directory above an include (include `core/X`, exclude `core`) leaves nothing. A file listed in `include` by name is always kept, even under an excluded directory, which pulls one file back out of an excluded tree.

## Multi-suite layout

A typical real-world `testo.php`:
Expand Down
206 changes: 195 additions & 11 deletions tests/Tokenizer/Unit/FinderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Testo\Application\Config\FinderConfig;
use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Core\Exception\SkipTest;
use Testo\Test;
use Testo\Tokenizer\Finder;

Expand Down Expand Up @@ -80,7 +81,7 @@ public function withFilterReturnsSelfWithFilterApplied(): void

Assert::true($filtered instanceof Finder);
Assert::true($filtered !== $finder);
Assert::same(0, $filtered->count());
Assert::same($filtered->count(), 0);
}

/**
Expand All @@ -96,8 +97,8 @@ public function cloneIsIndependentFromOriginal(): void

$derived = $original->withFilter(static fn(\Symfony\Component\Finder\SplFileInfo $f): bool => false);

Assert::same(0, $derived->count());
Assert::same($originalCount, $original->count());
Assert::same($derived->count(), 0);
Assert::same($original->count(), $originalCount);
}

/**
Expand All @@ -111,7 +112,7 @@ public function constructorWithIncludeFilesCountsThoseFiles(): void

$finder = new Finder(new FinderConfig(include: [$fileA, $fileB]));

Assert::same(2, $finder->count());
Assert::same($finder->count(), 2);
}

/**
Expand All @@ -132,20 +133,85 @@ public function constructorWithExcludeDirectoryFiltersFilesFromIt(): void
}

/**
* Constructor with a single exclude file still constructs successfully and counts items.
* An excluded file inside an included directory is absent from the results.
*/
public function constructorWithExcludeFileSucceeds(): void
public function excludedFileIsNotFound(): void
{
$stubDir = \dirname(__DIR__) . '/Stub';
$excludedFile = $stubDir . '/EmptyClass.php';
$excludedFile = \realpath($stubDir . '/EmptyClass.php');

$finder = new Finder(new FinderConfig(
$finder = (new Finder(new FinderConfig(
include: [$stubDir],
exclude: [$excludedFile],
));
)))->files();

// Construction succeeds and count is accessible (the finder is usable)
Assert::true($finder->count() > 0);
$all = (new Finder(new FinderConfig(include: [$stubDir])))->files();
Assert::false(\in_array($excludedFile, self::realPaths($finder), true));
Assert::same($finder->count(), $all->count() - 1);
}

/**
* An excluded file outside every included path does not join the results.
*/
public function excludedFileOutsideIncludesIsNotAdded(): void
{
$stubDir = \dirname(__DIR__) . '/Stub';
$outsideFile = \realpath(__FILE__);

$finder = (new Finder(new FinderConfig(
include: [$stubDir],
exclude: [$outsideFile],
)))->files();

Assert::false(\in_array($outsideFile, self::realPaths($finder), true));
}

/**
* A file listed in include by name is kept even under an excluded directory.
*/
public function includedFileWinsOverExcludedDirectory(): void
{
$stubDir = \dirname(__DIR__) . '/Stub';
$file = \realpath($stubDir . '/NoNamespace/GlobalClass.php');

$finder = (new Finder(new FinderConfig(
include: [$stubDir, $file],
exclude: [$stubDir . '/NoNamespace'],
)))->files();

Assert::true(\in_array($file, self::realPaths($finder), true));
}

/**
* Filters applied after construction see every entry, so an excluded file cannot slip past them.
*/
public function withFilterRejectsEverythingWhenFileExcluded(): void
{
$stubDir = \dirname(__DIR__) . '/Stub';

$finder = (new Finder(new FinderConfig(
include: [$stubDir],
exclude: [$stubDir . '/EmptyClass.php'],
)))->withFilter(static fn(\Symfony\Component\Finder\SplFileInfo $f): bool => false);

Assert::same($finder->count(), 0);
}

/**
* directories() yields no excluded file.
*/
public function directoriesSkipExcludedFile(): void
{
$stubDir = \dirname(__DIR__) . '/Stub';

$finder = (new Finder(new FinderConfig(
include: [$stubDir],
exclude: [$stubDir . '/EmptyClass.php'],
)))->directories();

$paths = self::realPaths($finder);
Assert::true(\in_array(\realpath($stubDir . '/NoNamespace'), $paths, true));
Assert::false(\in_array(\realpath($stubDir . '/EmptyClass.php'), $paths, true));
}

/**
Expand Down Expand Up @@ -175,4 +241,122 @@ public function constructorWithExcludeDirectoryFiltersFilesInsideIt(): void
// But some files should still be found
Assert::true(\count($foundPaths) > 0);
}

/**
* Paths spelled in another letter case than the filesystem keeps them still take effect.
*/
public function excludesMatchPathsSpelledInOtherLetterCase(): void
{
\DIRECTORY_SEPARATOR === '/' and throw new SkipTest('Paths are case-sensitive on this OS.');
$stubDir = \realpath(\dirname(__DIR__) . '/Stub');
$excludedFile = $stubDir . '\EmptyClass.php';
$excludedDirFile = $stubDir . '\NoNamespace\GlobalClass.php';

$finder = (new Finder(new FinderConfig(
include: [\strtolower($stubDir)],
exclude: [\strtoupper($excludedFile), \strtoupper(\dirname($excludedDirFile))],
)))->files();

$all = (new Finder(new FinderConfig(include: [$stubDir])))->files();
$found = self::realPaths($finder);
Assert::false(\in_array($excludedFile, $found, true));
Assert::false(\in_array($excludedDirFile, $found, true));
Assert::same($finder->count(), $all->count() - 2);
}

/**
* Paths reached through a symlink take effect, in includes and excludes alike.
*/
public function excludesMatchPathsReachedThroughSymlink(): void
{
$stubDir = \realpath(\dirname(__DIR__) . '/Stub');
$link = \sys_get_temp_dir() . '/testo-finder-' . \bin2hex(\random_bytes(4));
@\symlink($stubDir, $link) or throw new SkipTest('Symlinks cannot be created here.');

try {
$finder = (new Finder(new FinderConfig(
include: [$link],
exclude: [$link . '/EmptyClass.php', $link . '/NoNamespace'],
)))->files();

$all = (new Finder(new FinderConfig(include: [$stubDir])))->files();
$found = self::realPaths($finder);
Assert::false(\in_array(\realpath($stubDir . '/EmptyClass.php'), $found, true));
Assert::false(\in_array(\realpath($stubDir . '/NoNamespace/GlobalClass.php'), $found, true));
Assert::same($finder->count(), $all->count() - 2);
} finally {
\DIRECTORY_SEPARATOR === '/' ? \unlink($link) : \rmdir($link);
}
}

/**
* An entry without a real path, such as a dangling symlink, is skipped by the exclude filter.
*/
public function excludeFilterSkipsDanglingSymlink(): void
{
$dir = \sys_get_temp_dir() . '/testo-finder-' . \bin2hex(\random_bytes(4));
\mkdir($dir);
\touch($dir . '/Kept.php');
\touch($dir . '/Excluded.php');
$link = $dir . '/Dangling.php';

try {
@\symlink($dir . '/Missing.php', $link) or throw new SkipTest('Symlinks cannot be created here.');

$finder = new Finder(new FinderConfig(
include: [$dir],
exclude: [$dir . '/Excluded.php'],
));

Assert::same(self::realPaths($finder), [\realpath($dir . '/Kept.php')]);
} finally {
@\unlink($link);
\unlink($dir . '/Kept.php');
\unlink($dir . '/Excluded.php');
\rmdir($dir);
}
}

/**
* An excluded directory is not descended into, so an unreadable one breaks nothing.
*/
public function excludedDirectoryIsNotTraversed(): void
{
$dir = \sys_get_temp_dir() . '/testo-finder-' . \bin2hex(\random_bytes(4));
$locked = $dir . '/Locked';
\mkdir($locked, recursive: true);
\touch($dir . '/Kept.php');
\touch($locked . '/Hidden.php');
\chmod($locked, 0o000);

try {
\is_readable($locked) and throw new SkipTest('Directory permissions are not enforced here.');

$finder = new Finder(new FinderConfig(
include: [$dir],
exclude: [$locked],
));

Assert::same(self::realPaths($finder), [\realpath($dir . '/Kept.php')]);
} finally {
\chmod($locked, 0o700);
\unlink($locked . '/Hidden.php');
\rmdir($locked);
\unlink($dir . '/Kept.php');
\rmdir($dir);
}
}

/**
* @return list<string> Real paths of the entries.
*/
private static function realPaths(Finder $finder): array
{
$paths = [];
foreach ($finder as $file) {
$paths[] = $file->getRealPath();
}

return $paths;
}
}
Loading