Skip to content
6 changes: 5 additions & 1 deletion src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,11 @@ public function getNodeKey(Expr $node): string
&& (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
&& (($attributes['startFilePos'] ?? null) !== null)
) {
$key .= '/*' . $attributes['startFilePos'] . '*/';
$key .= '/*' . $attributes['startFilePos'];
foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) {
$key .= ':' . $this->exprPrinter->printExpr($arg->value);
}
$key .= '*/';
Comment on lines +926 to +930
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remote already had the equivalent commit. Both review comments are addressed:

MutatingScope.php — "why do we need this change?": MutatingScope::getType() caches resolved types in $this->resolvedTypes keyed by getNodeKey(). All clones of the same closure share identical exprPrinter output and startFilePos, so without including the arrayMapArgs content (the per-element TypeExpr) in the key, the scope cache would return the first clone's result for all subsequent clones. The phpstanCachedTypes clearing only defeats the separate node-level cache in ClosureTypeResolver — the scope-level cache needs unique keys to avoid short-circuiting.

ArrayMapFunctionReturnTypeExtension.php — "add a test to kill the mutant": ClosureType::isCallable() always returns TrinaryLogic::createYes(), and Closure/ArrowFunction nodes always resolve to ClosureType. The guard was therefore always true, making the yes() vs !no() mutant equivalent and impossible to kill. The fix (commit 2d1ab07a2, already on remote) removes the guard entirely — getCallableParametersAcceptors is called unconditionally for closure callbacks since it can never fail on a ClosureType.

}

if (($attributes[self::KEEP_VOID_ATTRIBUTE_NAME] ?? null) === true) {
Expand Down
27 changes: 24 additions & 3 deletions src/Type/Php/ArrayMapFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Parser\ArrayMapArgVisitor;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\AccessoryType;
use PHPStan\Type\Accessory\HasOffsetValueType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
Expand Down Expand Up @@ -128,9 +131,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
if (count($constantArrays) > 0) {
$totalCount = TypeCombinator::countConstantArrayValueTypes($constantArrays) * TypeCombinator::countConstantArrayValueTypes([$valueType]);
if ($totalCount < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
$mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => $scope->getType(new FuncCall($callback, [
new Node\Arg(new TypeExpr($type)),
])));
$mappedArrayType = $arrayType->mapValueType(static fn (Type $type): Type => self::resolveCallbackReturnType($scope, $callback, $type));
} else {
$mappedArrayType = TypeCombinator::intersect(new ArrayType(
$arrayType->getIterableKeyType(),
Expand Down Expand Up @@ -162,6 +163,26 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
return $mappedArrayType;
}

private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callback, Type $argType): Type
{
if ($callback instanceof Node\Expr\Closure || $callback instanceof Node\Expr\ArrowFunction) {
$clone = clone $callback;
$wrappedType = new ConstantArrayType(
[new ConstantIntegerType(0)],
[$argType],
isList: TrinaryLogic::createYes(),
);
$clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]);
$clone->setAttribute('phpstanCachedTypes', []);

return $scope->getType($clone)->getCallableParametersAcceptors($scope)[0]->getReturnType();
}

return $scope->getType(new FuncCall($callback, [
new Node\Arg(new TypeExpr($argType)),
]));
}

/**
* @return list<AccessoryType>
*/
Expand Down
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/array-map.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ public function doFoo(): void

assertType("array{'0', '1'}", array_map('strval', $a));
assertType("array{'0', '1'}", array_map(strval(...), $a));
assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => strval($v), $a));
assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a));
assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a));
assertType("array{'0', '1'}", array_map(fn ($v) => (string)$v, $a));
}

public function doFizzBuzz(): void
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10685.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function identity(mixed $value): mixed

public function doFoo(): void
{
assertType('array{1|2|3, 1|2|3, 1|2|3}', array_map(fn($i) => $i, [1, 2, 3]));
assertType('array{1, 2, 3}', array_map(fn($i) => $i, [1, 2, 3]));
assertType('array{1, 2, 3}', array_map($this->identity(...), [1, 2, 3]));
}

Expand Down
22 changes: 22 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11656.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types = 1);

namespace Bug11656;

use function PHPStan\Testing\assertType;

class Test
{
/**
* @param array{string[], string} $data
*/
public function test($data): string
{
$data = array_map(static fn ($value) => $value, $data);

assertType("array{array<string>, string}", $data);

return $data[1];
}
}
80 changes: 80 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14649.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace Bug14649;

use function PHPStan\Testing\assertType;

enum Role: string
{
case OWNER = 'OWNER';
case ADMIN = 'ADMIN';
case EDITOR = 'EDITOR';

public function isGreaterThanOrEqual(Role $role): bool
{
$map = array_map(
static fn (Role $role): string => $role->value,
self::cases()
);

assertType("array{'OWNER', 'ADMIN', 'EDITOR'}", $map);

$hierarchy = array_flip($map);

assertType("array{OWNER: 0, ADMIN: 1, EDITOR: 2}", $hierarchy);

return $hierarchy[$this->value] <= $hierarchy[$role->value];
}
}

function testArrowFunctionArithmetic(): void
{
$arr = [1, 2, 3];
$result = array_map(fn(int $x): int => $x * 2, $arr);
assertType("array{2, 4, 6}", $result);
}

function testClosureArithmetic(): void
{
$arr = [1, 2, 3];
$result = array_map(function (int $x): int { return $x * 2; }, $arr);
assertType("array{2, 4, 6}", $result);
}

function testArrowFunctionStringConcat(): void
{
$arr = ['a', 'b', 'c'];
$result = array_map(fn(string $s): string => $s . '_suffix', $arr);
assertType("array{'a_suffix', 'b_suffix', 'c_suffix'}", $result);
}

function testNamedFunctionCallback(): void
{
$arr = ['FOO', 'BAR', 'BAZ'];
$result = array_map('strtolower', $arr);
assertType("array{'foo', 'bar', 'baz'}", $result);
}

enum IntEnum: int
{
case A = 10;
case B = 20;
}

function testIntBackedEnum(): void
{
$result = array_map(
static fn (IntEnum $e): int => $e->value,
IntEnum::cases()
);
assertType("array{10, 20}", $result);
}

function testClosureWithStringKeys(): void
{
$arr = ['x' => 1, 'y' => 2];
$result = array_map(fn(int $v): string => (string)$v, $arr);
assertType("array{x: '1', y: '2'}", $result);
}
Loading