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
5 changes: 5 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ services:
tags:
- phpstan.ignoreErrorExtension

-
class: Pest\PHPStan\Type\Pest\HigherOrderExpectationMethodIgnoreExtension
tags:
- phpstan.ignoreErrorExtension

-
class: Pest\PHPStan\Rules\InvalidThrowsExceptionRule
arguments:
Expand Down
27 changes: 19 additions & 8 deletions src/Analysis/Expectation/ExpectationChainStateResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/Type/Pest/HigherOrderExpectationMethodIgnoreExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\Expectation;
use Pest\Expectations\HigherOrderExpectation;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\IgnoreErrorExtension;
use PHPStan\Analyser\Scope;
use PHPStan\Type\ObjectType;

final class HigherOrderExpectationMethodIgnoreExtension implements IgnoreErrorExtension
{
public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
{
if ($error->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;
}
}
114 changes: 109 additions & 5 deletions src/Type/Pest/HigherOrderExpectationTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
}
34 changes: 34 additions & 0 deletions tests/Rules/HigherOrderMethodCallTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Tests\Rules;

use PHPStan\Rules\Methods\CallMethodsRule;
use Tests\RuleTestCase;

beforeAll(function (): void {
RuleTestCase::$additionalConfigFiles = [
__DIR__.'/../extension.neon',
];
RuleTestCase::$rule = RuleTestCase::resolveRule(CallMethodsRule::class);
});

test('higher order methods report errors on undefined methods on value type', function (): void {
$this->analyse([
__DIR__.'/data/higher-order-method-call-errors.php',
], [
[
'Call to an undefined method Pest\Expectation<Tests\Type\Fixtures\Post>::typoMethod().',
18,
],
[
'Call to an undefined method Pest\Expectation<string>::foo().',
20,
],
[
'Call to an undefined method Pest\Expectations\HigherOrderExpectation<Pest\Expectation<Tests\Type\Fixtures\Policy>, Tests\Type\Fixtures\Policy>::invalidMethod().',
24,
],
]);
});
12 changes: 12 additions & 0 deletions tests/Rules/ImpossibleExpectationRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>; assertion is impossible.',
22,
'The expectation value is string, which can never satisfy toBeInt().',
],
]);
});
25 changes: 25 additions & 0 deletions tests/Rules/data/higher-order-method-call-errors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use Tests\Type\Fixtures\Policy;
use Tests\Type\Fixtures\Post;

it('tests higher order methods', function (): void {
$post = new Post;
$policy = new Policy;

expect($post)->getTitle()->toBe('Hello');

expect($policy)
->view()->toBeTrue()
->update()->toBeTrue();

expect($post)->typoMethod();

expect('string')->foo();

expect($policy)
->view()->toBeTrue()
->invalidMethod()->toBeTrue();
});
23 changes: 23 additions & 0 deletions tests/Rules/data/impossible-expectation-higher-order-methods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

use Tests\Type\Fixtures\Author;
use Tests\Type\Fixtures\Post;
use Tests\Type\Fixtures\Role;

it('valid higher-order method calls are not impossible', function (): void {
$post = new Post;
$author = new Author;

expect($post)->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();
});
5 changes: 5 additions & 0 deletions tests/Type/Fixtures/Author.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ final class Author
public string $name;

public string $email;

public function getName(): string
{
return $this->name;
}
}
Loading
Loading