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
42 changes: 42 additions & 0 deletions src/Resolver/SpecialTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
Expand Down Expand Up @@ -147,6 +149,26 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
);
}

if ($node instanceof ConditionalTypeNode) {
return new ConditionalTypeNode(
self::resolve($node->subjectType, $context, $thisObj),
self::resolve($node->targetType, $context, $thisObj),
self::resolve($node->if, $context, $thisObj),
self::resolve($node->else, $context, $thisObj),
$node->negated
);
}

if ($node instanceof ConditionalTypeForParameterNode) {
return new ConditionalTypeForParameterNode(
$node->parameterName,
self::resolve($node->targetType, $context, $thisObj),
self::resolve($node->if, $context, $thisObj),
self::resolve($node->else, $context, $thisObj),
$node->negated
);
}

if ($node instanceof OffsetAccessTypeNode) {
$baseType = self::resolve($node->type, $context, $thisObj);
$offsetType = self::resolve($node->offset, $context, $thisObj);
Expand Down Expand Up @@ -342,6 +364,26 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode
);
}

if ($node instanceof ConditionalTypeNode) {
return new ConditionalTypeNode(
self::resolveForFile($node->subjectType, $file),
self::resolveForFile($node->targetType, $file),
self::resolveForFile($node->if, $file),
self::resolveForFile($node->else, $file),
$node->negated
);
}

if ($node instanceof ConditionalTypeForParameterNode) {
return new ConditionalTypeForParameterNode(
$node->parameterName,
self::resolveForFile($node->targetType, $file),
self::resolveForFile($node->if, $file),
self::resolveForFile($node->else, $file),
$node->negated
);
}

if ($node instanceof OffsetAccessTypeNode) {
$baseType = self::resolveForFile($node->type, $file);
$offsetType = self::resolveForFile($node->offset, $file);
Expand Down
46 changes: 46 additions & 0 deletions src/Resolver/TemplateSubstitutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
namespace TypePHP\Resolver;

use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;

Expand Down Expand Up @@ -44,6 +48,26 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $
return $node;
}

if ($node instanceof ConditionalTypeNode) {
return new ConditionalTypeNode(
self::substitute($node->subjectType, $boundTemplates, $declaredTemplates),
self::substitute($node->targetType, $boundTemplates, $declaredTemplates),
self::substitute($node->if, $boundTemplates, $declaredTemplates),
self::substitute($node->else, $boundTemplates, $declaredTemplates),
$node->negated
);
}

if ($node instanceof ConditionalTypeForParameterNode) {
return new ConditionalTypeForParameterNode(
$node->parameterName,
self::substitute($node->targetType, $boundTemplates, $declaredTemplates),
self::substitute($node->if, $boundTemplates, $declaredTemplates),
self::substitute($node->else, $boundTemplates, $declaredTemplates),
$node->negated
);
}

if ($node instanceof ArrayTypeNode) {
return new ArrayTypeNode(self::substitute($node->type, $boundTemplates, $declaredTemplates));
}
Expand Down Expand Up @@ -80,6 +104,28 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $
));
}

if ($node instanceof ArrayShapeNode) {
foreach ($node->items as $item) {
$item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates);
}
if ($node->unsealedType !== null) {
if ($node->unsealedType->keyType !== null) {
$node->unsealedType->keyType = self::substitute($node->unsealedType->keyType, $boundTemplates, $declaredTemplates);
}
$node->unsealedType->valueType = self::substitute($node->unsealedType->valueType, $boundTemplates, $declaredTemplates);
}

return $node;
}

if ($node instanceof ObjectShapeNode) {
foreach ($node->items as $item) {
$item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates);
}

return $node;
}

return $node;
}
}
23 changes: 23 additions & 0 deletions tests/Fixtures/Generics/BaseConditionalBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Generics;

use TypePHP\Tests\Fixtures\Domain\Dog;

/**
* @template T
*/
abstract class BaseConditionalBox
{
/**
* @param mixed $input
*
* @return (T is Dog ? positive-int : non-empty-string)
*/
public function processInput(mixed $input): mixed
{
return $input;
}
}
29 changes: 29 additions & 0 deletions tests/Fixtures/Generics/BaseGenericClonable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Generics;

/**
* @template T
*/
abstract class BaseGenericClonable
{
/**
* @var T
*/
public mixed $item;

public function __construct(mixed $item)
{
$this->item = $item;
}

/**
* Parent method executing clone $this
*/
public function duplicate(): static
{
return clone $this;
}
}
21 changes: 21 additions & 0 deletions tests/Fixtures/Generics/ChildGenericClonable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Generics;

/**
* @template T
*
* @extends BaseGenericClonable<T>
*/
class ChildGenericClonable extends BaseGenericClonable
{
/**
* @param T $newItem
*/
public function setItem(mixed $newItem): void
{
$this->item = $newItem;
}
}
14 changes: 14 additions & 0 deletions tests/Fixtures/Generics/DogConditionalBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Generics;

use TypePHP\Tests\Fixtures\Domain\Dog;

/**
* @extends BaseConditionalBox<Dog>
*/
class DogConditionalBox extends BaseConditionalBox
{
}
37 changes: 37 additions & 0 deletions tests/Fixtures/Generics/GenericConditionalFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Generics;

use TypePHP\Tests\Fixtures\Domain\Animal;
use TypePHP\Tests\Fixtures\Domain\Dog;

class GenericConditionalFactory
{
/**
* @template T of Animal
*
* @param class-string<T> $class
* @param mixed $payload
*
* @return (T is Dog ? list<positive-int> : list<non-empty-string>)
*/
public function createPayload(string $class, mixed $payload): array
{
return $payload;
}

/**
* @template T
*
* @param T $input
* @param mixed $result
*
* @return (T is not Dog ? non-empty-string : positive-int)
*/
public function processNegated(mixed $input, mixed $result): mixed
{
return $result;
}
}
115 changes: 115 additions & 0 deletions tests/TypeChecking/ConditionalTypesWithGenericsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

use TypePHP\Tests\Fixtures\Domain\Cat;
use TypePHP\Tests\Fixtures\Domain\Dog;
use TypePHP\Tests\Fixtures\Generics\DogConditionalBox;
use TypePHP\Tests\Fixtures\Generics\GenericConditionalFactory;

/**
* Standalone function with generic conditional return type
*
* @template T
*
* @param T $typeSample
* @param mixed $value
*
* @return (T is Dog ? positive-int : non-empty-string)
*/
function testStandaloneGenericConditional(mixed $typeSample, mixed $value): mixed
{
return $value;
}

describe('Conditional Types with Generics (T is Target ? If : Else)', function () {

describe('Standalone Generic Functions', function () {

test('evaluates positive-int branch when template T is inferred as Dog', function () {
$dog = new Dog();

expect(testStandaloneGenericConditional($dog, 100))->toBe(100);

expect(fn () => testStandaloneGenericConditional($dog, -50))
->toThrow(TypeError::class, 'Return value must be of type positive-int')
;
});

test('evaluates non-empty-string branch when template T is inferred as Cat', function () {
$cat = new Cat();

expect(testStandaloneGenericConditional($cat, 'valid_string'))->toBe('valid_string');

expect(fn () => testStandaloneGenericConditional($cat, ''))
->toThrow(TypeError::class, 'Return value must be of type non-empty-string')
;
});

});

describe('Negated Conditional Types (T is not Target ? If : Else)', function () {

test('evaluates non-empty-string branch when T is not Dog', function () {
$factory = new GenericConditionalFactory();
$cat = new Cat();

expect($factory->processNegated($cat, 'hello'))->toBe('hello');

expect(fn () => $factory->processNegated($cat, ''))
->toThrow(TypeError::class, 'Return value must be of type non-empty-string')
;
});

test('evaluates positive-int branch when T is Dog in negated conditional', function () {
$factory = new GenericConditionalFactory();
$dog = new Dog();

expect($factory->processNegated($dog, 42))->toBe(42);

expect(fn () => $factory->processNegated($dog, -10))
->toThrow(TypeError::class, 'Return value must be of type positive-int')
;
});

});

describe('class-string<T> Factories with Conditional Return Types', function () {

test('evaluates list<positive-int> when class-string is Dog::class', function () {
$factory = new GenericConditionalFactory();

expect($factory->createPayload(Dog::class, [10, 20, 30]))->toBe([10, 20, 30]);

expect(fn () => $factory->createPayload(Dog::class, [10, -5]))
->toThrow(TypeError::class, 'Return value')
;
});

test('evaluates list<non-empty-string> when class-string is Cat::class', function () {
$factory = new GenericConditionalFactory();

expect($factory->createPayload(Cat::class, ['a', 'b']))->toBe(['a', 'b']);

expect(fn () => $factory->createPayload(Cat::class, ['a', '']))
->toThrow(TypeError::class, 'Return value')
;
});

});

describe('Inherited Generic Classes with Conditionals', function () {

test('evaluates conditional return types inherited from abstract parent generic class', function () {
$dogBox = new DogConditionalBox();

expect($dogBox->processInput(100))->toBe(100);

expect(fn () => $dogBox->processInput(-99))
->toThrow(TypeError::class, 'Return value must be of type positive-int')
;
});

});

});
Loading
Loading