Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/Reflection/GenericParametersAcceptorResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
46 changes: 43 additions & 3 deletions src/Type/CallableType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
43 changes: 40 additions & 3 deletions src/Type/ClosureType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
94 changes: 94 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-12038.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php // lint >= 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<A> $fa
* @param list<B> $fb
* @return list<array{A, B}>
*/
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<T>
*/
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<B>, list<A>): list<array{A, B}>', $flipZip);

/** @var list<string> */
$strings = [];
/** @var list<int> */
$ints = [];

assertType('list<array{int, string}>', $flipZip($strings, $ints));
assertType('list<array{string, int}>', $flipZip($ints, $strings));

// compose(toList(...), box(...)) should properly unify template types
$composed1 = compose(toList(...), box(...));
assertType('callable(A): array{boxed: list<A>}', $composed1);

// compose(box(...), toList(...)) should properly unify template types
$composed2 = compose(box(...), toList(...));
assertType('callable(A): list<array{boxed: A}>', $composed2);
Loading