From df83a6384ec042d166cb84a1e4c27a22e395aea3 Mon Sep 17 00:00:00 2001 From: Kenjo Date: Fri, 4 Sep 2026 02:31:39 +0800 Subject: [PATCH] fix: support higher-order method calls on expectations --- extension.neon | 5 + .../ExpectationChainStateResolver.php | 27 +++-- ...rOrderExpectationMethodIgnoreExtension.php | 52 ++++++++ .../HigherOrderExpectationTypeExtension.php | 114 +++++++++++++++++- tests/Rules/HigherOrderMethodCallTest.php | 34 ++++++ tests/Rules/ImpossibleExpectationRuleTest.php | 12 ++ .../data/higher-order-method-call-errors.php | 25 ++++ ...sible-expectation-higher-order-methods.php | 23 ++++ tests/Type/Fixtures/Author.php | 5 + tests/Type/Fixtures/Policy.php | 18 +++ tests/Type/Fixtures/Post.php | 10 ++ tests/Type/Fixtures/Role.php | 19 +++ tests/Type/data/higher-order-expectations.php | 40 ++++++ 13 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 src/Type/Pest/HigherOrderExpectationMethodIgnoreExtension.php create mode 100644 tests/Rules/HigherOrderMethodCallTest.php create mode 100644 tests/Rules/data/higher-order-method-call-errors.php create mode 100644 tests/Rules/data/impossible-expectation-higher-order-methods.php create mode 100644 tests/Type/Fixtures/Policy.php create mode 100644 tests/Type/Fixtures/Role.php diff --git a/extension.neon b/extension.neon index 6661e38..98a3ffa 100644 --- a/extension.neon +++ b/extension.neon @@ -104,6 +104,11 @@ services: tags: - phpstan.ignoreErrorExtension + - + class: Pest\PHPStan\Type\Pest\HigherOrderExpectationMethodIgnoreExtension + tags: + - phpstan.ignoreErrorExtension + - class: Pest\PHPStan\Rules\InvalidThrowsExceptionRule arguments: diff --git a/src/Analysis/Expectation/ExpectationChainStateResolver.php b/src/Analysis/Expectation/ExpectationChainStateResolver.php index 6f25cd3..54ff09f 100644 --- a/src/Analysis/Expectation/ExpectationChainStateResolver.php +++ b/src/Analysis/Expectation/ExpectationChainStateResolver.php @@ -6,6 +6,7 @@ use Countable; use Pest\Expectation; +use Pest\Expectations\HigherOrderExpectation; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; @@ -71,13 +72,19 @@ private function resolveState(MethodCall $methodCall, Scope $scope): ?Expectatio private function resolveRootState(MethodCall $methodCall, Scope $scope): ?ExpectationChainState { $callerType = $scope->getType($methodCall->var); - if (! new ObjectType(Expectation::class)->isSuperTypeOf($callerType)->yes()) { - return null; + if (new ObjectType(Expectation::class)->isSuperTypeOf($callerType)->yes()) { + return ExpectationChainState::root( + $callerType->getTemplateType(Expectation::class, 'TValue') + ); } - return ExpectationChainState::root( - $callerType->getTemplateType(Expectation::class, 'TValue') - ); + if (new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($callerType)->yes()) { + return ExpectationChainState::root( + $callerType->getTemplateType(HigherOrderExpectation::class, 'TValue') + ); + } + + return null; } private function resolveStepResult( @@ -169,11 +176,15 @@ private function resolveStepResult( private function resolveResultingValueType(MethodCall $methodCall, Scope $scope, Type $fallbackType): Type { $methodType = $scope->getType($methodCall); - if (! new ObjectType(Expectation::class)->isSuperTypeOf($methodType)->yes()) { - return $fallbackType; + if (new ObjectType(Expectation::class)->isSuperTypeOf($methodType)->yes()) { + return $methodType->getTemplateType(Expectation::class, 'TValue'); + } + + if (new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($methodType)->yes()) { + return $methodType->getTemplateType(HigherOrderExpectation::class, 'TValue'); } - return $methodType->getTemplateType(Expectation::class, 'TValue'); + return $fallbackType; } private function violatesRequirement(Type $valueType, string $requirement): bool diff --git a/src/Type/Pest/HigherOrderExpectationMethodIgnoreExtension.php b/src/Type/Pest/HigherOrderExpectationMethodIgnoreExtension.php new file mode 100644 index 0000000..d01b4dd --- /dev/null +++ b/src/Type/Pest/HigherOrderExpectationMethodIgnoreExtension.php @@ -0,0 +1,52 @@ +getIdentifier() !== 'method.notFound') { + return false; + } + + if (! $node instanceof MethodCall) { + return false; + } + + if (! $node->name instanceof Identifier) { + return false; + } + + $methodName = $node->name->name; + $varType = $scope->getType($node->var); + + $expectationType = new ObjectType(Expectation::class); + if ($expectationType->isSuperTypeOf($varType)->yes()) { + $valueType = $varType->getTemplateType(Expectation::class, 'TValue'); + + return $valueType->hasMethod($methodName)->yes(); + } + + $higherOrderType = new ObjectType(HigherOrderExpectation::class); + if ($higherOrderType->isSuperTypeOf($varType)->yes()) { + $valueType = $varType->getTemplateType(HigherOrderExpectation::class, 'TValue'); + + return $valueType->hasMethod($methodName)->yes(); + } + + return false; + } +} diff --git a/src/Type/Pest/HigherOrderExpectationTypeExtension.php b/src/Type/Pest/HigherOrderExpectationTypeExtension.php index 0c42f8b..2f72220 100644 --- a/src/Type/Pest/HigherOrderExpectationTypeExtension.php +++ b/src/Type/Pest/HigherOrderExpectationTypeExtension.php @@ -13,11 +13,14 @@ use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; use PHPStan\Analyser\Scope; +use PHPStan\Reflection\MissingMethodFromReflectionException; use PHPStan\Reflection\MissingPropertyFromReflectionException; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -143,33 +146,97 @@ private function resolveMethodCall(MethodCall $expr, Scope $scope): ?Type return null; } + $methodName = $expr->name->name; $varType = $scope->getType($expr->var); + + $expectationType = new ObjectType(Expectation::class); + if ($expectationType->isSuperTypeOf($varType)->yes()) { + return $this->resolveExpectationMethodCall($varType, $methodName, $expr, $scope); + } + $higherOrderType = new ObjectType(HigherOrderExpectation::class); + if ($higherOrderType->isSuperTypeOf($varType)->yes()) { + return $this->resolveHigherOrderMethodCall($varType, $methodName, $expr, $scope); + } - if (! $higherOrderType->isSuperTypeOf($varType)->yes()) { + return null; + } + + private function resolveExpectationMethodCall(Type $varType, string $methodName, MethodCall $expr, Scope $scope): ?Type + { + if ($this->isKnownExpectationMethod($methodName)) { return null; } - $methodName = $expr->name->name; + $valueType = $varType->getTemplateType(Expectation::class, 'TValue'); + $methodReturnType = $this->resolveMethodReturnType($valueType, $methodName, $expr, $scope); + + return new GenericObjectType(HigherOrderExpectation::class, [ + new GenericObjectType(Expectation::class, [$valueType]), + $methodReturnType, + ]); + } + + private function resolveHigherOrderMethodCall(Type $varType, string $methodName, MethodCall $expr, Scope $scope): ?Type + { if ($methodName === 'and') { return $this->resolveHigherOrderAndCall($expr, $scope); } - if (! Expectation::hasMethod($methodName)) { + if ($this->isNativeHigherOrderExpectationMethod($methodName)) { return null; } $originalType = $varType->getTemplateType(HigherOrderExpectation::class, 'TOriginalValue'); - $originalValueType = $originalType->getTemplateType(Expectation::class, 'TValue'); + if (Expectation::hasMethod($methodName)) { + $originalValueType = $originalType->getTemplateType(Expectation::class, 'TValue'); + + return new GenericObjectType(HigherOrderExpectation::class, [ + $originalType, + $originalValueType, + ]); + } + + $currentValueType = $varType->getTemplateType(HigherOrderExpectation::class, 'TValue'); + + $methodReturnType = $this->resolveMethodReturnType($currentValueType, $methodName, $expr, $scope); return new GenericObjectType(HigherOrderExpectation::class, [ $originalType, - $originalValueType, + $methodReturnType, ]); } + private function resolveMethodReturnType(Type $objectType, string $methodName, MethodCall $expr, Scope $scope): Type + { + if ($objectType->hasMethod($methodName)->no()) { + return new MixedType; + } + + try { + $methodReflection = $objectType->getMethod($methodName, $scope); + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + + $returnType = $parametersAcceptor->getReturnType(); + + if ($returnType->isVoid()->yes()) { + return new NullType; + } + + return $returnType; + } catch (MissingMethodFromReflectionException) { + return new MixedType; + } + } + private function resolveHigherOrderAndCall(MethodCall $expr, Scope $scope): ?Type { $args = $expr->getArgs(); @@ -204,4 +271,41 @@ private function isNativeExpectationProperty(string $propertyName): bool return $this->reflectionProvider->getClass(Expectation::class) ->hasNativeProperty($propertyName); } + + private function isNativeHigherOrderExpectationMethod(string $methodName): bool + { + if (! $this->reflectionProvider->hasClass(HigherOrderExpectation::class)) { + return false; + } + + return $this->reflectionProvider->getClass(HigherOrderExpectation::class) + ->hasNativeMethod($methodName); + } + + private function isKnownExpectationMethod(string $methodName): bool + { + if (Expectation::hasMethod($methodName)) { + return true; + } + + if (! $this->reflectionProvider->hasClass(Expectation::class)) { + return false; + } + + $classReflection = $this->reflectionProvider->getClass(Expectation::class); + + if ($classReflection->hasNativeMethod($methodName)) { + return true; + } + + foreach ($classReflection->getResolvedMixinTypes() as $mixinType) { + foreach ($mixinType->getObjectClassReflections() as $mixinClassReflection) { + if ($mixinClassReflection->hasNativeMethod($methodName)) { + return true; + } + } + } + + return false; + } } diff --git a/tests/Rules/HigherOrderMethodCallTest.php b/tests/Rules/HigherOrderMethodCallTest.php new file mode 100644 index 0000000..4408cec --- /dev/null +++ b/tests/Rules/HigherOrderMethodCallTest.php @@ -0,0 +1,34 @@ +analyse([ + __DIR__.'/data/higher-order-method-call-errors.php', + ], [ + [ + 'Call to an undefined method Pest\Expectation::typoMethod().', + 18, + ], + [ + 'Call to an undefined method Pest\Expectation::foo().', + 20, + ], + [ + 'Call to an undefined method Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Policy>::invalidMethod().', + 24, + ], + ]); +}); diff --git a/tests/Rules/ImpossibleExpectationRuleTest.php b/tests/Rules/ImpossibleExpectationRuleTest.php index 2c208c8..8e3ed08 100644 --- a/tests/Rules/ImpossibleExpectationRuleTest.php +++ b/tests/Rules/ImpossibleExpectationRuleTest.php @@ -340,3 +340,15 @@ ], ]); }); + +test('higher order method calls are supported in impossible expectation analysis', function (): void { + $this->analyse([ + __DIR__.'/data/impossible-expectation-higher-order-methods.php', + ], [ + [ + 'Calling toBeInt() on Expectation; assertion is impossible.', + 22, + 'The expectation value is string, which can never satisfy toBeInt().', + ], + ]); +}); diff --git a/tests/Rules/data/higher-order-method-call-errors.php b/tests/Rules/data/higher-order-method-call-errors.php new file mode 100644 index 0000000..2efdc02 --- /dev/null +++ b/tests/Rules/data/higher-order-method-call-errors.php @@ -0,0 +1,25 @@ +getTitle()->toBe('Hello'); + + expect($policy) + ->view()->toBeTrue() + ->update()->toBeTrue(); + + expect($post)->typoMethod(); + + expect('string')->foo(); + + expect($policy) + ->view()->toBeTrue() + ->invalidMethod()->toBeTrue(); +}); diff --git a/tests/Rules/data/impossible-expectation-higher-order-methods.php b/tests/Rules/data/impossible-expectation-higher-order-methods.php new file mode 100644 index 0000000..41fb29b --- /dev/null +++ b/tests/Rules/data/impossible-expectation-higher-order-methods.php @@ -0,0 +1,23 @@ +getTitle()->toBe('Hello'); + expect($post)->belongsToAuthor($author)->toBeTrue(); + expect($post)->author->getName()->toBe('Nuno'); + expect(Role::Admin)->label()->toBe('Admin'); +}); + +it('impossible assertions on higher-order method calls are reported', function (): void { + $post = new Post; + + expect($post)->getTitle()->toBeInt(); +}); diff --git a/tests/Type/Fixtures/Author.php b/tests/Type/Fixtures/Author.php index 66cafc4..ef839d0 100644 --- a/tests/Type/Fixtures/Author.php +++ b/tests/Type/Fixtures/Author.php @@ -9,4 +9,9 @@ final class Author public string $name; public string $email; + + public function getName(): string + { + return $this->name; + } } diff --git a/tests/Type/Fixtures/Policy.php b/tests/Type/Fixtures/Policy.php new file mode 100644 index 0000000..a9d9e38 --- /dev/null +++ b/tests/Type/Fixtures/Policy.php @@ -0,0 +1,18 @@ +title; + } + + public function belongsToAuthor(Author $author): bool + { + return $this->author === $author; + } } diff --git a/tests/Type/Fixtures/Role.php b/tests/Type/Fixtures/Role.php new file mode 100644 index 0000000..dd442e0 --- /dev/null +++ b/tests/Type/Fixtures/Role.php @@ -0,0 +1,19 @@ + 'Admin', + self::Guest => 'Guest', + }; + } +} diff --git a/tests/Type/data/higher-order-expectations.php b/tests/Type/data/higher-order-expectations.php index adaad48..d052739 100644 --- a/tests/Type/data/higher-order-expectations.php +++ b/tests/Type/data/higher-order-expectations.php @@ -4,9 +4,12 @@ namespace HigherOrderExpectations; +use Tests\Type\Fixtures\Author; use Tests\Type\Fixtures\MagicPropertyObject; use Tests\Type\Fixtures\NonFinalObject; +use Tests\Type\Fixtures\Policy; use Tests\Type\Fixtures\Post; +use Tests\Type\Fixtures\Role; use function PHPStan\Testing\assertType; @@ -149,3 +152,40 @@ function testUnionWithoutClassPropertyDoesNotCrash(): void $result = expect($payload)->currentUser; assertType('Pest\Expectations\HigherOrderExpectation|object|null>, mixed>', $result); } + +function testDirectMethodCall(): void +{ + $post = new Post; + $result = expect($post)->getTitle()->toBe('Hello'); + assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Post>', $result); +} + +function testMethodCallWithArguments(): void +{ + $post = new Post; + $author = new Author; + $result = expect($post)->belongsToAuthor($author)->toBeTrue(); + assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Post>', $result); +} + +function testMethodCallAfterPropertyFetch(): void +{ + $post = new Post; + $result = expect($post)->author->getName()->toBe('Nuno'); + assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Post>', $result); +} + +function testEnumMethodCall(): void +{ + $result = expect(Role::Admin)->label()->toBe('Admin'); + assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Role::Admin>', $result); +} + +function testMultipleMethodCallsWithAssertionReset(): void +{ + $policy = new Policy; + $result = expect($policy) + ->view()->toBeTrue() + ->update()->toBeTrue(); + assertType('Pest\Expectations\HigherOrderExpectation, Tests\Type\Fixtures\Policy>', $result); +}