diff --git a/extension.neon b/extension.neon index aab2056..f0f672d 100644 --- a/extension.neon +++ b/extension.neon @@ -41,11 +41,24 @@ services: tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: Pest\PHPStan\Type\Pest\PestTestCaseType + - class: Pest\PHPStan\Type\Pest\TestClosureThisTypeExtension tags: - phpstan.functionParameterClosureThisExtension + - + class: Pest\PHPStan\Type\Pest\WithClosureThisTypeExtension + tags: + - phpstan.methodParameterClosureThisExtension + + - + class: Pest\PHPStan\Type\Pest\WithDatasetClosureNodeVisitor + tags: + - phpstan.parser.richParserNodeVisitor + - class: Pest\PHPStan\Type\Pest\ExpectationPropertiesExtension tags: diff --git a/src/Type/Pest/PestConfigReader.php b/src/Type/Pest/PestConfigReader.php index 11b7af0..17a60a1 100644 --- a/src/Type/Pest/PestConfigReader.php +++ b/src/Type/Pest/PestConfigReader.php @@ -24,6 +24,9 @@ final class PestConfigReader /** @var array> */ private array $globalUseDirectoryMap = []; + /** @var list Bindings declared in a Pest.php config without an ->in() scope */ + private array $globalBindings = []; + /** @var array> Caches bindings declared directly inside a test file */ private array $fileBindingsCache = []; @@ -45,7 +48,7 @@ public function resolveBindings(string $filePath): array $this->ensureParsed(); $normalizedFile = $this->fileDiscoverer->normalizePath($filePath); - $bindings = []; + $bindings = [...$this->globalBindings]; foreach ($this->directoryMap as $bindingKey => $classNames) { if (! $this->targetResolver->matches($bindingKey, $normalizedFile)) { @@ -120,7 +123,7 @@ public function allBoundClasses(): array { $this->ensureParsed(); - $bindings = []; + $bindings = [...$this->globalBindings]; foreach ($this->directoryMap as $classNames) { array_push($bindings, ...$classNames); @@ -158,6 +161,27 @@ private function parsePestFile(string $filePath): void $this->extractUsesBindings($nodeFinder, $stmts, $pestFileDir, $filePath); $this->extractPestBindings($nodeFinder, $stmts, $pestFileDir, $filePath); + $this->extractGlobalBindings($stmts); + } + + /** + * @param Node[] $stmts + */ + private function extractGlobalBindings(array $stmts): void + { + foreach ($this->topLevelExpressions($stmts) as $expr) { + $usesArgs = $this->extractFileUsesArgs($expr); + if ($usesArgs !== []) { + array_push($this->globalBindings, ...$usesArgs); + + continue; + } + + $pestArgs = $this->extractFilePestArgs($expr); + if ($pestArgs !== []) { + array_push($this->globalBindings, ...$pestArgs); + } + } } /** diff --git a/src/Type/Pest/PestTestCaseType.php b/src/Type/Pest/PestTestCaseType.php new file mode 100644 index 0000000..c27f643 --- /dev/null +++ b/src/Type/Pest/PestTestCaseType.php @@ -0,0 +1,78 @@ +pestConfigReader->resolveFileBindings($filePath); + + if ($bindings === []) { + $bindings = $this->pestConfigReader->resolveBindings($filePath); + } + + $classNames = []; + $traitNames = []; + + foreach ($bindings as $binding) { + if (! $this->reflectionProvider->hasClass($binding)) { + continue; + } + + $reflection = $this->reflectionProvider->getClass($binding); + + if ($reflection->isTrait()) { + $traitNames[] = $binding; + + continue; + } + + $classNames[] = $binding; + } + + if ($classNames === []) { + $classNames[] = TestCase::class; + } + + $classType = $this->toObjectType($classNames); + + if ($traitNames === []) { + return $classType; + } + + return new PestTestCaseWithTraitsType( + $classNames[0], + $traitNames, + $this->reflectionProvider, + ); + } + + /** + * @param list $classNames + */ + private function toObjectType(array $classNames): Type + { + if (count($classNames) === 1) { + return new ObjectType($classNames[0]); + } + + return TypeCombinator::intersect(...array_map( + static fn (string $className): ObjectType => new ObjectType($className), + $classNames, + )); + } +} diff --git a/src/Type/Pest/PestTestCaseWithTraitsType.php b/src/Type/Pest/PestTestCaseWithTraitsType.php new file mode 100644 index 0000000..22059fe --- /dev/null +++ b/src/Type/Pest/PestTestCaseWithTraitsType.php @@ -0,0 +1,171 @@ + $traitNames */ + public function __construct( + string $className, + private readonly array $traitNames, + private readonly ReflectionProvider $reflectionProvider, + ) { + parent::__construct($className); + } + + #[Override] + public function hasMethod(string $methodName): TrinaryLogic + { + if (parent::hasMethod($methodName)->yes()) { + return TrinaryLogic::createYes(); + } + + if ($this->hasTraitMethod($methodName)) { + return TrinaryLogic::createYes(); + } + + return parent::hasMethod($methodName); + } + + #[Override] + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + if (parent::hasMethod($methodName)->yes()) { + return parent::getMethod($methodName, $scope); + } + + foreach ($this->traitNames as $traitName) { + $traitReflection = $this->reflectionProvider->getClass($traitName); + + if ($traitReflection->hasNativeMethod($methodName)) { + return $traitReflection->getNativeMethod($methodName); + } + } + + return parent::getMethod($methodName, $scope); + } + + #[Override] + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + if (parent::hasMethod($methodName)->yes()) { + return parent::getUnresolvedMethodPrototype($methodName, $scope); + } + + foreach ($this->traitNames as $traitName) { + if ($this->reflectionProvider->getClass($traitName)->hasNativeMethod($methodName)) { + return new ObjectType($traitName)->getUnresolvedMethodPrototype($methodName, $scope); + } + } + + return parent::getUnresolvedMethodPrototype($methodName, $scope); + } + + #[Override] + public function hasProperty(string $propertyName): TrinaryLogic + { + if (parent::hasProperty($propertyName)->yes()) { + return TrinaryLogic::createYes(); + } + + if ($this->hasTraitProperty($propertyName)) { + return TrinaryLogic::createYes(); + } + + return parent::hasProperty($propertyName); + } + + #[Override] + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + if (parent::hasProperty($propertyName)->yes()) { + return parent::getProperty($propertyName, $scope); + } + + foreach ($this->traitNames as $traitName) { + $traitReflection = $this->reflectionProvider->getClass($traitName); + + if ($traitReflection->hasNativeProperty($propertyName)) { + return $traitReflection->getNativeProperty($propertyName); + } + } + + return parent::getProperty($propertyName, $scope); + } + + #[Override] + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + if (parent::hasProperty($propertyName)->yes()) { + return parent::getUnresolvedPropertyPrototype($propertyName, $scope); + } + + foreach ($this->traitNames as $traitName) { + if ($this->reflectionProvider->getClass($traitName)->hasNativeProperty($propertyName)) { + return new ObjectType($traitName)->getUnresolvedPropertyPrototype($propertyName, $scope); + } + } + + return parent::getUnresolvedPropertyPrototype($propertyName, $scope); + } + + #[Override] + public function hasConstant(string $constantName): TrinaryLogic + { + if (parent::hasConstant($constantName)->yes()) { + return TrinaryLogic::createYes(); + } + + if ($this->hasTraitConstant($constantName)) { + return TrinaryLogic::createYes(); + } + + return parent::hasConstant($constantName); + } + + #[Override] + public function getConstant(string $constantName): ClassConstantReflection + { + if (parent::hasConstant($constantName)->yes()) { + return parent::getConstant($constantName); + } + + foreach ($this->traitNames as $traitName) { + $traitReflection = $this->reflectionProvider->getClass($traitName); + + if ($traitReflection->hasConstant($constantName)) { + return $traitReflection->getConstant($constantName); + } + } + + return parent::getConstant($constantName); + } + + private function hasTraitMethod(string $methodName): bool + { + return array_any($this->traitNames, fn (string $traitName): bool => $this->reflectionProvider->getClass($traitName)->hasNativeMethod($methodName)); + } + + private function hasTraitProperty(string $propertyName): bool + { + return array_any($this->traitNames, fn (string $traitName): bool => $this->reflectionProvider->getClass($traitName)->hasNativeProperty($propertyName)); + } + + private function hasTraitConstant(string $constantName): bool + { + return array_any($this->traitNames, fn (string $traitName): bool => $this->reflectionProvider->getClass($traitName)->hasConstant($constantName)); + } +} diff --git a/src/Type/Pest/TestClosureThisTypeExtension.php b/src/Type/Pest/TestClosureThisTypeExtension.php index 7ac3ac8..c4b51a4 100644 --- a/src/Type/Pest/TestClosureThisTypeExtension.php +++ b/src/Type/Pest/TestClosureThisTypeExtension.php @@ -8,12 +8,8 @@ use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParameterReflection; -use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\FunctionParameterClosureThisExtension; -use PHPStan\Type\ObjectType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use PHPUnit\Framework\TestCase; final class TestClosureThisTypeExtension implements FunctionParameterClosureThisExtension { @@ -31,8 +27,7 @@ final class TestClosureThisTypeExtension implements FunctionParameterClosureThis ]; public function __construct( - private readonly PestConfigReader $pestConfigReader, - private readonly ReflectionProvider $reflectionProvider, + private readonly PestTestCaseType $pestTestCaseType, ) {} public function isFunctionSupported(FunctionReflection $functionReflection, ParameterReflection $parameter): bool @@ -49,43 +44,6 @@ public function getClosureThisTypeFromFunctionCall( ParameterReflection $parameter, Scope $scope ): Type { - $types = $this->toClassObjectTypes( - $this->pestConfigReader->resolveFileBindings($scope->getFile()), - ); - - if ($types === []) { - $types = $this->toClassObjectTypes( - $this->pestConfigReader->resolveBindings($scope->getFile()), - ); - } - - if ($types === []) { - return new ObjectType(TestCase::class); - } - - return count($types) === 1 ? $types[0] : TypeCombinator::intersect(...$types); - } - - /** - * @param list $bindings - * @return list - */ - private function toClassObjectTypes(array $bindings): array - { - $types = []; - - foreach ($bindings as $binding) { - if (! $this->reflectionProvider->hasClass($binding)) { - continue; - } - - if ($this->reflectionProvider->getClass($binding)->isTrait()) { - continue; - } - - $types[] = new ObjectType($binding); - } - - return $types; + return $this->pestTestCaseType->resolve($scope->getFile()); } } diff --git a/src/Type/Pest/WithClosureThisTypeExtension.php b/src/Type/Pest/WithClosureThisTypeExtension.php new file mode 100644 index 0000000..61dfd9b --- /dev/null +++ b/src/Type/Pest/WithClosureThisTypeExtension.php @@ -0,0 +1,35 @@ +getName()) === 'with' + && $methodReflection->getDeclaringClass()->is(TestCall::class); + } + + public function getClosureThisTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + ParameterReflection $parameter, + Scope $scope + ): Type { + return $this->pestTestCaseType->resolve($scope->getFile()); + } +} diff --git a/src/Type/Pest/WithDatasetClosureNodeVisitor.php b/src/Type/Pest/WithDatasetClosureNodeVisitor.php new file mode 100644 index 0000000..7877839 --- /dev/null +++ b/src/Type/Pest/WithDatasetClosureNodeVisitor.php @@ -0,0 +1,88 @@ +name instanceof Identifier || $node->name->name !== 'with') { + return null; + } + + if (! $this->isPestTestChain($node)) { + return null; + } + + foreach ($node->getArgs() as $arg) { + $arg->value = $this->wrapInClosure($arg->value); + } + + return null; + } + + private function wrapInClosure(Expr $node): Expr + { + if ($node instanceof ClosureExpr || $node instanceof ArrowFunction) { + return $node; + } + + if (! $this->containsClosure($node)) { + return $node; + } + + return new ClosureExpr( + [ + 'stmts' => [ + new Return_($node), + ], + ], + $node->getAttributes(), + ); + } + + private function containsClosure(Expr $node): bool + { + $nodeFinder = new NodeFinder; + + return $nodeFinder->findFirst( + [$node], + static fn (Node $n): bool => $n instanceof ClosureExpr || $n instanceof ArrowFunction, + ) instanceof Node; + } + + private function isPestTestChain(MethodCall $methodCall): bool + { + $root = $methodCall->var; + + while ($root instanceof MethodCall) { + $root = $root->var; + } + + if (! $root instanceof FuncCall || ! $root->name instanceof Name) { + return false; + } + + return in_array($root->name->getLast(), self::PEST_TEST_FUNCTIONS, true); + } +} diff --git a/tests/Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php b/tests/Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php new file mode 100644 index 0000000..9f97c53 --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php @@ -0,0 +1,42 @@ +with(function (): array { + assertType(CustomTestCase::class, $this); + + return [ + 'data 1' => fn (): array => [$this], + ]; + }); +} + +function testThisTypeInsideWithArray(): void +{ + it('has custom $this type inside with array', function (): void { + assertType(CustomTestCase::class, $this); + })->with([ + 'data 1' => fn (): array => [$this], + ]); +} + +function testThisTypeInsideNestedWithClosure(): void +{ + test('has custom $this type in nested with closures', function (): void { + assertType(CustomTestCase::class, $this); + })->with([ + 'data 1' => fn (): array => [ + 'nested' => fn (): array => [$this], + ], + ]); +} diff --git a/tests/Fixtures/CustomTestCaseInference/LocalUses/local-uses-trait-only.php b/tests/Fixtures/CustomTestCaseInference/LocalUses/local-uses-trait-only.php index 4ab079b..44e9b27 100644 --- a/tests/Fixtures/CustomTestCaseInference/LocalUses/local-uses-trait-only.php +++ b/tests/Fixtures/CustomTestCaseInference/LocalUses/local-uses-trait-only.php @@ -10,9 +10,10 @@ uses(HelperTrait::class); -function testThisTypeFallsBackToTestCaseWhenOnlyATraitIsUsed(): void +function testThisTypeWhenOnlyATraitIsUsed(): void { - it('falls back to the default TestCase $this when only a trait is used', function (): void { + it('falls back to the default TestCase $this and exposes trait methods', function (): void { assertType(\PHPUnit\Framework\TestCase::class, $this); + assertType('string', $this->helperMethod()); }); } diff --git a/tests/Fixtures/CustomTestCaseInference/WithTrait/with-trait-method-call.php b/tests/Fixtures/CustomTestCaseInference/WithTrait/with-trait-method-call.php new file mode 100644 index 0000000..0b6c2d7 --- /dev/null +++ b/tests/Fixtures/CustomTestCaseInference/WithTrait/with-trait-method-call.php @@ -0,0 +1,21 @@ +helperMethod()); + }); +} + +function testTraitPropertyAccessibleOnThis(): void +{ + it('has trait methods in the $this type', function (): void { + assertType('string', $this->helperMethod()); + }); +} diff --git a/tests/Type/CustomTestCaseTest.php b/tests/Type/CustomTestCaseTest.php index db85a10..fb31376 100644 --- a/tests/Type/CustomTestCaseTest.php +++ b/tests/Type/CustomTestCaseTest.php @@ -8,12 +8,14 @@ $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-closures-custom-testcase.php'); + yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php'); }); test('custom testcase closure types when a class and a trait are bound', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/WithTrait/with-trait-testcase.php'); + yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/WithTrait/with-trait-method-call.php'); }); test('custom testcase closure types from file-level uses()', function (string $assertType, string $file, mixed ...$args): void { diff --git a/tests/Type/ExpectTypeTest.php b/tests/Type/ExpectTypeTest.php index f4c8b72..583c1a7 100644 --- a/tests/Type/ExpectTypeTest.php +++ b/tests/Type/ExpectTypeTest.php @@ -16,6 +16,12 @@ yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-closures.php'); }); +test('with closure types', function (string $assertType, string $file, mixed ...$args): void { + $this->assertFileAsserts($assertType, $file, ...$args); +})->with(function (): Iterator { + yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-with-closures.php'); +}); + test('expectation method types', function (string $assertType, string $file, mixed ...$args): void { $this->assertFileAsserts($assertType, $file, ...$args); })->with(function (): Iterator { diff --git a/tests/Type/Fixtures/pestconfig-global/Feature/.gitkeep b/tests/Type/Fixtures/pestconfig-global/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Type/Fixtures/pestconfig-global/Pest.php b/tests/Type/Fixtures/pestconfig-global/Pest.php new file mode 100644 index 0000000..d91e615 --- /dev/null +++ b/tests/Type/Fixtures/pestconfig-global/Pest.php @@ -0,0 +1,9 @@ +extend(CustomTestCase::class); +pest()->use(HelperTrait::class); diff --git a/tests/Type/PestConfigReaderTest.php b/tests/Type/PestConfigReaderTest.php index 9f5d084..b0d9a4f 100644 --- a/tests/Type/PestConfigReaderTest.php +++ b/tests/Type/PestConfigReaderTest.php @@ -78,6 +78,22 @@ expect($bindings)->toBeEmpty(); }); +test('resolves global bindings without in() scope to every file', function (): void { + $fixtureDir = realpath(__DIR__.'/Fixtures/pestconfig-global'); + if ($fixtureDir === false) { + throw new RuntimeException('Global fixtures directory not found.'); + } + + $reader = new PestConfigReader(new PestFileDiscoverer([$fixtureDir])); + + expect($reader->resolveBindings($fixtureDir.'/Feature/SomeTest.php')) + ->toContain(CustomTestCase::class) + ->toContain(HelperTrait::class) + ->and($reader->resolveBindings('/some/other/path/Test.php')) + ->toContain(CustomTestCase::class) + ->toContain(HelperTrait::class); +}); + test('resolves in() directories built from __DIR__', function (): void { $fixtureDir = realpath(__DIR__.'/Fixtures/pestconfig-magicdir'); if ($fixtureDir === false) { diff --git a/tests/Type/data/test-with-closures.php b/tests/Type/data/test-with-closures.php new file mode 100644 index 0000000..f68a44e --- /dev/null +++ b/tests/Type/data/test-with-closures.php @@ -0,0 +1,65 @@ +with(function (): array { + assertType(TestCase::class, $this); + + return [ + 'data 1' => fn (): array => [$this], + ]; + }); +} + +function testThisTypeInsideWithArray(): void +{ + test('has correct $this type inside with array', function (): void { + assertType(TestCase::class, $this); + })->with([ + 'data 1' => fn (): array => [$this], + ]); +} + +function testThisTypeInsideNestedWithClosure(): void +{ + it('has correct $this type in nested with closures', function (): void { + assertType(TestCase::class, $this); + })->with([ + 'data 1' => fn (): array => [ + 'nested' => fn (): array => [$this], + ], + ]); +} + +function testThisTypeInsideWithArrayOfClosures(): void +{ + test('has correct $this type in array of closures', function (): void { + assertType(TestCase::class, $this); + })->with([ + 'data 1' => fn (): array => [$this], + 'data 2' => fn (): array => [$this], + ]); +} + +function testThisTypeInsideWithFunctionReturningNestedClosures(): void +{ + test('has correct $this type in with closure returning nested closures', function (): void { + assertType(TestCase::class, $this); + })->with(function (): array { + return [ + 'data 1' => fn (): array => [ + 'deep' => fn (): array => [$this], + ], + ]; + }); +}