Skip to content

Commit c021975

Browse files
SanderMullerclaude
andcommitted
Generalize an inferred template type by what a conditional branch knows about it
In the else branch of `(TNewKey is \UnitEnum ? array-key : TNewKey)` with TNewKey of array-key|\UnitEnum, the branch knows TNewKey is an array-key. Since the branches hold NarrowedSubjectType references, the resolver traversed into them and generalized the inferred type against the subject's own bound, which is not scalar, so a literal key such as 'foo' became string. 2.2.14 kept 'foo'. The resolver now resolves the subject inside a NarrowedSubjectType without generalizing it, and generalizes the narrowed result against the reference, whose bound is the narrowed one. A template type in the target stays generalized, as the condition sees it: otherwise `(T is U ? T : int)` narrowed 'foo' & 'bar' to never. With featureToggles.unresolvedTemplateArguments (bleedingEdge) the inferred template arguments are solved after the call instead of generalized here, so the test runs without bleedingEdge: NarrowedSubjectInferredLiteralTest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0e156a6 commit c021975

5 files changed

Lines changed: 368 additions & 96 deletions

File tree

‎src/Reflection/ResolvedFunctionVariantWithOriginal.php‎

Lines changed: 83 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\Type\Generic\TemplateType;
1414
use PHPStan\Type\Generic\TemplateTypeHelper;
1515
use PHPStan\Type\Generic\TemplateTypeMap;
16+
use PHPStan\Type\Generic\TemplateTypeReference;
1617
use PHPStan\Type\Generic\TemplateTypeVariance;
1718
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
1819
use PHPStan\Type\Generic\UnresolvedTemplateArgumentType;
@@ -290,51 +291,7 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
290291
}
291292
}
292293

293-
$objectCb = function (Type $type, callable $traverse) use ($references, $onlyCovariant, $site, $frame, $allowUnresolved): Type {
294-
if (
295-
$type instanceof TemplateType
296-
&& !$type instanceof NarrowedSubjectType
297-
&& !$type->isArgument()
298-
&& $type->getScope()->getFunctionName() !== null
299-
) {
300-
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
301-
if ($newType === null || $newType instanceof ErrorType) {
302-
return $traverse($type);
303-
}
304-
305-
if ($site !== null && $frame !== null) {
306-
$newType = $this->unresolvedOrResolvedTemplateArgument($type, $newType, $site, $frame, $allowUnresolved, $onlyCovariant[$type->getName()] ?? false);
307-
} else {
308-
$newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType);
309-
}
310-
$variance = TemplateTypeVariance::createInvariant();
311-
foreach ($references as $reference) {
312-
// this uses identity to distinguish between different occurrences of the same template type
313-
// see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details
314-
if ($reference->getType() === $type) {
315-
$variance = $reference->getPositionVariance();
316-
break;
317-
}
318-
}
319-
320-
$callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName());
321-
if ($callSiteVariance === null || $callSiteVariance->invariant()) {
322-
return $newType;
323-
}
324-
325-
if (!$callSiteVariance->covariant() && $variance->covariant()) {
326-
return $traverse($type->getBound());
327-
}
328-
329-
if (!$callSiteVariance->contravariant() && $variance->contravariant()) {
330-
return new NonAcceptingNeverType();
331-
}
332-
333-
return $newType;
334-
}
335-
336-
return $traverse($type);
337-
};
294+
$objectCb = fn (Type $type, callable $traverse): Type => $this->resolveTemplateTypeInGenericType($type, $traverse, $references, $onlyCovariant, $site, $frame, $allowUnresolved, null);
338295

339296
return TypeTraverser::map($type, function (Type $type, callable $traverse) use ($references, $objectCb): Type {
340297
if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) {
@@ -383,6 +340,87 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
383340
});
384341
}
385342

343+
/**
344+
* @param callable(Type): Type $traverse
345+
* @param list<TemplateTypeReference> $references
346+
* @param array<string, bool> $onlyCovariant
347+
* @param string|null $keepInferredName a template type whose inferred type is not generalized here
348+
*/
349+
private function resolveTemplateTypeInGenericType(
350+
Type $type,
351+
callable $traverse,
352+
array $references,
353+
array $onlyCovariant,
354+
?Expr $site,
355+
?TemplateArgumentFrame $frame,
356+
bool $allowUnresolved,
357+
?string $keepInferredName,
358+
): Type
359+
{
360+
if (
361+
$type instanceof NarrowedSubjectType
362+
&& $keepInferredName === null
363+
&& !$type->isArgument()
364+
&& $type->getScope()->getFunctionName() !== null
365+
) {
366+
// generalize what the branch knows about the subject, not the subject: in the
367+
// else branch of `(T is \UnitEnum ? array-key : T)` with T of array-key|\UnitEnum,
368+
// T is a scalar and an inferred 'foo' stays 'foo'. Only the subject is taken as
369+
// inferred - a template type in the target is generalized as the condition sees it.
370+
$subjectName = $type->getName();
371+
$resolved = TypeTraverser::map($type, fn (Type $type, callable $traverse): Type => $this->resolveTemplateTypeInGenericType($type, $traverse, $references, $onlyCovariant, $site, $frame, $allowUnresolved, $subjectName));
372+
373+
return TemplateTypeHelper::generalizeInferredTemplateType($type, $resolved);
374+
}
375+
376+
if (
377+
$type instanceof TemplateType
378+
&& !$type instanceof NarrowedSubjectType
379+
&& !$type->isArgument()
380+
&& $type->getScope()->getFunctionName() !== null
381+
) {
382+
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
383+
if ($newType === null || $newType instanceof ErrorType) {
384+
return $traverse($type);
385+
}
386+
387+
// the subject of a narrowed subject is generalized as a whole by the caller
388+
if ($type->getName() !== $keepInferredName) {
389+
if ($site !== null && $frame !== null) {
390+
$newType = $this->unresolvedOrResolvedTemplateArgument($type, $newType, $site, $frame, $allowUnresolved, $onlyCovariant[$type->getName()] ?? false);
391+
} else {
392+
$newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType);
393+
}
394+
}
395+
$variance = TemplateTypeVariance::createInvariant();
396+
foreach ($references as $reference) {
397+
// this uses identity to distinguish between different occurrences of the same template type
398+
// see https://github.com/phpstan/phpstan-src/pull/2485#discussion_r1328555397 for details
399+
if ($reference->getType() === $type) {
400+
$variance = $reference->getPositionVariance();
401+
break;
402+
}
403+
}
404+
405+
$callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName());
406+
if ($callSiteVariance === null || $callSiteVariance->invariant()) {
407+
return $newType;
408+
}
409+
410+
if (!$callSiteVariance->covariant() && $variance->covariant()) {
411+
return $traverse($type->getBound());
412+
}
413+
414+
if (!$callSiteVariance->contravariant() && $variance->contravariant()) {
415+
return new NonAcceptingNeverType();
416+
}
417+
418+
return $newType;
419+
}
420+
421+
return $traverse($type);
422+
}
423+
386424
/**
387425
* An inferred template argument inside a generic object of the return type:
388426
* during the body's observation pass a marker keyed by the call (an inferred
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PHPStan\Testing\TypeInferenceTestCase;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
use PHPUnit\Framework\Attributes\RequiresPhp;
8+
9+
/**
10+
* Without bleedingEdge: the inferred template arguments are generalized right away
11+
* (featureToggles.unresolvedTemplateArguments is off), which is where a conditional
12+
* branch's subject has to keep what the branch knows about it.
13+
*/
14+
#[RequiresPhp('>= 8.1')]
15+
class NarrowedSubjectInferredLiteralTest extends TypeInferenceTestCase
16+
{
17+
18+
public static function dataFileAsserts(): iterable
19+
{
20+
yield from self::gatherAssertTypes(__DIR__ . '/data/narrowed-subject-inferred-literal.php');
21+
}
22+
23+
/**
24+
* @param mixed ...$args
25+
*/
26+
#[DataProvider('dataFileAsserts')]
27+
public function testFileAsserts(
28+
string $assertType,
29+
string $file,
30+
...$args,
31+
): void
32+
{
33+
$this->assertFileAsserts($assertType, $file, ...$args);
34+
}
35+
36+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace NarrowedSubjectInferredLiteral;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
class User
10+
{
11+
12+
}
13+
14+
enum Digit: int
15+
{
16+
17+
case One = 1;
18+
19+
}
20+
21+
/**
22+
* @template TKey of array-key
23+
* @template TValue
24+
*/
25+
class Collection
26+
{
27+
28+
/**
29+
* @template TNewKey of array-key|\UnitEnum
30+
*
31+
* @param (callable(TValue, TKey): TNewKey)|array<mixed>|string $keyBy
32+
* @return static<($keyBy is (array|string) ? array-key : (TNewKey is \UnitEnum ? array-key : TNewKey)), TValue>
33+
*/
34+
public function keyBy($keyBy)
35+
{
36+
throw new \Exception();
37+
}
38+
39+
/**
40+
* @template TNewKey of array-key|\UnitEnum
41+
*
42+
* @param callable(TValue, TKey): TNewKey $keyBy
43+
* @return static<(TNewKey is \UnitEnum ? array-key : TNewKey), TValue>
44+
*/
45+
public function keyByCallback($keyBy)
46+
{
47+
throw new \Exception();
48+
}
49+
50+
}
51+
52+
/**
53+
* @template T of array-key
54+
* @template U of array-key
55+
* @param T $a
56+
* @param U $b
57+
* @return Collection<(T is U ? T : int), int>
58+
*/
59+
function templateTarget($a, $b)
60+
{
61+
throw new \Exception();
62+
}
63+
64+
/**
65+
* @template T of array-key
66+
* @template U of array-key
67+
* @param T $a
68+
* @param U $b
69+
* @return Collection<(T is not U ? int : T), int>
70+
*/
71+
function negatedTemplateTarget($a, $b)
72+
{
73+
throw new \Exception();
74+
}
75+
76+
/** @param Collection<int, User> $collection */
77+
function test(Collection $collection): void
78+
{
79+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyBy(fn ($user) => 'foo'));
80+
assertType('NarrowedSubjectInferredLiteral\\Collection<0, NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user): int => 0));
81+
assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy('name'));
82+
assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user) => Digit::One));
83+
84+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyByCallback(fn ($user) => 'foo'));
85+
assertType('NarrowedSubjectInferredLiteral\\Collection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyByCallback(static fn ($user) => Digit::One));
86+
87+
// the condition compares the generalized T and U, the branch keeps the inferred T
88+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', int>", templateTarget('foo', 'bar'));
89+
assertType("NarrowedSubjectInferredLiteral\\Collection<'foo', int>", negatedTemplateTarget('foo', 'bar'));
90+
}
91+
92+
/**
93+
* @template TKey of array-key
94+
* @template TValue
95+
*/
96+
interface Enumerable
97+
{
98+
99+
/**
100+
* @template TNewKey of array-key|\UnitEnum
101+
*
102+
* @param (callable(TValue, TKey): TNewKey)|array<mixed>|string $keyBy
103+
* @return static<($keyBy is (array|string) ? array-key : (TNewKey is \UnitEnum ? array-key : TNewKey)), TValue>
104+
*/
105+
public function keyBy($keyBy);
106+
107+
}
108+
109+
/**
110+
* @template TKey of array-key
111+
* @template TValue
112+
* @implements Enumerable<TKey, TValue>
113+
*/
114+
class InheritedCollection implements Enumerable
115+
{
116+
117+
/**
118+
* {@inheritDoc}
119+
*/
120+
public function keyBy($keyBy)
121+
{
122+
throw new \Exception();
123+
}
124+
125+
}
126+
127+
/** @param InheritedCollection<int, User> $collection */
128+
function testInherited(InheritedCollection $collection): void
129+
{
130+
assertType("NarrowedSubjectInferredLiteral\\InheritedCollection<'foo', NarrowedSubjectInferredLiteral\\User>", $collection->keyBy(function ($user, $int) {
131+
return 'foo';
132+
}));
133+
assertType('NarrowedSubjectInferredLiteral\\InheritedCollection<0, NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user): int => 0));
134+
assertType('NarrowedSubjectInferredLiteral\\InheritedCollection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy('name'));
135+
assertType('NarrowedSubjectInferredLiteral\\InheritedCollection<(int|string), NarrowedSubjectInferredLiteral\\User>', $collection->keyBy(static fn ($user) => Digit::One));
136+
}

0 commit comments

Comments
 (0)