diff --git a/core/Application/Config/FinderConfig.php b/core/Application/Config/FinderConfig.php index 58946e4a..7f8156b9 100644 --- a/core/Application/Config/FinderConfig.php +++ b/core/Application/Config/FinderConfig.php @@ -24,8 +24,10 @@ public array $excludes; /** - * @param iterable $include Include directories or files to the scope - * @param iterable $exclude Exclude directories or files from the scope + * @param iterable $include Include directories or files to the scope. + * A file listed here is kept even when an exclude covers it. + * @param iterable $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 */ diff --git a/core/Tokenizer/Finder.php b/core/Tokenizer/Finder.php index c6fa511e..68e9decb 100644 --- a/core/Tokenizer/Finder.php +++ b/core/Tokenizer/Finder.php @@ -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, ); } diff --git a/skills/testo-configure/SKILL.md b/skills/testo-configure/SKILL.md index 139c917a..40d33795 100644 --- a/skills/testo-configure/SKILL.md +++ b/skills/testo-configure/SKILL.md @@ -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`: diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index d16ab56b..b5224aad 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -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; @@ -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); } /** @@ -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); } /** @@ -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); } /** @@ -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)); } /** @@ -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 Real paths of the entries. + */ + private static function realPaths(Finder $finder): array + { + $paths = []; + foreach ($finder as $file) { + $paths[] = $file->getRealPath(); + } + + return $paths; + } }