Skip to content

Commit 293ea40

Browse files
Rework ArrayReplaceFunctionReturnTypeExtension
1 parent 8a6f7e9 commit 293ea40

File tree

3 files changed

+123
-35
lines changed

3 files changed

+123
-35
lines changed

src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php

+92-32
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\Reflection\FunctionReflection;
8+
use PHPStan\TrinaryLogic;
9+
use PHPStan\Type\Accessory\AccessoryArrayListType;
810
use PHPStan\Type\Accessory\NonEmptyArrayType;
911
use PHPStan\Type\ArrayType;
12+
use PHPStan\Type\Constant\ConstantArrayType;
13+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
14+
use PHPStan\Type\Constant\ConstantIntegerType;
15+
use PHPStan\Type\Constant\ConstantStringType;
1016
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
17+
use PHPStan\Type\NeverType;
1118
use PHPStan\Type\Type;
1219
use PHPStan\Type\TypeCombinator;
1320
use function count;
@@ -23,54 +30,107 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo
2330

2431
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
2532
{
26-
$arrayTypes = $this->collectArrayTypes($functionCall, $scope);
33+
$args = $functionCall->getArgs();
2734

28-
if (count($arrayTypes) === 0) {
35+
if (!isset($args[0])) {
2936
return null;
3037
}
3138

32-
return $this->getResultType(...$arrayTypes);
33-
}
39+
$argTypes = [];
40+
$optionalArgTypes = [];
41+
foreach ($args as $arg) {
42+
$argType = $scope->getType($arg->value);
3443

35-
private function getResultType(Type ...$arrayTypes): Type
36-
{
37-
$keyTypes = [];
38-
$valueTypes = [];
39-
$nonEmptyArray = false;
40-
foreach ($arrayTypes as $arrayType) {
41-
if (!$nonEmptyArray && $arrayType->isIterableAtLeastOnce()->yes()) {
42-
$nonEmptyArray = true;
44+
if ($arg->unpack) {
45+
if ($argType->isConstantArray()->yes()) {
46+
foreach ($argType->getConstantArrays() as $constantArray) {
47+
foreach ($constantArray->getValueTypes() as $valueType) {
48+
$argTypes[] = $valueType;
49+
}
50+
}
51+
} else {
52+
$argTypes[] = $argType->getIterableValueType();
53+
}
54+
55+
if (!$argType->isIterableAtLeastOnce()->yes()) {
56+
// unpacked params can be empty, making them optional
57+
$optionalArgTypesOffset = count($argTypes) - 1;
58+
foreach (array_keys($argTypes) as $key) {
59+
$optionalArgTypes[] = $optionalArgTypesOffset + $key;
60+
}
61+
}
62+
} else {
63+
$argTypes[] = $argType;
4364
}
44-
45-
$keyTypes[] = $arrayType->getIterableKeyType();
46-
$valueTypes[] = $arrayType->getIterableValueType();
4765
}
4866

49-
$keyType = TypeCombinator::union(...$keyTypes);
50-
$valueType = TypeCombinator::union(...$valueTypes);
67+
$allConstant = TrinaryLogic::createYes()->lazyAnd(
68+
$argTypes,
69+
static fn (Type $argType) => $argType->isConstantArray(),
70+
);
71+
72+
if ($allConstant->yes()) {
73+
$newArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
74+
75+
foreach ($argTypes as $argType) {
76+
/** @var array<int|string, ConstantIntegerType|ConstantStringType> $keyTypes */
77+
$keyTypes = [];
78+
foreach ($argType->getConstantArrays() as $constantArray) {
79+
foreach ($constantArray->getKeyTypes() as $keyType) {
80+
$keyTypes[$keyType->getValue()] = $keyType;
81+
}
82+
}
83+
84+
foreach ($keyTypes as $keyType) {
85+
$newArrayBuilder->setOffsetValueType(
86+
$keyType,
87+
$argType->getOffsetValueType($keyType),
88+
!$argType->hasOffsetValueType($keyType)->yes(),
89+
);
90+
}
91+
}
5192

52-
$arrayType = new ArrayType($keyType, $valueType);
53-
return $nonEmptyArray ? TypeCombinator::intersect($arrayType, new NonEmptyArrayType()) : $arrayType;
54-
}
93+
return $newArrayBuilder->getArray();
94+
}
5595

56-
/**
57-
* @return Type[]
58-
*/
59-
private function collectArrayTypes(FuncCall $functionCall, Scope $scope): array
60-
{
61-
$args = $functionCall->getArgs();
96+
$keyTypes = [];
97+
$valueTypes = [];
98+
$nonEmpty = false;
99+
$isList = true;
100+
foreach ($argTypes as $key => $argType) {
101+
$keyType = $argType->getIterableKeyType();
102+
$keyTypes[] = $keyType;
103+
$valueTypes[] = $argType->getIterableValueType();
104+
105+
if (!$argType->isList()->yes()) {
106+
$isList = false;
107+
}
62108

63-
$arrayTypes = [];
64-
foreach ($args as $arg) {
65-
$argType = $scope->getType($arg->value);
66-
if (!$argType->isArray()->yes()) {
109+
if (in_array($key, $optionalArgTypes, true) || !$argType->isIterableAtLeastOnce()->yes()) {
67110
continue;
68111
}
69112

70-
$arrayTypes[] = $arg->unpack ? $argType->getIterableValueType() : $argType;
113+
$nonEmpty = true;
114+
}
115+
116+
$keyType = TypeCombinator::union(...$keyTypes);
117+
if ($keyType instanceof NeverType) {
118+
return new ConstantArrayType([], []);
119+
}
120+
121+
$arrayType = new ArrayType(
122+
$keyType,
123+
TypeCombinator::union(...$valueTypes),
124+
);
125+
126+
if ($nonEmpty) {
127+
$arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
128+
}
129+
if ($isList) {
130+
$arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
71131
}
72132

73-
return $arrayTypes;
133+
return $arrayType;
74134
}
75135

76136
}

tests/PHPStan/Analyser/nsrt/array-replace.php

+21-3
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ public function arrayReplace($array1, $array2): void
2323
*/
2424
public function arrayReplaceArrayShapes($array1, $array2): void
2525
{
26-
assertType("non-empty-array<'bar'|'foo', '1'|'2'>", array_replace($array1));
27-
assertType("non-empty-array<'bar'|'foo', '1'|'2'>", array_replace([], $array1));
28-
assertType("non-empty-array<'bar'|'foo', '1'|'2'|'4'>", array_replace($array1, $array2));
26+
assertType("array{foo: '1', bar: '2'}", array_replace($array1));
27+
assertType("array{foo: '1', bar: '2'}", array_replace([], $array1));
28+
assertType("array{foo: '1', bar: '4'}", array_replace($array1, $array2));
2929
}
3030

3131
/**
@@ -68,4 +68,22 @@ public function arrayReplaceUnionTypeArrayShapes($array1, $array2): void
6868
assertType("array<int, array{bar: '2'}|array{bar: '3'}|array{foo: '1'}|array{foo: '2'}>", array_replace($array1, $array2));
6969
assertType("array<int, array{bar: '2'}|array{bar: '3'}|array{foo: '1'}|array{foo: '2'}>", array_replace($array2, $array1));
7070
}
71+
72+
/**
73+
* @param array{0: 1, 1: 2} $array1
74+
* @param array{1: 3, 2: 4} $array2
75+
*/
76+
public function arrayReplaceNumericKeys($array1, $array2): void
77+
{
78+
assertType("array{1, 3, 4}", array_replace($array1, $array2));
79+
}
80+
81+
/**
82+
* @param list<int> $array1
83+
* @param list<int> $array2
84+
*/
85+
public function arrayReplaceLists($array1, $array2): void
86+
{
87+
assertType("list<int>", array_replace($array1, $array2));
88+
}
7189
}
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug12828;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
$a = ['abc' => 'def', 'hello' => 'world'];
8+
assertType("array{abc: 'def', hello: 'world'}", $a);
9+
$a = array_replace($a, ['hello' => 'country']);
10+
assertType("array{abc: 'def', hello: 'country'}", $a);

0 commit comments

Comments
 (0)