Skip to content
Draft
3 changes: 2 additions & 1 deletion src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class ConstantArrayType implements Type

private const DESCRIBE_LIMIT = 8;
private const CHUNK_FINITE_TYPES_LIMIT = 5;
private const OPTIONAL_KEYS_POWER_SET_LIMIT = 10;

private TrinaryLogic $isList;

Expand Down Expand Up @@ -232,7 +233,7 @@ public function getAllArrays(): array
return $this->allArrays;
}

if (count($this->optionalKeys) <= 10) {
if (count($this->optionalKeys) <= self::OPTIONAL_KEYS_POWER_SET_LIMIT) {
$optionalKeysCombinations = $this->powerSet($this->optionalKeys);
} else {
$optionalKeysCombinations = [
Expand Down
63 changes: 59 additions & 4 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
final class TypeCombinator
{

private const KEEP_SEPARATE_ARRAYS_LIMIT = 2;

public static function addNull(Type $type): Type
{
$nullType = new NullType();
Expand Down Expand Up @@ -774,22 +776,27 @@ private static function intersectWithSubtractedType(
}

/**
* @param Type[] $arrayTypes
* @return list<Type>
* @param list<Type> $arrayTypes
* @return array{list<Type>, list<Type>} tuple of [arrays with accessory wrappers stripped, common accessory types]
*/
private static function processArrayAccessoryTypes(array $arrayTypes): array
{
$isIterableAtLeastOnce = [];
$accessoryTypes = [];
$strippedArrays = [];
foreach ($arrayTypes as $i => $arrayType) {
$isIterableAtLeastOnce[] = $arrayType->isIterableAtLeastOnce();

if ($arrayType instanceof IntersectionType) {
$nonAccessoryInner = [];
$skipStrip = false;
foreach ($arrayType->getTypes() as $innerType) {
if ($innerType instanceof TemplateType) {
$skipStrip = true;
break;
}
if (!($innerType instanceof AccessoryType) && !($innerType instanceof CallableType)) {
$nonAccessoryInner[] = $innerType;
continue;
}
if ($innerType instanceof HasOffsetType) {
Expand All @@ -802,6 +809,9 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array

$accessoryTypes[$innerType->describe(VerbosityLevel::cache())][$i] = $innerType;
}
$strippedArrays[] = $skipStrip || count($nonAccessoryInner) !== 1 ? $arrayType : $nonAccessoryInner[0];
} else {
$strippedArrays[] = $arrayType;
}

if (!$arrayType->isConstantArray()->yes()) {
Expand Down Expand Up @@ -847,7 +857,7 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array
$commonAccessoryTypes[] = new NonEmptyArrayType();
}

return $commonAccessoryTypes;
return [$strippedArrays, $commonAccessoryTypes];
}

/**
Expand All @@ -860,7 +870,7 @@ private static function processArrayTypes(array $arrayTypes): array
return [];
}

$accessoryTypes = self::processArrayAccessoryTypes($arrayTypes);
[$strippedArrays, $accessoryTypes] = self::processArrayAccessoryTypes($arrayTypes);

if (count($arrayTypes) === 1) {
return [
Expand Down Expand Up @@ -922,6 +932,51 @@ private static function processArrayTypes(array $arrayTypes): array
return [self::intersect($reducedArrayTypes[0], ...$accessoryTypes)];
}

$hasEmptyConstantArray = false;
foreach ($arrayTypes as $arrayType) {
if ($arrayType->isIterableAtLeastOnce()->no() && $arrayType->isConstantArray()->yes()) {
$hasEmptyConstantArray = true;
break;
}
}

// TMP WIP: run for everyone, not just bleedingEdge, so CI exercises the new path
// Keep distinct array shapes (e.g. `list<int>|list<string>` rather than
// `list<int|string>`), but reduce the result before returning so analysis-
// emergent intermediate state (foreach over mixed[], deeply nested
// ArrayDimFetch writes, sequential `if ($x !== null) $arr['x'] = $x;`) does
// not blow up. `array{} | non-empty-array<X>` -> `array<X>` and the
// $overflowed safety valve still go to the old collapse path.
if (!$hasEmptyConstantArray && !$overflowed) {
// Dedupe using the stripped (accessory-free) describe as the key, but
// keep the original (with accessories) as the value. This collapses
// members that share an underlying shape but differ only in stacked
// per-element accessories like hasOffsetValue (cf. the optional-
// properties repro: 4 same-shape members differing only in stacked
// hasOffsetValue accessories), while preserving per-member accessories
// like list / non-empty on whichever original survives.
$deduped = [];
foreach ($strippedArrays as $i => $stripped) {
$deduped[$stripped->describe(VerbosityLevel::cache())] ??= $arrayTypes[$i];
}
$deduped = array_values($deduped);

// Budget: when more distinct shapes remain than a small union would have,
// they are almost certainly analysis-emergent rather than user-written;
// fall through to the old collapse path to keep downstream tractable.
// Mirrors optimizeConstantArrays() which generalizes once a similar
// budget (256 constant value types) is exceeded.
if (count($deduped) <= self::KEEP_SEPARATE_ARRAYS_LIMIT) {
$results = [];
foreach ($deduped as $arrayType) {
$results[] = $accessoryTypes === []
? $arrayType
: self::intersect($arrayType, ...$accessoryTypes);
}
return $results;
}
}

$templateArrayType = null;
foreach ($arrayTypes as $arrayType) {
if (!$arrayType instanceof TemplateArrayType) {
Expand Down
4 changes: 3 additions & 1 deletion src/Type/TypeUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
final class TypeUtils
{

private const FLATTEN_CONSTANT_ARRAYS_LIMIT = 16384;

/**
* @return list<ConstantIntegerType>
*/
Expand Down Expand Up @@ -157,7 +159,7 @@ public static function flattenTypes(Type $type): array
foreach ($constantArrays as $constantArray) {
$optionalCount = count($constantArray->getOptionalKeys());
$arrayCount = $optionalCount <= 20 ? (1 << $optionalCount) : PHP_INT_MAX;
if ($arrayCount > 16384 || $estimatedCount > 16384 / max($arrayCount, 1)) {
if ($arrayCount > self::FLATTEN_CONSTANT_ARRAYS_LIMIT || $estimatedCount > self::FLATTEN_CONSTANT_ARRAYS_LIMIT / max($arrayCount, 1)) {
$bail = true;
break;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array-slice.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function nonEmpty(array $a, array $b, array $c): void
{
assertType('array', array_slice($a, 1));
assertType('list', array_slice($b, 1));
assertType('array<int|string>', array_slice($c, 1));
assertType('array<int>|list<string>', array_slice($c, 1));
}

/**
Expand Down
118 changes: 118 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php declare(strict_types=1);

namespace ArrayUnionKeepSeparate;

use function PHPStan\Testing\assertType;

class KeepSeparate
{

/** @param array<int>|array<string> $arr */
public function plainArray(array $arr): void
{
assertType('array<int>|array<string>', $arr);
}

/** @param list<int>|list<string> $list */
public function listUnion(array $list): void
{
assertType('list<int>|list<string>', $list);
}

/** @param non-empty-array<int, int>|non-empty-array<string, string> $arr */
public function distinctKeyAndValue(array $arr): void
{
assertType('non-empty-array<int, int>|non-empty-array<string, string>', $arr);
}

/**
* Subsumption: array<int> is a subtype of array<int|string>, so the
* union must collapse to the wider member.
*
* @param array<int>|array<int|string> $arr
*/
public function subsumesWider(array $arr): void
{
assertType('array<int|string>', $arr);
}

/**
* Subsumption across list/array: list<int> is a subtype of array<int>.
*
* @param list<int>|array<int> $arr
*/
public function subsumesListIntoArray(array $arr): void
{
assertType('array<int>', $arr);
}

/**
* Identical members dedupe.
*
* @param array<int>|array<int> $arr
*/
public function identicalMembers(array $arr): void
{
assertType('array<int>', $arr);
}

/**
* Narrowing the value via offset-access propagates back to the array.
*
* @param list<int>|list<string> $list
*/
public function narrowByOffset(array $list): void
{
if (count($list) === 0) {
return;
}

if (is_string($list[0])) {
assertType('non-empty-list<string>&hasOffsetValue(0, string)', $list);
} else {
assertType('non-empty-list<int>&hasOffsetValue(0, int)', $list);
}
}

/**
* A mixed array is not a subtype of a union of homogeneous arrays.
*
* @param array<int>|array<string> $arr
*/
public function acceptsTaker(array $arr): void
{
}

public function callerWithMixedArray(): void
{
/** @var array<int|string> $mixed */
$mixed = [];
// phpstan-should-error: passing array<int|string> does not satisfy array<int>|array<string>
$this->acceptsTaker($mixed);
}

/**
* Constant array stays separate from a general array (no folding into
* array<int|string, ...>).
*
* @param array{foo: int}|array<string, string> $arr
*/
public function constantAndGeneral(array $arr): void
{
assertType("array{foo: int}|array<string, string>", $arr);
}

/**
* Iterating a union preserves the element union (existing UnionType
* behavior; regression guard for the keep-separate change).
*
* @param list<int>|list<string> $list
*/
public function iteration(array $list): void
{
foreach ($list as $value) {
assertType('int|string', $value);
}
}

}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array_splice.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function constantArrays(array $arr, array $arr2): void
/** @var array{x: 'x', y?: 'y', 3: 66}|array{z: 'z', 5?: 77, 4: int}|array<object|null> $arr2 */
$arr;
$extract = array_splice($arr, 0, 1, $arr2);
assertType('non-empty-array<\'b\'|int<0, max>, \'bar\'|\'baz\'|\'x\'|\'y\'|\'z\'|int|object|null>', $arr);
assertType("non-empty-array<'b'|int<0, max>, 'bar'|'baz'|'x'|'y'|'z'|int|object|null>", $arr);
assertType('array{\'foo\'}', $extract);
}

Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Analyser/nsrt/binary.php
Original file line number Diff line number Diff line change
Expand Up @@ -369,12 +369,12 @@ public function doFoo(array $generalArray)
assertType('0', min(0, ...[1, 2, 3]));
assertType('array{5, 6, 9}', max([1, 10, 8], [5, 6, 9]));
assertType('array{1, 1, 1, 1}', max(array(2, 2, 2), array(1, 1, 1, 1)));
assertType('array<int>', max($arrayOfUnknownIntegers, $arrayOfUnknownIntegers));
assertType('non-empty-array<int>&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, $arrayOfUnknownIntegers));
assertType('array{1, 1, 1, 1}', max(array(2, 2, 2), 5, array(1, 1, 1, 1)));
assertType('array{int, int, int}', max($arrayOfIntegers, 5));
assertType('array<int>', max($arrayOfUnknownIntegers, 5));
assertType('array<int>|int', max($arrayOfUnknownIntegers, $integer, $arrayOfUnknownIntegers));
assertType('array<int>', max($arrayOfUnknownIntegers, $conditionalInt));
assertType('non-empty-array<int>&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, 5));
assertType('(non-empty-array<int>&hasOffsetValue(108, int)&hasOffsetValue(42, int))|int', max($arrayOfUnknownIntegers, $integer, $arrayOfUnknownIntegers));
assertType('non-empty-array<int>&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, $conditionalInt));
assertType('5', min($arrayOfIntegers, 5));
assertType('5', min($arrayOfUnknownIntegers, 5));
assertType('1|2', min($arrayOfUnknownIntegers, $conditionalInt));
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10025.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function x(array $foos, array $bars): void
$arr[$bar->groupId]['bar'][] = $bar;
}

assertType('array<int, non-empty-array{foo?: non-empty-list<Bug10025\MyClass>, bar?: non-empty-list<Bug10025\MyClass>}>', $arr);
assertType('non-empty-array<int, array{foo?: non-empty-list<Bug10025\MyClass>, bar: non-empty-list<Bug10025\MyClass>}>|array<int, array{foo: non-empty-list<Bug10025\MyClass>}>', $arr);
foreach ($arr as $groupId => $group) {
if (isset($group['foo'])) {
}
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10089.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ protected function create_matrix(int $size): array
$matrix[$size - 1][8] = 3;

// non-empty-array<int, non-empty-array<int, 0|3>&hasOffsetValue(8, 3)>
assertType('non-empty-list<non-empty-array<int<0, max>, 0|3>>', $matrix);
assertType('non-empty-list<(non-empty-array<int<0, max>, 0|3>&hasOffsetValue(8, 3))|non-empty-list<0>>', $matrix);

for ($i = 0; $i <= $size; $i++) {
if ($matrix[$i][8] === 0) {
Expand Down
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/bug-10438.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ public function extract(SimpleXMLElement $data, string $type = 'Meta'): array
$meta[$key] = (string)$tag->{$valueName};
continue;
}
assertType('array<string, list<string>|string>', $meta);
assertType('array<string, array{}|array{string}|string>', $meta);
$meta[$key] = [];
assertType('array{}', $meta[$key]);
foreach ($tag->{$valueName} as $value) {
assertType('list<string>', $meta[$key]);
assertType('array{}|array{0: string, 1?: string}', $meta[$key]);
$meta[$key][] = (string)$value;
}
}
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10640.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
foreach (toRem() as $del) {
$changes[$add['id']]['del'][] = doSomething($del);
}
assertType('array<non-empty-array{add?: non-empty-list, del?: non-empty-list}>', $changes);
assertType('non-empty-array<array{add?: non-empty-list, del: non-empty-list}>|array<array{add: non-empty-list}>', $changes);

foreach ($changes as $changeSet) {
if (isset($changeSet['del'])) {
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10650.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ public function repro(array $distPoints): void
}
}

assertType('array<int<0, max>, \'x\'>', $ranges);
assertType("list<'x'>", $ranges);
}
}
13 changes: 13 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11176.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug11176;

use function PHPStan\Testing\assertType;

/** @param int|int[]|array{module: string} $arr */
function test(int|array $arr): void
{
assertType('array<int>|array{module: string}|int', $arr);
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-11518-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function blah(array $a): array
assertType('non-empty-array&hasOffset(\'thing\')', $a);
}

assertType('non-empty-array&hasOffsetValue(\'thing\', mixed)', $a);
assertType("non-empty-array&hasOffset('thing')", $a);

return $a;
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-12078.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function main()
]
*/

assertType("array<string, non-empty-array{'6M'?: non-empty-list<string>, '3M'?: non-empty-list<string>}>", $arrDataByKey);
assertType("non-empty-array<string, array{'6M'?: non-empty-list<string>, '3M': non-empty-list<string>}>|array<string, array{'6M': non-empty-list<string>}>", $arrDataByKey);
foreach ($arrDataByKey as $key => $arrDataByKeyForKey) {
assertType("non-empty-array{'6M'?: non-empty-list<string>, '3M'?: non-empty-list<string>}", $arrDataByKeyForKey);
echo [] === ($arrDataByKeyForKey['6M'] ?? []) ? 'No 6M data for key ' . $key . "\n" : 'We got 6M data for key ' . $key . "\n";
Expand Down
Loading
Loading