Skip to content

Commit 480f443

Browse files
committed
feat: add support for $this type in closures with new PestTestCaseType and related extensions
1 parent 6c883aa commit 480f443

9 files changed

Lines changed: 313 additions & 44 deletions

extension.neon

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,24 @@ services:
4141
tags:
4242
- phpstan.broker.dynamicMethodReturnTypeExtension
4343

44+
-
45+
class: Pest\PHPStan\Type\Pest\PestTestCaseType
46+
4447
-
4548
class: Pest\PHPStan\Type\Pest\TestClosureThisTypeExtension
4649
tags:
4750
- phpstan.functionParameterClosureThisExtension
4851

52+
-
53+
class: Pest\PHPStan\Type\Pest\WithClosureThisTypeExtension
54+
tags:
55+
- phpstan.methodParameterClosureThisExtension
56+
57+
-
58+
class: Pest\PHPStan\Type\Pest\WithDatasetClosureNodeVisitor
59+
tags:
60+
- phpstan.parser.richParserNodeVisitor
61+
4962
-
5063
class: Pest\PHPStan\Type\Pest\ExpectationPropertiesExtension
5164
tags:

src/Type/Pest/PestTestCaseType.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\PHPStan\Type\Pest;
6+
7+
use PHPStan\Reflection\ReflectionProvider;
8+
use PHPStan\Type\ObjectType;
9+
use PHPStan\Type\Type;
10+
use PHPStan\Type\TypeCombinator;
11+
use PHPUnit\Framework\TestCase;
12+
13+
final class PestTestCaseType
14+
{
15+
public function __construct(
16+
private readonly PestConfigReader $pestConfigReader,
17+
private readonly ReflectionProvider $reflectionProvider,
18+
) {}
19+
20+
public function resolve(string $filePath): Type
21+
{
22+
$types = $this->toClassObjectTypes(
23+
$this->pestConfigReader->resolveFileBindings($filePath),
24+
);
25+
26+
if ($types === []) {
27+
$types = $this->toClassObjectTypes(
28+
$this->pestConfigReader->resolveBindings($filePath),
29+
);
30+
}
31+
32+
if ($types === []) {
33+
return new ObjectType(TestCase::class);
34+
}
35+
36+
return count($types) === 1 ? $types[0] : TypeCombinator::intersect(...$types);
37+
}
38+
39+
/**
40+
* @param list<string> $bindings
41+
* @return list<ObjectType>
42+
*/
43+
private function toClassObjectTypes(array $bindings): array
44+
{
45+
$types = [];
46+
47+
foreach ($bindings as $binding) {
48+
if (! $this->reflectionProvider->hasClass($binding)) {
49+
continue;
50+
}
51+
52+
if ($this->reflectionProvider->getClass($binding)->isTrait()) {
53+
continue;
54+
}
55+
56+
$types[] = new ObjectType($binding);
57+
}
58+
59+
return $types;
60+
}
61+
}

src/Type/Pest/TestClosureThisTypeExtension.php

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,8 @@
88
use PHPStan\Analyser\Scope;
99
use PHPStan\Reflection\FunctionReflection;
1010
use PHPStan\Reflection\ParameterReflection;
11-
use PHPStan\Reflection\ReflectionProvider;
1211
use PHPStan\Type\FunctionParameterClosureThisExtension;
13-
use PHPStan\Type\ObjectType;
1412
use PHPStan\Type\Type;
15-
use PHPStan\Type\TypeCombinator;
16-
use PHPUnit\Framework\TestCase;
1713

1814
final class TestClosureThisTypeExtension implements FunctionParameterClosureThisExtension
1915
{
@@ -31,8 +27,7 @@ final class TestClosureThisTypeExtension implements FunctionParameterClosureThis
3127
];
3228

3329
public function __construct(
34-
private readonly PestConfigReader $pestConfigReader,
35-
private readonly ReflectionProvider $reflectionProvider,
30+
private readonly PestTestCaseType $pestTestCaseType,
3631
) {}
3732

3833
public function isFunctionSupported(FunctionReflection $functionReflection, ParameterReflection $parameter): bool
@@ -49,43 +44,6 @@ public function getClosureThisTypeFromFunctionCall(
4944
ParameterReflection $parameter,
5045
Scope $scope
5146
): Type {
52-
$types = $this->toClassObjectTypes(
53-
$this->pestConfigReader->resolveFileBindings($scope->getFile()),
54-
);
55-
56-
if ($types === []) {
57-
$types = $this->toClassObjectTypes(
58-
$this->pestConfigReader->resolveBindings($scope->getFile()),
59-
);
60-
}
61-
62-
if ($types === []) {
63-
return new ObjectType(TestCase::class);
64-
}
65-
66-
return count($types) === 1 ? $types[0] : TypeCombinator::intersect(...$types);
67-
}
68-
69-
/**
70-
* @param list<string> $bindings
71-
* @return list<ObjectType>
72-
*/
73-
private function toClassObjectTypes(array $bindings): array
74-
{
75-
$types = [];
76-
77-
foreach ($bindings as $binding) {
78-
if (! $this->reflectionProvider->hasClass($binding)) {
79-
continue;
80-
}
81-
82-
if ($this->reflectionProvider->getClass($binding)->isTrait()) {
83-
continue;
84-
}
85-
86-
$types[] = new ObjectType($binding);
87-
}
88-
89-
return $types;
47+
return $this->pestTestCaseType->resolve($scope->getFile());
9048
}
9149
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\PHPStan\Type\Pest;
6+
7+
use Pest\PendingCalls\TestCall;
8+
use PhpParser\Node\Expr\MethodCall;
9+
use PHPStan\Analyser\Scope;
10+
use PHPStan\Reflection\MethodReflection;
11+
use PHPStan\Reflection\ParameterReflection;
12+
use PHPStan\Type\MethodParameterClosureThisExtension;
13+
use PHPStan\Type\Type;
14+
15+
final class WithClosureThisTypeExtension implements MethodParameterClosureThisExtension
16+
{
17+
public function __construct(
18+
private readonly PestTestCaseType $pestTestCaseType,
19+
) {}
20+
21+
public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
22+
{
23+
return mb_strtolower($methodReflection->getName()) === 'with'
24+
&& $methodReflection->getDeclaringClass()->is(TestCall::class);
25+
}
26+
27+
public function getClosureThisTypeFromMethodCall(
28+
MethodReflection $methodReflection,
29+
MethodCall $methodCall,
30+
ParameterReflection $parameter,
31+
Scope $scope
32+
): Type {
33+
return $this->pestTestCaseType->resolve($scope->getFile());
34+
}
35+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\PHPStan\Type\Pest;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr;
9+
use PhpParser\Node\Expr\ArrowFunction;
10+
use PhpParser\Node\Expr\Closure as ClosureExpr;
11+
use PhpParser\Node\Expr\FuncCall;
12+
use PhpParser\Node\Expr\MethodCall;
13+
use PhpParser\Node\Identifier;
14+
use PhpParser\Node\Name;
15+
use PhpParser\Node\Stmt\Return_;
16+
use PhpParser\NodeFinder;
17+
use PhpParser\NodeVisitorAbstract;
18+
19+
final class WithDatasetClosureNodeVisitor extends NodeVisitorAbstract
20+
{
21+
private const array PEST_TEST_FUNCTIONS = ['test', 'it'];
22+
23+
public function enterNode(Node $node): ?Node
24+
{
25+
if (! $node instanceof MethodCall) {
26+
return null;
27+
}
28+
29+
if (! $node->name instanceof Identifier || $node->name->name !== 'with') {
30+
return null;
31+
}
32+
33+
if (! $this->isPestTestChain($node)) {
34+
return null;
35+
}
36+
37+
foreach ($node->getArgs() as $arg) {
38+
$arg->value = $this->wrapInClosure($arg->value);
39+
}
40+
41+
return null;
42+
}
43+
44+
private function wrapInClosure(Expr $node): Expr
45+
{
46+
if ($node instanceof ClosureExpr || $node instanceof ArrowFunction) {
47+
return $node;
48+
}
49+
50+
if (! $this->containsClosure($node)) {
51+
return $node;
52+
}
53+
54+
return new ClosureExpr(
55+
[
56+
'stmts' => [
57+
new Return_($node),
58+
],
59+
],
60+
$node->getAttributes(),
61+
);
62+
}
63+
64+
private function containsClosure(Expr $node): bool
65+
{
66+
$nodeFinder = new NodeFinder;
67+
68+
return $nodeFinder->findFirst(
69+
[$node],
70+
static fn (Node $n): bool => $n instanceof ClosureExpr || $n instanceof ArrowFunction,
71+
) instanceof Node;
72+
}
73+
74+
private function isPestTestChain(MethodCall $methodCall): bool
75+
{
76+
$root = $methodCall->var;
77+
78+
while ($root instanceof MethodCall) {
79+
$root = $root->var;
80+
}
81+
82+
if (! $root instanceof FuncCall || ! $root->name instanceof Name) {
83+
return false;
84+
}
85+
86+
return in_array($root->name->getLast(), self::PEST_TEST_FUNCTIONS, true);
87+
}
88+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TestWithClosuresCustomTestCase;
6+
7+
use Tests\Type\Fixtures\CustomTestCase;
8+
9+
use function PHPStan\Testing\assertType;
10+
11+
function testThisTypeInsideWithClosure(): void
12+
{
13+
test('has custom $this type inside with closure', function (): void {
14+
assertType(CustomTestCase::class, $this);
15+
})->with(function (): array {
16+
assertType(CustomTestCase::class, $this);
17+
18+
return [
19+
'data 1' => fn (): array => [$this],
20+
];
21+
});
22+
}
23+
24+
function testThisTypeInsideWithArray(): void
25+
{
26+
it('has custom $this type inside with array', function (): void {
27+
assertType(CustomTestCase::class, $this);
28+
})->with([
29+
'data 1' => fn (): array => [$this],
30+
]);
31+
}
32+
33+
function testThisTypeInsideNestedWithClosure(): void
34+
{
35+
test('has custom $this type in nested with closures', function (): void {
36+
assertType(CustomTestCase::class, $this);
37+
})->with([
38+
'data 1' => fn (): array => [
39+
'nested' => fn (): array => [$this],
40+
],
41+
]);
42+
}

tests/Type/CustomTestCaseTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
$this->assertFileAsserts($assertType, $file, ...$args);
99
})->with(function (): Iterator {
1010
yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-closures-custom-testcase.php');
11+
yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/Feature/test-with-closures-custom-testcase.php');
1112
});
1213

1314
test('custom testcase closure types when a class and a trait are bound', function (string $assertType, string $file, mixed ...$args): void {

tests/Type/ExpectTypeTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-closures.php');
1717
});
1818

19+
test('with closure types', function (string $assertType, string $file, mixed ...$args): void {
20+
$this->assertFileAsserts($assertType, $file, ...$args);
21+
})->with(function (): Iterator {
22+
yield from TestCase::gatherAssertTypes(__DIR__.'/data/test-with-closures.php');
23+
});
24+
1925
test('expectation method types', function (string $assertType, string $file, mixed ...$args): void {
2026
$this->assertFileAsserts($assertType, $file, ...$args);
2127
})->with(function (): Iterator {

0 commit comments

Comments
 (0)