Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
parameters:
pest:
configPaths: null
universalObjectCratesClasses:
- PHPUnit\Framework\TestCase
scanDirectories:
- src
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
Expand Down
10 changes: 6 additions & 4 deletions src/Type/Pest/PestFileDiscoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
}
}
Expand All @@ -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();
}
}
Expand Down
10 changes: 5 additions & 5 deletions tests/Analysis/ExpectationChainStateResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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(
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions tests/Type/Fixtures/pest-discovery.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
pest:
configPaths:
- %currentWorkingDirectory%/tests/Type/Fixtures/pesthook-scope
73 changes: 73 additions & 0 deletions tests/Type/PestFileDiscovererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
});
Loading