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
38 changes: 37 additions & 1 deletion src/Internal/Checker/ParamChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
use TypePHP\Internal\Diagnostic\ErrorFactory;
use TypePHP\Internal\Diagnostic\ErrorMessage;
use TypePHP\Internal\Diagnostic\TypeFormatter;
use TypePHP\Internal\Docblock\DocblockExtractor;
use TypePHP\Internal\Docblock\DocblockParser;
use TypePHP\Internal\Generics\TemplateManager;
use TypePHP\Internal\Generics\TemplateSubstitutor;
use TypePHP\Internal\Resolver\HierarchyResolver;
use TypePHP\Internal\Resolver\SpecialTypeResolver;
use TypePHP\Internal\Util\ClassNameValidator;
use TypePHP\Internal\Util\Config;
use TypePHP\Internal\Util\StubManager;
use TypePHP\Internal\Validator\TypeValidatorRegistry;

/**
Expand All @@ -51,13 +53,21 @@ final class ParamChecker
*/
private static array $baseTypeCache = [];

/**
* Cache for declared class template names by class FQCN.
*
* @var array<string, list<string>>
*/
private static array $classDeclaredTemplateNamesCache = [];

/**
* Resets internal caches. Useful for test isolation.
*/
public static function reset(): void
{
self::$effectiveFunctionCache = [];
self::$noParamContractCache = [];
self::$classDeclaredTemplateNamesCache = [];
ClassNameValidator::reset();
self::$baseTypeCache = [];
}
Expand Down Expand Up @@ -803,6 +813,25 @@ private static function inferGenericObjectNode(
return;
}

$targetClassFqcn = SpecialTypeResolver::resolveFqcn($typeNode->type->name, new \ReflectionClass($value));
$targetTemplateNames = self::$classDeclaredTemplateNamesCache[$targetClassFqcn] ?? null;

if ($targetTemplateNames === null) {
$targetTemplateNames = [];
if (class_exists($targetClassFqcn, false) || class_exists($targetClassFqcn) || interface_exists($targetClassFqcn) || trait_exists($targetClassFqcn)) {
try {
$targetRef = new \ReflectionClass($targetClassFqcn);
$targetDoc = StubManager::getClassDoc($targetClassFqcn) ?? $targetRef->getDocComment();
if ($targetDoc !== false && $targetDoc !== null) {
$targetPhpDocNode = DocblockExtractor::parseDocString($targetDoc);
$targetTemplateNames = array_keys(DocblockExtractor::extractTemplates($targetPhpDocNode));
}
} catch (\Throwable $e) {
}
}
self::$classDeclaredTemplateNamesCache[$targetClassFqcn] = $targetTemplateNames;
}

$instanceBoundTypes = array_values($boundOnInstance);

foreach ($typeNode->genericTypes as $idx => $gtNode) {
Expand All @@ -812,7 +841,14 @@ private static function inferGenericObjectNode(
$targetObj = $isClassLevel ? $thisObj : null;

if (! TemplateManager::isBound($effectiveFunction, $targetObj, $tName)) {
$inferredCandidate = $instanceBoundTypes[$idx] ?? null;
$targetTemplateName = $targetTemplateNames[$idx] ?? null;
$inferredCandidate = null;

if ($targetTemplateName !== null && isset($boundOnInstance[$targetTemplateName])) {
$inferredCandidate = $boundOnInstance[$targetTemplateName];
} elseif (isset($instanceBoundTypes[$idx])) {
$inferredCandidate = $instanceBoundTypes[$idx];
}

if ($inferredCandidate !== null) {
$templateTag = $templates[$tName];
Expand Down
18 changes: 18 additions & 0 deletions tests/Fixtures/Forwarding/AbstractBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

/**
* Notice the extra leading template TSelf that shifts T and U by +1
*
* @template TSelf of AbstractBox
* @template T of ItemBase
* @template U of ItemBase
*
* @implements BoxInterface<T, U>
*/
abstract class AbstractBox implements BoxInterface
{
}
72 changes: 72 additions & 0 deletions tests/Fixtures/Forwarding/BoxConsumer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

final class BoxConsumer
{
/**
* @template T of ItemBase
* @template U of ItemBase
*
* @param BoxInterface<T, U> $box
*/
public function __construct(public readonly BoxInterface $box)
{
}

/**
* @template T of ItemBase
* @template U of ItemBase
*
* @param BoxInterface<T, U> $box
*
* @return T
*/
public function extractFirst(BoxInterface $box): ItemBase
{
return new ItemA();
}

/**
* @template T of ItemBase
* @template U of ItemBase
*
* @param BoxInterface<T, U> $box
*
* @return U
*/
public function extractSecond(BoxInterface $box): ItemBase
{
return new ItemB();
}

/**
* Method returning invalid type (returns ItemB when T is ItemA)
*
* @template T of ItemBase
* @template U of ItemBase
*
* @param BoxInterface<T, U> $box
*
* @return T
*/
public function extractBad(BoxInterface $box): ItemBase
{
return new ItemB();
}

/**
* @template T of ItemBase
* @template U of ItemBase
*
* @param BoxInterface<T, U> $box
*
* @return BoxInterface<T, U>
*/
public function passThrough(BoxInterface $box): BoxInterface
{
return $box;
}
}
13 changes: 13 additions & 0 deletions tests/Fixtures/Forwarding/BoxInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

/**
* @template T of ItemBase
* @template U of ItemBase
*/
interface BoxInterface
{
}
12 changes: 12 additions & 0 deletions tests/Fixtures/Forwarding/ConcreteBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

/**
* @extends AbstractBox<ConcreteBox, ItemA, ItemB>
*/
final class ConcreteBox extends AbstractBox
{
}
12 changes: 12 additions & 0 deletions tests/Fixtures/Forwarding/ItemA.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

final class ItemA implements ItemBase
{
public function __construct(public string $name = 'A')
{
}
}
12 changes: 12 additions & 0 deletions tests/Fixtures/Forwarding/ItemB.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

final class ItemB implements ItemBase
{
public function __construct(public string $name = 'B')
{
}
}
9 changes: 9 additions & 0 deletions tests/Fixtures/Forwarding/ItemBase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Forwarding;

interface ItemBase
{
}
67 changes: 67 additions & 0 deletions tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\TypeChecking\Generics;

use TypePHP\Exception\TypeError;
use TypePHP\Tests\Fixtures\Forwarding\BoxConsumer;
use TypePHP\Tests\Fixtures\Forwarding\ConcreteBox;
use TypePHP\Tests\Fixtures\Forwarding\ItemA;
use TypePHP\Tests\Fixtures\Forwarding\ItemB;
use TypePHP\TypePHP;

describe('Generic Template Forwarding Across Abstract Hierarchies with Shifted Template Indexes', function () {
test('infers method template arguments T and U from implementing class when abstract parent has extra leading template', function () {
$concreteBox = new ConcreteBox();
$consumer = new BoxConsumer($concreteBox);

expect($consumer->box)->toBe($concreteBox);
});

test('reifies bound generic types on concrete box instance matching both interface and class templates', function () {
$concreteBox = new ConcreteBox();

expect(TypePHP::getGenericType($concreteBox, 'T'))->toBe(ItemA::class)
->and(TypePHP::getGenericType($concreteBox, 'U'))->toBe(ItemB::class)
;
});

describe('Return Type Contracts with Forwarded Generics', function () {
test('validates @return T correctly uses inferred ItemA', function () {
$concreteBox = new ConcreteBox();
$consumer = new BoxConsumer($concreteBox);

$first = $consumer->extractFirst($concreteBox);
expect($first)->toBeInstanceOf(ItemA::class);
});

test('validates @return U correctly uses inferred ItemB', function () {
$concreteBox = new ConcreteBox();
$consumer = new BoxConsumer($concreteBox);

$second = $consumer->extractSecond($concreteBox);
expect($second)->toBeInstanceOf(ItemB::class);
});

test('throws TypeError when @return T returns ItemB instead of inferred ItemA', function () {
$concreteBox = new ConcreteBox();
$consumer = new BoxConsumer($concreteBox);

expect(fn () => $consumer->extractBad($concreteBox))
->toThrow(
TypeError::class,
'Return value must be of type ' . ItemA::class . ', ' . ItemB::class . ' returned'
)
;
});

test('validates @return BoxInterface<T, U> returning the forwarded concrete instance', function () {
$concreteBox = new ConcreteBox();
$consumer = new BoxConsumer($concreteBox);

$result = $consumer->passThrough($concreteBox);
expect($result)->toBe($concreteBox);
});
});
});
Loading