diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 0d0fa29b07e..19585a32f7e 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -118,6 +118,7 @@ class ConstantArrayType implements Type private const DESCRIBE_LIMIT = 8; private const CHUNK_FINITE_TYPES_LIMIT = 5; private const UNSEALED_ARRAY_SHAPES_LINK = 'https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys'; + private const OPTIONAL_KEYS_POWER_SET_LIMIT = 10; private TrinaryLogic $isList; @@ -389,7 +390,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 = [ diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 7a2e6e49666..e8aa4a80c1e 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -57,6 +57,8 @@ final class TypeCombinator { + private const KEEP_SEPARATE_ARRAYS_LIMIT = 4; + /** * Turned on by TurboExtensionEnabler when the native extension is active, so that * union(), intersect() and remove() route through the memoizing TypeCombinatorCache. @@ -1094,22 +1096,27 @@ private static function intersectWithSubtractedType( } /** - * @param Type[] $arrayTypes - * @return list + * @param list $arrayTypes + * @return array{list, list} 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) { @@ -1122,6 +1129,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()) { @@ -1167,7 +1177,7 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array $commonAccessoryTypes[] = new NonEmptyArrayType(); } - return $commonAccessoryTypes; + return [$strippedArrays, $commonAccessoryTypes]; } /** @@ -1218,7 +1228,7 @@ private static function processArrayTypes(array $arrayTypes): array return [$emptyArray, ...self::processArrayTypes($otherArrayTypes)]; } - $accessoryTypes = self::processArrayAccessoryTypes($arrayTypes); + [$strippedArrays, $accessoryTypes] = self::processArrayAccessoryTypes($arrayTypes); if (count($arrayTypes) === 1) { return [ @@ -1265,6 +1275,63 @@ 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|list` rather than + // `list`), 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` -> `array` still goes to + // the old collapse path. + if (!$hasEmptyConstantArray) { + // 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 when only one original has the shape. Two + // originals of one shape that differ in their accessories collapse to + // the stripped shape: keeping either of them would drop values of the + // other, and the accessories they share are added back below. + $deduped = []; + foreach ($strippedArrays as $i => $stripped) { + $key = $stripped->describe(VerbosityLevel::cache()); + if (!array_key_exists($key, $deduped)) { + $deduped[$key] = $arrayTypes[$i]; + continue; + } + if ($deduped[$key]->equals($arrayTypes[$i])) { + continue; + } + + $deduped[$key] = $stripped; + } + $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) { diff --git a/src/Type/TypeUtils.php b/src/Type/TypeUtils.php index 5d84f222c45..410a77445e8 100644 --- a/src/Type/TypeUtils.php +++ b/src/Type/TypeUtils.php @@ -23,6 +23,8 @@ final class TypeUtils { + private const FLATTEN_CONSTANT_ARRAYS_LIMIT = 16384; + /** * @return list */ @@ -159,7 +161,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; } diff --git a/tests/PHPStan/Analyser/nsrt/array-slice.php b/tests/PHPStan/Analyser/nsrt/array-slice.php index caf08c8d65a..553ac9bd1fb 100644 --- a/tests/PHPStan/Analyser/nsrt/array-slice.php +++ b/tests/PHPStan/Analyser/nsrt/array-slice.php @@ -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', array_slice($c, 1)); + assertType('array|list', array_slice($c, 1)); } /** diff --git a/tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php b/tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php new file mode 100644 index 00000000000..8532f858c07 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php @@ -0,0 +1,118 @@ +|array $arr */ + public function plainArray(array $arr): void + { + assertType('array|array', $arr); + } + + /** @param list|list $list */ + public function listUnion(array $list): void + { + assertType('list|list', $list); + } + + /** @param non-empty-array|non-empty-array $arr */ + public function distinctKeyAndValue(array $arr): void + { + assertType('non-empty-array|non-empty-array', $arr); + } + + /** + * Subsumption: array is a subtype of array, so the + * union must collapse to the wider member. + * + * @param array|array $arr + */ + public function subsumesWider(array $arr): void + { + assertType('array', $arr); + } + + /** + * Subsumption across list/array: list is a subtype of array. + * + * @param list|array $arr + */ + public function subsumesListIntoArray(array $arr): void + { + assertType('array', $arr); + } + + /** + * Identical members dedupe. + * + * @param array|array $arr + */ + public function identicalMembers(array $arr): void + { + assertType('array', $arr); + } + + /** + * Narrowing the value via offset-access propagates back to the array. + * + * @param list|list $list + */ + public function narrowByOffset(array $list): void + { + if (count($list) === 0) { + return; + } + + if (is_string($list[0])) { + assertType('non-empty-list&hasOffsetValue(0, string)', $list); + } else { + assertType('non-empty-list&hasOffsetValue(0, int)', $list); + } + } + + /** + * A mixed array is not a subtype of a union of homogeneous arrays. + * + * @param array|array $arr + */ + public function acceptsTaker(array $arr): void + { + } + + public function callerWithMixedArray(): void + { + /** @var array $mixed */ + $mixed = []; + // phpstan-should-error: passing array does not satisfy array|array + $this->acceptsTaker($mixed); + } + + /** + * Constant array stays separate from a general array (no folding into + * array). + * + * @param array{foo: int}|array $arr + */ + public function constantAndGeneral(array $arr): void + { + assertType("array{foo: int}|array", $arr); + } + + /** + * Iterating a union preserves the element union (existing UnionType + * behavior; regression guard for the keep-separate change). + * + * @param list|list $list + */ + public function iteration(array $list): void + { + foreach ($list as $value) { + assertType('int|string', $value); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array_splice.php b/tests/PHPStan/Analyser/nsrt/array_splice.php index 9e524d32125..a9e4694533d 100644 --- a/tests/PHPStan/Analyser/nsrt/array_splice.php +++ b/tests/PHPStan/Analyser/nsrt/array_splice.php @@ -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 $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("array{0: 'x', 1: 66|'y', 2: 66|'baz', b: 'bar', 3?: 'baz'}|array{0: 'z', 1: int, 2: 'baz'|int, b: 'bar', 3?: 'baz'}|non-empty-array<'b'|int<0, max>, 'bar'|'baz'|object|null>", $arr); assertType('array{\'foo\'}', $extract); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10025.php b/tests/PHPStan/Analyser/nsrt/bug-10025.php index 4d172f2aaca..7694995c90b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10025.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10025.php @@ -23,7 +23,7 @@ function x(array $foos, array $bars): void $arr[$bar->groupId]['bar'][] = $bar; } - assertType('array, bar?: non-empty-list}>', $arr); + assertType('non-empty-array, bar: non-empty-list}>|array}>', $arr); foreach ($arr as $groupId => $group) { if (isset($group['foo'])) { } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10089.php b/tests/PHPStan/Analyser/nsrt/bug-10089.php index 01aafa9790e..99f8014d040 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10089.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10089.php @@ -24,7 +24,7 @@ protected function create_matrix(int $size): array $matrix[$size - 1][8] = 3; // non-empty-array&hasOffsetValue(8, 3)> - assertType('non-empty-list, 0|3>>', $matrix); + assertType('non-empty-list<(non-empty-array, 0|3>&hasOffsetValue(8, 3))|non-empty-list<0>>', $matrix); for ($i = 0; $i <= $size; $i++) { if ($matrix[$i][8] === 0) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10640.php b/tests/PHPStan/Analyser/nsrt/bug-10640.php index 12452341c6f..02ae9795d62 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10640.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10640.php @@ -13,7 +13,7 @@ foreach (toRem() as $del) { $changes[$add['id']]['del'][] = doSomething($del); } -assertType('array', $changes); +assertType('non-empty-array|array', $changes); foreach ($changes as $changeSet) { if (isset($changeSet['del'])) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10650.php b/tests/PHPStan/Analyser/nsrt/bug-10650.php index 97ce54a9af0..814da29c2a8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10650.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10650.php @@ -28,6 +28,6 @@ public function repro(array $distPoints): void } } - assertType('array, \'x\'>', $ranges); + assertType("list<'x'>", $ranges); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11176.php b/tests/PHPStan/Analyser/nsrt/bug-11176.php new file mode 100644 index 00000000000..1a0f5cdb063 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11176.php @@ -0,0 +1,13 @@ += 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|array{module: string}|int', $arr); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11518-types.php b/tests/PHPStan/Analyser/nsrt/bug-11518-types.php index 6f63647dab2..bafee9b3a3a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11518-types.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11518-types.php @@ -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; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12078.php b/tests/PHPStan/Analyser/nsrt/bug-12078.php index c1d22c1ab11..d7624f3ccae 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12078.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12078.php @@ -57,7 +57,7 @@ function main() ] */ - assertType("array, '3M'?: non-empty-list}>", $arrDataByKey); + assertType("non-empty-array, '3M': non-empty-list}>|array}>", $arrDataByKey); foreach ($arrDataByKey as $key => $arrDataByKeyForKey) { assertType("non-empty-array{'6M'?: non-empty-list, '3M'?: non-empty-list}", $arrDataByKeyForKey); echo [] === ($arrDataByKeyForKey['6M'] ?? []) ? 'No 6M data for key ' . $key . "\n" : 'We got 6M data for key ' . $key . "\n"; diff --git a/tests/PHPStan/Analyser/nsrt/bug-12195.php b/tests/PHPStan/Analyser/nsrt/bug-12195.php new file mode 100644 index 00000000000..edb632b4ecb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12195.php @@ -0,0 +1,13 @@ +|array{0: null} $list + */ +function test(array $list): void +{ + assertType('array{null}|list', $list); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12434.php b/tests/PHPStan/Analyser/nsrt/bug-12434.php new file mode 100644 index 00000000000..9d85029f710 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12434.php @@ -0,0 +1,33 @@ +|non-empty-list $values + */ + public function sayHello(array $values): void + { + assertType('non-empty-list|non-empty-list', $values); + if ($this->testShape($values)) { + assertType('non-empty-list', $values); + } else { + assertType('non-empty-list', $values); + } + } + + /** + * @param non-empty-list|non-empty-list $values + * @phpstan-assert-if-true non-empty-list $values + */ + private function testShape(array $values): bool + { + return true; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php index ecab6997b83..b9e38331e58 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php @@ -19,11 +19,11 @@ public function parseData(array $data): array if (!array_key_exists('priceWithVat', $data['price'])) { $data['price']['priceWithVat'] = null; } - assertType("non-empty-array&hasOffsetValue('priceWithVat', mixed)", $data['price']); + assertType("non-empty-array&hasOffset('priceWithVat')", $data['price']); if (!array_key_exists('priceWithoutVat', $data['price'])) { $data['price']['priceWithoutVat'] = null; } - assertType("non-empty-array&hasOffsetValue('priceWithoutVat', mixed)&hasOffsetValue('priceWithVat', mixed)", $data['price']); + assertType("non-empty-array&hasOffset('priceWithoutVat')&hasOffset('priceWithVat')", $data['price']); } return $data; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index a1720ba36be..8b5d48a2865 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -113,10 +113,10 @@ public function doMaybeBar($listA, $listB): void } if (2 <= count($list)) { - assertType('non-empty-list&hasOffsetValue(1, int|string)', $list); + assertType('(non-empty-list&hasOffsetValue(1, int))|(non-empty-list&hasOffsetValue(1, string))', $list); assertType('int<2, max>', count($list)); } else { - assertType('list', $list); + assertType('list|non-empty-list', $list); assertType('int<0, 1>', count($list)); } } @@ -134,10 +134,10 @@ public function doMaybeArray($aArray, $aList): void } if (2 <= count($listOrArray)) { - assertType('non-empty-array', $listOrArray); + assertType('non-empty-array|non-empty-list', $listOrArray); assertType('int<2, max>', count($listOrArray)); } else { - assertType('array', $listOrArray); + assertType('array|non-empty-list', $listOrArray); assertType('int<0, 1>', count($listOrArray)); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14245.php b/tests/PHPStan/Analyser/nsrt/bug-14245.php index 63333b5de2e..066dbede812 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14245.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14245.php @@ -63,7 +63,7 @@ function listKnownHugeSize(): void { assertType('non-empty-array, int>', $list); } - assertType('array, int>', $list); + assertType('non-empty-array, int>|list', $list); } function overwriteKeyLast(): void { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14319.php b/tests/PHPStan/Analyser/nsrt/bug-14319.php index c253aa5cdc2..0713b829e9a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14319.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14319.php @@ -43,7 +43,7 @@ protected function edit(int|string|null $IdNum = null): void if ($rows['rap_roz3']) { $raport .= 'Roz: '.$rows['rap_roz3'].", \n"; } - assertType("(non-empty-array&hasOffsetValue('rap_br', mixed)&hasOffsetValue('rap_cz', mixed)&hasOffsetValue('rap_fil', mixed)&hasOffsetValue('rap_ks', mixed)&hasOffsetValue('rap_roz', mixed)&hasOffsetValue('rap_roz2', mixed)&hasOffsetValue('rap_roz3', mixed)&hasOffsetValue('rap_tr', mixed))|(ArrayAccess&hasOffsetValue('rap_br', mixed)&hasOffsetValue('rap_cz', mixed)&hasOffsetValue('rap_fil', mixed)&hasOffsetValue('rap_ks', mixed)&hasOffsetValue('rap_roz', mixed)&hasOffsetValue('rap_roz2', mixed)&hasOffsetValue('rap_roz3', mixed)&hasOffsetValue('rap_tr', mixed))", $rows); + assertType("(non-empty-array&hasOffsetValue('rap_br', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_cz', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_fil', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_ks', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_roz', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_roz2', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_roz3', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_tr', 0|0.0|''|'0'|array{}|false|null))|(ArrayAccess&hasOffsetValue('rap_br', mixed)&hasOffsetValue('rap_cz', mixed)&hasOffsetValue('rap_fil', mixed)&hasOffsetValue('rap_ks', mixed)&hasOffsetValue('rap_roz', mixed)&hasOffsetValue('rap_roz2', mixed)&hasOffsetValue('rap_roz3', mixed)&hasOffsetValue('rap_tr', mixed))", $rows); } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-4708.php b/tests/PHPStan/Analyser/nsrt/bug-4708.php index b6f23027223..b5f6ae47385 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4708.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4708.php @@ -87,10 +87,10 @@ function GetASCConfig() } } - assertType("non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result); + assertType("non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result); } - assertType('non-empty-array', $result); + assertType("(non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int))|array{result: false, dberror: 'xyz'}", $result); return $result; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5017.php b/tests/PHPStan/Analyser/nsrt/bug-5017.php index aa3abb30359..eeb2ef22411 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5017.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5017.php @@ -12,10 +12,10 @@ public function doFoo() $items = [0, 1, 2, 3, 4]; while ($items) { - assertType('non-empty-list>', $items); + assertType('array{0, 1, 2, 3, 4}|non-empty-list<2|3|4>', $items); $batch = array_splice($items, 0, 2); - assertType('list>', $items); - assertType('non-empty-list>', $batch); + assertType('list<2|3|4>', $items); + assertType('array{0, 1}|non-empty-list<2|3|4>', $batch); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-6173.php b/tests/PHPStan/Analyser/nsrt/bug-6173.php index 41ced55a638..b5702514efc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6173.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6173.php @@ -21,7 +21,7 @@ public function sayHello(array $ids1, array $ids2): bool $res[$id]['bar'] = $id; } - assertType('array', $res); + assertType('non-empty-array|array', $res); foreach ($res as $id => $r) { assertType('non-empty-array{foo?: int, bar?: int}', $r); return isset($r['foo']); diff --git a/tests/PHPStan/Analyser/nsrt/bug-9662.php b/tests/PHPStan/Analyser/nsrt/bug-9662.php index d88555a8632..207bbe92460 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9662.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9662.php @@ -20,63 +20,63 @@ function doFoo(string $s, $a, $strings, $mixed) { if (in_array('foo', $a, false)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array('foo', $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array('0', $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array('1', $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array(true, $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array(false, $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array($s, $a, true)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array($s, $a, false)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array($s, $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); diff --git a/tests/PHPStan/Analyser/nsrt/bug-9734.php b/tests/PHPStan/Analyser/nsrt/bug-9734.php index 52c3dd1c20a..456b59acef0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9734.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9734.php @@ -121,25 +121,25 @@ public function doFoo(array $a): void if ($this->assertIsStringList($a)) { assertType('list', $a); } else { - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); } if ($this->assertIsConstantList($a)) { assertType('array{string, string}', $a); } else { - assertType('array', $a); + assertType('non-empty-array|list', $a); } if ($this->assertIsOptionalConstantList($a)) { assertType('list{0?: string, 1?: string}', $a); } else { - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); } if ($this->assertIsStringArray($a)) { - assertType('array', $a); + assertType('non-empty-array|list{0?: string, 1?: string}', $a); } else { - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug7856.php b/tests/PHPStan/Analyser/nsrt/bug7856.php index 8da8b7343e2..42ba3a4a934 100644 --- a/tests/PHPStan/Analyser/nsrt/bug7856.php +++ b/tests/PHPStan/Analyser/nsrt/bug7856.php @@ -10,7 +10,7 @@ function doFoo() { $endDate = new DateTimeImmutable('+1year'); do { - assertType("list", $intervals); + assertType("array{'+1week', '+1months', '+6months', '+17months'}|list<'+17months'|'+1months'|'+6months'>", $intervals); $periodEnd = $periodEnd->modify(array_shift($intervals)); } while (count($intervals) > 0 && $periodEnd->format('U') < $endDate); } diff --git a/tests/PHPStan/Analyser/nsrt/composer-array-bug.php b/tests/PHPStan/Analyser/nsrt/composer-array-bug.php index cca8e42d9d4..1c38eb4cd08 100644 --- a/tests/PHPStan/Analyser/nsrt/composer-array-bug.php +++ b/tests/PHPStan/Analyser/nsrt/composer-array-bug.php @@ -57,7 +57,7 @@ public function doFoo(): void assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config); } - assertType("array", $this->config); + assertType("array", $this->config); } } diff --git a/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php b/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php index ca8dacc0a2b..463ba6b7dd6 100644 --- a/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php +++ b/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php @@ -12,9 +12,9 @@ class HelloWorld public function conditionalVarInTernary(array $innerHits): void { if (array_key_exists('nearest_premise', $innerHits) || array_key_exists('matching_premises', $innerHits)) { - assertType('non-empty-array', $innerHits); + assertType("non-empty-array&hasOffset('matching_premises')", $innerHits); $x = array_key_exists('nearest_premise', $innerHits) - ? assertType("non-empty-array&hasOffset('nearest_premise')", $innerHits) + ? assertType("non-empty-array&hasOffset('matching_premises')&hasOffset('nearest_premise')", $innerHits) : assertType("non-empty-array", $innerHits); assertType('non-empty-array', $innerHits); @@ -26,9 +26,9 @@ public function conditionalVarInTernary(array $innerHits): void public function conditionalVarInIf(array $innerHits): void { if (array_key_exists('nearest_premise', $innerHits) || array_key_exists('matching_premises', $innerHits)) { - assertType('non-empty-array', $innerHits); + assertType("non-empty-array&hasOffset('matching_premises')", $innerHits); if (array_key_exists('nearest_premise', $innerHits)) { - assertType("non-empty-array&hasOffset('nearest_premise')", $innerHits); + assertType("non-empty-array&hasOffset('matching_premises')&hasOffset('nearest_premise')", $innerHits); } else { assertType("non-empty-array", $innerHits); } diff --git a/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php b/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php index 0cf08820232..72e5cc83b64 100644 --- a/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php +++ b/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php @@ -8,5 +8,5 @@ function test($resource): void { - assertType('non-empty-list|false', fgetcsv($resource)); + assertType('array{null}|non-empty-list|false', fgetcsv($resource)); } diff --git a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php index 876baaff498..dced7bf5508 100644 --- a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php +++ b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php @@ -31,7 +31,7 @@ function getItemsWithForLoopInvertLastCond(array $items): array $items[$i] = 'hello'; } - assertType('list', $items); + assertType("non-empty-list&hasOffsetValue(0, 'hello')", $items); return $items; } @@ -47,6 +47,6 @@ function getItemsArray(array $items): array $items[$i] = 'hello'; } - assertType('array', $items); + assertType("non-empty-array&hasOffsetValue(0, 'hello')", $items); return $items; } diff --git a/tests/PHPStan/Analyser/nsrt/getopt.php b/tests/PHPStan/Analyser/nsrt/getopt.php index aae0e128e54..f0e095dee88 100644 --- a/tests/PHPStan/Analyser/nsrt/getopt.php +++ b/tests/PHPStan/Analyser/nsrt/getopt.php @@ -6,5 +6,5 @@ use function PHPStan\Testing\assertType; $opts = getopt("ab:c::", ["longopt1", "longopt2:", "longopt3::"], $restIndex); -assertType('(array|string|false>|false)', $opts); +assertType('(array|array>|array|false)', $opts); assertType('int<1, max>', $restIndex); diff --git a/tests/PHPStan/Analyser/nsrt/in-array-enum.php b/tests/PHPStan/Analyser/nsrt/in-array-enum.php index 66ae5799800..4ad2457a128 100644 --- a/tests/PHPStan/Analyser/nsrt/in-array-enum.php +++ b/tests/PHPStan/Analyser/nsrt/in-array-enum.php @@ -54,15 +54,15 @@ public function looseCheckEnumSpecifyHaystack(array $haystack): void } if (! in_array(rand() ? FooUnitEnum::A : FooUnitEnum::B, $haystack, true)) { - assertType('array', $haystack); + assertType('array|non-empty-array', $haystack); } if (! in_array(rand() ? 5 : 6, $haystack, true)) { - assertType('array', $haystack); + assertType('array|non-empty-array', $haystack); } if (! in_array(rand() ? 5 : rand(), $haystack, true)) { - assertType('array', $haystack); + assertType('array|non-empty-array', $haystack); } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index 9f2937e9bd1..25f5c172167 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -133,7 +133,7 @@ function sayEqualArrayShapeAfterNarrowedCount($arrA, array $arrB): void assertType('array{mixed, mixed, mixed}', $arrB); } assertType('array{int, int, int}', $arrA); - assertType('non-empty-list', $arrB); + assertType('non-empty-list&hasOffsetValue(1, mixed)', $arrB); } /** @@ -186,7 +186,7 @@ function skipRecursiveLeftCount($arrA, array $arrB): void assertType('array{mixed, mixed, mixed}', $arrB); } assertType('array{int, int, int}', $arrA); - assertType('non-empty-list', $arrB); + assertType('non-empty-list&hasOffsetValue(1, mixed)', $arrB); } /** diff --git a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php index b8bdfe121c8..6095fb5932d 100644 --- a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php +++ b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php @@ -83,11 +83,11 @@ public function nestedArrays(array $arr): void public function mixedArrays(array $arr): void { if (count($arr, COUNT_NORMAL) === 3) { - assertType("non-empty-array", $arr); // could be array{string, '', non-empty-string}|non-empty-array + assertType("array{string, '', non-empty-string}|non-empty-array", $arr); // could be array{string, '', non-empty-string}|non-empty-array } else { - assertType("array", $arr); // could be array{string, '', non-empty-string}|array + assertType("array{string, '', non-empty-string}|array", $arr); // could be array{string, '', non-empty-string}|array } - assertType("array", $arr); // could be array{string, '', non-empty-string}|array + assertType("array{string, '', non-empty-string}|array", $arr); // could be array{string, '', non-empty-string}|array } public function arrayIntRangeSize(): void diff --git a/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php b/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php index 4d3a9ef9202..fde952a6c26 100644 --- a/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php +++ b/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php @@ -68,7 +68,7 @@ function phase2TriangularCollapse(): array return []; } - assertType("non-empty-list", $items); + assertType("non-empty-list|non-empty-list", $items); return $items; } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index d8a68124cc7..a9db2c7cd11 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -122,7 +122,7 @@ function doOffsetCapture(string $s): void { function doUnknownFlags(string $s, int $flags): void { if (preg_match('/(foo)(bar)(baz)/xyz', $s, $matches, $flags)) { - assertType('array}|string|null>', $matches); + assertType('array}>|array}>|array', $matches); } assertType('array}|string|null>', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php b/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php index 431a78fcbf0..033c24fc897 100644 --- a/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php +++ b/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php @@ -29,7 +29,7 @@ public function getGroupedQueries(): void $connectionGroupedQueries[$key]['index'] = $i; // "Explain query" relies on query index in 'queries'. } - assertType("non-empty-array, count: 0, index: int}|array{sql: string, executionMS: float, types: array, count: int<1, max>, index: int}>", $connectionGroupedQueries); + assertType('non-empty-array, count: int<1, max>, index: int}>|non-empty-array, count: 0, index: int}>', $connectionGroupedQueries); $connectionGroupedQueries[$key]['executionMS'] += $query['executionMS']; assertType("non-empty-array, count: int<0, max>, index: int}>", $connectionGroupedQueries); ++$connectionGroupedQueries[$key]['count']; diff --git a/tests/PHPStan/Analyser/nsrt/shuffle.php b/tests/PHPStan/Analyser/nsrt/shuffle.php index 6b699e598ae..9a77d15f669 100644 --- a/tests/PHPStan/Analyser/nsrt/shuffle.php +++ b/tests/PHPStan/Analyser/nsrt/shuffle.php @@ -106,10 +106,10 @@ public function constantArrays6(array $arr): void { /** @var array{foo?: 1, bar: 2, }|array{baz: 3, foobar?: 4} $arr */ shuffle($arr); - assertType('non-empty-list<1|2|3|4>', $arr); + assertType('non-empty-list<1|2>|non-empty-list<3|4>', $arr); assertNativeType('list', $arr); assertType('non-empty-list<0|1>', array_keys($arr)); - assertType('non-empty-list<1|2|3|4>', array_values($arr)); + assertType('non-empty-list<1|2>|non-empty-list<3|4>', array_values($arr)); } public function mixed($arr): void diff --git a/tests/PHPStan/Analyser/nsrt/sort.php b/tests/PHPStan/Analyser/nsrt/sort.php index 47f9b3f030b..d87ef8ca7bc 100644 --- a/tests/PHPStan/Analyser/nsrt/sort.php +++ b/tests/PHPStan/Analyser/nsrt/sort.php @@ -71,18 +71,18 @@ public function constantArrayUnion(): void $arr1 = $arr; sort($arr1); - assertType('non-empty-list<1|2|5>', $arr1); - assertNativeType('non-empty-list<1|2|5>', $arr1); + assertType('non-empty-list<1|5>|non-empty-list<2>', $arr1); + assertNativeType('non-empty-list<1|5>|non-empty-list<2>', $arr1); $arr2 = $arr; rsort($arr2); - assertType('non-empty-list<1|2|5>', $arr2); - assertNativeType('non-empty-list<1|2|5>', $arr2); + assertType('non-empty-list<1|5>|non-empty-list<2>', $arr2); + assertNativeType('non-empty-list<1|5>|non-empty-list<2>', $arr2); $arr3 = $arr; usort($arr3, fn(int $a, int $b) => $a <=> $b); - assertType('non-empty-list<1|2|5>', $arr3); - assertNativeType('non-empty-list<1|2|5>', $arr3); + assertType('non-empty-list<1|5>|non-empty-list<2>', $arr3); + assertNativeType('non-empty-list<1|5>|non-empty-list<2>', $arr3); } /** @param array $arr */ diff --git a/tests/PHPStan/Rules/Arrays/data/bug-6000.php b/tests/PHPStan/Rules/Arrays/data/bug-6000.php index c409cbfda10..db68dce1ae1 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-6000.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-6000.php @@ -9,7 +9,7 @@ function (): void { $data = []; foreach ($data as $key => $value) { - assertType('array|string, array|string>', $data[$key]); + assertType('array|string>|list', $data[$key]); if ($key === 'classmap') { assertType('list', $data[$key]); assertType('list', $value); diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8467a.php b/tests/PHPStan/Rules/Arrays/data/bug-8467a.php index bba055bf546..e58e1c40b78 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-8467a.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-8467a.php @@ -27,7 +27,7 @@ public function foo (CompletePackageInterface $package): void { if (\count($package->getAutoload()) > 0) { $autoloadConfig = $package->getAutoload(); foreach ($autoloadConfig as $type => $autoloads) { - assertType('array|string, array|string>', $autoloadConfig[$type]); + assertType('array|string>|list', $autoloadConfig[$type]); if ($type === 'psr-0' || $type === 'psr-4') { } elseif ($type === 'classmap') { diff --git a/tests/PHPStan/Rules/Comparison/data/bug-4708.php b/tests/PHPStan/Rules/Comparison/data/bug-4708.php index 2afa1643402..22d52e7bc4b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-4708.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-4708.php @@ -88,7 +88,7 @@ function GetASCConfig() } } - assertType("non-empty-array", $result); + assertType("(non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int))|array{result: false, dberror: 'xyz'}", $result); return $result; } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 67b5837fe6d..ec76f65c63c 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -384,6 +384,21 @@ public function testBug13711(): void $this->analyse([__DIR__ . '/data/bug-13711.php'], []); } + public function testBug7759(): void + { + $this->analyse([__DIR__ . '/data/bug-7759.php'], []); + } + + public function testBug8963(): void + { + $this->analyse([__DIR__ . '/data/bug-8963.php'], [ + [ + 'Parameter #1 $array of function Bug8963\test expects array|array, array given.', + 13, + ], + ]); + } + public function testImplodeOnPhp74(): void { $errors = [ diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index b1868e4f98b..368e3b6560f 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -156,6 +156,14 @@ public function testBug7218(): void $this->analyse([__DIR__ . '/data/bug-7218.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug13394(): void + { + $this->checkExplicitMixed = false; + $this->checkNullables = true; + $this->analyse([__DIR__ . '/data/bug-13394.php'], []); + } + public function testBug5751(): void { $this->checkExplicitMixed = true; diff --git a/tests/PHPStan/Rules/Functions/data/bug-13394.php b/tests/PHPStan/Rules/Functions/data/bug-13394.php new file mode 100644 index 00000000000..a7ca4c6c11d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13394.php @@ -0,0 +1,19 @@ += 8.0 + +declare(strict_types=1); + +namespace Bug13394; + +/** + * @template T of int|array + * @param T $bar + * @return T + */ +function foo(int|array $bar): int|array +{ + if (is_array($bar) && isset($bar[0])) { + $unused = $bar[0] ? 1 : 2; + } + + return $bar; +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-7759.php b/tests/PHPStan/Rules/Functions/data/bug-7759.php new file mode 100644 index 00000000000..4c97b5314cf --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-7759.php @@ -0,0 +1,15 @@ + $in + */ +function check(array $in): void +{ + if (array_key_exists('test123', $in)) { + take_string($in['test123']); + } +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-8963.php b/tests/PHPStan/Rules/Functions/data/bug-8963.php new file mode 100644 index 00000000000..89eeb828053 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-8963.php @@ -0,0 +1,13 @@ +|array $array + */ +function test(array $array): void +{ +} + +$array = ['a', 2, 'c']; +test($array); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 8e6c4084026..ce8fe1e0da5 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4616,4 +4616,12 @@ public function testDeadCatch(): void ]); } + public function testBug15431(): void + { + $this->checkThisOnly = false; + $this->checkNullables = false; + $this->checkUnionTypes = false; + $this->analyse([__DIR__ . '/data/bug-15431.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php index f24317d6b61..ee2b6a7c1bf 100644 --- a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php @@ -676,4 +676,11 @@ public function testPureUnlessParameterPassedOverride(): void ]); } + public function testBug15006(): void + { + $this->reportMaybes = true; + $this->reportStatic = true; + $this->analyse([__DIR__ . '/data/bug-15006.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-15006.php b/tests/PHPStan/Rules/Methods/data/bug-15006.php new file mode 100644 index 00000000000..7a087b234e9 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15006.php @@ -0,0 +1,27 @@ + : array) + */ + public function bar(?int $field = null): array { + return [ ]; + } +} + +class child extends base { + /** + * @param ?non-negative-int $field + * @return ($field is null + * ? ($nullable is true ? ($numeric is true ? list : list) : ($numeric is true ? list : list)) + * : ($nullable is true ? ($numeric is true ? array : array) : ($numeric is true ? array : array)) + * ) + */ + #[\Override] + public function bar(?int $field = null, bool $nullable = true, bool $numeric = false): array { + return [ ]; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-15431.php b/tests/PHPStan/Rules/Methods/data/bug-15431.php new file mode 100644 index 00000000000..118482a5cbf --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15431.php @@ -0,0 +1,44 @@ + + */ +class WeakAnalysingBoxedArray +{ + /** @var T */ + public array $value = []; +} + +/** + * @template TKey of object|array + * @template TValue of object|array + * @template TOwner of object + */ +class WeakAnalysingMap +{ + /** + * @template T of TKey|TValue + * + * @param (T is object ? T : WeakAnalysingBoxedArray) $value + * + * @return T + */ + protected function unboxValue(object $value) + { + return $value instanceof WeakAnalysingBoxedArray + ? $value->value + : $value; + } + + /** + * @param (TValue is object ? TValue : WeakAnalysingBoxedArray) $v + * + * @return TValue|null + */ + public function unboxValueOnly($v) + { + return $this->unboxValue($v); + } +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-13921.php b/tests/PHPStan/Rules/Variables/data/bug-13921.php index 91f86ee0db4..384bec20227 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-13921.php +++ b/tests/PHPStan/Rules/Variables/data/bug-13921.php @@ -8,35 +8,35 @@ /** @param list> $x */ function foo(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("list>", $x); + assertType("non-empty-list>&hasOffsetValue(0, non-empty-array&hasOffsetValue('bar', string))", $x); var_dump($x[0] ?? null); } /** @param non-empty-list> $x */ function nonEmptyFoo(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("non-empty-list>", $x); + assertType("non-empty-list>&hasOffsetValue(0, non-empty-array&hasOffsetValue('bar', string))", $x); var_dump($x[0] ?? null); } /** @param list> $x */ function bar(array $x): void { var_dump($x[0] ?? null); - assertType("list>", $x); + assertType('non-empty-list>&hasOffsetValue(0, array)', $x); var_dump($x[0]['bar'] ?? null); } /** @param list> $x */ function baz(array $x): void { var_dump($x[1] ?? null); - assertType("list>", $x); + assertType('non-empty-list>&hasOffsetValue(1, array)', $x); var_dump($x[0]['bar'] ?? null); } /** @param list> $x */ function boo(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("list>", $x); + assertType("non-empty-list>&hasOffsetValue(0, non-empty-array&hasOffsetValue('bar', string))", $x); var_dump($x[1] ?? null); } @@ -51,6 +51,6 @@ function doBar(array $array) /** @param list $x */ function sooSimpleElement(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("list", $x); + assertType("non-empty-list&hasOffsetValue(0, SimpleXMLElement&hasOffset('bar'))", $x); var_dump($x[0] ?? null); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-8113.php b/tests/PHPStan/Rules/Variables/data/bug-8113.php index b07ba386ced..aec35e94c2e 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-8113.php +++ b/tests/PHPStan/Rules/Variables/data/bug-8113.php @@ -34,7 +34,7 @@ function () { ]; assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', non-empty-array&hasOffsetValue('review', null))", $review); unset($review['SurveyInvitation']['review']); - assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', array)", $review); + assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', array)", $review); } assertType('array>', $review); if (array_key_exists('User', $review['Review'])) { @@ -44,5 +44,5 @@ function () { unset($review['Review']['User']); assertType("non-empty-array&hasOffsetValue('Review', array)&hasOffsetValue('User', mixed)", $review); } - assertType("non-empty-array&hasOffsetValue('Review', array)", $review); + assertType("(non-empty-array&hasOffsetValue('Review', array)&hasOffsetValue('User', mixed))|(non-empty-array>&hasOffsetValue('Review', array))", $review); }; diff --git a/tests/bench/data/optional-properties-blowup.php b/tests/bench/data/optional-properties-blowup.php new file mode 100644 index 00000000000..0e94f86b4d8 --- /dev/null +++ b/tests/bench/data/optional-properties-blowup.php @@ -0,0 +1,430 @@ += 8.2 + +declare(strict_types=1); + +namespace OptionalPropertiesBlowup; + +class OwnerModel {}; +class PermissionsModel {}; + +final readonly class TemplateRepositoryModel implements \JsonSerializable +{ + public function __construct( + public null|int $id = null, + public null|string $node_id = null, + public null|string $name = null, + public null|string $full_name = null, + public null|OwnerModel $owner = null, + public null|bool $private = null, + public null|string $html_url = null, + public null|string $description = null, + public null|bool $fork = null, + public null|string $url = null, + public null|string $archive_url = null, + public null|string $assignees_url = null, + public null|string $blobs_url = null, + public null|string $branches_url = null, + public null|string $collaborators_url = null, + public null|string $comments_url = null, + public null|string $commits_url = null, + public null|string $compare_url = null, + public null|string $contents_url = null, + public null|string $contributors_url = null, + public null|string $deployments_url = null, + public null|string $downloads_url = null, + public null|string $events_url = null, + public null|string $forks_url = null, + public null|string $git_commits_url = null, + public null|string $git_refs_url = null, + public null|string $git_tags_url = null, + public null|string $git_url = null, + public null|string $issue_comment_url = null, + public null|string $issue_events_url = null, + public null|string $issues_url = null, + public null|string $keys_url = null, + public null|string $labels_url = null, + public null|string $languages_url = null, + public null|string $merges_url = null, + public null|string $milestones_url = null, + public null|string $notifications_url = null, + public null|string $pulls_url = null, + public null|string $releases_url = null, + public null|string $ssh_url = null, + public null|string $stargazers_url = null, + public null|string $statuses_url = null, + public null|string $subscribers_url = null, + public null|string $subscription_url = null, + public null|string $tags_url = null, + public null|string $teams_url = null, + public null|string $trees_url = null, + public null|string $clone_url = null, + public null|string $mirror_url = null, + public null|string $hooks_url = null, + public null|string $svn_url = null, + public null|string $homepage = null, + public null|string $language = null, + public null|int $forks_count = null, + public null|int $stargazers_count = null, + public null|int $watchers_count = null, + public null|int $size = null, + public null|string $default_branch = null, + public null|int $open_issues_count = null, + public null|bool $is_template = null, + /** @var null|list */ + public null|array $topics = null, + public null|bool $has_issues = null, + public null|bool $has_projects = null, + public null|bool $has_wiki = null, + public null|bool $has_pages = null, + public null|bool $has_downloads = null, + public null|bool $archived = null, + public null|bool $disabled = null, + public null|string $visibility = null, + public null|string $pushed_at = null, + public null|string $created_at = null, + public null|string $updated_at = null, + public null|PermissionsModel $permissions = null, + public null|bool $allow_rebase_merge = null, + public null|string $template_repository = null, + public null|string $temp_clone_token = null, + public null|bool $allow_squash_merge = null, + public null|bool $delete_branch_on_merge = null, + public null|bool $allow_merge_commit = null, + public null|int $subscribers_count = null, + public null|int $network_count = null, + ) {} + + /** + * @return array{ + * 'id'?: int, + * 'node_id'?: string, + * 'name'?: string, + * 'full_name'?: string, + * 'owner'?: OwnerModel, + * 'private'?: bool, + * 'html_url'?: string, + * 'description'?: string, + * 'fork'?: bool, + * 'url'?: string, + * 'archive_url'?: string, + * 'assignees_url'?: string, + * 'blobs_url'?: string, + * 'branches_url'?: string, + * 'collaborators_url'?: string, + * 'comments_url'?: string, + * 'commits_url'?: string, + * 'compare_url'?: string, + * 'contents_url'?: string, + * 'contributors_url'?: string, + * 'deployments_url'?: string, + * 'downloads_url'?: string, + * 'events_url'?: string, + * 'forks_url'?: string, + * 'git_commits_url'?: string, + * 'git_refs_url'?: string, + * 'git_tags_url'?: string, + * 'git_url'?: string, + * 'issue_comment_url'?: string, + * 'issue_events_url'?: string, + * 'issues_url'?: string, + * 'keys_url'?: string, + * 'labels_url'?: string, + * 'languages_url'?: string, + * 'merges_url'?: string, + * 'milestones_url'?: string, + * 'notifications_url'?: string, + * 'pulls_url'?: string, + * 'releases_url'?: string, + * 'ssh_url'?: string, + * 'stargazers_url'?: string, + * 'statuses_url'?: string, + * 'subscribers_url'?: string, + * 'subscription_url'?: string, + * 'tags_url'?: string, + * 'teams_url'?: string, + * 'trees_url'?: string, + * 'clone_url'?: string, + * 'mirror_url'?: string, + * 'hooks_url'?: string, + * 'svn_url'?: string, + * 'homepage'?: string, + * 'language'?: string, + * 'forks_count'?: int, + * 'stargazers_count'?: int, + * 'watchers_count'?: int, + * 'size'?: int, + * 'default_branch'?: string, + * 'open_issues_count'?: int, + * 'is_template'?: bool, + * 'topics'?: list, + * 'has_issues'?: bool, + * 'has_projects'?: bool, + * 'has_wiki'?: bool, + * 'has_pages'?: bool, + * 'has_downloads'?: bool, + * 'archived'?: bool, + * 'disabled'?: bool, + * 'visibility'?: string, + * 'pushed_at'?: string, + * 'created_at'?: string, + * 'updated_at'?: string, + * 'permissions'?: PermissionsModel, + * 'allow_rebase_merge'?: bool, + * 'template_repository'?: string, + * 'temp_clone_token'?: string, + * 'allow_squash_merge'?: bool, + * 'delete_branch_on_merge'?: bool, + * 'allow_merge_commit'?: bool, + * 'subscribers_count'?: int, + * 'network_count'?: int, + * } + */ + public function jsonSerialize(): array + { + $properties = []; + if ($this->id !== null) { + $properties['id'] = $this->id; + } + if ($this->node_id !== null) { + $properties['node_id'] = $this->node_id; + } + if ($this->name !== null) { + $properties['name'] = $this->name; + } + if ($this->full_name !== null) { + $properties['full_name'] = $this->full_name; + } + if ($this->owner !== null) { + $properties['owner'] = $this->owner; + } + if ($this->private !== null) { + $properties['private'] = $this->private; + } + if ($this->html_url !== null) { + $properties['html_url'] = $this->html_url; + } + if ($this->description !== null) { + $properties['description'] = $this->description; + } + if ($this->fork !== null) { + $properties['fork'] = $this->fork; + } + if ($this->url !== null) { + $properties['url'] = $this->url; + } + if ($this->archive_url !== null) { + $properties['archive_url'] = $this->archive_url; + } + if ($this->assignees_url !== null) { + $properties['assignees_url'] = $this->assignees_url; + } + if ($this->blobs_url !== null) { + $properties['blobs_url'] = $this->blobs_url; + } + if ($this->branches_url !== null) { + $properties['branches_url'] = $this->branches_url; + } + if ($this->collaborators_url !== null) { + $properties['collaborators_url'] = $this->collaborators_url; + } + if ($this->comments_url !== null) { + $properties['comments_url'] = $this->comments_url; + } + if ($this->commits_url !== null) { + $properties['commits_url'] = $this->commits_url; + } + if ($this->compare_url !== null) { + $properties['compare_url'] = $this->compare_url; + } + if ($this->contents_url !== null) { + $properties['contents_url'] = $this->contents_url; + } + if ($this->contributors_url !== null) { + $properties['contributors_url'] = $this->contributors_url; + } + if ($this->deployments_url !== null) { + $properties['deployments_url'] = $this->deployments_url; + } + if ($this->downloads_url !== null) { + $properties['downloads_url'] = $this->downloads_url; + } + if ($this->events_url !== null) { + $properties['events_url'] = $this->events_url; + } + if ($this->forks_url !== null) { + $properties['forks_url'] = $this->forks_url; + } + if ($this->git_commits_url !== null) { + $properties['git_commits_url'] = $this->git_commits_url; + } + if ($this->git_refs_url !== null) { + $properties['git_refs_url'] = $this->git_refs_url; + } + if ($this->git_tags_url !== null) { + $properties['git_tags_url'] = $this->git_tags_url; + } + if ($this->git_url !== null) { + $properties['git_url'] = $this->git_url; + } + if ($this->issue_comment_url !== null) { + $properties['issue_comment_url'] = $this->issue_comment_url; + } + if ($this->issue_events_url !== null) { + $properties['issue_events_url'] = $this->issue_events_url; + } + if ($this->issues_url !== null) { + $properties['issues_url'] = $this->issues_url; + } + if ($this->keys_url !== null) { + $properties['keys_url'] = $this->keys_url; + } + if ($this->labels_url !== null) { + $properties['labels_url'] = $this->labels_url; + } + if ($this->languages_url !== null) { + $properties['languages_url'] = $this->languages_url; + } + if ($this->merges_url !== null) { + $properties['merges_url'] = $this->merges_url; + } + if ($this->milestones_url !== null) { + $properties['milestones_url'] = $this->milestones_url; + } + if ($this->notifications_url !== null) { + $properties['notifications_url'] = $this->notifications_url; + } + if ($this->pulls_url !== null) { + $properties['pulls_url'] = $this->pulls_url; + } + if ($this->releases_url !== null) { + $properties['releases_url'] = $this->releases_url; + } + if ($this->ssh_url !== null) { + $properties['ssh_url'] = $this->ssh_url; + } + if ($this->stargazers_url !== null) { + $properties['stargazers_url'] = $this->stargazers_url; + } + if ($this->statuses_url !== null) { + $properties['statuses_url'] = $this->statuses_url; + } + if ($this->subscribers_url !== null) { + $properties['subscribers_url'] = $this->subscribers_url; + } + if ($this->subscription_url !== null) { + $properties['subscription_url'] = $this->subscription_url; + } + if ($this->tags_url !== null) { + $properties['tags_url'] = $this->tags_url; + } + if ($this->teams_url !== null) { + $properties['teams_url'] = $this->teams_url; + } + if ($this->trees_url !== null) { + $properties['trees_url'] = $this->trees_url; + } + if ($this->clone_url !== null) { + $properties['clone_url'] = $this->clone_url; + } + if ($this->mirror_url !== null) { + $properties['mirror_url'] = $this->mirror_url; + } + if ($this->hooks_url !== null) { + $properties['hooks_url'] = $this->hooks_url; + } + if ($this->svn_url !== null) { + $properties['svn_url'] = $this->svn_url; + } + if ($this->homepage !== null) { + $properties['homepage'] = $this->homepage; + } + if ($this->language !== null) { + $properties['language'] = $this->language; + } + if ($this->forks_count !== null) { + $properties['forks_count'] = $this->forks_count; + } + if ($this->stargazers_count !== null) { + $properties['stargazers_count'] = $this->stargazers_count; + } + if ($this->watchers_count !== null) { + $properties['watchers_count'] = $this->watchers_count; + } + if ($this->size !== null) { + $properties['size'] = $this->size; + } + if ($this->default_branch !== null) { + $properties['default_branch'] = $this->default_branch; + } + if ($this->open_issues_count !== null) { + $properties['open_issues_count'] = $this->open_issues_count; + } + if ($this->is_template !== null) { + $properties['is_template'] = $this->is_template; + } + if ($this->topics !== null) { + $properties['topics'] = $this->topics; + } + if ($this->has_issues !== null) { + $properties['has_issues'] = $this->has_issues; + } + if ($this->has_projects !== null) { + $properties['has_projects'] = $this->has_projects; + } + if ($this->has_wiki !== null) { + $properties['has_wiki'] = $this->has_wiki; + } + if ($this->has_pages !== null) { + $properties['has_pages'] = $this->has_pages; + } + if ($this->has_downloads !== null) { + $properties['has_downloads'] = $this->has_downloads; + } + if ($this->archived !== null) { + $properties['archived'] = $this->archived; + } + if ($this->disabled !== null) { + $properties['disabled'] = $this->disabled; + } + if ($this->visibility !== null) { + $properties['visibility'] = $this->visibility; + } + if ($this->pushed_at !== null) { + $properties['pushed_at'] = $this->pushed_at; + } + if ($this->created_at !== null) { + $properties['created_at'] = $this->created_at; + } + if ($this->updated_at !== null) { + $properties['updated_at'] = $this->updated_at; + } + if ($this->permissions !== null) { + $properties['permissions'] = $this->permissions; + } + if ($this->allow_rebase_merge !== null) { + $properties['allow_rebase_merge'] = $this->allow_rebase_merge; + } + if ($this->template_repository !== null) { + $properties['template_repository'] = $this->template_repository; + } + if ($this->temp_clone_token !== null) { + $properties['temp_clone_token'] = $this->temp_clone_token; + } + if ($this->allow_squash_merge !== null) { + $properties['allow_squash_merge'] = $this->allow_squash_merge; + } + if ($this->delete_branch_on_merge !== null) { + $properties['delete_branch_on_merge'] = $this->delete_branch_on_merge; + } + if ($this->allow_merge_commit !== null) { + $properties['allow_merge_commit'] = $this->allow_merge_commit; + } + if ($this->subscribers_count !== null) { + $properties['subscribers_count'] = $this->subscribers_count; + } + if ($this->network_count !== null) { + $properties['network_count'] = $this->network_count; + } + return $properties; + } +} diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index aa3bfd12158..99470a5f078 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -39,14 +39,14 @@ zend_class_entry *pt_ce_constant_array_type = nullptr; /* AcceptsResult.cpp: new ($trinary, $reasons, $lazyReasons) */ bool pt_result_object_create(zval *out, zend_class_entry *ce, zval *trinary, zval *reasons, zval *lazyReasons); -/* the twin's private constants */ +/* the twin's private constants; getAllArrays() enumerates the power set of + * up to OPTIONAL_KEYS_POWER_SET_LIMIT optional keys */ #define PT_CAT_DESCRIBE_LIMIT 8 #define PT_CAT_CHUNK_FINITE_TYPES_LIMIT 5 #define PT_CAT_UNSEALED_ARRAY_SHAPES_LINK "https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys" -/* the twin's inline thresholds: getAllArrays() enumerates the power set of - * up to this many optional keys, generalize() keeps per-offset accessories - * below this many required keys */ #define PT_CAT_POWER_SET_OPTIONAL_KEYS_LIMIT 10 +/* the twin's inline threshold: generalize() keeps per-offset accessories + * below this many required keys */ #define PT_CAT_GENERALIZE_OFFSET_ACCESSORIES_LIMIT 32 /* PHP's CASE_LOWER / CASE_UPPER */ @@ -7308,6 +7308,7 @@ PT_MINIT_REGISTRATION(pt_register_constant_array_type) cls.privateClassConstantLong("DESCRIBE_LIMIT", PT_CAT_DESCRIBE_LIMIT); cls.privateClassConstantLong("CHUNK_FINITE_TYPES_LIMIT", PT_CAT_CHUNK_FINITE_TYPES_LIMIT); cls.privateClassConstantString("UNSEALED_ARRAY_SHAPES_LINK", PT_CAT_UNSEALED_ARRAY_SHAPES_LINK); + cls.privateClassConstantLong("OPTIONAL_KEYS_POWER_SET_LIMIT", PT_CAT_POWER_SET_OPTIONAL_KEYS_LIMIT); cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) { zval *keyTypes, *valueTypes, *nextAutoIndexes = NULL, *optionalKeys = NULL, *isList = NULL, *unsealed = NULL; diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index fa52a6fc038..75cb9b7bad0 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -37,6 +37,8 @@ namespace sigs = ptdecl::TypeCombinator::sig; zend_class_entry *pt_ce_type_combinator = nullptr; +#define PT_TC_KEEP_SEPARATE_ARRAYS_LIMIT 4 + /* an UNDEF zv::Val / a negative int = pending exception */ #define PT_FAIL_IF_UNDEF(v) \ do { \ @@ -1859,8 +1861,10 @@ class TypeCombinator /* {{{ the array arm of doUnion() */ - /* private static processArrayAccessoryTypes(array $arrayTypes): list */ - static zv::Val processArrayAccessoryTypes(TypeList &arrayTypes) + /* private static processArrayAccessoryTypes(array $arrayTypes): + * array{list, list} - the common accessory types are returned, + * the arrays with accessory wrappers stripped go to strippedArrays */ + static zv::Val processArrayAccessoryTypes(TypeList &arrayTypes, TypeList &strippedArrays) { bool allIterableAtLeastOnce = true; zv::Arr accessoryTypes = zv::Arr::create(0); @@ -1876,14 +1880,24 @@ class TypeCombinator if (isInstance(arrayType, pt_ce_intersection_type)) { zv::Val innerTypes = getTypes(arrayType); PT_FAIL_IF_UNDEF(innerTypes); + zval *nonAccessoryInner = NULL; + uint32_t nonAccessoryInnerCount = 0; + bool skipStrip = false; for (zv::ArrayEntry entry : zv::ArrRef(innerTypes.raw())) { zv::Val innerType = copy(entry.value().raw()); int isTemplate = isInstanceMap(innerType.raw(), PT_CLASS_TEMPLATE_TYPE); PT_FAIL_IF_NEG(isTemplate); - if (isTemplate) break; + if (isTemplate) { + skipStrip = true; + break; + } int isAccessory = isInstanceMap(innerType.raw(), PT_CLASS_ACCESSORY_TYPE); PT_FAIL_IF_NEG(isAccessory); - if (!isAccessory && !isInstance(innerType.raw(), pt_ce_callable_type)) continue; + if (!isAccessory && !isInstance(innerType.raw(), pt_ce_callable_type)) { + nonAccessoryInner = entry.value().raw(); + nonAccessoryInnerCount++; + continue; + } if (isInstance(innerType.raw(), pt_ce_has_offset_type)) { zv::Val offsetType = pt_has_offset_type_get_offset_type(Z_OBJ_P(innerType.raw())); PT_FAIL_IF_UNDEF(offsetType); @@ -1909,6 +1923,9 @@ class TypeCombinator if (UNEXPECTED(key == NULL)) return zv::Val(); nestedIndexSet(accessoryTypes, key, (zend_ulong) i, std::move(innerType)); } + strippedArrays.push_back(copy(skipStrip || nonAccessoryInnerCount != 1 ? arrayType : nonAccessoryInner)); + } else { + strippedArrays.push_back(copy(arrayType)); } zend_long isConstantArray = callTrinary(arrayType, PT_LC("isconstantarray")); @@ -2048,7 +2065,8 @@ class TypeCombinator } } - zv::Val accessoryTypesArray = processArrayAccessoryTypes(arrayTypes); + TypeList strippedArrays; + zv::Val accessoryTypesArray = processArrayAccessoryTypes(arrayTypes, strippedArrays); PT_FAIL_IF_UNDEF(accessoryTypesArray); TypeList accessoryTypes; listFrom(accessoryTypesArray.raw(), accessoryTypes); @@ -2131,6 +2149,65 @@ class TypeCombinator return zv::Val(std::move(result)); } + bool hasEmptyConstantArray = false; + for (zv::Val &arrayType : arrayTypes) { + int empty = isEmptyConstantArray(arrayType.raw()); + PT_FAIL_IF_NEG(empty); + if (empty == 1) { + hasEmptyConstantArray = true; + break; + } + } + + // TMP WIP: run for everyone, not just bleedingEdge, so CI exercises the new path + // Keep distinct array shapes (e.g. `list|list` rather than + // `list`), but reduce the result before returning so analysis- + // emergent intermediate state does not blow up. `array{} | non-empty-array` + // -> `array` still goes to the old collapse path. + if (!hasEmptyConstantArray) { + // Dedupe using the stripped (accessory-free) describe as the key, but + // keep the original (with accessories) as the value when only one + // original has the shape. Two originals of one shape that differ in + // their accessories collapse to the stripped shape: keeping either of + // them would drop values of the other, and the accessories they share + // are added back below. + Level cacheLevel(PT_LC("cache")); + zv::Arr deduped = zv::Arr::create((uint32_t) arrayTypes.size()); + for (size_t i = 0; i < strippedArrays.size(); i++) { + zv::Val description = describe(strippedArrays[i].raw(), cacheLevel); + zend_string *key = describedKey(description); + if (UNEXPECTED(key == NULL)) return zv::Val(); + zval *kept = zend_symtable_find(deduped.table(), key); + if (kept == NULL) { + deduped.set(key, zv::Val::copyOf(arrayTypes[i].ref())); + continue; + } + zv::Val equal = call(kept, PT_LC("equals"), 1, arrayTypes[i].raw()); + PT_FAIL_IF_UNDEF(equal); + if (zend_is_true(equal.raw())) continue; + + deduped.set(key, zv::Val::copyOf(strippedArrays[i].ref())); + } + + // 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. + if (countOf(deduped.raw()) <= PT_TC_KEEP_SEPARATE_ARRAYS_LIMIT) { + zv::Arr results = zv::Arr::create(countOf(deduped.raw())); + for (zv::ArrayEntry entry : zv::ArrRef(deduped.raw())) { + zval *arrayType = entry.value().raw(); + if (accessoryTypes.empty()) { + results.push(zv::Ref(arrayType)); + continue; + } + zv::Val intersected = intersectWith(arrayType, accessoryTypes); + PT_FAIL_IF_UNDEF(intersected); + results.push(std::move(intersected)); + } + return zv::Val(std::move(results)); + } + } + zval *templateArrayType = NULL; for (zv::Val &arrayType : arrayTypes) { if (!isInstance(arrayType.raw(), pt_ce_template_array_type)) { @@ -4420,6 +4497,7 @@ PT_MINIT_REGISTRATION(pt_register_type_combinator) reg::Class cls("PHPStan\\Type\\TypeCombinator"); ptdecl::TypeCombinator::declareClass(cls); + cls.privateClassConstantLong("KEEP_SEPARATE_ARRAYS_LIMIT", PT_TC_KEEP_SEPARATE_ARRAYS_LIMIT); cls.privateStaticTypedPropertyDefaultNull("cacheEnabled", MAY_BE_BOOL); cls.method(sigs::clearCache, [](INTERNAL_FUNCTION_PARAMETERS) { diff --git a/turbo-ext/src/TypeUtils.cpp b/turbo-ext/src/TypeUtils.cpp index ae07279ed9c..7def5fb2ef0 100644 --- a/turbo-ext/src/TypeUtils.cpp +++ b/turbo-ext/src/TypeUtils.cpp @@ -23,6 +23,9 @@ namespace sigs = ptdecl::TypeUtils::sig; zend_class_entry *pt_ce_type_utils = nullptr; +/* the twin's private constant */ +#define PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT 16384 + namespace phpstanturbo { struct ConstantArrayCombinationContext @@ -215,7 +218,7 @@ class TypeUtils * expanding: each ConstantArrayType with N optional keys produces * 2^N variants from getAllArrays(), multiplied across the * constant arrays; bail out above the limit rather than allocate - * O(2^N). The twin's `16384 / max($arrayCount, 1)` is a float + * O(2^N). The twin's `FLATTEN_CONSTANT_ARRAYS_LIMIT / max($arrayCount, 1)` is a float * division — compared as one here. */ zend_long estimatedCount = 1; bool bail = false; @@ -233,7 +236,7 @@ class TypeUtils } zend_long optionalCount = (zend_long) zv::ArrRef(optionalKeys.raw()).size(); zend_long arrayCount = optionalCount <= 20 ? ((zend_long) 1 << optionalCount) : ZEND_LONG_MAX; - if (arrayCount > 16384 || (double) estimatedCount > 16384.0 / (double) (arrayCount > 1 ? arrayCount : 1)) { + if (arrayCount > PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT || (double) estimatedCount > (double) PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT / (double) (arrayCount > 1 ? arrayCount : 1)) { bail = true; break; } @@ -481,6 +484,7 @@ PT_MINIT_REGISTRATION(pt_register_type_utils) { reg::Class cls("PHPStan\\Type\\TypeUtils"); ptdecl::TypeUtils::declareClass(cls); + cls.privateClassConstantLong("FLATTEN_CONSTANT_ARRAYS_LIMIT", PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT); ptdecl::TypeUtils::declareProperties(cls); static const reg::Arg nullableThisType = reg::obj("", "PHPStan\\Type\\ThisType", true);