diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index 27d8cfc11c..7f625de439 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -32,11 +32,23 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $passedArgs = []; $parameters = $parametersAcceptor->getParameters(); + + // Build a name map that handles unnamed parameters (e.g. from PHPDoc callables) + // by assigning unique synthetic names to avoid collisions + $paramNameMap = []; + foreach ($parameters as $idx => $param) { + $name = $param->getName(); + if ($name === '' || isset($paramNameMap[$name])) { + $name = '__param_' . $idx; + } + $paramNameMap[$idx] = $name; + } + $namedArgTypes = []; foreach ($argTypes as $i => $argType) { if (is_int($i)) { if (isset($parameters[$i])) { - $namedArgTypes[$parameters[$i]->getName()] = $argType; + $namedArgTypes[$paramNameMap[$i]] = $argType; continue; } if (count($parameters) > 0) { @@ -56,8 +68,11 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $namedArgTypes[$i] = $argType; } - foreach ($parameters as $param) { - if (isset($namedArgTypes[$param->getName()])) { + foreach ($parameters as $idx => $param) { + $lookupName = $paramNameMap[$idx] ?? $param->getName(); + if (isset($namedArgTypes[$lookupName])) { + $argType = $namedArgTypes[$lookupName]; + } elseif (isset($namedArgTypes[$param->getName()])) { $argType = $namedArgTypes[$param->getName()]; } elseif ($param->getDefaultValue() !== null) { $argType = $param->getDefaultValue(); diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 9d9cfb65e4..1b39bb8093 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -500,9 +500,49 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap { $parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters()); - $parametersAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); - $args = $parametersAcceptor->getParameters(); - $returnType = $parametersAcceptor->getReturnType(); + $resolvedAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); + + // If the inner callable had template types that couldn't be resolved + // (mapped to ErrorType), use the original unresolved parameters to + // preserve template types through composition (e.g. flip(zip(...))) + // If the inner callable had template types that couldn't be resolved + // (mapped to ErrorType), use the original unresolved parameters to + // preserve template types through composition (e.g. flip(zip(...))) + // But only when inner template names don't collide with outer ones, + // to avoid cross-resolution issues. + $useOriginal = false; + if ($parametersAcceptor->getTemplateTypeMap()->count() > 0) { + $hasUnresolved = false; + foreach ($resolvedAcceptor->getResolvedTemplateTypeMap()->getTypes() as $type) { + if ($type instanceof ErrorType) { + $hasUnresolved = true; + break; + } + } + if ($hasUnresolved) { + $outerTemplateNames = []; + foreach ($this->getParameters() as $param) { + foreach ($param->getType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + } + foreach ($this->getReturnType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + $hasCollision = false; + foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $name => $type) { + if (isset($outerTemplateNames[$name])) { + $hasCollision = true; + break; + } + } + $useOriginal = !$hasCollision; + } + } + + $acceptor = $useOriginal ? $parametersAcceptor : $resolvedAcceptor; + $args = $acceptor->getParameters(); + $returnType = $acceptor->getReturnType(); $typeMap = TemplateTypeMap::createEmpty(); foreach ($this->getParameters() as $i => $param) { diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index dac3a2a07f..f40bff5002 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -596,9 +596,46 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap { $parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters()); - $parametersAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); - $args = $parametersAcceptor->getParameters(); - $returnType = $parametersAcceptor->getReturnType(); + $resolvedAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); + + // If the inner callable had template types that couldn't be resolved + // (mapped to ErrorType), use the original unresolved parameters to + // preserve template types through composition (e.g. flip(zip(...))) + // But only when inner template names don't collide with outer ones, + // to avoid cross-resolution issues. + $useOriginal = false; + if ($parametersAcceptor->getTemplateTypeMap()->count() > 0) { + $hasUnresolved = false; + foreach ($resolvedAcceptor->getResolvedTemplateTypeMap()->getTypes() as $type) { + if ($type instanceof ErrorType) { + $hasUnresolved = true; + break; + } + } + if ($hasUnresolved) { + $outerTemplateNames = []; + foreach ($this->getParameters() as $param) { + foreach ($param->getType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + } + foreach ($this->getReturnType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + $hasCollision = false; + foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $name => $type) { + if (isset($outerTemplateNames[$name])) { + $hasCollision = true; + break; + } + } + $useOriginal = !$hasCollision; + } + } + + $acceptor = $useOriginal ? $parametersAcceptor : $resolvedAcceptor; + $args = $acceptor->getParameters(); + $returnType = $acceptor->getReturnType(); $typeMap = TemplateTypeMap::createEmpty(); foreach ($this->getParameters() as $i => $param) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-12038.php b/tests/PHPStan/Analyser/nsrt/bug-12038.php new file mode 100644 index 0000000000..c521833db7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12038.php @@ -0,0 +1,94 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug12038; + +use function PHPStan\Testing\assertType; + +/** + * @template X + * @template Y + * @template Z + * + * @param callable(X, Y): Z $fn + * @return callable(Y, X): Z + */ +function flip(callable $fn): callable +{ + return fn ($y, $x) => $fn($x, $y); +} + +/** + * @template A + * @template B + * + * @param list $fa + * @param list $fb + * @return list + */ +function zip(array $fa, array $fb): array +{ + $length = min(count($fa), count($fb)); + $zipped = []; + + for ($i = 0; $i < $length; $i++) { + $zipped[] = [$fa[$i], $fb[$i]]; + } + + return $zipped; +} + +/** + * @template A + * @template B + * @template C + * + * @param callable(A): B $ab + * @param callable(B): C $bc + * @return callable(A): C + */ +function compose(callable $ab, callable $bc): callable +{ + return fn($a) => $bc($ab($a)); +} + +/** + * @template T + * @param T $a + * @return list + */ +function toList(mixed $a): array +{ + return [$a]; +} + +/** + * @template V + * @param V $a + * @return array{boxed: V} + */ +function box(mixed $a): array +{ + return ['boxed' => $a]; +} + +// flip(zip(...)) should preserve template types +$flipZip = flip(zip(...)); +assertType('callable(list, list): list', $flipZip); + +/** @var list */ +$strings = []; +/** @var list */ +$ints = []; + +assertType('list', $flipZip($strings, $ints)); +assertType('list', $flipZip($ints, $strings)); + +// compose(toList(...), box(...)) should properly unify template types +$composed1 = compose(toList(...), box(...)); +assertType('callable(A): array{boxed: list}', $composed1); + +// compose(box(...), toList(...)) should properly unify template types +$composed2 = compose(box(...), toList(...)); +assertType('callable(A): list', $composed2);