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
14 changes: 14 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ services:
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: Pest\PHPStan\Type\Pest\TestFunctionReturnTypeExtension
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: Pest\PHPStan\Type\Pest\PestTestFunction

-
class: Pest\PHPStan\Type\Pest\TestFunctionProxyMethodExtension
tags:
- phpstan.broker.expressionTypeResolverExtension
- phpstan.ignoreErrorExtension

-
class: Pest\PHPStan\Type\Pest\ExpectationMethodReturnTypeExtension
tags:
Expand Down
24 changes: 13 additions & 11 deletions src/Type/Pest/PestHookPropertyReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,17 +288,7 @@ private function extractPropertyAssignments(Closure|ArrowFunction $hook, array $
continue;
}

if (! $var->var instanceof Variable) {
continue;
}

$thisVariable = $var->var;

if (! is_string($thisVariable->name)) {
continue;
}

if ($thisVariable->name !== 'this') {
if (! $this->isTestCaseReference($var->var)) {
continue;
}

Expand Down Expand Up @@ -330,6 +320,18 @@ private function extractPropertyAssignments(Closure|ArrowFunction $hook, array $
return $properties;
}

private function isTestCaseReference(Expr $expr): bool
{
// @note: a bare test() proxies the running test case, so `test()->name = …` sets the same property as `$this->name = …`.
if ($expr instanceof FuncCall) {
return $expr->name instanceof Name
&& $expr->name->toString() === 'test'
&& $expr->getArgs() === [];
}

return $expr instanceof Variable && $expr->name === 'this';
}

/**
* @param array<string, string> $useMap
* @return array<string, Expr>
Expand Down
10 changes: 10 additions & 0 deletions src/Type/Pest/PestTestCaseType.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ public function __construct(

public function resolve(string $filePath): Type
{
return $this->resolveIfBound($filePath) ?? new ObjectType(TestCase::class);
}

public function resolveIfBound(string $filePath): ?Type
{
// @note: returns null when no binding covers the file, so callers can tell an unbound file from one bound to PHPUnit's TestCase.
[$classNames, $traitNames] = $this->partition(
$this->pestConfigReader->resolveFileBindings($filePath),
);
Expand All @@ -31,6 +37,10 @@ public function resolve(string $filePath): Type
$traitNames = array_values(array_unique([...$directoryTraitNames, ...$traitNames]));
}

if ($classNames === [] && $traitNames === []) {
return null;
}

if ($classNames === []) {
$classNames[] = TestCase::class;
}
Expand Down
57 changes: 57 additions & 0 deletions src/Type/Pest/PestTestFunction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\TestSuite;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ReflectionProvider;

final readonly class PestTestFunction
{
public function __construct(
private ReflectionProvider $reflectionProvider,
) {}

public function supports(FunctionReflection $function): bool
{
return $this->isPestFunction($function, 'test');
}

public function supportsTodoHelper(Scope $scope): bool
{
$name = new FullyQualified('todo');

return $this->reflectionProvider->hasFunction($name, $scope)
&& $this->isPestFunction($this->reflectionProvider->getFunction($name, $scope), 'todo');
}

public function isBareCall(Expr $expression, Scope $scope): bool
{
return $expression instanceof FuncCall
&& $expression->name instanceof Name
&& $expression->getArgs() === []
&& $this->reflectionProvider->hasFunction($expression->name, $scope)
&& $this->supports($this->reflectionProvider->getFunction($expression->name, $scope));
}

private function isPestFunction(FunctionReflection $function, string $name): bool
{
if ($function->getName() !== $name) {
return false;
}

$filename = $function->getFileName();
$testSuiteFilename = $this->reflectionProvider->getClass(TestSuite::class)->getFileName();

return $filename !== null
&& $testSuiteFilename !== null
&& str_replace('\\', '/', $filename) === str_replace('\\', '/', dirname($testSuiteFilename).'/Functions.php');
}
}
25 changes: 14 additions & 11 deletions src/Type/Pest/TestCaseMemberVisibilityIgnoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ final class TestCaseMemberVisibilityIgnoreExtension implements IgnoreErrorExtens

private const array CONSTANT_IDENTIFIERS = ['classConstant.protected', 'classConstant.private'];

public function __construct(
private readonly PestTestFunction $testFunction,
) {}

public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
{
$identifier = $error->getIdentifier();
Expand All @@ -37,20 +41,19 @@ public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
default => null,
};

if (! $receiver instanceof Variable || $receiver->name !== 'this') {
return false;
}

if (! $scope->isInAnonymousFunction()) {
if ($receiver instanceof Expr && $this->testFunction->isBareCall($receiver, $scope)) {
$thisType = $scope->getType($receiver);
} elseif (
$receiver instanceof Variable
&& $receiver->name === 'this'
&& $scope->isInAnonymousFunction()
&& $scope->hasVariableType('this')->yes()
) {
$thisType = $scope->getVariableType('this');
} else {
return false;
}

if (! $scope->hasVariableType('this')->yes()) {
return false;
}

$thisType = $scope->getVariableType('this');

if (! new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) {
return false;
}
Expand Down
47 changes: 47 additions & 0 deletions src/Type/Pest/TestFunctionProxyMethodExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Pest\PHPStan\Type\Pest;

use Pest\PendingCalls\TestCall;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Error;
use PHPStan\Analyser\IgnoreErrorExtension;
use PHPStan\Analyser\Scope;
use PHPStan\Type\ExpressionTypeResolverExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

final readonly class TestFunctionProxyMethodExtension implements ExpressionTypeResolverExtension, IgnoreErrorExtension
{
public function __construct(
private PestTestFunction $testFunction,
private PestTestCaseType $testCaseType,
) {}

public function getType(Expr $expr, Scope $scope): ?Type
{
return $this->isTodoCall($expr, $scope) ? new ObjectType(TestCall::class) : null;
}

public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
{
return $error->getIdentifier() === 'method.notFound'
&& $this->isTodoCall($node, $scope);
}

private function isTodoCall(Node $node, Scope $scope): bool
{
return $node instanceof MethodCall
&& $node->name instanceof Identifier
&& $node->name->toLowerString() === 'todo'
&& $this->testFunction->isBareCall($node->var, $scope)
&& $this->testCaseType->resolveIfBound($scope->getFile()) instanceof Type
&& ! $scope->getType($node->var)->hasMethod('todo')->yes()
&& $this->testFunction->supportsTodoHelper($scope);
}
}
52 changes: 52 additions & 0 deletions src/Type/Pest/TestFunctionReturnTypeExtension.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 PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPUnit\Framework\TestCase;

final class TestFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
public function __construct(
private readonly PestTestCaseType $pestTestCaseType,
private readonly PestTestFunction $testFunction,
) {}

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $this->testFunction->supports($functionReflection);
}

public function getTypeFromFunctionCall(
FunctionReflection $functionReflection,
FuncCall $functionCall,
Scope $scope
): ?Type {
if ($functionCall->getArgs() !== []) {
return null;
}

$boundType = $this->pestTestCaseType->resolveIfBound($scope->getFile());

if (! $boundType instanceof Type) {
return null;
}

if ($scope->hasVariableType('this')->yes()) {
$thisType = $scope->getVariableType('this');

if (new ObjectType(TestCase::class)->isSuperTypeOf($thisType)->yes()) {
return $thisType;
}
}

return $boundType;
}
}
27 changes: 27 additions & 0 deletions tests/Analysis/PestTestFunctionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

use Pest\PHPStan\Type\Pest\PestTestFunction;
use Pest\TestSuite;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ReflectionProvider;

test('only Pest provides the supported global test function', function (): void {
$provider = self::getContainer()->getByType(ReflectionProvider::class);
$testFunction = new PestTestFunction($provider);
$pestFilename = $provider->getClass(TestSuite::class)->getFileName();

expect($pestFilename)->not->toBeNull();

$pest = $this->createStub(FunctionReflection::class);
$pest->method('getName')->willReturn('test');
$pest->method('getFileName')->willReturn(dirname($pestFilename).'/Functions.php');

$custom = $this->createStub(FunctionReflection::class);
$custom->method('getName')->willReturn('test');
$custom->method('getFileName')->willReturn(__FILE__);

expect($testFunction->supports($pest))->toBeTrue()
->and($testFunction->supports($custom))->toBeFalse();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace TestFunctionCustomTestCase;

use Pest\PendingCalls\TestCall;
use Tests\Type\Fixtures\CustomTestCase;
use Tests\Type\Fixtures\Post;

use function PHPStan\Testing\assertType;

function testBareTestCallInTestClosure(): void
{
it('types a bare test() as the bound test case', function (): void {
assertType(CustomTestCase::class, test());
});
}

function testBareTestCallInBeforeEach(): void
{
beforeEach(function (): void {
assertType(CustomTestCase::class, test());
});
}

function testBareTestCallInHelperFunction(): void
{
assertType(CustomTestCase::class, test());
assertType('string', test()->createHelper());
}

function testBareTestCallInStaticCallback(): void
{
it('uses the bound type in static callbacks without $this', function (): void {
$resolve = static fn () => test();

assertType(CustomTestCase::class, $resolve());
});
}

function testBareTestCallInNestedArrowFunction(): void
{
it('types a bare test() inside a nested arrow function', function (): void {
$resolve = fn () => test();

assertType(CustomTestCase::class, $resolve());
});
}

function testMethodCallThroughBareTestCall(): void
{
it('resolves methods through a bare test()', function (): void {
assertType('string', test()->createHelper());
});
}

function testHookPropertyReadThroughBareTestCall(): void
{
beforeEach(function (): void {
test()->post = new Post;
});

it('resolves a hook property read through a bare test()', function (): void {
assertType(Post::class, test()->post);
});
}

function testTestCallWithDescriptionIsUnchanged(): void
{
assertType(TestCall::class, test('has a description'));
}

function testTodoThroughBareTestCall(): void
{
it('preserves the todo helper on the proxy', function (): void {
assertType(TestCall::class, test()->todo('implement this test'));
});
}
Loading
Loading