Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
identifier: phpstanApi.instanceofType
count: 18
count: 19
path: src/Type/TypeCombinator.php

-
Expand Down
94 changes: 94 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use function array_values;
use function count;
use function get_class;
use function implode;
use function in_array;
use function is_int;
use function sprintf;
Expand Down Expand Up @@ -976,6 +977,53 @@ private static function optimizeConstantArrays(array $types): array
return $types;
}

// Stage 1: collapse same-key-set ConstantArrayType variants per-position
// before the (lossy) generalization below kicks in. Variants with the
// same key signature mergeWith losslessly into a single shape whose
// values at each position are the union of the variants' values, which
// drops the count while keeping the per-position structure. Without
// this, a list of N similarly-shaped records (e.g. bug-7963) hits the
// limit and the generalization decomposes every nested constant array
// into a flat `non-empty-list<unionOfAllPositionValues>`, losing the
// shape entirely.
$signatureGroups = [];
$nonConstantTypes = [];
foreach ($types as $idx => $type) {
if (!$type instanceof ConstantArrayType) {
$nonConstantTypes[$idx] = $type;
continue;
}
$signatureParts = [];
$signatureParts[] = $type->isList()->yes() ? 'L' : 'A';
foreach ($type->getKeyTypes() as $i => $keyType) {
$signatureParts[] = ($type->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue();
}
$signatureGroups[implode(',', $signatureParts)][] = $type;
}
if ($signatureGroups !== []) {
$collapsed = $nonConstantTypes;
$anyMerged = false;
foreach ($signatureGroups as $group) {
if (count($group) === 1) {
$collapsed[] = $group[0];
continue;
}
$merged = $group[0];
for ($i = 1, $count = count($group); $i < $count; $i++) {
$merged = $merged->mergeWith($group[$i]);
}
$collapsed[] = $merged;
$anyMerged = true;
}
if ($anyMerged) {
$types = array_values($collapsed);
$constantArrayValuesCount = self::countConstantArrayValueTypes($types);
if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
return $types;
}
}
}

$results = [];
$eachIsOversized = true;
foreach ($types as $type) {
Expand Down Expand Up @@ -1208,6 +1256,52 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
}
}

// Final pass: collapse the loop-accumulator pattern where each iteration
// produced a longer non-empty list variant. When several non-empty list
// ConstantArrayTypes survive earlier merging and together push the
// constant-array value count past the limit, fold them into a single
// non-empty-list<unionValueType> so the result stays bounded without
// going through the lossier optimizeConstantArrays generalization.
// Skip when every list variant shares one key signature — those collapse
// losslessly via the stage 1 same-key-set merge in optimizeConstantArrays
// (each position keeps its own value union), which is strictly more
// precise than this flat fold.
if ($preserveTaggedUnions && count($arraysToProcess) > 1) {
$listVariantIndices = [];
$listValueTypes = [];
$listVariants = [];
$listVariantSignatures = [];
foreach ($arraysToProcess as $idx => $arr) {
if (!$arr->isList()->yes() || !$arr->isIterableAtLeastOnce()->yes()) {
continue;
}
$listVariantIndices[] = $idx;
$listValueTypes[] = $arr->getIterableValueType();
$listVariants[] = $arr;
$signatureParts = [];
foreach ($arr->getKeyTypes() as $i => $keyType) {
$signatureParts[] = ($arr->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue();
}
$listVariantSignatures[implode(',', $signatureParts)] = true;
}
if (
count($listVariantIndices) >= 2
&& count($listVariantSignatures) >= 2
&& self::countConstantArrayValueTypes($listVariants) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
) {
$mergedValueType = self::union(...$listValueTypes);
$merged = self::intersect(
new ArrayType(new IntegerType(), $mergedValueType),
new NonEmptyArrayType(),
new AccessoryArrayListType(),
);
$newArrays[] = $merged;
foreach ($listVariantIndices as $idx) {
unset($arraysToProcess[$idx]);
}
}
}

return array_merge($newArrays, $arraysToProcess);
}

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-10717.php
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ function test(string $code): void
if ($country === 'fo' || $country === 'Faroese' || $country === 'Føroyskt') {
// foo
} else {
assertType('(bool|(literal-string&non-falsy-string))', $country);
assertType("'al'|'am'|'az'|'ba'|'bd'|'bg'|'br'|'by'|'ca'|'cn'|'cz'|'de'|'dk'|'ee'|'eo'|'er'|'es'|'es-ca'|'es-ga'|'et'|'eus'|'fi'|'fj'|'fr'|'fr-co'|'gb'|'gb-sct'|'gb-wls'|'ge'|'gh'|'gr'|'hmn'|'hr'|'ht'|'hu'|'hw'|'id'|'ie'|'il'|'in'|'iq'|'ir'|'is'|'it'|'jp'|'ke'|'kg'|'kh'|'kr'|'kz'|'la'|'lk'|'lt'|'lu'|'lv'|'mg'|'mk'|'ml'|'mm'|'mn'|'mt'|'mw'|'my'|'ne'|'ng'|'nl'|'no'|'np'|'nz'|'pf'|'ph'|'pk'|'pl'|'pt'|'ro'|'rs'|'ru'|'rw'|'sa'|'sd'|'se'|'si'|'sk'|'so'|'th'|'tj'|'to'|'tr'|'tw'|'ua'|'ug'|'uig'|'uz'|'vn'|'ws'|'za'|'zw'", $country);
}
}

2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-13509.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function alert(): ?array
return null;
}

assertType('non-empty-list<non-empty-array<literal-string&lowercase-string&non-falsy-string, int|(literal-string&non-falsy-string)|null>&oversized-array>&oversized-array', $alerts);
assertType("non-empty-list<array{message: 'Foo', details: 'bar', duration: int<1, max>|null, severity: 100}|array{message: 'Idle', duration: int<1, max>|null, severity: 23}|array{message: 'No Queue', duration: int<1, max>|null, severity: 60}|array{message: 'Not Scheduled', duration: null, severity: 25}|array{message: 'Offline', duration: int<1, max>|null, severity: 99}|array{message: 'On Break'|'On Lunch', duration: int<1, max>|null, severity: 24}|array{message: 'Running W/O Operator', duration: int<1, max>|null, severity: 75}>", $alerts);

usort($alerts, fn ($a, $b) => $b['severity'] <=> $a['severity']);

Expand Down
74 changes: 74 additions & 0 deletions tests/PHPStan/Analyser/nsrt/oversized-array-stages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace OversizedArrayStages;

use function PHPStan\Testing\assertType;

/**
* Phase 1: small enough that no generalization is needed. The
* cumulative `countConstantArrayValueTypes` stays under
* `ARRAY_COUNT_LIMIT`, so `optimizeConstantArrays` short-circuits and
* each variant is preserved literally.
*/
function phase1Small(): array
{
$arr = [];
$arr[] = ['kind' => 'a', 'value' => 1];
$arr[] = ['kind' => 'b', 'value' => 2];
$arr[] = ['kind' => 'c', 'value' => 3];
assertType("array{array{kind: 'a', value: 1}, array{kind: 'b', value: 2}, array{kind: 'c', value: 3}}", $arr);

return $arr;
}

/**
* Phase 2: conditional `$items[] = …` pushes leave behind a triangular
* union of list variants of progressively longer length. Together
* they push `countConstantArrayValueTypes` past `ARRAY_COUNT_LIMIT`,
* which triggers the `reduceArrays` final-pass list-collapse: the
* variants fold into `non-empty-list<unionValueType>` — the
* `unionValueType` is the union of each variant's iterable value
* type, which preserves the per-record `(kind, value, opts)`
* correlation as a tagged union of the eight original record shapes.
* Without the list-collapse, `optimizeConstantArrays`'s fallback
* generalization would decompose every record into a flat
* `non-empty-array<keyUnion, valueUnion>&oversized-array`, losing
* both the per-record correlation and the sealed shape.
*/
function phase2TriangularCollapse(): array
{
$items = [];

if (rand()) {
$items[] = ['kind' => 'k1', 'value' => 1, 'opts' => ['a' => 1]];
}
if (rand()) {
$items[] = ['kind' => 'k2', 'value' => 2, 'opts' => ['a' => 2]];
}
if (rand()) {
$items[] = ['kind' => 'k3', 'value' => 3, 'opts' => ['a' => 3]];
}
if (rand()) {
$items[] = ['kind' => 'k4', 'value' => 4, 'opts' => ['a' => 4]];
}
if (rand()) {
$items[] = ['kind' => 'k5', 'value' => 5, 'opts' => ['a' => 5]];
}
if (rand()) {
$items[] = ['kind' => 'k6', 'value' => 6, 'opts' => ['a' => 6]];
}
if (rand()) {
$items[] = ['kind' => 'k7', 'value' => 7, 'opts' => ['a' => 7]];
}
if (rand()) {
$items[] = ['kind' => 'k8', 'value' => 8, 'opts' => ['a' => 8]];
}

if ($items === []) {
return [];
}

assertType("non-empty-list<array{kind: 'k1', value: 1, opts: array{a: 1}}|array{kind: 'k2', value: 2, opts: array{a: 2}}|array{kind: 'k3', value: 3, opts: array{a: 3}}|array{kind: 'k4', value: 4, opts: array{a: 4}}|array{kind: 'k5', value: 5, opts: array{a: 5}}|array{kind: 'k6', value: 6, opts: array{a: 6}}|array{kind: 'k7', value: 7, opts: array{a: 7}}|array{kind: 'k8', value: 8, opts: array{a: 8}}>", $items);

return $items;
}
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,11 @@ public function testBug2885(): void
$this->analyse([__DIR__ . '/data/bug-2885.php'], []);
}

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

public function testMergeInheritedPhpDocs(): void
{
$this->analyse([__DIR__ . '/data/merge-inherited-return.php'], [
Expand Down
Loading
Loading