Skip to content

Commit 3fa8853

Browse files
committed
Fix mutable scalar type refinement in return generic contracts
1 parent 931d5f4 commit 3fa8853

4 files changed

Lines changed: 114 additions & 11 deletions

File tree

src/Internal/StreamWrapper.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,14 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
219219

220220
self::unregister();
221221

222-
$exists = (bool) self::silent(fn() => file_exists($path));
223-
$resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false;
222+
$exists = (bool) self::silent(fn () => file_exists($path));
223+
$resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false;
224224

225225
if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) {
226226
$target = ($resolvedPath !== false) ? $resolvedPath : $path;
227227
/** @var resource|false $handle */
228228
$handle = self::silent(
229-
fn() => ($this->context !== null)
229+
fn () => ($this->context !== null)
230230
? fopen($target, $mode, false, $this->context)
231231
: fopen($target, $mode)
232232
);
@@ -270,7 +270,7 @@ private function openDirectHandle(string $targetFile, string $mode, int $options
270270
self::unregister();
271271
/** @var resource|false $handle */
272272
$handle = self::silent(
273-
fn() => ($this->context !== null)
273+
fn () => ($this->context !== null)
274274
? fopen($targetFile, $mode, $useIncludePath, $this->context)
275275
: fopen($targetFile, $mode, $useIncludePath)
276276
);
@@ -425,7 +425,7 @@ public function url_stat(string $path, int $flags): array|false
425425

426426
self::unregister();
427427
/** @var array<int|string, int>|false $result */
428-
$result = self::silent(fn() => $isLink ? @lstat($path) : @stat($path));
428+
$result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path));
429429
self::register();
430430

431431
if ($result !== false) {
@@ -460,11 +460,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool
460460
$valueArray = \is_array($value) ? $value : [];
461461
$time = $valueArray[0] ?? time();
462462
$atime = $valueArray[1] ?? $time;
463-
$result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime));
463+
$result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime));
464464
} elseif ($option === STREAM_META_ACCESS) {
465465
/** @var int $mode */
466466
$mode = \is_int($value) ? $value : 0777;
467-
$result = (bool) self::silent(fn() => @chmod($path, $mode));
467+
$result = (bool) self::silent(fn () => @chmod($path, $mode));
468468
}
469469
self::register();
470470

@@ -476,7 +476,7 @@ public function dir_opendir(string $path, int $options): bool
476476
self::unregister();
477477
/** @var resource|false $dh */
478478
$dh = self::silent(
479-
fn() => ($this->context !== null)
479+
fn () => ($this->context !== null)
480480
? @opendir($path, $this->context)
481481
: @opendir($path)
482482
);
@@ -603,7 +603,7 @@ public function rename(string $pathFrom, string $pathTo): bool
603603
*/
604604
private static function silent(callable $callback): mixed
605605
{
606-
set_error_handler(static fn() => true);
606+
set_error_handler(static fn () => true);
607607

608608
try {
609609
return $callback();

src/Resolver/TemplateManager.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,16 @@ private static function bindSingleTemplateArgument(
627627
$valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance);
628628

629629
if (! $valid) {
630+
// In return context, allow methods to specialize broad bounds to narrower types
631+
// (e.g. @return static<int, TValue> specializing TKey of array-key to int)
632+
if ($isReturnContext && self::checkVariance($expectedTypeNode, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT)) {
633+
$bindings = self::$instanceTemplateBindings[$instance] ?? [];
634+
$bindings[$templateName] = $expectedTypeNode;
635+
self::$instanceTemplateBindings[$instance] = $bindings;
636+
637+
return null;
638+
}
639+
630640
return ErrorFactory::createError(
631641
$context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given"
632642
);

tests/Internal/StreamWrapperTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ function testIgnoredFileFunc(int $id): int
115115
file_put_contents($targetFile, '<?php // target file');
116116

117117
$symlinkCreated = false;
118+
118119
try {
119120
$symlinkCreated = @symlink($targetFile, $linkFile);
120-
} catch (\Throwable $e) {
121+
} catch (Throwable $e) {
121122
// Windows without developer mode may disallow symlinks
122123
}
123124

@@ -569,4 +570,4 @@ function dedicatedStreamAction(int $id): int
569570
}
570571
});
571572
});
572-
});
573+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Exception\TypeError;
6+
use TypePHP\Tests\Fixtures\Domain\Car;
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
use TypePHP\TypePHP;
9+
10+
/**
11+
* Mutable collection modifying $this in place
12+
*
13+
* @template TKey of array-key = array-key
14+
* @template TValue = mixed
15+
*/
16+
class MutableSpecializedCollection
17+
{
18+
public array $items;
19+
20+
public function __construct(mixed $items = [])
21+
{
22+
$this->items = \is_array($items) ? $items : [$items];
23+
}
24+
25+
/**
26+
* Modifies $this in place and returns $this as static<int, TValue>
27+
*
28+
* @return static<int, TValue>
29+
*/
30+
public function values(): static
31+
{
32+
$this->items = array_values($this->items);
33+
34+
return $this; // Returns $this which had TKey = array-key!
35+
}
36+
37+
/**
38+
* Modifies $this in place and returns $this as static<string, TValue>
39+
*
40+
* @return static<string, TValue>
41+
*/
42+
public function stringKeys(): static
43+
{
44+
$this->items = ['key_1' => reset($this->items)];
45+
46+
return $this;
47+
}
48+
49+
/**
50+
* Method returning invalid specialization violating the existing TValue = Dog binding
51+
*
52+
* @return static<int, Car>
53+
*/
54+
public function badValueSpecialization(): static
55+
{
56+
return $this;
57+
}
58+
}
59+
60+
describe('Fluent Mutable Generic Return Type Specialization (static<NarrowerKey, TValue>)', function () {
61+
test('allows mutable methods like values() to specialize broad TKey of array-key down to int on returned $this instance', function () {
62+
/** @var MutableSpecializedCollection<array-key, string> $collection */
63+
$collection = new MutableSpecializedCollection(['first' => 'Alice', 'second' => 'Bob']);
64+
65+
$valuesResult = $collection->values();
66+
67+
expect($valuesResult)->toBe($collection)
68+
->and(TypePHP::getGenericType($valuesResult, 'TKey'))->toBe('int')
69+
->and($valuesResult->items)->toBe(['Alice', 'Bob'])
70+
;
71+
});
72+
73+
test('allows mutable methods to specialize broad TKey down to string on returned $this instance', function () {
74+
/** @var MutableSpecializedCollection<array-key, string> $collection */
75+
$collection = new MutableSpecializedCollection([0 => 'Alice']);
76+
77+
$stringKeysResult = $collection->stringKeys();
78+
79+
expect($stringKeysResult)->toBe($collection)
80+
->and(TypePHP::getGenericType($stringKeysResult, 'TKey'))->toBe('string')
81+
;
82+
});
83+
84+
test('strictly throws TypeError when return specialization violates underlying value type', function () {
85+
/** @var MutableSpecializedCollection<array-key, Dog> $collection */
86+
$collection = new MutableSpecializedCollection();
87+
88+
expect(fn () => $collection->badValueSpecialization())
89+
->toThrow(TypeError::class)
90+
;
91+
});
92+
});

0 commit comments

Comments
 (0)