From 72c20d182566548db040d05425f1e4d193b8eb18 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Thu, 28 May 2026 11:11:19 +0200 Subject: [PATCH] Initial worl on filesystem-based targeting --- src/Test/Target/Directory.php | 68 ++++++++++ src/Test/Target/DirectoryRecursively.php | 68 ++++++++++ src/Test/Target/File.php | 68 ++++++++++ src/Test/Target/MapBuilder.php | 28 +++- src/Test/Target/Mapper.php | 13 +- src/Test/Target/Target.php | 39 ++++++ .../Target/file-target-tree/Subdir/Inner.php | 3 + tests/_files/Target/file-target-tree/Top.php | 3 + tests/tests/FilterProcessorTest.php | 5 +- tests/tests/Test/Target/MapBuilderTest.php | 126 ++++++++++++++++-- tests/tests/Test/Target/MapperTest.php | 109 +++++++++++++++ tests/tests/Test/Target/TargetTest.php | 72 ++++++++++ .../UnintentionallyCoveredCodeCheckerTest.php | 5 +- 13 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 src/Test/Target/Directory.php create mode 100644 src/Test/Target/DirectoryRecursively.php create mode 100644 src/Test/Target/File.php create mode 100644 tests/_files/Target/file-target-tree/Subdir/Inner.php create mode 100644 tests/_files/Target/file-target-tree/Top.php diff --git a/src/Test/Target/Directory.php b/src/Test/Target/Directory.php new file mode 100644 index 000000000..1837b0174 --- /dev/null +++ b/src/Test/Target/Directory.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 SebastianBergmann\CodeCoverage\Test\Target; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class Directory extends Target +{ + /** + * @var non-empty-string + */ + private string $directory; + + /** + * @param non-empty-string $directory + */ + protected function __construct(string $directory) + { + $this->directory = $directory; + } + + public function isDirectory(): true + { + return true; + } + + /** + * @return non-empty-string + */ + public function directory(): string + { + return $this->directory; + } + + /** + * @return non-empty-string + */ + public function key(): string + { + return 'directories'; + } + + /** + * @return non-empty-string + */ + public function target(): string + { + return $this->directory; + } + + /** + * @return non-empty-string + */ + public function description(): string + { + return 'Directory ' . $this->target(); + } +} diff --git a/src/Test/Target/DirectoryRecursively.php b/src/Test/Target/DirectoryRecursively.php new file mode 100644 index 000000000..3c4990275 --- /dev/null +++ b/src/Test/Target/DirectoryRecursively.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 SebastianBergmann\CodeCoverage\Test\Target; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class DirectoryRecursively extends Target +{ + /** + * @var non-empty-string + */ + private string $directory; + + /** + * @param non-empty-string $directory + */ + protected function __construct(string $directory) + { + $this->directory = $directory; + } + + public function isDirectoryRecursively(): true + { + return true; + } + + /** + * @return non-empty-string + */ + public function directory(): string + { + return $this->directory; + } + + /** + * @return non-empty-string + */ + public function key(): string + { + return 'directoriesRecursively'; + } + + /** + * @return non-empty-string + */ + public function target(): string + { + return $this->directory; + } + + /** + * @return non-empty-string + */ + public function description(): string + { + return 'Directory (recursively) ' . $this->target(); + } +} diff --git a/src/Test/Target/File.php b/src/Test/Target/File.php new file mode 100644 index 000000000..e547a7d39 --- /dev/null +++ b/src/Test/Target/File.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 SebastianBergmann\CodeCoverage\Test\Target; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class File extends Target +{ + /** + * @var non-empty-string + */ + private string $path; + + /** + * @param non-empty-string $path + */ + protected function __construct(string $path) + { + $this->path = $path; + } + + public function isFile(): true + { + return true; + } + + /** + * @return non-empty-string + */ + public function path(): string + { + return $this->path; + } + + /** + * @return non-empty-string + */ + public function key(): string + { + return 'files'; + } + + /** + * @return non-empty-string + */ + public function target(): string + { + return $this->path; + } + + /** + * @return non-empty-string + */ + public function description(): string + { + return 'File ' . $this->target(); + } +} diff --git a/src/Test/Target/MapBuilder.php b/src/Test/Target/MapBuilder.php index fa3de853f..bd5fd00ac 100644 --- a/src/Test/Target/MapBuilder.php +++ b/src/Test/Target/MapBuilder.php @@ -14,6 +14,7 @@ use function array_slice; use function array_unique; use function count; +use function dirname; use function explode; use function implode; use function range; @@ -52,6 +53,9 @@ public function build(Filter $filter, FileAnalyser $analyser): array $traits = []; $methods = []; $functions = []; + $files = []; + $directories = []; + $directoriesRecursively = []; $reverseLookup = []; foreach ($filter->files() as $file) { @@ -118,8 +122,8 @@ public function build(Filter $filter, FileAnalyser $analyser): array } } - foreach ($namespaces as $namespace => $files) { - foreach (array_keys($files) as $file) { + foreach ($namespaces as $namespace => $filesInNamespace) { + foreach (array_keys($filesInNamespace) as $file) { $namespaces[$namespace][$file] = array_unique($namespaces[$namespace][$file]); } } @@ -158,6 +162,23 @@ public function build(Filter $filter, FileAnalyser $analyser): array unset($classesThatExtendClass[$className]); } + foreach ($filter->files() as $file) { + $lines = range(1, $analyser->analyse($file)->linesOfCode()->linesOfCode()); + + $files[$file] = [$file => $lines]; + + $parent = dirname($file); + + $directories[$parent][$file] = $lines; + + $ancestor = $parent; + + while ($ancestor !== dirname($ancestor)) { + $directoriesRecursively[$ancestor][$file] = $lines; + $ancestor = dirname($ancestor); + } + } + return [ 'namespaces' => $namespaces, 'traits' => $traits, @@ -166,6 +187,9 @@ public function build(Filter $filter, FileAnalyser $analyser): array 'classesThatImplementInterface' => $classesThatImplementInterface, 'methods' => $methods, 'functions' => $functions, + 'files' => $files, + 'directories' => $directories, + 'directoriesRecursively' => $directoriesRecursively, 'reverseLookup' => $reverseLookup, ]; } diff --git a/src/Test/Target/Mapper.php b/src/Test/Target/Mapper.php index 50c243bec..3ba397c87 100644 --- a/src/Test/Target/Mapper.php +++ b/src/Test/Target/Mapper.php @@ -12,10 +12,11 @@ use function array_keys; use function array_merge; use function array_unique; +use function realpath; use function strcasecmp; /** - * @phpstan-type TargetMap array{namespaces: TargetMapPart, traits: TargetMapPart, classes: TargetMapPart, classesThatExtendClass: TargetMapPart, classesThatImplementInterface: TargetMapPart, methods: TargetMapPart, functions: TargetMapPart, reverseLookup: ReverseLookup} + * @phpstan-type TargetMap array{namespaces: TargetMapPart, traits: TargetMapPart, classes: TargetMapPart, classesThatExtendClass: TargetMapPart, classesThatImplementInterface: TargetMapPart, methods: TargetMapPart, functions: TargetMapPart, files: TargetMapPart, directories: TargetMapPart, directoriesRecursively: TargetMapPart, reverseLookup: ReverseLookup} * @phpstan-type TargetMapPart array>> * @phpstan-type ReverseLookup array * @@ -73,6 +74,16 @@ public function mapTarget(Target $target): array return $this->map[$target->key()][$target->target()]; } + if ($target->isFile() || $target->isDirectory() || $target->isDirectoryRecursively()) { + $resolved = realpath($target->target()); + + if ($resolved !== false && isset($this->map[$target->key()][$resolved])) { + return $this->map[$target->key()][$resolved]; + } + + throw new InvalidCodeCoverageTargetException($target); + } + foreach (array_keys($this->map[$target->key()]) as $key) { if (strcasecmp($key, $target->target()) === 0) { return $this->map[$target->key()][$key]; diff --git a/src/Test/Target/Target.php b/src/Test/Target/Target.php index 7432c81a9..4b8e7c8d0 100644 --- a/src/Test/Target/Target.php +++ b/src/Test/Target/Target.php @@ -73,6 +73,30 @@ public static function forTrait(string $traitName): Trait_ return new Trait_($traitName); } + /** + * @param non-empty-string $path + */ + public static function forFile(string $path): File + { + return new File($path); + } + + /** + * @param non-empty-string $directory + */ + public static function forDirectory(string $directory): Directory + { + return new Directory($directory); + } + + /** + * @param non-empty-string $directory + */ + public static function forDirectoryRecursively(string $directory): DirectoryRecursively + { + return new DirectoryRecursively($directory); + } + public function isNamespace(): bool { return false; @@ -108,6 +132,21 @@ public function isTrait(): bool return false; } + public function isFile(): bool + { + return false; + } + + public function isDirectory(): bool + { + return false; + } + + public function isDirectoryRecursively(): bool + { + return false; + } + /** * @return non-empty-string */ diff --git a/tests/_files/Target/file-target-tree/Subdir/Inner.php b/tests/_files/Target/file-target-tree/Subdir/Inner.php new file mode 100644 index 000000000..15e04c620 --- /dev/null +++ b/tests/_files/Target/file-target-tree/Subdir/Inner.php @@ -0,0 +1,3 @@ +, traits: array, classes: array, classesThatExtendClass: array, classesThatImplementInterface: array, methods: array, functions: array, reverseLookup: array} + * @return array{namespaces: array, traits: array, classes: array, classesThatExtendClass: array, classesThatImplementInterface: array, methods: array, functions: array, files: array, directories: array, directoriesRecursively: array, reverseLookup: array} */ private function emptyMap(): array { @@ -427,6 +427,9 @@ private function emptyMap(): array 'classesThatImplementInterface' => [], 'methods' => [], 'functions' => [], + 'files' => [], + 'directories' => [], + 'directoriesRecursively' => [], 'reverseLookup' => [], ]; } diff --git a/tests/tests/Test/Target/MapBuilderTest.php b/tests/tests/Test/Target/MapBuilderTest.php index ada4c1fe7..274db5072 100644 --- a/tests/tests/Test/Target/MapBuilderTest.php +++ b/tests/tests/Test/Target/MapBuilderTest.php @@ -10,6 +10,7 @@ namespace SebastianBergmann\CodeCoverage\Test\Target; use function array_merge; +use function dirname; use function range; use function realpath; use PHPUnit\Framework\Attributes\CoversClass; @@ -36,13 +37,17 @@ /** * @phpstan-import-type TargetMap from Mapper + * @phpstan-import-type TargetMapPart from Mapper + * @phpstan-import-type ReverseLookup from Mapper + * + * @phpstan-type PartialTargetMap array{namespaces: TargetMapPart, traits: TargetMapPart, classes: TargetMapPart, classesThatExtendClass: TargetMapPart, classesThatImplementInterface: TargetMapPart, methods: TargetMapPart, functions: TargetMapPart, reverseLookup: ReverseLookup} */ #[CoversClass(MapBuilder::class)] #[Small] final class MapBuilderTest extends TestCase { /** - * @return non-empty-array}> + * @return non-empty-array}> */ public static function provider(): array { @@ -419,13 +424,58 @@ public static function provider(): array } /** - * @param TargetMap $expected, + * @param PartialTargetMap $expected * @param non-empty-list $files */ #[DataProvider('provider')] public function testBuildsMap(array $expected, array $files): void { - $this->assertSame($expected, $this->map($files)); + $this->assertSame($this->withFileAndDirectoryEntries($expected, $files), $this->map($files)); + } + + public function testBuildsMapForFileAndDirectoryTargets(): void + { + $top = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Top.php'); + $inner = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Subdir/Inner.php'); + + $topDir = dirname($top); + $subDir = dirname($inner); + + $map = $this->map([$top, $inner]); + + $topLines = range(1, 4); + $innerLines = range(1, 4); + + $this->assertSame( + [ + $top => [$top => $topLines], + $inner => [$inner => $innerLines], + ], + $map['files'], + ); + + $this->assertSame( + [$top => $topLines], + $map['directories'][$topDir], + ); + + $this->assertSame( + [$inner => $innerLines], + $map['directories'][$subDir], + ); + + $this->assertSame( + [ + $top => $topLines, + $inner => $innerLines, + ], + $map['directoriesRecursively'][$topDir], + ); + + $this->assertSame( + [$inner => $innerLines], + $map['directoriesRecursively'][$subDir], + ); } #[Ticket('https://github.com/sebastianbergmann/php-code-coverage/issues/1066')] @@ -437,8 +487,16 @@ public function testIssue1066(): void $dummyWithTrait = realpath(__DIR__ . '/../../../_files/Target/regression/1066/DummyWithTrait.php'); $someTrait = realpath(__DIR__ . '/../../../_files/Target/regression/1066/SomeTrait.php'); + $files = [ + $baseDummy, + $dummy, + $dummy2, + $dummyWithTrait, + $someTrait, + ]; + $this->assertSame( - [ + $this->withFileAndDirectoryEntries([ 'namespaces' => [ 'SebastianBergmann' => [ $someTrait => range(4, 6), @@ -554,16 +612,8 @@ public function testIssue1066(): void $dummyWithTrait . ':15' => DummyWithTrait::class . '::method2', $dummyWithTrait . ':16' => DummyWithTrait::class . '::method2', ], - ], - $this->map( - [ - $baseDummy, - $dummy, - $dummy2, - $dummyWithTrait, - $someTrait, - ], - ), + ], $files), + $this->map($files), ); } @@ -587,4 +637,52 @@ private function map(array $files): array ), ); } + + /** + * @param PartialTargetMap $expected + * @param non-empty-list $files + * + * @return TargetMap + */ + private function withFileAndDirectoryEntries(array $expected, array $files): array + { + $filter = new Filter; + $filter->includeFiles($files); + + $analyser = new FileAnalyser(new ParsingSourceAnalyser, false, false); + + $filesMap = []; + $directoriesMap = []; + $directoriesRecursivelyMap = []; + + foreach ($filter->files() as $file) { + $lines = range(1, $analyser->analyse($file)->linesOfCode()->linesOfCode()); + + $filesMap[$file] = [$file => $lines]; + + $parent = dirname($file); + $directoriesMap[$parent][$file] = $lines; + + $ancestor = $parent; + + while ($ancestor !== dirname($ancestor)) { + $directoriesRecursivelyMap[$ancestor][$file] = $lines; + $ancestor = dirname($ancestor); + } + } + + return [ + 'namespaces' => $expected['namespaces'], + 'traits' => $expected['traits'], + 'classes' => $expected['classes'], + 'classesThatExtendClass' => $expected['classesThatExtendClass'], + 'classesThatImplementInterface' => $expected['classesThatImplementInterface'], + 'methods' => $expected['methods'], + 'functions' => $expected['functions'], + 'files' => $filesMap, + 'directories' => $directoriesMap, + 'directoriesRecursively' => $directoriesRecursivelyMap, + 'reverseLookup' => $expected['reverseLookup'], + ]; + } } diff --git a/tests/tests/Test/Target/MapperTest.php b/tests/tests/Test/Target/MapperTest.php index f69eb28e5..d068322ed 100644 --- a/tests/tests/Test/Target/MapperTest.php +++ b/tests/tests/Test/Target/MapperTest.php @@ -11,6 +11,9 @@ use function array_keys; use function array_merge; +use function chdir; +use function dirname; +use function getcwd; use function range; use function realpath; use function strtolower; @@ -44,6 +47,9 @@ public static function provider(): array { $file = realpath(__DIR__ . '/../../../_files/source_with_interfaces_classes_traits_functions.php'); + $top = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Top.php'); + $inner = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Subdir/Inner.php'); + return [ 'class' => [ [ @@ -191,6 +197,40 @@ public static function provider(): array ], ), ], + + 'file' => [ + [ + $top => range(1, 4), + ], + TargetCollection::fromArray( + [ + Target::forFile($top), + ], + ), + ], + + 'directory (direct children only)' => [ + [ + $top => range(1, 4), + ], + TargetCollection::fromArray( + [ + Target::forDirectory(dirname($top)), + ], + ), + ], + + 'directory (recursively)' => [ + [ + $top => range(1, 4), + $inner => range(1, 4), + ], + TargetCollection::fromArray( + [ + Target::forDirectoryRecursively(dirname($top)), + ], + ), + ], ]; } @@ -252,6 +292,30 @@ public static function invalidProvider(): array ], ), ], + 'file' => [ + 'File /path/that/does/not/exist.php is not a valid target for code coverage', + TargetCollection::fromArray( + [ + Target::forFile('/path/that/does/not/exist.php'), + ], + ), + ], + 'directory' => [ + 'Directory /path/that/does/not/exist is not a valid target for code coverage', + TargetCollection::fromArray( + [ + Target::forDirectory('/path/that/does/not/exist'), + ], + ), + ], + 'directory (recursively)' => [ + 'Directory (recursively) /path/that/does/not/exist is not a valid target for code coverage', + TargetCollection::fromArray( + [ + Target::forDirectoryRecursively('/path/that/does/not/exist'), + ], + ), + ], ]; } @@ -328,6 +392,51 @@ public function testIssue1066(): void ); } + public function testRelativeFileTargetIsResolvedViaRealpath(): void + { + $top = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Top.php'); + $mapper = $this->mapper([$top]); + + $previousCwd = getcwd(); + chdir(dirname($top)); + + try { + $this->assertSame( + [$top => range(1, 4)], + $mapper->mapTarget(Target::forFile('./Top.php')), + ); + } finally { + chdir($previousCwd); + } + } + + public function testRelativeDirectoryTargetIsResolvedViaRealpath(): void + { + $top = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Top.php'); + $inner = realpath(__DIR__ . '/../../../_files/Target/file-target-tree/Subdir/Inner.php'); + $mapper = $this->mapper([$top, $inner]); + + $previousCwd = getcwd(); + chdir(dirname($top, 2)); + + try { + $this->assertSame( + [$top => range(1, 4)], + $mapper->mapTarget(Target::forDirectory('./file-target-tree')), + ); + + $this->assertSame( + [ + $top => range(1, 4), + $inner => range(1, 4), + ], + $mapper->mapTarget(Target::forDirectoryRecursively('./file-target-tree')), + ); + } finally { + chdir($previousCwd); + } + } + public function testLineOfCodeInGlobalScopeDoesNotBelongToCodeUnit(): void { $file = realpath(__DIR__ . '/../../../_files/source_without_ignore.php'); diff --git a/tests/tests/Test/Target/TargetTest.php b/tests/tests/Test/Target/TargetTest.php index 526670095..57b6c8d87 100644 --- a/tests/tests/Test/Target/TargetTest.php +++ b/tests/tests/Test/Target/TargetTest.php @@ -20,6 +20,9 @@ #[CoversClass(Class_::class)] #[CoversClass(ClassesThatExtendClass::class)] #[CoversClass(ClassesThatImplementInterface::class)] +#[CoversClass(Directory::class)] +#[CoversClass(DirectoryRecursively::class)] +#[CoversClass(File::class)] #[CoversClass(Function_::class)] #[CoversClass(Method::class)] #[CoversClass(Namespace_::class)] @@ -168,4 +171,73 @@ public function testCanBeTrait(): void $this->assertSame($traitName, $target->target()); $this->assertSame('Trait ' . $traitName, $target->description()); } + + public function testCanBeFile(): void + { + $path = '/path/to/file.php'; + + $target = Target::forFile($path); + + $this->assertTrue($target->isFile()); + $this->assertFalse($target->isClass()); + $this->assertFalse($target->isClassesThatExtendClass()); + $this->assertFalse($target->isClassesThatImplementInterface()); + $this->assertFalse($target->isDirectory()); + $this->assertFalse($target->isDirectoryRecursively()); + $this->assertFalse($target->isFunction()); + $this->assertFalse($target->isMethod()); + $this->assertFalse($target->isNamespace()); + $this->assertFalse($target->isTrait()); + + $this->assertSame($path, $target->path()); + $this->assertSame('files', $target->key()); + $this->assertSame($path, $target->target()); + $this->assertSame('File ' . $path, $target->description()); + } + + public function testCanBeDirectory(): void + { + $directory = '/path/to/directory'; + + $target = Target::forDirectory($directory); + + $this->assertTrue($target->isDirectory()); + $this->assertFalse($target->isClass()); + $this->assertFalse($target->isClassesThatExtendClass()); + $this->assertFalse($target->isClassesThatImplementInterface()); + $this->assertFalse($target->isDirectoryRecursively()); + $this->assertFalse($target->isFile()); + $this->assertFalse($target->isFunction()); + $this->assertFalse($target->isMethod()); + $this->assertFalse($target->isNamespace()); + $this->assertFalse($target->isTrait()); + + $this->assertSame($directory, $target->directory()); + $this->assertSame('directories', $target->key()); + $this->assertSame($directory, $target->target()); + $this->assertSame('Directory ' . $directory, $target->description()); + } + + public function testCanBeDirectoryRecursively(): void + { + $directory = '/path/to/directory'; + + $target = Target::forDirectoryRecursively($directory); + + $this->assertTrue($target->isDirectoryRecursively()); + $this->assertFalse($target->isClass()); + $this->assertFalse($target->isClassesThatExtendClass()); + $this->assertFalse($target->isClassesThatImplementInterface()); + $this->assertFalse($target->isDirectory()); + $this->assertFalse($target->isFile()); + $this->assertFalse($target->isFunction()); + $this->assertFalse($target->isMethod()); + $this->assertFalse($target->isNamespace()); + $this->assertFalse($target->isTrait()); + + $this->assertSame($directory, $target->directory()); + $this->assertSame('directoriesRecursively', $target->key()); + $this->assertSame($directory, $target->target()); + $this->assertSame('Directory (recursively) ' . $directory, $target->description()); + } } diff --git a/tests/tests/UnintentionallyCoveredCodeCheckerTest.php b/tests/tests/UnintentionallyCoveredCodeCheckerTest.php index 71351d248..78777dd70 100644 --- a/tests/tests/UnintentionallyCoveredCodeCheckerTest.php +++ b/tests/tests/UnintentionallyCoveredCodeCheckerTest.php @@ -589,7 +589,7 @@ public function testCheckUsesClassLevelTargetsFromUsesCollection(): void } /** - * @return array{namespaces: array, traits: array, classes: array, classesThatExtendClass: array, classesThatImplementInterface: array, methods: array, functions: array, reverseLookup: array} + * @return array{namespaces: array, traits: array, classes: array, classesThatExtendClass: array, classesThatImplementInterface: array, methods: array, functions: array, files: array, directories: array, directoriesRecursively: array, reverseLookup: array} */ private function emptyMap(): array { @@ -601,6 +601,9 @@ private function emptyMap(): array 'classesThatImplementInterface' => [], 'methods' => [], 'functions' => [], + 'files' => [], + 'directories' => [], + 'directoriesRecursively' => [], 'reverseLookup' => [], ]; }