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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PHPStan\Parser\ArrowFunctionArgVisitor;
use PHPStan\Parser\ClosureArgVisitor;
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
use PHPStan\Reflection\ArrayMapCallbackParameterTypes;
use PHPStan\Reflection\Native\NativeParameterReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Reflection\PassedByReference;
Expand Down Expand Up @@ -90,17 +91,38 @@ public function resolve(
);
}

$parameters = [];
$nativeParameters = [];
$types = [];
$nativeTypes = [];
$arrayTypes = [];
$nativeArrayTypes = [];
$hasUnpackedArg = false;
foreach ($intrinsicArgs as $arg) {
$hasUnpackedArg = $hasUnpackedArg || $arg->unpack;
$result = $storage !== null ? $storage->findExpressionResult($arg->value) : null;
$type = $result !== null ? $result->getType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope);
$nativeType = $result !== null ? $result->getNativeType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope->doNotTreatPhpDocTypesAsCertain());
if ($arrayMapArgs !== null) {
$arrayTypes[] = $type;
$nativeArrayTypes[] = $nativeType;
$type = $type->getIterableValueType();
$nativeType = $nativeType->getIterableValueType();
}
$types[] = $type;
$nativeTypes[] = $nativeType;
}

// an unpacked arg is one parameter here, so its arrays cannot be told apart
if ($arrayMapArgs !== null && !$hasUnpackedArg) {
$types = ArrayMapCallbackParameterTypes::addPaddingNull($types, $arrayTypes);
$nativeTypes = ArrayMapCallbackParameterTypes::addPaddingNull($nativeTypes, $nativeArrayTypes);
}

$parameters = [];
foreach ($types as $type) {
$parameters[] = new DummyParameter('item', $type, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
}
$nativeParameters = [];
foreach ($nativeTypes as $nativeType) {

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.

same here.. might need assertNativeType to cover this code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered now. The new assertNativeType lines fail when this line or the native padding above it is removed. They also pass with Turbo loaded.

$nativeParameters[] = new DummyParameter('item', $nativeType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
}

Expand Down
70 changes: 70 additions & 0 deletions src/Reflection/ArrayMapCallbackParameterTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php declare(strict_types = 1);

namespace PHPStan\Reflection;

use PHPStan\Turbo\ReferencedByTurboExtension;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;
use function is_int;
use function max;

/**
* With more than one array, array_map() calls the callback once per index of
* the longest array, and passes null for each array that is shorter.
*/
#[ReferencedByTurboExtension(key: 'arrayMapCallbackParameterTypes')]
final class ArrayMapCallbackParameterTypes
Comment thread
staabm marked this conversation as resolved.
Resolved
{

/**
* Adds null to the value type of each array that is shorter than another one
* when both have their largest size. Only when the size of every array is known.
*
* The largest sizes are compared, not the smallest, because two arrays can be
* one array: array_map($cb, $a, $a) never passes null, whatever sizes $a can have.
*
* @param list<Type> $valueTypes
* @param list<Type> $arrayTypes the array each value type comes from
* @return list<Type>
*/
public static function addPaddingNull(array $valueTypes, array $arrayTypes): array
{
if (count($arrayTypes) < 2) {
return $valueTypes;
}

$maxSizes = [];
foreach ($arrayTypes as $i => $arrayType) {
$sizes = [];
foreach ($arrayType->getArraySize()->getFiniteTypes() as $sizeType) {
foreach ($sizeType->getConstantScalarValues() as $size) {
if (!is_int($size)) {
continue;
}

$sizes[] = $size;
}
}

if ($sizes === []) {
// general arrays can have the same size, but the type system cannot say so
return $valueTypes;
}

$maxSizes[$i] = max($sizes);
}

$longestSize = max($maxSizes);
foreach ($valueTypes as $i => $valueType) {
if ($maxSizes[$i] === $longestSize) {
continue;
}

$valueTypes[$i] = TypeCombinator::addNull($valueType);
}

return $valueTypes;
}

}
27 changes: 21 additions & 6 deletions src/Reflection/ParametersAcceptorSelector.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,10 @@ public static function applyIntrinsicArgOverrides(
) {
$arrayMapArgs = $args[0]->value->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME);
if ($arrayMapArgs !== null) {
$callbackParameters = [];
$nativeCallbackParameters = [];
$callbackValueTypes = [];
$callbackArrayTypes = [];
$nativeCallbackValueTypes = [];
$nativeCallbackArrayTypes = [];
foreach ($arrayMapArgs as $arg) {
$argType = ($typeGetter)($arg->value);
$nativeArgType = ($nativeTypeGetter)($arg->value);
Expand All @@ -217,7 +219,8 @@ public static function applyIntrinsicArgOverrides(
foreach ($constantArrays as $constantArray) {
$valueTypes = $constantArray->getValueTypes();
foreach ($valueTypes as $valueType) {
$callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
$callbackValueTypes[] = ($iterableValueTypeGetter)($valueType);
$callbackArrayTypes[] = $valueType;
}
}
}
Expand All @@ -226,16 +229,28 @@ public static function applyIntrinsicArgOverrides(
foreach ($nativeConstantArrays as $constantArray) {
$valueTypes = $constantArray->getValueTypes();
foreach ($valueTypes as $valueType) {
$nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
$nativeCallbackValueTypes[] = ($iterableValueTypeGetter)($valueType);
$nativeCallbackArrayTypes[] = $valueType;
}
}
}
} else {
$callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
$nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
$callbackValueTypes[] = ($iterableValueTypeGetter)($argType);
$callbackArrayTypes[] = $argType;
$nativeCallbackValueTypes[] = ($iterableValueTypeGetter)($nativeArgType);
$nativeCallbackArrayTypes[] = $nativeArgType;
}
}

$callbackParameters = [];
foreach (ArrayMapCallbackParameterTypes::addPaddingNull($callbackValueTypes, $callbackArrayTypes) as $valueType) {
$callbackParameters[] = new DummyParameter('item', $valueType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
}
$nativeCallbackParameters = [];
foreach (ArrayMapCallbackParameterTypes::addPaddingNull($nativeCallbackValueTypes, $nativeCallbackArrayTypes) as $valueType) {
$nativeCallbackParameters[] = new DummyParameter('item', $valueType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
}
Comment on lines +245 to +252

@staabm staabm Oct 30, 2026 •

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.

since we have 2 separate loops for native-types vs. non-native-types, I think we should also have assertType and assertNativeType assertions.

said differently: do we have test-coverage for both these loops here?

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.

just double checked: if I comment out line 251, no tests fail

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added assertNativeType lines, but none of them fail when line 251 is commented out. An inline closure gets its native types from ContextualClosureParameterResolver, not from this callable type. I also tried a closure in a ternary, a closure in a variable and a typed arrow function, and the output did not change. I found no case where the native callable type here has an effect. It was built the same way before this PR, so I kept the two loops in line.


$acceptor = $parametersAcceptors[0];
$parameters = $acceptor->getParameters();
if (isset($parameters[0])) {
Expand Down
12 changes: 10 additions & 2 deletions src/Type/Php/ArrayMapFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Parser\ArrayMapArgVisitor;
use PHPStan\Reflection\ArrayMapCallbackParameterTypes;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\AccessoryArrayListType;
Expand All @@ -25,6 +26,7 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeUtils;
use function array_filter;
use function array_map;
use function array_reduce;
use function array_slice;
Expand Down Expand Up @@ -53,11 +55,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
$callableIsNull = $callableType->isNull()->yes();

if ($callableType->isCallable()->yes()) {
$arrayArgs = array_slice($args, 1);
$arrayTypes = array_map(static fn (Node\Arg $arg) => $scope->getType($arg->value), $arrayArgs);
$callbackValueTypes = array_map(static fn (Type $arrayType) => $arrayType->getIterableValueType(), $arrayTypes);
if (count(array_filter($arrayArgs, static fn (Node\Arg $arg) => $arg->unpack)) === 0) {
$callbackValueTypes = ArrayMapCallbackParameterTypes::addPaddingNull($callbackValueTypes, $arrayTypes);
}
$valueType = $scope->getType(new FuncCall(
$callback,
array_map(
static fn (Node\Arg $arg) => new Node\Arg(new TypeExpr($scope->getType($arg->value)->getIterableValueType())),
array_slice($args, 1),
static fn (Type $valueType) => new Node\Arg(new TypeExpr($valueType)),
$callbackValueTypes,
),
));
} elseif ($callableIsNull) {
Expand Down
15 changes: 12 additions & 3 deletions src/Type/Php/ArrayMapParameterClosureTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\ArrayMapCallbackParameterTypes;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\Native\NativeParameterReflection;
use PHPStan\Reflection\ParameterReflection;
Expand All @@ -31,7 +32,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
return null;
}

$callbackParameters = [];
$valueTypes = [];
$arrayTypes = [];
$argCount = count($args);
for ($i = 1; $i < $argCount; $i++) {
$arg = $args[$i];
Expand All @@ -43,14 +45,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
}
foreach ($constantArrays as $constantArray) {
foreach ($constantArray->getValueTypes() as $valueType) {
$callbackParameters[] = $this->createParameter($scope->getIterableValueType($valueType));
$valueTypes[] = $scope->getIterableValueType($valueType);
$arrayTypes[] = $valueType;
}
}
} else {
$callbackParameters[] = $this->createParameter($scope->getIterableValueType($arrayType));
$valueTypes[] = $scope->getIterableValueType($arrayType);
$arrayTypes[] = $arrayType;
}
}

$callbackParameters = [];
foreach (ArrayMapCallbackParameterTypes::addPaddingNull($valueTypes, $arrayTypes) as $valueType) {
$callbackParameters[] = $this->createParameter($valueType);
}

return new ClosureType($callbackParameters, new MixedType());
}

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

namespace Bug13217;

use function PHPStan\Testing\assertNativeType;
use function PHPStan\Testing\assertType;

class Foo
{

public function shorterSecondArray(): void
{
array_map(function ($a, $b) {
assertType('1|2', $a);
assertType('3|null', $b);
Comment on lines +14 to +15

@staabm staabm Oct 30, 2026 •

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.

unrelated side-question:

do we differentiate between types beeing infered from native vs. phpdoc?
so would assertType and assertNativeType lead different results when we feed a phpdoc array-shape or native values like [1, 2] into array_map?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, they can differ. With native arrays like [1, 2] and [3], assertType and assertNativeType both give 3|null for $b. With a PHPDoc shape, the native type of the array is array, so assertNativeType gives mixed for its value. I added both cases to the test.

assertNativeType('1|2', $a);
assertNativeType('3|null', $b);
}, [1, 2], [3]);

array_map(fn ($a, $b) => assertType('3|null', $b), [1, 2], [3]);

assertType('non-empty-list<1|3>', array_map(fn ($a, $b) => $b ?? 1, [1, 2], [3]));
}

public function shorterFirstArray(): void
{
array_map(function ($a, $b) {
assertType('1|null', $a);
assertType('3|4', $b);
assertNativeType('1|null', $a);
assertNativeType('3|4', $b);
}, [1], [3, 4]);
}

public function sameSize(): void
{
array_map(function ($a, $b) {
assertType('1|2', $a);
assertType('3|4', $b);
}, [1, 2], [3, 4]);
}

/**
* @param array{1, 2}|array{1} $maybe
*/
public function sizeRange(array $maybe): void
{
array_map(function ($a, $b) {
assertType('1|2|null', $a);
assertType('3|4|5', $b);
assertNativeType('mixed', $a);
assertNativeType('3|4|5', $b);
}, $maybe, [3, 4, 5]);

array_map(function ($a, $b) {
assertType('1|2', $a);
assertType('3|null', $b);
}, $maybe, [3]);

array_map(function ($a, $b) {
assertType('1|2', $a);
assertType('1|2', $b);
}, $maybe, $maybe);
}

public function singleArray(): void
{
array_map(function ($a) {
assertType('1|2', $a);
}, [1, 2]);
}

/**
* @param list<int> $ints
* @param list<string> $strings
*/
public function generalArrays(array $ints, array $strings): void
{
array_map(function ($a, $b) {
assertType('int', $a);
assertType('string', $b);
}, $ints, $strings);

array_map(function ($a) {
assertType('int', $a);
}, $ints);

array_map(function ($a, $b) {
assertType('int', $a);
assertType('3', $b);
}, $ints, [3]);
}

public function returnType(): void
{
assertType('non-empty-list<3|null>', array_map('Bug13217\\pick', [1, 2], [3]));
}

}

/**
* @template T
* @param mixed $a
* @param T $b
* @return T
*/
function pick($a, $b)
{
return $b;
}
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,11 @@ public function testInTrait(): void
]);
}

public function testBug13217(): void
{
$this->analyse([__DIR__ . '/data/bug-13217.php'], []);
}

public function testBug12780(): void
{
$this->analyse([__DIR__ . '/data/bug-12780.php'], []);
Expand Down
3 changes: 3 additions & 0 deletions tests/PHPStan/Rules/Variables/data/bug-13217.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php declare(strict_types = 1);

array_map(fn($_, $bValue) => $bValue ?? 1, [1, 2], [3]);
Loading
Loading