diff --git a/extension.neon b/extension.neon index ac7f226..dc73a43 100644 --- a/extension.neon +++ b/extension.neon @@ -1,4 +1,6 @@ parameters: + pest: + configPaths: null universalObjectCratesClasses: - PHPUnit\Framework\TestCase scanDirectories: @@ -6,12 +8,18 @@ parameters: stubFiles: - stubs/DescribeCall.stub +parametersSchema: + pest: structure([ + configPaths: schema(listOf(string()), nullable()) + ]) + services: - class: Pest\PHPStan\Type\Pest\PestFileDiscoverer arguments: scanPaths: %paths% rootDir: %currentWorkingDirectory% + configPaths: %pest.configPaths% - class: Pest\PHPStan\Type\Pest\PestConfigReader diff --git a/src/Type/Pest/PestFileDiscoverer.php b/src/Type/Pest/PestFileDiscoverer.php index 6c7aa34..e7e1c80 100644 --- a/src/Type/Pest/PestFileDiscoverer.php +++ b/src/Type/Pest/PestFileDiscoverer.php @@ -30,10 +30,12 @@ final class PestFileDiscoverer /** * @param string[] $scanPaths PHPStan's configured analysis paths + * @param string[]|null $configPaths Explicit paths for Pest.php discovery, or null to use analysis paths and the project root */ public function __construct( private readonly array $scanPaths, private readonly string $rootDir = '', + private readonly ?array $configPaths = null, ) { $this->parser = (new ParserFactory)->createForNewestSupportedVersion(); } @@ -49,9 +51,9 @@ public function discoverPestFiles(): array $files = []; - $scanPaths = $this->scanPaths; + $scanPaths = $this->configPaths ?? $this->scanPaths; - if ($this->rootDir !== '') { + if ($this->configPaths === null && $this->rootDir !== '') { $scanPaths[] = $this->rootDir; } @@ -196,7 +198,7 @@ private function extractUseMap(array $stmts): array foreach ($stmts as $stmt) { if ($stmt instanceof Use_) { foreach ($stmt->uses as $use) { - $alias = $use->alias !== null ? $use->alias->name : $use->name->getLast(); + $alias = $use->alias->name ?? $use->name->getLast(); $useMap[$alias] = $use->name->toString(); } } @@ -205,7 +207,7 @@ private function extractUseMap(array $stmts): array foreach ($stmt->stmts as $namespacedStmt) { if ($namespacedStmt instanceof Use_) { foreach ($namespacedStmt->uses as $use) { - $alias = $use->alias !== null ? $use->alias->name : $use->name->getLast(); + $alias = $use->alias->name ?? $use->name->getLast(); $useMap[$alias] = $use->name->toString(); } } diff --git a/tests/Analysis/ExpectationChainStateResolverTest.php b/tests/Analysis/ExpectationChainStateResolverTest.php index 82f3413..9141a46 100644 --- a/tests/Analysis/ExpectationChainStateResolverTest.php +++ b/tests/Analysis/ExpectationChainStateResolverTest.php @@ -23,8 +23,8 @@ $resolver = new ExpectationChainStateResolver; $intCall = expectationEachCall(); $stringCall = expectationEachCall(); - $intState = $resolver->resolve($intCall, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createMock($class))); - $stringState = $resolver->resolve($stringCall, scopeReturningExpectationOf(new StringType, fn (string $class): Scope => $this->createMock($class))); + $intState = $resolver->resolve($intCall, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createStub($class))); + $stringState = $resolver->resolve($stringCall, scopeReturningExpectationOf(new StringType, fn (string $class): Scope => $this->createStub($class))); expect($intState)->not->toBeNull() ->and($stringState)->not->toBeNull() ->and($intState->originalValueType->describe(VerbosityLevel::typeOnly()))->toBe('int') @@ -36,7 +36,7 @@ $cache = cacheOf($resolver); expect($cache)->toBeEmpty(); $call = expectationEachCall(); - $resolver->resolve($call, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createMock($class))); + $resolver->resolve($call, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createStub($class))); expect($cache)->toHaveCount(1, 'The resolved node should be memoised.'); unset($call); expect($cache)->toHaveCount( @@ -50,10 +50,10 @@ function expectationEachCall(): MethodCall return new MethodCall(new Variable('expectation'), new Identifier('each')); } -function scopeReturningExpectationOf(Type $valueType, callable $mockFactory): Scope +function scopeReturningExpectationOf(Type $valueType, callable $stubFactory): Scope { $expectationType = new GenericObjectType(Expectation::class, [$valueType]); - $scope = $mockFactory(Scope::class); + $scope = $stubFactory(Scope::class); $scope->method('getType')->willReturn($expectationType); return $scope; diff --git a/tests/Type/Fixtures/pest-discovery.neon b/tests/Type/Fixtures/pest-discovery.neon new file mode 100644 index 0000000..65203d0 --- /dev/null +++ b/tests/Type/Fixtures/pest-discovery.neon @@ -0,0 +1,4 @@ +parameters: + pest: + configPaths: + - %currentWorkingDirectory%/tests/Type/Fixtures/pesthook-scope diff --git a/tests/Type/PestFileDiscovererTest.php b/tests/Type/PestFileDiscovererTest.php index 2479eaf..488800d 100644 --- a/tests/Type/PestFileDiscovererTest.php +++ b/tests/Type/PestFileDiscovererTest.php @@ -4,7 +4,11 @@ namespace Tests\Type; +use FilesystemIterator; use Pest\PHPStan\Type\Pest\PestFileDiscoverer; +use PHPStan\DependencyInjection\ContainerFactory; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RuntimeException; $fixtureDir = static function (string $name): string { @@ -72,3 +76,72 @@ ->and(basename($pestFile))->toBe('Pest.php'); } }); + +test('explicit configuration paths do not search analysis paths or the project root', function () use ($fixtureDir): void { + $dir = $fixtureDir('pesthook-scope'); + $outside = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([$outside], dirname(__DIR__, 2), [$dir]); + + expect($instance->isPestConfigFile($dir.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($outside.'/Pest.php'))->toBeFalse() + ->and($instance->isPestConfigFile(__DIR__.'/../Pest.php'))->toBeTrue(); +}); + +test('explicit configuration paths support multiple test directories', function () use ($fixtureDir): void { + $first = $fixtureDir('pesthook-scope'); + $second = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([], '', [$first, $second]); + + expect($instance->isPestConfigFile($first.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($second.'/Pest.php'))->toBeTrue(); +}); + +test('an empty explicit configuration path list disables discovery', function () use ($fixtureDir): void { + $dir = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([$dir], dirname(__DIR__, 2), []); + + expect($instance->discoverPestFiles())->toBeEmpty(); +}); + +test('default discovery still searches the project root', function () use ($fixtureDir): void { + $dir = $fixtureDir('pesthook-scope'); + $outside = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([$dir], dirname($dir)); + + expect($instance->isPestConfigFile($outside.'/Pest.php'))->toBeTrue(); +}); + +test('PHPStan passes explicit configuration paths to the discovery service', function () use ($fixtureDir): void { + $dir = $fixtureDir('pesthook-scope'); + $outside = $fixtureDir('pestconfig-matrix'); + $project = dirname(__DIR__, 2); + $temporaryDir = sys_get_temp_dir().'/pest-discovery-container-'.bin2hex(random_bytes(16)); + mkdir($temporaryDir, 0700); + + try { + $container = new ContainerFactory($project)->create($temporaryDir, [ + $project.'/extension.neon', + __DIR__.'/Fixtures/pest-discovery.neon', + ], [$outside]); + $instance = $container->getByType(PestFileDiscoverer::class); + + expect($instance->isPestConfigFile($dir.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($outside.'/Pest.php'))->toBeFalse() + ->and($instance->isPestConfigFile(__DIR__.'/../Pest.php'))->toBeTrue(); + } finally { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($temporaryDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($temporaryDir); + } +});