Skip to content

Commit 3f5ceab

Browse files
committed
fix: resolve trait methods on \ when classes/traits are bound
PHPStan treats traits as non-instantiable, so intersecting a class ObjectType with a trait ObjectType collapses to NEVER. Introduce PestTestCaseWithTraitsType, an ObjectType subclass that delegates to the test case class while also exposing methods/properties/constants from bound traits. Also extend PestConfigReader to treat uses()/pest()->... bindings without an ->in() scope as global bindings that apply to every test file, matching Pest runtime behavior. Closes pestphp/pest#1815
1 parent 480f443 commit 3f5ceab

9 files changed

Lines changed: 288 additions & 28 deletions

File tree

‎src/Type/Pest/PestConfigReader.php‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ final class PestConfigReader
2424
/** @var array<string, list<array{class: string, config: string}>> */
2525
private array $globalUseDirectoryMap = [];
2626

27+
/** @var list<string> Bindings declared in a Pest.php config without an ->in() scope */
28+
private array $globalBindings = [];
29+
2730
/** @var array<string, list<string>> Caches bindings declared directly inside a test file */
2831
private array $fileBindingsCache = [];
2932

@@ -45,7 +48,7 @@ public function resolveBindings(string $filePath): array
4548
$this->ensureParsed();
4649

4750
$normalizedFile = $this->fileDiscoverer->normalizePath($filePath);
48-
$bindings = [];
51+
$bindings = [...$this->globalBindings];
4952

5053
foreach ($this->directoryMap as $bindingKey => $classNames) {
5154
if (! $this->targetResolver->matches($bindingKey, $normalizedFile)) {
@@ -120,7 +123,7 @@ public function allBoundClasses(): array
120123
{
121124
$this->ensureParsed();
122125

123-
$bindings = [];
126+
$bindings = [...$this->globalBindings];
124127

125128
foreach ($this->directoryMap as $classNames) {
126129
array_push($bindings, ...$classNames);
@@ -158,6 +161,27 @@ private function parsePestFile(string $filePath): void
158161

159162
$this->extractUsesBindings($nodeFinder, $stmts, $pestFileDir, $filePath);
160163
$this->extractPestBindings($nodeFinder, $stmts, $pestFileDir, $filePath);
164+
$this->extractGlobalBindings($stmts);
165+
}
166+
167+
/**
168+
* @param Node[] $stmts
169+
*/
170+
private function extractGlobalBindings(array $stmts): void
171+
{
172+
foreach ($this->topLevelExpressions($stmts) as $expr) {
173+
$usesArgs = $this->extractFileUsesArgs($expr);
174+
if ($usesArgs !== []) {
175+
array_push($this->globalBindings, ...$usesArgs);
176+
177+
continue;
178+
}
179+
180+
$pestArgs = $this->extractFilePestArgs($expr);
181+
if ($pestArgs !== []) {
182+
array_push($this->globalBindings, ...$pestArgs);
183+
}
184+
}
161185
}
162186

163187
/**

‎src/Type/Pest/PestTestCaseType.php‎

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,43 +19,60 @@ public function __construct(
1919

2020
public function resolve(string $filePath): Type
2121
{
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-
}
22+
$bindings = $this->pestConfigReader->resolveFileBindings($filePath);
3123

32-
if ($types === []) {
33-
return new ObjectType(TestCase::class);
24+
if ($bindings === []) {
25+
$bindings = $this->pestConfigReader->resolveBindings($filePath);
3426
}
3527

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 = [];
28+
$classNames = [];
29+
$traitNames = [];
4630

4731
foreach ($bindings as $binding) {
4832
if (! $this->reflectionProvider->hasClass($binding)) {
4933
continue;
5034
}
5135

52-
if ($this->reflectionProvider->getClass($binding)->isTrait()) {
36+
$reflection = $this->reflectionProvider->getClass($binding);
37+
38+
if ($reflection->isTrait()) {
39+
$traitNames[] = $binding;
40+
5341
continue;
5442
}
5543

56-
$types[] = new ObjectType($binding);
44+
$classNames[] = $binding;
45+
}
46+
47+
if ($classNames === []) {
48+
$classNames[] = TestCase::class;
49+
}
50+
51+
$classType = $this->toObjectType($classNames);
52+
53+
if ($traitNames === []) {
54+
return $classType;
55+
}
56+
57+
return new PestTestCaseWithTraitsType(
58+
$classNames[0],
59+
$traitNames,
60+
$this->reflectionProvider,
61+
);
62+
}
63+
64+
/**
65+
* @param list<string> $classNames
66+
*/
67+
private function toObjectType(array $classNames): Type
68+
{
69+
if (count($classNames) === 1) {
70+
return new ObjectType($classNames[0]);
5771
}
5872

59-
return $types;
73+
return TypeCombinator::intersect(...array_map(
74+
static fn (string $className): ObjectType => new ObjectType($className),
75+
$classNames,
76+
));
6077
}
6178
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\PHPStan\Type\Pest;
6+
7+
use Override;
8+
use PHPStan\Reflection\ClassConstantReflection;
9+
use PHPStan\Reflection\ClassMemberAccessAnswerer;
10+
use PHPStan\Reflection\ExtendedMethodReflection;
11+
use PHPStan\Reflection\ExtendedPropertyReflection;
12+
use PHPStan\Reflection\ReflectionProvider;
13+
use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection;
14+
use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection;
15+
use PHPStan\TrinaryLogic;
16+
use PHPStan\Type\ObjectType;
17+
18+
final class PestTestCaseWithTraitsType extends ObjectType
19+
{
20+
/** @param list<class-string> $traitNames */
21+
public function __construct(
22+
string $className,
23+
private readonly array $traitNames,
24+
private readonly ReflectionProvider $reflectionProvider,
25+
) {
26+
parent::__construct($className);
27+
}
28+
29+
#[Override]
30+
public function hasMethod(string $methodName): TrinaryLogic
31+
{
32+
if (parent::hasMethod($methodName)->yes()) {
33+
return TrinaryLogic::createYes();
34+
}
35+
36+
if ($this->hasTraitMethod($methodName)) {
37+
return TrinaryLogic::createYes();
38+
}
39+
40+
return parent::hasMethod($methodName);
41+
}
42+
43+
#[Override]
44+
public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection
45+
{
46+
if (parent::hasMethod($methodName)->yes()) {
47+
return parent::getMethod($methodName, $scope);
48+
}
49+
50+
foreach ($this->traitNames as $traitName) {
51+
$traitReflection = $this->reflectionProvider->getClass($traitName);
52+
53+
if ($traitReflection->hasNativeMethod($methodName)) {
54+
return $traitReflection->getNativeMethod($methodName);
55+
}
56+
}
57+
58+
return parent::getMethod($methodName, $scope);
59+
}
60+
61+
#[Override]
62+
public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection
63+
{
64+
if (parent::hasMethod($methodName)->yes()) {
65+
return parent::getUnresolvedMethodPrototype($methodName, $scope);
66+
}
67+
68+
foreach ($this->traitNames as $traitName) {
69+
if ($this->reflectionProvider->getClass($traitName)->hasNativeMethod($methodName)) {
70+
return new ObjectType($traitName)->getUnresolvedMethodPrototype($methodName, $scope);
71+
}
72+
}
73+
74+
return parent::getUnresolvedMethodPrototype($methodName, $scope);
75+
}
76+
77+
#[Override]
78+
public function hasProperty(string $propertyName): TrinaryLogic
79+
{
80+
if (parent::hasProperty($propertyName)->yes()) {
81+
return TrinaryLogic::createYes();
82+
}
83+
84+
if ($this->hasTraitProperty($propertyName)) {
85+
return TrinaryLogic::createYes();
86+
}
87+
88+
return parent::hasProperty($propertyName);
89+
}
90+
91+
#[Override]
92+
public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection
93+
{
94+
if (parent::hasProperty($propertyName)->yes()) {
95+
return parent::getProperty($propertyName, $scope);
96+
}
97+
98+
foreach ($this->traitNames as $traitName) {
99+
$traitReflection = $this->reflectionProvider->getClass($traitName);
100+
101+
if ($traitReflection->hasNativeProperty($propertyName)) {
102+
return $traitReflection->getNativeProperty($propertyName);
103+
}
104+
}
105+
106+
return parent::getProperty($propertyName, $scope);
107+
}
108+
109+
#[Override]
110+
public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection
111+
{
112+
if (parent::hasProperty($propertyName)->yes()) {
113+
return parent::getUnresolvedPropertyPrototype($propertyName, $scope);
114+
}
115+
116+
foreach ($this->traitNames as $traitName) {
117+
if ($this->reflectionProvider->getClass($traitName)->hasNativeProperty($propertyName)) {
118+
return new ObjectType($traitName)->getUnresolvedPropertyPrototype($propertyName, $scope);
119+
}
120+
}
121+
122+
return parent::getUnresolvedPropertyPrototype($propertyName, $scope);
123+
}
124+
125+
#[Override]
126+
public function hasConstant(string $constantName): TrinaryLogic
127+
{
128+
if (parent::hasConstant($constantName)->yes()) {
129+
return TrinaryLogic::createYes();
130+
}
131+
132+
if ($this->hasTraitConstant($constantName)) {
133+
return TrinaryLogic::createYes();
134+
}
135+
136+
return parent::hasConstant($constantName);
137+
}
138+
139+
#[Override]
140+
public function getConstant(string $constantName): ClassConstantReflection
141+
{
142+
if (parent::hasConstant($constantName)->yes()) {
143+
return parent::getConstant($constantName);
144+
}
145+
146+
foreach ($this->traitNames as $traitName) {
147+
$traitReflection = $this->reflectionProvider->getClass($traitName);
148+
149+
if ($traitReflection->hasConstant($constantName)) {
150+
return $traitReflection->getConstant($constantName);
151+
}
152+
}
153+
154+
return parent::getConstant($constantName);
155+
}
156+
157+
private function hasTraitMethod(string $methodName): bool
158+
{
159+
return array_any($this->traitNames, fn (string $traitName): bool => $this->reflectionProvider->getClass($traitName)->hasNativeMethod($methodName));
160+
}
161+
162+
private function hasTraitProperty(string $propertyName): bool
163+
{
164+
return array_any($this->traitNames, fn (string $traitName): bool => $this->reflectionProvider->getClass($traitName)->hasNativeProperty($propertyName));
165+
}
166+
167+
private function hasTraitConstant(string $constantName): bool
168+
{
169+
return array_any($this->traitNames, fn (string $traitName): bool => $this->reflectionProvider->getClass($traitName)->hasConstant($constantName));
170+
}
171+
}

‎tests/Fixtures/CustomTestCaseInference/LocalUses/local-uses-trait-only.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010

1111
uses(HelperTrait::class);
1212

13-
function testThisTypeFallsBackToTestCaseWhenOnlyATraitIsUsed(): void
13+
function testThisTypeWhenOnlyATraitIsUsed(): void
1414
{
15-
it('falls back to the default TestCase $this when only a trait is used', function (): void {
15+
it('falls back to the default TestCase $this and exposes trait methods', function (): void {
1616
assertType(\PHPUnit\Framework\TestCase::class, $this);
17+
assertType('string', $this->helperMethod());
1718
});
1819
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WithTraitMethodCall;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
function testTraitMethodCallableOnThis(): void
10+
{
11+
it('can call trait methods on $this', function (): void {
12+
assertType('string', $this->helperMethod());
13+
});
14+
}
15+
16+
function testTraitPropertyAccessibleOnThis(): void
17+
{
18+
it('has trait methods in the $this type', function (): void {
19+
assertType('string', $this->helperMethod());
20+
});
21+
}

‎tests/Type/CustomTestCaseTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
$this->assertFileAsserts($assertType, $file, ...$args);
1616
})->with(function (): Iterator {
1717
yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/WithTrait/with-trait-testcase.php');
18+
yield from CustomTestCaseTestCase::gatherAssertTypes(__DIR__.'/../Fixtures/CustomTestCaseInference/WithTrait/with-trait-method-call.php');
1819
});
1920

2021
test('custom testcase closure types from file-level uses()', function (string $assertType, string $file, mixed ...$args): void {

‎tests/Type/Fixtures/pestconfig-global/Feature/.gitkeep‎

Whitespace-only changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tests\Type\Fixtures\CustomTestCase;
6+
use Tests\Type\Fixtures\HelperTrait;
7+
8+
pest()->extend(CustomTestCase::class);
9+
pest()->use(HelperTrait::class);

‎tests/Type/PestConfigReaderTest.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@
7878
expect($bindings)->toBeEmpty();
7979
});
8080

81+
test('resolves global bindings without in() scope to every file', function (): void {
82+
$fixtureDir = realpath(__DIR__.'/Fixtures/pestconfig-global');
83+
if ($fixtureDir === false) {
84+
throw new RuntimeException('Global fixtures directory not found.');
85+
}
86+
87+
$reader = new PestConfigReader(new PestFileDiscoverer([$fixtureDir]));
88+
89+
expect($reader->resolveBindings($fixtureDir.'/Feature/SomeTest.php'))
90+
->toContain(CustomTestCase::class)
91+
->toContain(HelperTrait::class)
92+
->and($reader->resolveBindings('/some/other/path/Test.php'))
93+
->toContain(CustomTestCase::class)
94+
->toContain(HelperTrait::class);
95+
});
96+
8197
test('resolves in() directories built from __DIR__', function (): void {
8298
$fixtureDir = realpath(__DIR__.'/Fixtures/pestconfig-magicdir');
8399
if ($fixtureDir === false) {

0 commit comments

Comments
 (0)