diff --git a/src/Plugin.php b/src/Plugin.php index 6b50628..779ef12 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,6 +12,7 @@ use Pest\TypeCoverage\Logging\JsonLogger; use Pest\TypeCoverage\Logging\NullLogger; use Pest\TypeCoverage\Support\ConfigurationSourceDetector; +use Pest\TypeCoverage\Support\FileResolver; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Finder\Finder; @@ -63,6 +64,8 @@ public function handleOriginalArguments(array $arguments): void $startTime = microtime(true); + $filePaths = $this->extractFileArguments($arguments); + foreach ($arguments as $argument) { if (str_starts_with($argument, '--min')) { // grab the value of the --min argument @@ -111,24 +114,39 @@ public function handleOriginalArguments(array $arguments): void } } - $source = ConfigurationSourceDetector::detect(); + if ($filePaths !== []) { + $resolvedFiles = FileResolver::resolve($filePaths); - if ($source === []) { - View::render('components.badge', [ - 'type' => 'ERROR', - 'content' => 'No source section found. Did you forget to add a `source` section to your `phpunit.xml` file?', - ]); + if ($resolvedFiles === []) { + View::render('components.badge', [ + 'type' => 'ERROR', + 'content' => 'No PHP files found in the specified paths.', + ]); - $this->exit(1); - } + $this->exit(1); + } - $files = Finder::create()->in($source)->name('*.php')->files(); + $files = array_combine($resolvedFiles, $resolvedFiles); + } else { + $source = ConfigurationSourceDetector::detect(); + + if ($source === []) { + View::render('components.badge', [ + 'type' => 'ERROR', + 'content' => 'No source section found. Did you forget to add a `source` section to your `phpunit.xml` file?', + ]); + + $this->exit(1); + } + + $files = Finder::create()->in($source)->name('*.php')->files(); + } $totals = []; $this->output->writeln(['']); Analyser::analyse( - array_keys(iterator_to_array($files)), + is_array($files) ? array_keys($files) : array_keys(iterator_to_array($files)), function (Result $result) use (&$totals): void { $path = str_replace(TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR, '', $result->file); @@ -215,6 +233,32 @@ function (Result $result) use (&$totals): void { $this->exit($exitCode); } + /** + * Extracts file/directory arguments from the command line that come after the -- separator. + * + * @param array $arguments + * @return array + */ + public function extractFileArguments(array $arguments): array + { + $filePaths = []; + $afterDoubleDash = false; + + foreach ($arguments as $argument) { + if ($argument === '--') { + $afterDoubleDash = true; + + continue; + } + + if ($afterDoubleDash && ! str_starts_with($argument, '-')) { + $filePaths[] = $argument; + } + } + + return $filePaths; + } + /** * Exits the process with the given code. */ diff --git a/src/Support/FileResolver.php b/src/Support/FileResolver.php new file mode 100644 index 0000000..886937f --- /dev/null +++ b/src/Support/FileResolver.php @@ -0,0 +1,49 @@ + $paths + * @return array + */ + public static function resolve(array $paths): array + { + if ($paths === []) { + return []; + } + + $files = []; + + foreach ($paths as $path) { + $realPath = realpath($path); + + if ($realPath === false || ! file_exists($realPath)) { + continue; + } + + if (is_file($realPath)) { + if (pathinfo($realPath, PATHINFO_EXTENSION) === 'php') { + $files[] = $realPath; + } + } elseif (is_dir($realPath)) { + $finder = Finder::create()->in($realPath)->name('*.php')->files(); + foreach ($finder as $file) { + $files[] = $file->getRealPath(); + } + } + } + + return array_unique($files); + } +} diff --git a/tests/Fixtures/TestFiles/EmptyDir/readme.txt b/tests/Fixtures/TestFiles/EmptyDir/readme.txt new file mode 100644 index 0000000..6c3518e --- /dev/null +++ b/tests/Fixtures/TestFiles/EmptyDir/readme.txt @@ -0,0 +1 @@ +This directory contains no PHP files and is used for testing scenarios where no PHP files are found. \ No newline at end of file diff --git a/tests/Fixtures/TestFiles/MissingTypes.php b/tests/Fixtures/TestFiles/MissingTypes.php new file mode 100644 index 0000000..6b50721 --- /dev/null +++ b/tests/Fixtures/TestFiles/MissingTypes.php @@ -0,0 +1,16 @@ +value = $value; + } + + public function getValue() + { + return $this->value; + } +} diff --git a/tests/Fixtures/TestFiles/readme.txt b/tests/Fixtures/TestFiles/readme.txt new file mode 100644 index 0000000..65d9a31 --- /dev/null +++ b/tests/Fixtures/TestFiles/readme.txt @@ -0,0 +1 @@ +This is a non-PHP file that should be ignored during file resolution. \ No newline at end of file diff --git a/tests/Plugin.php b/tests/Plugin.php index db7fc21..87c36b2 100644 --- a/tests/Plugin.php +++ b/tests/Plugin.php @@ -169,3 +169,157 @@ public function exit(int $code): never unlink(__DIR__.'/../test.json'); })->todo(); + +test('extracts file arguments correctly', function () { + $plugin = new class(new BufferedOutput) extends Plugin + { + public function exit(int $code): never + { + throw new Exception($code); + } + }; + + $arguments = ['--type-coverage', '--', 'file1.php', 'dir1/', 'file2.php']; + expect($plugin->extractFileArguments($arguments)) + ->toBe(['file1.php', 'dir1/', 'file2.php']); + + $arguments = ['--type-coverage', '--min=80']; + expect($plugin->extractFileArguments($arguments))->toBe([]); + + $arguments = ['--type-coverage', '--', 'file1.php', '--some-option']; + expect($plugin->extractFileArguments($arguments))->toBe(['file1.php']); +}); + +test('handles specific file arguments', function () { + $output = new BufferedOutput; + $plugin = new class($output) extends Plugin + { + public function exit(int $code): never + { + throw new Exception($code); + } + }; + + $testFile = __DIR__.'/Fixtures/TestFiles/SimpleClass.php'; + $arguments = ['--type-coverage', '--', $testFile]; + + expect(fn () => $plugin->handleOriginalArguments($arguments))->toThrow(Exception::class, 0); + + $output = $output->fetch(); + expect($output)->toContain('SimpleClass.php') + ->and($output)->toContain('100%') + ->and($output)->toContain('Total: 100.0 %'); +}); + +test('handles directory arguments', function () { + $output = new BufferedOutput; + $plugin = new class($output) extends Plugin + { + public function exit(int $code): never + { + throw new Exception($code); + } + }; + + $testDir = __DIR__.'/Fixtures/TestFiles'; + $arguments = ['--type-coverage', '--', $testDir]; + + expect(fn () => $plugin->handleOriginalArguments($arguments))->toThrow(Exception::class, 0); + + $output = $output->fetch(); + expect($output)->toContain('SimpleClass.php') + ->and($output)->toContain('MissingTypes.php') + ->and($output)->toContain('NestedClass.php') + ->and($output)->not->toContain('readme.txt'); +}); + +test('handles mixed file and directory arguments', function () { + $output = new BufferedOutput; + $plugin = new class($output) extends Plugin + { + public function exit(int $code): never + { + throw new Exception($code); + } + }; + + $testFile = __DIR__.'/Fixtures/TestFiles/SimpleClass.php'; + $testSubDir = __DIR__.'/Fixtures/TestFiles/SubDir'; + $arguments = ['--type-coverage', '--', $testFile, $testSubDir]; + + expect(fn () => $plugin->handleOriginalArguments($arguments))->toThrow(Exception::class, 0); + + $output = $output->fetch(); + expect($output)->toContain('SimpleClass.php') + ->and($output)->toContain('NestedClass.php') + ->and($output)->not->toContain('MissingTypes.php'); +}); + +test('shows error when no PHP files found in specified paths', function () { + $output = new BufferedOutput; + $plugin = new class($output) extends Plugin + { + public function exit(int $code): never + { + throw new Exception($code); + } + }; + + $emptyDir = __DIR__.'/Fixtures/TestFiles/EmptyDir'; + $arguments = ['--type-coverage', '--', $emptyDir]; + + $initialLevel = ob_get_level(); + + expect(fn () => $plugin->handleOriginalArguments($arguments))->toThrow(Exception::class, '1'); + + while (ob_get_level() > $initialLevel) { + ob_end_clean(); + } +}); + +test('works with non-existent files and valid files mixed', function () { + $output = new BufferedOutput; + $plugin = new class($output) extends Plugin + { + public function exit(int $code): never + { + throw new Exception($code); + } + }; + + $validFile = __DIR__.'/Fixtures/TestFiles/SimpleClass.php'; + $invalidFile = __DIR__.'/Fixtures/TestFiles/NonExistent.php'; + $arguments = ['--type-coverage', '--', $validFile, $invalidFile]; + + expect(fn () => $plugin->handleOriginalArguments($arguments))->toThrow(Exception::class, 0); + + $output = $output->fetch(); + expect($output)->toContain('SimpleClass.php') + ->and($output)->toContain('Total: 100.0 %'); +}); + +test('combines file arguments with other options', function () { + $output = new BufferedOutput; + $plugin = new class($output) extends Plugin + { + public function exit(int $code): never + { + throw new Exception((string) $code); + } + }; + + $testFile = __DIR__.'/Fixtures/TestFiles/MissingTypes.php'; + $arguments = ['--type-coverage', '--compact', '--min=90.0', '--', $testFile]; + + $initialLevel = ob_get_level(); + + expect(fn () => $plugin->handleOriginalArguments($arguments))->toThrow(Exception::class, '1'); + + while (ob_get_level() > $initialLevel) { + ob_end_clean(); + } + + $output = $output->fetch(); + expect($output)->toContain('MissingTypes.php') + ->and($output)->toContain('50%'); +}); diff --git a/tests/Support/FileResolver.php b/tests/Support/FileResolver.php new file mode 100644 index 0000000..50d33b3 --- /dev/null +++ b/tests/Support/FileResolver.php @@ -0,0 +1,93 @@ +fixturesPath = __DIR__.'/../Fixtures/TestFiles'; +}); + +test('resolves empty array when no paths provided', function () { + expect(FileResolver::resolve([]))->toBe([]); +}); + +test('resolves single PHP file', function () { + $phpFile = $this->fixturesPath.'/SimpleClass.php'; + + $result = FileResolver::resolve([$phpFile]); + + expect($result)->toHaveCount(1) + ->and($result[0])->toBe(realpath($phpFile)); +}); + +test('ignores non-PHP files', function () { + $phpFile = $this->fixturesPath.'/SimpleClass.php'; + $txtFile = $this->fixturesPath.'/readme.txt'; + + $result = FileResolver::resolve([$phpFile, $txtFile]); + + expect($result)->toHaveCount(1) + ->and($result[0])->toBe(realpath($phpFile)); +}); + +test('resolves directory to PHP files', function () { + $result = FileResolver::resolve([$this->fixturesPath]); + + expect($result)->toHaveCount(3) + ->and(in_array(realpath($this->fixturesPath.'/SimpleClass.php'), $result))->toBeTrue() + ->and(in_array(realpath($this->fixturesPath.'/MissingTypes.php'), $result))->toBeTrue() + ->and(in_array(realpath($this->fixturesPath.'/SubDir/NestedClass.php'), $result))->toBeTrue(); +}); + +test('resolves nested directories', function () { + $result = FileResolver::resolve([$this->fixturesPath]); + + expect($result)->toHaveCount(3) + ->and(in_array(realpath($this->fixturesPath.'/SimpleClass.php'), $result))->toBeTrue() + ->and(in_array(realpath($this->fixturesPath.'/SubDir/NestedClass.php'), $result))->toBeTrue(); +}); + +test('handles mixed files and directories', function () { + $directFile = $this->fixturesPath.'/SimpleClass.php'; + $subDir = $this->fixturesPath.'/SubDir'; + + $result = FileResolver::resolve([$directFile, $subDir]); + + expect($result)->toHaveCount(2) + ->and(in_array(realpath($directFile), $result))->toBeTrue() + ->and(in_array(realpath($this->fixturesPath.'/SubDir/NestedClass.php'), $result))->toBeTrue(); +}); + +test('skips non-existent paths', function () { + $existingFile = $this->fixturesPath.'/SimpleClass.php'; + $nonExistentFile = $this->fixturesPath.'/nonexistent.php'; + $nonExistentDir = $this->fixturesPath.'/nonexistent_dir'; + + $result = FileResolver::resolve([$existingFile, $nonExistentFile, $nonExistentDir]); + + expect($result)->toHaveCount(1) + ->and($result[0])->toBe(realpath($existingFile)); +}); + +test('removes duplicate files', function () { + $phpFile = $this->fixturesPath.'/SimpleClass.php'; + + $result = FileResolver::resolve([$phpFile, $phpFile, $this->fixturesPath]); + + expect($result)->toHaveCount(3); +}); + +test('handles empty directory', function () { + $emptyDir = $this->fixturesPath.'/EmptyDir'; + + $result = FileResolver::resolve([$emptyDir]); + + expect($result)->toBe([]); +}); + +test('handles directory with no PHP files', function () { + $emptyDir = $this->fixturesPath.'/EmptyDir'; + + $result = FileResolver::resolve([$emptyDir]); + + expect($result)->toBe([]); +});