diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 77deb81..4a243f1 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -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; @@ -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); @@ -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); diff --git a/src/Resolver/TemplateSubstitutor.php b/src/Resolver/TemplateSubstitutor.php index 66cd8e2..3061db4 100644 --- a/src/Resolver/TemplateSubstitutor.php +++ b/src/Resolver/TemplateSubstitutor.php @@ -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; @@ -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)); } @@ -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; } } diff --git a/tests/Fixtures/Generics/BaseConditionalBox.php b/tests/Fixtures/Generics/BaseConditionalBox.php new file mode 100644 index 0000000..161cb3e --- /dev/null +++ b/tests/Fixtures/Generics/BaseConditionalBox.php @@ -0,0 +1,23 @@ +item = $item; + } + + /** + * Parent method executing clone $this + */ + public function duplicate(): static + { + return clone $this; + } +} diff --git a/tests/Fixtures/Generics/ChildGenericClonable.php b/tests/Fixtures/Generics/ChildGenericClonable.php new file mode 100644 index 0000000..c28ddfb --- /dev/null +++ b/tests/Fixtures/Generics/ChildGenericClonable.php @@ -0,0 +1,21 @@ + + */ +class ChildGenericClonable extends BaseGenericClonable +{ + /** + * @param T $newItem + */ + public function setItem(mixed $newItem): void + { + $this->item = $newItem; + } +} diff --git a/tests/Fixtures/Generics/DogConditionalBox.php b/tests/Fixtures/Generics/DogConditionalBox.php new file mode 100644 index 0000000..ac30969 --- /dev/null +++ b/tests/Fixtures/Generics/DogConditionalBox.php @@ -0,0 +1,14 @@ + + */ +class DogConditionalBox extends BaseConditionalBox +{ +} diff --git a/tests/Fixtures/Generics/GenericConditionalFactory.php b/tests/Fixtures/Generics/GenericConditionalFactory.php new file mode 100644 index 0000000..9eb7dcc --- /dev/null +++ b/tests/Fixtures/Generics/GenericConditionalFactory.php @@ -0,0 +1,37 @@ + $class + * @param mixed $payload + * + * @return (T is Dog ? list : list) + */ + 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; + } +} diff --git a/tests/TypeChecking/ConditionalTypesWithGenericsTest.php b/tests/TypeChecking/ConditionalTypesWithGenericsTest.php new file mode 100644 index 0000000..40253fb --- /dev/null +++ b/tests/TypeChecking/ConditionalTypesWithGenericsTest.php @@ -0,0 +1,115 @@ +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 Factories with Conditional Return Types', function () { + + test('evaluates list 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 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') + ; + }); + + }); + +}); diff --git a/tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php b/tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php new file mode 100644 index 0000000..90445fb --- /dev/null +++ b/tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php @@ -0,0 +1,44 @@ + $child */ + $child = new ChildGenericClonable(new Dog()); + + $clonedChild = $child->duplicate(); + + expect($clonedChild)->toBeInstanceOf(ChildGenericClonable::class); + + $clonedChild->setItem(new Dog()); + expect($clonedChild->item)->toBeInstanceOf(Dog::class); + + expect(fn () => $clonedChild->setItem(new Car())) + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + + }); + + describe('Conditional Return Types in Inherited Generic Methods', function () { + + test('evaluates conditional return types based on inherited template mappings', function () { + $box = new DogConditionalBox(); + + expect($box->processInput(42))->toBe(42); + + expect(fn () => $box->processInput(-5)) + ->toThrow(TypeError::class, 'Return value must be of type positive-int') + ; + }); + }); +}); diff --git a/tests/TypeChecking/UppercaseAndArrayKeyTest.php b/tests/TypeChecking/UppercaseAndArrayKeyTest.php index e585925..ccf6d9e 100644 --- a/tests/TypeChecking/UppercaseAndArrayKeyTest.php +++ b/tests/TypeChecking/UppercaseAndArrayKeyTest.php @@ -96,7 +96,8 @@ function testNonEmptyUppercaseStringParam(string $str): string expect(CurrencyFormatter::sanitizeCode('CAD'))->toBe('CAD'); expect(fn () => CurrencyFormatter::sanitizeCode('cad')) - ->toThrow(TypeError::class, 'Return value'); + ->toThrow(TypeError::class, 'Return value') + ; }); });