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
17 changes: 16 additions & 1 deletion src/Internal/Ast/ContractVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,22 @@ private function handleAssign(Node\Expr\Assign $node): void
$typeString = $this->scopeManager->getVarTypeFromScope($varName);

if ($typeString !== null) {
$node->expr = $this->wrapVariableCheck($node->expr, $typeString, $varName, $node->var->getStartLine());
$expr = $node->expr;
if ($expr instanceof Node\Expr\New_ && str_contains($typeString, '<')) {
$expr = new Node\Expr\StaticCall(
new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'),
'withPendingGeneric',
[
new Node\Arg(new Node\Scalar\String_($typeString)),
new Node\Arg(new Node\Expr\ArrowFunction([
'expr' => $expr,
])),
new Node\Arg(new Node\Scalar\MagicConst\File()),
]
);
}

$node->expr = $this->wrapVariableCheck($expr, $typeString, $varName, $node->var->getStartLine());
}
} elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) {
$node->expr = $this->wrapPropertyCheck($node->expr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine());
Expand Down
47 changes: 47 additions & 0 deletions src/Internal/Generics/TemplateManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ final class TemplateManager
*/
private static array $isMethodTemplateCache = [];

/**
* Stack storing pending generic instantiations for constructors.
*
* @var list<array{typeString: string, file: string, targetClass: string}>
*/
private static array $pendingInstantiations = [];

/**
* Resets all static generic template bindings, call stack frames, and method template caches.
*/
Expand All @@ -375,6 +382,46 @@ public static function reset(): void
self::$pendingCloneSource = null;
self::$methodTemplatesCache = [];
self::$isMethodTemplateCache = [];
self::$pendingInstantiations = [];
}

/**
* Pushes a pending generic instantiation for a constructor.
*/
public static function pushPendingInstantiation(string $typeString, string $file): void
{
$pos = strpos($typeString, '<');
$rawClass = $pos !== false ? trim(substr($typeString, 0, $pos)) : $typeString;
$targetClass = SpecialTypeResolver::resolveFqcnForFile($rawClass, $file);

self::$pendingInstantiations[] = [
'typeString' => $typeString,
'file' => $file,
'targetClass' => $targetClass,
];
}

/**
* Pops the last pending generic instantiation for a constructor.
*/
public static function popPendingInstantiation(): void
{
array_pop(self::$pendingInstantiations);
}

/**
* Applies the last pending generic instantiation for a constructor to the given instance.
*/
public static function applyPendingInstantiation(object $instance): void
{
if (self::$pendingInstantiations === []) {
return;
}

$pending = end(self::$pendingInstantiations);
if ($pending !== false && is_a($instance, $pending['targetClass'])) {
self::bindInstance($instance, $pending['typeString'], $pending['file']);
}
}

/**
Expand Down
12 changes: 6 additions & 6 deletions src/Internal/Resolver/SpecialTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct

if ($node instanceof GenericTypeNode) {
$genericType = self::resolve($node->type, $context, $thisObj);
$innerTypes = array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->genericTypes);
$innerTypes = array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->genericTypes);

return new GenericTypeNode(
$genericType instanceof IdentifierTypeNode ? $genericType : $node->type,
Expand Down Expand Up @@ -261,11 +261,11 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
}

if ($node instanceof UnionTypeNode) {
return new UnionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types));
return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types));
}

if ($node instanceof IntersectionTypeNode) {
return new IntersectionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types));
return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types));
}

return $node;
Expand Down Expand Up @@ -299,7 +299,7 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode

if ($node instanceof GenericTypeNode) {
$genericType = self::resolveForFile($node->type, $file);
$innerTypes = array_map(fn($t) => self::resolveForFile($t, $file), $node->genericTypes);
$innerTypes = array_map(fn ($t) => self::resolveForFile($t, $file), $node->genericTypes);

return new GenericTypeNode(
$genericType instanceof IdentifierTypeNode ? $genericType : $node->type,
Expand Down Expand Up @@ -353,11 +353,11 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode
}

if ($node instanceof UnionTypeNode) {
return new UnionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types));
return new UnionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types));
}

if ($node instanceof IntersectionTypeNode) {
return new IntersectionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types));
return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types));
}

return clone $node;
Expand Down
20 changes: 20 additions & 0 deletions src/Internal/RuntimeTypeChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ public static function isEnabled(): bool
return Config::isEnabled();
}

/**
* Pre-binds generic template state on a class before its constructor executes.
*
* @param \Closure(): mixed $factory
*/
public static function withPendingGeneric(string $typeString, \Closure $factory, string $file = ''): mixed
{
TemplateManager::pushPendingInstantiation($typeString, $file);

try {
return $factory();
} finally {
TemplateManager::popPendingInstantiation();
}
}

/**
* Delegates generic template binding for class instances.
*/
Expand Down Expand Up @@ -146,6 +162,10 @@ public static function setupScope(string $function, array $vars, object|string|n
return null;
}

if ($thisObj !== null && str_ends_with($effectiveFunction, '::__construct')) {
TemplateManager::applyPendingInstantiation($thisObj);
}

if (
isset(ParamChecker::$noParamContractCache[$function])
&& ! (self::$hasMethodTemplatesCache[$function] ?? false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,10 @@ function fetchBroadTuple(int $id, string $name): array

describe('mixed type validation with @var and param', function () {
test('enforces stricter inline @var annotation over broader function return contract', function () {
// Valid call: [10, 'Alice'] satisfies both @return and @var
/** @var array{0: positive-int, 1: non-empty-string} $userData */
$userData = fetchBroadTuple(10, 'Alice');
expect($userData[0])->toBe(10);

// Invalid call: [-5, 'Alice'] satisfies @return (int), BUT violates @var (positive-int)
expect(function () {
/** @var array{0: positive-int, 1: non-empty-string} $userData */
$userData = fetchBroadTuple(-5, 'Alice');
Expand Down Expand Up @@ -202,6 +200,11 @@ function fetchBroadTuple(int $id, string $name): array
expect($producer->item)->toBeInstanceOf(Cat::class);

expect(fn () => $producer = new Producer(new Car()))
->toThrow(TypeError::class, 'Argument $item (template T =')
;

$carProducer = new Producer(new Car());
expect(fn () => $producer = $carProducer)
->toThrow(TypeError::class, 'Variable $producer')
;
});
Expand Down
84 changes: 84 additions & 0 deletions tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\TypeChecking\Generics;

use TypePHP\Exception\TypeError;
use TypePHP\TypePHP;

class ConstructorPrebindAnimal
{
}

class ConstructorPrebindDog extends ConstructorPrebindAnimal
{
}

class ConstructorPrebindCat extends ConstructorPrebindAnimal
{
}

class ConstructorPrebindCar
{
}

/**
* @template T
*/
class ConstructorPrebindBox
{
/**
* @param T[] $content
*/
public function __construct(public array $content)
{
}

/**
* @return T[]
*/
public function getContent(): array
{
return $this->content;
}
}

describe('Constructor Generic Pre-binding with Inline @var Annotation', function () {
test('prebinds generic template to instance before constructor executes and rejects invalid items', function () {
expect(function () {
/** @var ConstructorPrebindBox<ConstructorPrebindAnimal> $box */
$box = new ConstructorPrebindBox([1, 2, '3']);
})->toThrow(
TypeError::class,
'Argument $content[0] (template T = TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal) must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal'
);
});

test('accepts valid items matching pre-bound template in constructor', function () {
$dog = new ConstructorPrebindDog();
$cat = new ConstructorPrebindCat();

/** @var ConstructorPrebindBox<ConstructorPrebindAnimal> $box */
$box = new ConstructorPrebindBox([$dog, $cat]);

expect($box->getContent())->toHaveCount(2)
->and($box->getContent()[0])->toBe($dog)
->and($box->getContent()[1])->toBe($cat)
->and(TypePHP::getGenericType($box))->toBe(ConstructorPrebindAnimal::class)
;
});

test('rejects items violating pre-bound template in constructor even if first item is a valid subtype', function () {
$dog = new ConstructorPrebindDog();
$car = new ConstructorPrebindCar();

expect(function () use ($dog, $car) {
/** @var ConstructorPrebindBox<ConstructorPrebindAnimal> $box */
$box = new ConstructorPrebindBox([$dog, $car]);
})->toThrow(
TypeError::class,
'Argument $content[1] (template T = TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal) must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal'
);
});
});
Loading