Skip to content

Commit 78d719c

Browse files
committed
Add conditional type handling and tests for generics in the resolver and fixtures
1 parent 0dac4d4 commit 78d719c

10 files changed

Lines changed: 373 additions & 1 deletion

src/Resolver/SpecialTypeResolver.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
1616
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
1717
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
18+
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
19+
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
1820
use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
1921
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
2022
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
@@ -147,6 +149,26 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
147149
);
148150
}
149151

152+
if ($node instanceof ConditionalTypeNode) {
153+
return new ConditionalTypeNode(
154+
self::resolve($node->subjectType, $context, $thisObj),
155+
self::resolve($node->targetType, $context, $thisObj),
156+
self::resolve($node->if, $context, $thisObj),
157+
self::resolve($node->else, $context, $thisObj),
158+
$node->negated
159+
);
160+
}
161+
162+
if ($node instanceof ConditionalTypeForParameterNode) {
163+
return new ConditionalTypeForParameterNode(
164+
$node->parameterName,
165+
self::resolve($node->targetType, $context, $thisObj),
166+
self::resolve($node->if, $context, $thisObj),
167+
self::resolve($node->else, $context, $thisObj),
168+
$node->negated
169+
);
170+
}
171+
150172
if ($node instanceof OffsetAccessTypeNode) {
151173
$baseType = self::resolve($node->type, $context, $thisObj);
152174
$offsetType = self::resolve($node->offset, $context, $thisObj);
@@ -342,6 +364,26 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode
342364
);
343365
}
344366

367+
if ($node instanceof ConditionalTypeNode) {
368+
return new ConditionalTypeNode(
369+
self::resolveForFile($node->subjectType, $file),
370+
self::resolveForFile($node->targetType, $file),
371+
self::resolveForFile($node->if, $file),
372+
self::resolveForFile($node->else, $file),
373+
$node->negated
374+
);
375+
}
376+
377+
if ($node instanceof ConditionalTypeForParameterNode) {
378+
return new ConditionalTypeForParameterNode(
379+
$node->parameterName,
380+
self::resolveForFile($node->targetType, $file),
381+
self::resolveForFile($node->if, $file),
382+
self::resolveForFile($node->else, $file),
383+
$node->negated
384+
);
385+
}
386+
345387
if ($node instanceof OffsetAccessTypeNode) {
346388
$baseType = self::resolveForFile($node->type, $file);
347389
$offsetType = self::resolveForFile($node->offset, $file);

src/Resolver/TemplateSubstitutor.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
namespace TypePHP\Resolver;
66

77
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
8+
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
89
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
10+
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
11+
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
912
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
1013
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
1114
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
1215
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
16+
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
1317
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
1418
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
1519

@@ -44,6 +48,26 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $
4448
return $node;
4549
}
4650

51+
if ($node instanceof ConditionalTypeNode) {
52+
return new ConditionalTypeNode(
53+
self::substitute($node->subjectType, $boundTemplates, $declaredTemplates),
54+
self::substitute($node->targetType, $boundTemplates, $declaredTemplates),
55+
self::substitute($node->if, $boundTemplates, $declaredTemplates),
56+
self::substitute($node->else, $boundTemplates, $declaredTemplates),
57+
$node->negated
58+
);
59+
}
60+
61+
if ($node instanceof ConditionalTypeForParameterNode) {
62+
return new ConditionalTypeForParameterNode(
63+
$node->parameterName,
64+
self::substitute($node->targetType, $boundTemplates, $declaredTemplates),
65+
self::substitute($node->if, $boundTemplates, $declaredTemplates),
66+
self::substitute($node->else, $boundTemplates, $declaredTemplates),
67+
$node->negated
68+
);
69+
}
70+
4771
if ($node instanceof ArrayTypeNode) {
4872
return new ArrayTypeNode(self::substitute($node->type, $boundTemplates, $declaredTemplates));
4973
}
@@ -80,6 +104,28 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $
80104
));
81105
}
82106

107+
if ($node instanceof ArrayShapeNode) {
108+
foreach ($node->items as $item) {
109+
$item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates);
110+
}
111+
if ($node->unsealedType !== null) {
112+
if ($node->unsealedType->keyType !== null) {
113+
$node->unsealedType->keyType = self::substitute($node->unsealedType->keyType, $boundTemplates, $declaredTemplates);
114+
}
115+
$node->unsealedType->valueType = self::substitute($node->unsealedType->valueType, $boundTemplates, $declaredTemplates);
116+
}
117+
118+
return $node;
119+
}
120+
121+
if ($node instanceof ObjectShapeNode) {
122+
foreach ($node->items as $item) {
123+
$item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates);
124+
}
125+
126+
return $node;
127+
}
128+
83129
return $node;
84130
}
85131
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
9+
/**
10+
* @template T
11+
*/
12+
abstract class BaseConditionalBox
13+
{
14+
/**
15+
* @param mixed $input
16+
*
17+
* @return (T is Dog ? positive-int : non-empty-string)
18+
*/
19+
public function processInput(mixed $input): mixed
20+
{
21+
return $input;
22+
}
23+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
/**
8+
* @template T
9+
*/
10+
abstract class BaseGenericClonable
11+
{
12+
/**
13+
* @var T
14+
*/
15+
public mixed $item;
16+
17+
public function __construct(mixed $item)
18+
{
19+
$this->item = $item;
20+
}
21+
22+
/**
23+
* Parent method executing clone $this
24+
*/
25+
public function duplicate(): static
26+
{
27+
return clone $this;
28+
}
29+
}
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 TypePHP\Tests\Fixtures\Generics;
6+
7+
/**
8+
* @template T
9+
*
10+
* @extends BaseGenericClonable<T>
11+
*/
12+
class ChildGenericClonable extends BaseGenericClonable
13+
{
14+
/**
15+
* @param T $newItem
16+
*/
17+
public function setItem(mixed $newItem): void
18+
{
19+
$this->item = $newItem;
20+
}
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
9+
/**
10+
* @extends BaseConditionalBox<Dog>
11+
*/
12+
class DogConditionalBox extends BaseConditionalBox
13+
{
14+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Animal;
8+
use TypePHP\Tests\Fixtures\Domain\Dog;
9+
10+
class GenericConditionalFactory
11+
{
12+
/**
13+
* @template T of Animal
14+
*
15+
* @param class-string<T> $class
16+
* @param mixed $payload
17+
*
18+
* @return (T is Dog ? list<positive-int> : list<non-empty-string>)
19+
*/
20+
public function createPayload(string $class, mixed $payload): array
21+
{
22+
return $payload;
23+
}
24+
25+
/**
26+
* @template T
27+
*
28+
* @param T $input
29+
* @param mixed $result
30+
*
31+
* @return (T is not Dog ? non-empty-string : positive-int)
32+
*/
33+
public function processNegated(mixed $input, mixed $result): mixed
34+
{
35+
return $result;
36+
}
37+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Tests\Fixtures\Domain\Cat;
6+
use TypePHP\Tests\Fixtures\Domain\Dog;
7+
use TypePHP\Tests\Fixtures\Generics\DogConditionalBox;
8+
use TypePHP\Tests\Fixtures\Generics\GenericConditionalFactory;
9+
10+
/**
11+
* Standalone function with generic conditional return type
12+
*
13+
* @template T
14+
*
15+
* @param T $typeSample
16+
* @param mixed $value
17+
*
18+
* @return (T is Dog ? positive-int : non-empty-string)
19+
*/
20+
function testStandaloneGenericConditional(mixed $typeSample, mixed $value): mixed
21+
{
22+
return $value;
23+
}
24+
25+
describe('Conditional Types with Generics (T is Target ? If : Else)', function () {
26+
27+
describe('Standalone Generic Functions', function () {
28+
29+
test('evaluates positive-int branch when template T is inferred as Dog', function () {
30+
$dog = new Dog();
31+
32+
expect(testStandaloneGenericConditional($dog, 100))->toBe(100);
33+
34+
expect(fn () => testStandaloneGenericConditional($dog, -50))
35+
->toThrow(TypeError::class, 'Return value must be of type positive-int')
36+
;
37+
});
38+
39+
test('evaluates non-empty-string branch when template T is inferred as Cat', function () {
40+
$cat = new Cat();
41+
42+
expect(testStandaloneGenericConditional($cat, 'valid_string'))->toBe('valid_string');
43+
44+
expect(fn () => testStandaloneGenericConditional($cat, ''))
45+
->toThrow(TypeError::class, 'Return value must be of type non-empty-string')
46+
;
47+
});
48+
49+
});
50+
51+
describe('Negated Conditional Types (T is not Target ? If : Else)', function () {
52+
53+
test('evaluates non-empty-string branch when T is not Dog', function () {
54+
$factory = new GenericConditionalFactory();
55+
$cat = new Cat();
56+
57+
expect($factory->processNegated($cat, 'hello'))->toBe('hello');
58+
59+
expect(fn () => $factory->processNegated($cat, ''))
60+
->toThrow(TypeError::class, 'Return value must be of type non-empty-string')
61+
;
62+
});
63+
64+
test('evaluates positive-int branch when T is Dog in negated conditional', function () {
65+
$factory = new GenericConditionalFactory();
66+
$dog = new Dog();
67+
68+
expect($factory->processNegated($dog, 42))->toBe(42);
69+
70+
expect(fn () => $factory->processNegated($dog, -10))
71+
->toThrow(TypeError::class, 'Return value must be of type positive-int')
72+
;
73+
});
74+
75+
});
76+
77+
describe('class-string<T> Factories with Conditional Return Types', function () {
78+
79+
test('evaluates list<positive-int> when class-string is Dog::class', function () {
80+
$factory = new GenericConditionalFactory();
81+
82+
expect($factory->createPayload(Dog::class, [10, 20, 30]))->toBe([10, 20, 30]);
83+
84+
expect(fn () => $factory->createPayload(Dog::class, [10, -5]))
85+
->toThrow(TypeError::class, 'Return value')
86+
;
87+
});
88+
89+
test('evaluates list<non-empty-string> when class-string is Cat::class', function () {
90+
$factory = new GenericConditionalFactory();
91+
92+
expect($factory->createPayload(Cat::class, ['a', 'b']))->toBe(['a', 'b']);
93+
94+
expect(fn () => $factory->createPayload(Cat::class, ['a', '']))
95+
->toThrow(TypeError::class, 'Return value')
96+
;
97+
});
98+
99+
});
100+
101+
describe('Inherited Generic Classes with Conditionals', function () {
102+
103+
test('evaluates conditional return types inherited from abstract parent generic class', function () {
104+
$dogBox = new DogConditionalBox();
105+
106+
expect($dogBox->processInput(100))->toBe(100);
107+
108+
expect(fn () => $dogBox->processInput(-99))
109+
->toThrow(TypeError::class, 'Return value must be of type positive-int')
110+
;
111+
});
112+
113+
});
114+
115+
});

0 commit comments

Comments
 (0)