Skip to content

Commit ed213ec

Browse files
authored
Implement constructor generic pre-binding and enhance runtime type checking (#73)
1 parent 85e53a7 commit ed213ec

6 files changed

Lines changed: 178 additions & 9 deletions

File tree

src/Internal/Ast/ContractVisitor.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,22 @@ private function handleAssign(Node\Expr\Assign $node): void
301301
$typeString = $this->scopeManager->getVarTypeFromScope($varName);
302302

303303
if ($typeString !== null) {
304-
$node->expr = $this->wrapVariableCheck($node->expr, $typeString, $varName, $node->var->getStartLine());
304+
$expr = $node->expr;
305+
if ($expr instanceof Node\Expr\New_ && str_contains($typeString, '<')) {
306+
$expr = new Node\Expr\StaticCall(
307+
new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'),
308+
'withPendingGeneric',
309+
[
310+
new Node\Arg(new Node\Scalar\String_($typeString)),
311+
new Node\Arg(new Node\Expr\ArrowFunction([
312+
'expr' => $expr,
313+
])),
314+
new Node\Arg(new Node\Scalar\MagicConst\File()),
315+
]
316+
);
317+
}
318+
319+
$node->expr = $this->wrapVariableCheck($expr, $typeString, $varName, $node->var->getStartLine());
305320
}
306321
} elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) {
307322
$node->expr = $this->wrapPropertyCheck($node->expr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine());

src/Internal/Generics/TemplateManager.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,13 @@ final class TemplateManager
362362
*/
363363
private static array $isMethodTemplateCache = [];
364364

365+
/**
366+
* Stack storing pending generic instantiations for constructors.
367+
*
368+
* @var list<array{typeString: string, file: string, targetClass: string}>
369+
*/
370+
private static array $pendingInstantiations = [];
371+
365372
/**
366373
* Resets all static generic template bindings, call stack frames, and method template caches.
367374
*/
@@ -375,6 +382,46 @@ public static function reset(): void
375382
self::$pendingCloneSource = null;
376383
self::$methodTemplatesCache = [];
377384
self::$isMethodTemplateCache = [];
385+
self::$pendingInstantiations = [];
386+
}
387+
388+
/**
389+
* Pushes a pending generic instantiation for a constructor.
390+
*/
391+
public static function pushPendingInstantiation(string $typeString, string $file): void
392+
{
393+
$pos = strpos($typeString, '<');
394+
$rawClass = $pos !== false ? trim(substr($typeString, 0, $pos)) : $typeString;
395+
$targetClass = SpecialTypeResolver::resolveFqcnForFile($rawClass, $file);
396+
397+
self::$pendingInstantiations[] = [
398+
'typeString' => $typeString,
399+
'file' => $file,
400+
'targetClass' => $targetClass,
401+
];
402+
}
403+
404+
/**
405+
* Pops the last pending generic instantiation for a constructor.
406+
*/
407+
public static function popPendingInstantiation(): void
408+
{
409+
array_pop(self::$pendingInstantiations);
410+
}
411+
412+
/**
413+
* Applies the last pending generic instantiation for a constructor to the given instance.
414+
*/
415+
public static function applyPendingInstantiation(object $instance): void
416+
{
417+
if (self::$pendingInstantiations === []) {
418+
return;
419+
}
420+
421+
$pending = end(self::$pendingInstantiations);
422+
if ($pending !== false && is_a($instance, $pending['targetClass'])) {
423+
self::bindInstance($instance, $pending['typeString'], $pending['file']);
424+
}
378425
}
379426

380427
/**

src/Internal/Resolver/SpecialTypeResolver.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
207207

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

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

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

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

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

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

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

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

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

363363
return clone $node;

src/Internal/RuntimeTypeChecker.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ public static function isEnabled(): bool
5555
return Config::isEnabled();
5656
}
5757

58+
/**
59+
* Pre-binds generic template state on a class before its constructor executes.
60+
*
61+
* @param \Closure(): mixed $factory
62+
*/
63+
public static function withPendingGeneric(string $typeString, \Closure $factory, string $file = ''): mixed
64+
{
65+
TemplateManager::pushPendingInstantiation($typeString, $file);
66+
67+
try {
68+
return $factory();
69+
} finally {
70+
TemplateManager::popPendingInstantiation();
71+
}
72+
}
73+
5874
/**
5975
* Delegates generic template binding for class instances.
6076
*/
@@ -146,6 +162,10 @@ public static function setupScope(string $function, array $vars, object|string|n
146162
return null;
147163
}
148164

165+
if ($thisObj !== null && str_ends_with($effectiveFunction, '::__construct')) {
166+
TemplateManager::applyPendingInstantiation($thisObj);
167+
}
168+
149169
if (
150170
isset(ParamChecker::$noParamContractCache[$function])
151171
&& ! (self::$hasMethodTemplatesCache[$function] ?? false)

tests/TypeChecking/Boundaries/InlineVariableValidationTest.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,10 @@ function fetchBroadTuple(int $id, string $name): array
3939

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

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

204202
expect(fn () => $producer = new Producer(new Car()))
203+
->toThrow(TypeError::class, 'Argument $item (template T =')
204+
;
205+
206+
$carProducer = new Producer(new Car());
207+
expect(fn () => $producer = $carProducer)
205208
->toThrow(TypeError::class, 'Variable $producer')
206209
;
207210
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\TypeChecking\Generics;
6+
7+
use TypePHP\Exception\TypeError;
8+
use TypePHP\TypePHP;
9+
10+
class ConstructorPrebindAnimal
11+
{
12+
}
13+
14+
class ConstructorPrebindDog extends ConstructorPrebindAnimal
15+
{
16+
}
17+
18+
class ConstructorPrebindCat extends ConstructorPrebindAnimal
19+
{
20+
}
21+
22+
class ConstructorPrebindCar
23+
{
24+
}
25+
26+
/**
27+
* @template T
28+
*/
29+
class ConstructorPrebindBox
30+
{
31+
/**
32+
* @param T[] $content
33+
*/
34+
public function __construct(public array $content)
35+
{
36+
}
37+
38+
/**
39+
* @return T[]
40+
*/
41+
public function getContent(): array
42+
{
43+
return $this->content;
44+
}
45+
}
46+
47+
describe('Constructor Generic Pre-binding with Inline @var Annotation', function () {
48+
test('prebinds generic template to instance before constructor executes and rejects invalid items', function () {
49+
expect(function () {
50+
/** @var ConstructorPrebindBox<ConstructorPrebindAnimal> $box */
51+
$box = new ConstructorPrebindBox([1, 2, '3']);
52+
})->toThrow(
53+
TypeError::class,
54+
'Argument $content[0] (template T = TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal) must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal'
55+
);
56+
});
57+
58+
test('accepts valid items matching pre-bound template in constructor', function () {
59+
$dog = new ConstructorPrebindDog();
60+
$cat = new ConstructorPrebindCat();
61+
62+
/** @var ConstructorPrebindBox<ConstructorPrebindAnimal> $box */
63+
$box = new ConstructorPrebindBox([$dog, $cat]);
64+
65+
expect($box->getContent())->toHaveCount(2)
66+
->and($box->getContent()[0])->toBe($dog)
67+
->and($box->getContent()[1])->toBe($cat)
68+
->and(TypePHP::getGenericType($box))->toBe(ConstructorPrebindAnimal::class)
69+
;
70+
});
71+
72+
test('rejects items violating pre-bound template in constructor even if first item is a valid subtype', function () {
73+
$dog = new ConstructorPrebindDog();
74+
$car = new ConstructorPrebindCar();
75+
76+
expect(function () use ($dog, $car) {
77+
/** @var ConstructorPrebindBox<ConstructorPrebindAnimal> $box */
78+
$box = new ConstructorPrebindBox([$dog, $car]);
79+
})->toThrow(
80+
TypeError::class,
81+
'Argument $content[1] (template T = TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal) must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal'
82+
);
83+
});
84+
});

0 commit comments

Comments
 (0)