Skip to content

Commit bb22ec9

Browse files
phpstan-botondrejmirtes
authored andcommitted
Correctly mark all unpacked constant array items as optional in array_merge/array_replace
- Fix `$optionalArgTypesOffset` calculation in both `ArrayMergeFunctionDynamicReturnTypeExtension` and `ArrayReplaceFunctionReturnTypeExtension`: track start index before adding items to `$argTypes` instead of computing a wrong offset from `count($argTypes) - 1` after adding. The old formula only marked the last item as optional when unpacking a multi-element constant array union with an empty variant, leaving earlier items incorrectly marked as required. - In the allConstant code path, use `$optionalArgTypes` to mark keys from optional args as optional in `ConstantArrayTypeBuilder`, so the result correctly allows an empty array. - In the non-constant code path, skip optional args when building `$offsetTypes` so that `HasOffsetType`/`HasOffsetValueType` accessory types are not added for keys that may not exist. - Same fix applied to `ArrayReplaceFunctionReturnTypeExtension` which had identical code.
1 parent ed8ccee commit bb22ec9

3 files changed

Lines changed: 99 additions & 16 deletions

File tree

src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
use PHPStan\Type\Type;
2424
use PHPStan\Type\TypeCombinator;
2525
use PHPStan\Type\TypeUtils;
26-
use function array_keys;
2726
use function count;
2827
use function in_array;
2928
use function is_int;
@@ -51,6 +50,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5150
$argType = $scope->getType($arg->value);
5251

5352
if ($arg->unpack) {
53+
$startIndex = count($argTypes);
54+
5455
if ($argType->isConstantArray()->yes()) {
5556
foreach ($argType->getConstantArrays() as $constantArray) {
5657
foreach ($constantArray->getValueTypes() as $valueType) {
@@ -62,10 +63,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6263
}
6364

6465
if (!$argType->isIterableAtLeastOnce()->yes()) {
65-
// unpacked params can be empty, making them optional
66-
$optionalArgTypesOffset = count($argTypes) - 1;
67-
foreach (array_keys($argTypes) as $key) {
68-
$optionalArgTypes[] = $optionalArgTypesOffset + $key;
66+
for ($i = $startIndex; $i < count($argTypes); $i++) {
67+
$optionalArgTypes[] = $i;
6968
}
7069
}
7170
} else {
@@ -80,7 +79,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
8079

8180
if ($allConstant->yes()) {
8281
$newArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
83-
foreach ($argTypes as $argType) {
82+
foreach ($argTypes as $argIndex => $argType) {
83+
$isOptionalArg = in_array($argIndex, $optionalArgTypes, true);
84+
8485
/** @var array<int|string, ConstantIntegerType|ConstantStringType> $keyTypes */
8586
$keyTypes = [];
8687
foreach ($argType->getConstantArrays() as $constantArray) {
@@ -93,7 +94,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
9394
$newArrayBuilder->setOffsetValueType(
9495
$keyType instanceof ConstantIntegerType ? null : $keyType,
9596
$argType->getOffsetValueType($keyType),
96-
!$argType->hasOffsetValueType($keyType)->yes(),
97+
$isOptionalArg || !$argType->hasOffsetValueType($keyType)->yes(),
9798
);
9899
}
99100
}
@@ -102,7 +103,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
102103
}
103104

104105
$offsetTypes = [];
105-
foreach ($argTypes as $argType) {
106+
foreach ($argTypes as $argIndex => $argType) {
107+
if (in_array($argIndex, $optionalArgTypes, true)) {
108+
continue;
109+
}
110+
106111
$constArrays = $argType->getConstantArrays();
107112
if ($constArrays !== []) {
108113
foreach ($constArrays as $constantArray) {

src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
use PHPStan\Type\Type;
2323
use PHPStan\Type\TypeCombinator;
2424
use PHPStan\Type\TypeUtils;
25-
use function array_keys;
2625
use function count;
2726
use function in_array;
2827
use function is_string;
@@ -51,6 +50,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5150
$argType = $scope->getType($arg->value);
5251

5352
if ($arg->unpack) {
53+
$startIndex = count($argTypes);
54+
5455
if ($argType->isConstantArray()->yes()) {
5556
foreach ($argType->getConstantArrays() as $constantArray) {
5657
foreach ($constantArray->getValueTypes() as $valueType) {
@@ -62,10 +63,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6263
}
6364

6465
if (!$argType->isIterableAtLeastOnce()->yes()) {
65-
// unpacked params can be empty, making them optional
66-
$optionalArgTypesOffset = count($argTypes) - 1;
67-
foreach (array_keys($argTypes) as $key) {
68-
$optionalArgTypes[] = $optionalArgTypesOffset + $key;
66+
for ($i = $startIndex; $i < count($argTypes); $i++) {
67+
$optionalArgTypes[] = $i;
6968
}
7069
}
7170
} else {
@@ -81,7 +80,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
8180
if ($allConstant->yes()) {
8281
$newArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
8382

84-
foreach ($argTypes as $argType) {
83+
foreach ($argTypes as $argIndex => $argType) {
84+
$isOptionalArg = in_array($argIndex, $optionalArgTypes, true);
85+
8586
/** @var array<int|string, ConstantIntegerType|ConstantStringType> $keyTypes */
8687
$keyTypes = [];
8788
foreach ($argType->getConstantArrays() as $constantArray) {
@@ -94,7 +95,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
9495
$newArrayBuilder->setOffsetValueType(
9596
$keyType,
9697
$argType->getOffsetValueType($keyType),
97-
!$argType->hasOffsetValueType($keyType)->yes(),
98+
$isOptionalArg || !$argType->hasOffsetValueType($keyType)->yes(),
9899
);
99100
}
100101
}
@@ -103,7 +104,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
103104
}
104105

105106
$offsetTypes = [];
106-
foreach ($argTypes as $argType) {
107+
foreach ($argTypes as $argIndex => $argType) {
108+
if (in_array($argIndex, $optionalArgTypes, true)) {
109+
continue;
110+
}
111+
107112
$constArrays = $argType->getConstantArrays();
108113
if ($constArrays !== []) {
109114
foreach ($constArrays as $constantArray) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
namespace Bug14526;
4+
5+
use function array_merge;
6+
use function array_replace;
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @param array{array{foo: int}, array<string, int>}|array{} $values
11+
*/
12+
function testMergeUnpackUnionWithEmpty(array $values): void
13+
{
14+
$result = array_merge(...$values);
15+
assertType('array<string, int>', $result);
16+
}
17+
18+
/**
19+
* @param array{non-empty-array<string, int>, array<string, int>}|array{} $values
20+
*/
21+
function testMergeUnpackUnionNonEmptyFirstWithEmpty(array $values): void
22+
{
23+
$result = array_merge(...$values);
24+
assertType('array<string, int>', $result);
25+
}
26+
27+
/**
28+
* @param array{non-empty-array<string, int>}|array{} $values
29+
*/
30+
function testMergeUnpackUnionSingleWithEmpty(array $values): void
31+
{
32+
$result = array_merge(...$values);
33+
assertType('array<string, int>', $result);
34+
}
35+
36+
function testMergeUnpackConstantUnionWithEmpty(): void
37+
{
38+
$values = rand(0, 1) ? [['a' => 1], ['b' => 2]] : [];
39+
$result = array_merge(...$values);
40+
assertType('array{a?: 1, b?: 2}', $result);
41+
}
42+
43+
function testMergeUnpackConstantUnionWithEmptyThreeElements(): void
44+
{
45+
$values = rand(0, 1) ? [['a' => 1], ['b' => 2], ['c' => 3]] : [];
46+
$result = array_merge(...$values);
47+
assertType('array{a?: 1, b?: 2, c?: 3}', $result);
48+
}
49+
50+
/**
51+
* @param array{array{foo: int}, array<string, int>}|array{} $values
52+
*/
53+
function testReplaceUnpackUnionWithEmpty(array $values): void
54+
{
55+
$result = array_replace(...$values);
56+
assertType('array<string, int>', $result);
57+
}
58+
59+
/**
60+
* @param array{non-empty-array<string, int>, array<string, int>}|array{} $values
61+
*/
62+
function testReplaceUnpackUnionNonEmptyFirstWithEmpty(array $values): void
63+
{
64+
$result = array_replace(...$values);
65+
assertType('array<string, int>', $result);
66+
}
67+
68+
function testReplaceUnpackConstantUnionWithEmpty(): void
69+
{
70+
$values = rand(0, 1) ? [['a' => 1], ['b' => 2]] : [];
71+
$result = array_replace(...$values);
72+
assertType('array{a?: 1, b?: 2}', $result);
73+
}

0 commit comments

Comments
 (0)