Skip to content
5 changes: 1 addition & 4 deletions src/Type/Accessory/HasOffsetType.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,7 @@ public function __construct(private ConstantStringType|ConstantIntegerType $offs
{
}

/**
* @return ConstantStringType|ConstantIntegerType
*/
public function getOffsetType(): Type
public function getOffsetType(): ConstantStringType|ConstantIntegerType
{
return $this->offsetType;
}
Expand Down
73 changes: 73 additions & 0 deletions src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use PHPStan\Reflection\FunctionReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\HasOffsetType;
use PHPStan\Type\Accessory\HasOffsetValueType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
Expand All @@ -16,12 +18,15 @@
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeUtils;
use function array_keys;
use function count;
use function in_array;
use function is_int;

#[AutowiredService]
final class ArrayMergeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
Expand Down Expand Up @@ -96,6 +101,44 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
return $newArrayBuilder->getArray();
}

$offsetTypes = [];
foreach ($argTypes as $argType) {
$constArrays = $argType->getConstantArrays();
if ($constArrays !== []) {
foreach ($constArrays as $constantArray) {
foreach ($constantArray->getKeyTypes() as $keyType) {
$hasOffsetValue = TrinaryLogic::createFromBoolean($argType->hasOffsetValueType($keyType)->yes());
$offsetTypes[$keyType->getValue()] = [
$hasOffsetValue,
$argType->getOffsetValueType($keyType),
];
}
}
} else {
foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) {
$offsetTypes[$key] = [
$hasOffsetValue->and(TrinaryLogic::createMaybe()),
new MixedType(),
];
}
}

foreach (TypeUtils::getAccessoryTypes($argType) as $accessoryType) {
if (
!($accessoryType instanceof HasOffsetType)
&& !($accessoryType instanceof HasOffsetValueType)
) {
continue;
}

$offsetType = $accessoryType->getOffsetType();
$offsetTypes[$offsetType->getValue()] = [
TrinaryLogic::createYes(),
$argType->getOffsetValueType($offsetType),
];
}
}

$keyTypes = [];
$valueTypes = [];
$nonEmpty = false;
Expand Down Expand Up @@ -132,6 +175,36 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
if ($isList) {
$arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
}
if ($offsetTypes !== []) {
$knownOffsetValues = [];
foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) {
if (is_int($key)) {
// int keys will be appended and renumbered.
// at this point we can't reason about them, because unknown arrays are in the mix.
continue;
}
$keyType = new ConstantStringType($key);

if ($hasOffsetValue->yes()) {
// the last string-keyed offset will overwrite previous values
$hasOffsetType = new HasOffsetValueType(
$keyType,
$offsetType,
);
} elseif ($hasOffsetValue->maybe()) {
$hasOffsetType = new HasOffsetType(
$keyType,
);
} else {
continue;
}

$knownOffsetValues[] = $hasOffsetType;
}
if ($knownOffsetValues !== []) {
$arrayType = TypeCombinator::intersect($arrayType, ...$knownOffsetValues);
}
}

return $arrayType;
}
Expand Down
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4697,11 +4697,11 @@ public static function dataArrayFunctions(): array
'array_merge($generalStringKeys, $generalDateTimeValues)',
],
[
'non-empty-array<1|string, int|stdClass>',
"non-empty-array<1|string, int|stdClass>&hasOffsetValue('foo', stdClass)",
'array_merge($generalStringKeys, $stringOrIntegerKeys)',
],
[
'non-empty-array<1|string, int|stdClass>',
"non-empty-array<1|string, int|stdClass>&hasOffset('foo')",
'array_merge($stringOrIntegerKeys, $generalStringKeys)',
],
[
Expand Down
134 changes: 134 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace ArrayMergeConstNonConst;

use function PHPStan\Testing\assertType;

function doFoo(array $post): void {
assertType(
"non-empty-array&hasOffset('a')&hasOffset('b')",
array_merge(['a' => 1, 'b' => false, 10 => 99], $post)
);
}

function doBar(array $array): void {
assertType(
"non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)",
array_merge($array, ['a' => 1, 'b' => false, 10 => 99])
);
}

function doFooBar(array $array): void {
assertType(
"non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')",
array_merge(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e'])
);
}

function doFooInts(array $array): void {
// int keys will be renumbered therefore we can't reason about them in case we don't know all arrays involved
assertType(
"non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('c', 'e')",
array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e'])
);
}

/**
* @param array<string> $array
*/
function floatKey(array $array): void {
assertType(
"non-empty-array<string>&hasOffsetValue('a', '1')&hasOffsetValue('c', 'e')",
array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e'])
);
}

function doOptKeys(array $array, array $arr2): void {
if (rand(0, 1)) {
$array['abc'] = 'def';
}
assertType("array", array_merge($arr2, $array));
assertType("array", array_merge($array, $arr2));
}

/**
* @param array{a?: 1, b: 2} $array
*/
function doOptShapeKeys(array $array, array $arr2): void {
assertType("non-empty-array&hasOffsetValue('b', 2)", array_merge($arr2, $array));
assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2));
}

function hasOffsetKeys(array $array, array $arr2): void {
if (array_key_exists('b', $array)) {
assertType("non-empty-array&hasOffsetValue('b', mixed)", array_merge($arr2, $array));
assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2));
}
}

function maybeHasOffsetKeys(array $array): void {
$arr2 = [];
if (rand(0,1)) {
$arr2 ['ab'] = 'def';
}

assertType("array", array_merge($arr2, $array));
assertType("array", array_merge($array, $arr2));
}

function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void {
$hasB['b'] = 123;
$hasC['c'] = 'def';

assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($mixedArray, $hasB));
assertType("non-empty-array&hasOffset('b')", array_merge($hasB, $mixedArray));

assertType(
"non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')",
array_merge($mixedArray, $hasB, $hasC)
);
assertType(
"non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')",
array_merge($hasB, $mixedArray, $hasC)
);

assertType(
"non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)",
array_merge($hasC, $mixedArray, $hasB)
);
assertType(
"non-empty-array&hasOffset('b')&hasOffset('c')",
array_merge($hasC, $hasB, $mixedArray)
);

if (rand(0, 1)) {
$hasBorC = ['b' => 1];
} else {
$hasBorC = ['c' => 2];
}
assertType('array{b: 1}|array{c: 2}', $hasBorC);
assertType("non-empty-array", array_merge($mixedArray, $hasBorC));
assertType("non-empty-array", array_merge($hasBorC, $mixedArray));

if (rand(0, 1)) {
$differentCs = ['c' => 10];
} else {
$differentCs = ['c' => 20];
}
assertType('array{c: 10}|array{c: 20}', $differentCs);
assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $differentCs));
assertType("non-empty-array&hasOffset('c')", array_merge($differentCs, $mixedArray));

assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $hasBorC, $differentCs));
assertType("non-empty-array", array_merge($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c')
assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($hasBorC, $mixedArray, $differentCs));
assertType("non-empty-array", array_merge($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c')
}

/**
* @param array{a?: 1, b?: 2} $allOptional
*/
function doAllOptional(array $allOptional, array $arr2): void {
assertType("array", array_merge($arr2, $allOptional));
assertType("array", array_merge($allOptional, $arr2));
}
12 changes: 6 additions & 6 deletions tests/PHPStan/Analyser/nsrt/bug-2911.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,23 @@ public function __construct(MutatorConfig $config)
private function getResultSettings(array $settings): array
{
$settings = array_merge(self::DEFAULT_SETTINGS, $settings);
assertType('non-empty-array<string, mixed>', $settings);
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffset('remove')", $settings);

if (!is_string($settings['remove'])) {
throw $this->configException($settings, 'remove');
}

assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', string)", $settings);
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', string)", $settings);

$settings['remove'] = strtolower($settings['remove']);

assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', lowercase-string)", $settings);
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', lowercase-string)", $settings);

if (!in_array($settings['remove'], ['first', 'last', 'all'], true)) {
throw $this->configException($settings, 'remove');
}

assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', 'all'|'first'|'last')", $settings);
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', 'all'|'first'|'last')", $settings);

if (!is_numeric($settings['limit']) || $settings['limit'] < 1) {
throw $this->configException($settings, 'limit');
Expand Down Expand Up @@ -110,13 +110,13 @@ private function getResultSettings(array $settings): array
{
$settings = array_merge(self::DEFAULT_SETTINGS, $settings);

assertType('non-empty-array<string, mixed>', $settings);
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffset('remove')", $settings);

if (!is_string($settings['remove'])) {
throw new Exception();
}

assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', string)", $settings);
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', string)", $settings);

if (!is_int($settings['limit'])) {
throw new Exception();
Expand Down
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1276,4 +1276,9 @@ public function testBug9494(): void
$this->analyse([__DIR__ . '/data/bug-9494.php'], []);
}

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

}
26 changes: 26 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-8438.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Bug8438;

class HelloWorld
{
/**
* @param array<string, string> $array
*
* @return array{expr: mixed, ...}
*/
protected function foo(array $array): array
{
$rnd = mt_rand();
if ($rnd === 0) {
return ['expr' => 'test'];
} elseif ($rnd === 1) {
// no error with checkBenevolentUnionTypes: false (default even with l9 + strict rules)
return ['expr' => 'test', 1 => 'ok'];
} else {
// phpstan must understand 'expr' key is always present in the result,
// then there will be no error here neither
return array_merge($array, ['expr' => 'test', 1 => 'ok']);
}
}
}
Loading