Skip to content

Commit b2cb812

Browse files
github-actions[bot]ondrejmirtes
authored andcommitted
Fix phpstan/phpstan#12038: anonymous polymorphic functions not composable
Two issues prevented generic callables from composing correctly: 1. inferTemplateTypesOnParametersAcceptor in CallableType/ClosureType eagerly resolved inner callable template types to ErrorType (then mixed) when the outer callable's parameters were themselves TemplateTypes. Now preserves original unresolved inner templates when their names don't collide with outer template names. 2. GenericParametersAcceptorResolver::resolve collapsed all positional arguments into one map entry when PHPDoc callable parameters had empty names (e.g. callable(X, Y): Z). Now assigns unique synthetic names to unnamed parameters.
1 parent 0bbe115 commit b2cb812

File tree

4 files changed

+195
-9
lines changed

4 files changed

+195
-9
lines changed

src/Reflection/GenericParametersAcceptorResolver.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,23 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
3232
$passedArgs = [];
3333

3434
$parameters = $parametersAcceptor->getParameters();
35+
36+
// Build a name map that handles unnamed parameters (e.g. from PHPDoc callables)
37+
// by assigning unique synthetic names to avoid collisions
38+
$paramNameMap = [];
39+
foreach ($parameters as $idx => $param) {
40+
$name = $param->getName();
41+
if ($name === '' || isset($paramNameMap[$name])) {
42+
$name = '__param_' . $idx;
43+
}
44+
$paramNameMap[$idx] = $name;
45+
}
46+
3547
$namedArgTypes = [];
3648
foreach ($argTypes as $i => $argType) {
3749
if (is_int($i)) {
3850
if (isset($parameters[$i])) {
39-
$namedArgTypes[$parameters[$i]->getName()] = $argType;
51+
$namedArgTypes[$paramNameMap[$i]] = $argType;
4052
continue;
4153
}
4254
if (count($parameters) > 0) {
@@ -56,8 +68,11 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
5668
$namedArgTypes[$i] = $argType;
5769
}
5870

59-
foreach ($parameters as $param) {
60-
if (isset($namedArgTypes[$param->getName()])) {
71+
foreach ($parameters as $idx => $param) {
72+
$lookupName = $paramNameMap[$idx] ?? $param->getName();
73+
if (isset($namedArgTypes[$lookupName])) {
74+
$argType = $namedArgTypes[$lookupName];
75+
} elseif (isset($namedArgTypes[$param->getName()])) {
6176
$argType = $namedArgTypes[$param->getName()];
6277
} elseif ($param->getDefaultValue() !== null) {
6378
$argType = $param->getDefaultValue();

src/Type/CallableType.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -500,9 +500,49 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
500500
private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap
501501
{
502502
$parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters());
503-
$parametersAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false);
504-
$args = $parametersAcceptor->getParameters();
505-
$returnType = $parametersAcceptor->getReturnType();
503+
$resolvedAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false);
504+
505+
// If the inner callable had template types that couldn't be resolved
506+
// (mapped to ErrorType), use the original unresolved parameters to
507+
// preserve template types through composition (e.g. flip(zip(...)))
508+
// If the inner callable had template types that couldn't be resolved
509+
// (mapped to ErrorType), use the original unresolved parameters to
510+
// preserve template types through composition (e.g. flip(zip(...)))
511+
// But only when inner template names don't collide with outer ones,
512+
// to avoid cross-resolution issues.
513+
$useOriginal = false;
514+
if ($parametersAcceptor->getTemplateTypeMap()->count() > 0) {
515+
$hasUnresolved = false;
516+
foreach ($resolvedAcceptor->getResolvedTemplateTypeMap()->getTypes() as $type) {
517+
if ($type instanceof ErrorType) {
518+
$hasUnresolved = true;
519+
break;
520+
}
521+
}
522+
if ($hasUnresolved) {
523+
$outerTemplateNames = [];
524+
foreach ($this->getParameters() as $param) {
525+
foreach ($param->getType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) {
526+
$outerTemplateNames[$ref->getType()->getName()] = true;
527+
}
528+
}
529+
foreach ($this->getReturnType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) {
530+
$outerTemplateNames[$ref->getType()->getName()] = true;
531+
}
532+
$hasCollision = false;
533+
foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $name => $type) {
534+
if (isset($outerTemplateNames[$name])) {
535+
$hasCollision = true;
536+
break;
537+
}
538+
}
539+
$useOriginal = !$hasCollision;
540+
}
541+
}
542+
543+
$acceptor = $useOriginal ? $parametersAcceptor : $resolvedAcceptor;
544+
$args = $acceptor->getParameters();
545+
$returnType = $acceptor->getReturnType();
506546

507547
$typeMap = TemplateTypeMap::createEmpty();
508548
foreach ($this->getParameters() as $i => $param) {

src/Type/ClosureType.php

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -596,9 +596,46 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
596596
private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap
597597
{
598598
$parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters());
599-
$parametersAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false);
600-
$args = $parametersAcceptor->getParameters();
601-
$returnType = $parametersAcceptor->getReturnType();
599+
$resolvedAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false);
600+
601+
// If the inner callable had template types that couldn't be resolved
602+
// (mapped to ErrorType), use the original unresolved parameters to
603+
// preserve template types through composition (e.g. flip(zip(...)))
604+
// But only when inner template names don't collide with outer ones,
605+
// to avoid cross-resolution issues.
606+
$useOriginal = false;
607+
if ($parametersAcceptor->getTemplateTypeMap()->count() > 0) {
608+
$hasUnresolved = false;
609+
foreach ($resolvedAcceptor->getResolvedTemplateTypeMap()->getTypes() as $type) {
610+
if ($type instanceof ErrorType) {
611+
$hasUnresolved = true;
612+
break;
613+
}
614+
}
615+
if ($hasUnresolved) {
616+
$outerTemplateNames = [];
617+
foreach ($this->getParameters() as $param) {
618+
foreach ($param->getType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) {
619+
$outerTemplateNames[$ref->getType()->getName()] = true;
620+
}
621+
}
622+
foreach ($this->getReturnType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) {
623+
$outerTemplateNames[$ref->getType()->getName()] = true;
624+
}
625+
$hasCollision = false;
626+
foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $name => $type) {
627+
if (isset($outerTemplateNames[$name])) {
628+
$hasCollision = true;
629+
break;
630+
}
631+
}
632+
$useOriginal = !$hasCollision;
633+
}
634+
}
635+
636+
$acceptor = $useOriginal ? $parametersAcceptor : $resolvedAcceptor;
637+
$args = $acceptor->getParameters();
638+
$returnType = $acceptor->getReturnType();
602639

603640
$typeMap = TemplateTypeMap::createEmpty();
604641
foreach ($this->getParameters() as $i => $param) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug12038;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @template X
11+
* @template Y
12+
* @template Z
13+
*
14+
* @param callable(X, Y): Z $fn
15+
* @return callable(Y, X): Z
16+
*/
17+
function flip(callable $fn): callable
18+
{
19+
return fn ($y, $x) => $fn($x, $y);
20+
}
21+
22+
/**
23+
* @template A
24+
* @template B
25+
*
26+
* @param list<A> $fa
27+
* @param list<B> $fb
28+
* @return list<array{A, B}>
29+
*/
30+
function zip(array $fa, array $fb): array
31+
{
32+
$length = min(count($fa), count($fb));
33+
$zipped = [];
34+
35+
for ($i = 0; $i < $length; $i++) {
36+
$zipped[] = [$fa[$i], $fb[$i]];
37+
}
38+
39+
return $zipped;
40+
}
41+
42+
/**
43+
* @template A
44+
* @template B
45+
* @template C
46+
*
47+
* @param callable(A): B $ab
48+
* @param callable(B): C $bc
49+
* @return callable(A): C
50+
*/
51+
function compose(callable $ab, callable $bc): callable
52+
{
53+
return fn($a) => $bc($ab($a));
54+
}
55+
56+
/**
57+
* @template T
58+
* @param T $a
59+
* @return list<T>
60+
*/
61+
function toList(mixed $a): array
62+
{
63+
return [$a];
64+
}
65+
66+
/**
67+
* @template V
68+
* @param V $a
69+
* @return array{boxed: V}
70+
*/
71+
function box(mixed $a): array
72+
{
73+
return ['boxed' => $a];
74+
}
75+
76+
// flip(zip(...)) should preserve template types
77+
$flipZip = flip(zip(...));
78+
assertType('callable(list<B>, list<A>): list<array{A, B}>', $flipZip);
79+
80+
/** @var list<string> */
81+
$strings = [];
82+
/** @var list<int> */
83+
$ints = [];
84+
85+
assertType('list<array{int, string}>', $flipZip($strings, $ints));
86+
assertType('list<array{string, int}>', $flipZip($ints, $strings));
87+
88+
// compose(toList(...), box(...)) should properly unify template types
89+
$composed1 = compose(toList(...), box(...));
90+
assertType('callable(A): array{boxed: list<A>}', $composed1);
91+
92+
// compose(box(...), toList(...)) should properly unify template types
93+
$composed2 = compose(box(...), toList(...));
94+
assertType('callable(A): list<array{boxed: A}>', $composed2);

0 commit comments

Comments
 (0)