Skip to content

Commit b3ead90

Browse files
ondrejmirtesclaude
andcommitted
Extract Type::truncateListToSize() from TypeSpecifier
`specifyTypesForCountFuncCall` had ~100 lines of inline shape-specific narrowing (rebuild as N-element list, build with required prefix + optional middle, probe `hasOffsetValueType` for unbounded max, intersect with `HasOffsetValueType` accessories for non-CAT lists, plus several `LIMIT` bail-outs that returned the original array). The five branches were interleaved with the same outer `count()`-call / size-superType filters, making the per-shape logic hard to read. Push the per-list rebuild into a new `Type::truncateListToSize(Type $sizeType): Type`: - `ConstantArrayType`: required prefix `[0, min)`, optional middle `[min, max)` when `max` is set, or probe explicit offsets via `hasOffsetValueType` until `no` when `max` is unbounded. - `ArrayType`: same prefix/middle for bounded ranges; for unbounded `max`, intersect with `HasOffsetValueType` accessories. - Non-array types (`NonArrayTypeTrait`, `MaybeArrayTypeTrait`): return `ErrorType`. - `LateResolvableTypeTrait`: delegate to the resolved type. - `UnionType` / `IntersectionType` / `StaticType`: dispatch. - `NeverType` / `MixedType` / accessory types: identity-ish — the accessories represent metadata orthogonal to size, so the narrowing flows through `IntersectionType`'s dispatcher unchanged. The method is named for its actual contract: each implementation assumes a list shape. The call site (`TypeSpecifier`) is responsible for gating on outer list-ness — a CAT inside a `non-empty-list<T>` intersection may have its own `isList()` weakened to `Maybe` even though the aggregate is definitely a list. Letting the call site decide preserves the original behavior exactly. A private static helper `ConstantArrayType::extractTruncateListBounds()` peels `[min, max]` out of either a `ConstantIntegerType` (`[N, N]`) or an `IntegerRangeType`, sparing both implementations the same two-line shape check. Behavior preserved: full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 293a6ea commit b3ead90

17 files changed

Lines changed: 265 additions & 97 deletions

src/Analyser/TypeSpecifier.php

Lines changed: 9 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
use PHPStan\Type\BooleanType;
5050
use PHPStan\Type\ConditionalTypeForParameter;
5151
use PHPStan\Type\Constant\ConstantArrayType;
52-
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
5352
use PHPStan\Type\Constant\ConstantBooleanType;
5453
use PHPStan\Type\Constant\ConstantFloatType;
5554
use PHPStan\Type\Constant\ConstantIntegerType;
@@ -100,8 +99,6 @@
10099
final class TypeSpecifier
101100
{
102101

103-
private const MAX_ACCESSORIES_LIMIT = 8;
104-
105102
private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4;
106103

107104
/** @var MethodTypeSpecifyingExtension[][]|null */
@@ -1432,100 +1429,15 @@ private function specifyTypesForCountFuncCall(
14321429
continue;
14331430
}
14341431

1435-
if (
1436-
$sizeType instanceof ConstantIntegerType
1437-
&& $sizeType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
1438-
&& $isList->yes()
1439-
&& $arrayType->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, $sizeType->getValue() - 1))->yes()
1440-
) {
1441-
// turn optional offsets non-optional
1442-
$valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty();
1443-
for ($i = 0; $i < $sizeType->getValue(); $i++) {
1444-
$offsetType = new ConstantIntegerType($i);
1445-
$valueTypesBuilder->setOffsetValueType($offsetType, $arrayType->getOffsetValueType($offsetType));
1446-
}
1447-
$resultTypes[] = $valueTypesBuilder->getArray();
1448-
continue;
1449-
}
1450-
1451-
if (
1452-
$sizeType instanceof IntegerRangeType
1453-
&& $sizeType->getMin() !== null
1454-
&& $sizeType->getMin() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
1455-
&& $isList->yes()
1456-
&& $arrayType->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($sizeType->getMax() ?? $sizeType->getMin()) - 1))->yes()
1457-
) {
1458-
$builderData = [];
1459-
// turn optional offsets non-optional
1460-
for ($i = 0; $i < $sizeType->getMin(); $i++) {
1461-
$offsetType = new ConstantIntegerType($i);
1462-
$builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), false];
1463-
}
1464-
if ($sizeType->getMax() !== null) {
1465-
if ($sizeType->getMax() - $sizeType->getMin() > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
1466-
$resultTypes[] = $arrayType;
1467-
continue;
1468-
}
1469-
for ($i = $sizeType->getMin(); $i < $sizeType->getMax(); $i++) {
1470-
$offsetType = new ConstantIntegerType($i);
1471-
$builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), true];
1472-
}
1473-
} elseif ($arrayType->isConstantArray()->yes()) {
1474-
for ($i = $sizeType->getMin();; $i++) {
1475-
$offsetType = new ConstantIntegerType($i);
1476-
$hasOffset = $arrayType->hasOffsetValueType($offsetType);
1477-
if ($hasOffset->no()) {
1478-
break;
1479-
}
1480-
$builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), !$hasOffset->yes()];
1481-
}
1482-
} else {
1483-
$intersection = [];
1484-
$intersection[] = $arrayType;
1485-
$intersection[] = new NonEmptyArrayType();
1486-
1487-
$zero = new ConstantIntegerType(0);
1488-
$i = 0;
1489-
foreach ($builderData as [$offsetType, $valueType]) {
1490-
// non-empty-list already implies the offset 0
1491-
if ($zero->isSuperTypeOf($offsetType)->yes()) {
1492-
continue;
1493-
}
1494-
1495-
if ($i > self::MAX_ACCESSORIES_LIMIT) {
1496-
break;
1497-
}
1498-
1499-
$intersection[] = new HasOffsetValueType($offsetType, $valueType);
1500-
$i++;
1501-
}
1502-
1503-
$resultTypes[] = TypeCombinator::intersect(...$intersection);
1504-
continue;
1505-
}
1506-
1507-
if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
1508-
$resultTypes[] = $arrayType;
1509-
continue;
1510-
}
1511-
1512-
$builder = ConstantArrayTypeBuilder::createEmpty();
1513-
foreach ($builderData as [$offsetType, $valueType, $optional]) {
1514-
$builder->setOffsetValueType($offsetType, $valueType, $optional);
1515-
}
1516-
1517-
$builtArray = $builder->getArray();
1518-
if ($isList->yes() && !$builder->isList()) {
1519-
$constantArrays = $builtArray->getConstantArrays();
1520-
if (count($constantArrays) === 1) {
1521-
$builtArray = $constantArrays[0]->makeList();
1522-
}
1523-
}
1524-
$resultTypes[] = $builtArray;
1525-
continue;
1526-
}
1527-
1528-
$resultTypes[] = TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
1432+
// `truncateListToSize` rebuilds the inner array as a list shape
1433+
// — that's only sound when the *outer* type is definitely a
1434+
// list. The inner array alone may have `isList()` answer `Maybe`
1435+
// (e.g. `ArrayType<int<0, max>, T>` inside a
1436+
// `non-empty-list<T>` intersection), so the gate has to live
1437+
// here, not on the per-array method.
1438+
$resultTypes[] = $isList->yes()
1439+
? $arrayType->truncateListToSize($sizeType)
1440+
: TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
15291441
}
15301442

15311443
if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) {

src/Type/Accessory/AccessoryArrayListType.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
261261
return $this;
262262
}
263263

264+
public function truncateListToSize(Type $sizeType): Type
265+
{
266+
// List-ness survives a count narrowing — the resulting array is
267+
// still a list, just of a constrained size.
268+
return $this;
269+
}
270+
264271
public function makeListMaybe(): Type
265272
{
266273
// This accessory is the list assertion itself; weakening the

src/Type/Accessory/HasOffsetType.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
225225
return new MixedType();
226226
}
227227

228+
public function truncateListToSize(Type $sizeType): Type
229+
{
230+
// Having a specific offset is independent of the array's size bound.
231+
return $this;
232+
}
233+
228234
public function makeListMaybe(): Type
229235
{
230236
// Having an offset doesn't conflict with list-being-maybe.

src/Type/Accessory/HasOffsetValueType.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
314314
return new MixedType();
315315
}
316316

317+
public function truncateListToSize(Type $sizeType): Type
318+
{
319+
// `HasOffsetValueType` is metadata about a specific key — independent
320+
// of the array's overall size constraint.
321+
return $this;
322+
}
323+
317324
public function makeListMaybe(): Type
318325
{
319326
// Knowing a specific offset/value is independent of list-ness.

src/Type/Accessory/NonEmptyArrayType.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
248248
return new MixedType();
249249
}
250250

251+
public function truncateListToSize(Type $sizeType): Type
252+
{
253+
// The accessory only asserts "this array is non-empty" — truncating
254+
// to a positive size leaves that property in place.
255+
return $this;
256+
}
257+
251258
public function makeListMaybe(): Type
252259
{
253260
// Non-emptiness is independent of list-ness; weaken-list keeps it.

src/Type/Accessory/OversizedArrayType.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
225225
return $this;
226226
}
227227

228+
public function truncateListToSize(Type $sizeType): Type
229+
{
230+
return $this;
231+
}
232+
228233
public function makeListMaybe(): Type
229234
{
230235
return $this;

src/Type/ArrayType.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class ArrayType implements Type
5555
use UndecidedComparisonTypeTrait;
5656
use NonGeneralizableTypeTrait;
5757

58+
private const TRUNCATE_ACCESSORIES_LIMIT = 8;
59+
5860
private Type $keyType;
5961

6062
private ?TrinaryLogic $isList = null;
@@ -589,6 +591,74 @@ public function makeListMaybe(): Type
589591
return $this;
590592
}
591593

594+
public function truncateListToSize(Type $sizeType): Type
595+
{
596+
[$min, $max] = ConstantArrayType::extractTruncateListBounds($sizeType);
597+
598+
// `isList()` is deliberately NOT checked here — see the matching
599+
// note on `ConstantArrayType::truncateListToSize`. The call site
600+
// has already established outer list-ness.
601+
if (
602+
$min === null
603+
|| $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
604+
|| !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes()
605+
) {
606+
return TypeCombinator::intersect($this, new NonEmptyArrayType());
607+
}
608+
609+
if ($max !== null) {
610+
// Bounded range — `ArrayType` doesn't carry per-offset types, so
611+
// rebuild via the same CAT builder logic as `ConstantArrayType`.
612+
// The values come from `$this->getOffsetValueType()` (which on a
613+
// general `ArrayType` collapses to the iterable value type).
614+
if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
615+
return TypeCombinator::intersect($this, new NonEmptyArrayType());
616+
}
617+
618+
$builder = ConstantArrayTypeBuilder::createEmpty();
619+
for ($i = 0; $i < $min; $i++) {
620+
$offsetType = new ConstantIntegerType($i);
621+
$builder->setOffsetValueType($offsetType, $this->getOffsetValueType($offsetType), false);
622+
}
623+
for ($i = $min; $i < $max; $i++) {
624+
$offsetType = new ConstantIntegerType($i);
625+
$builder->setOffsetValueType($offsetType, $this->getOffsetValueType($offsetType), true);
626+
}
627+
628+
$builtArray = $builder->getArray();
629+
if (!$builder->isList()) {
630+
$constantArrays = $builtArray->getConstantArrays();
631+
if (count($constantArrays) === 1) {
632+
$builtArray = $constantArrays[0]->makeList();
633+
}
634+
}
635+
636+
return $builtArray;
637+
}
638+
639+
// Unbounded max on a general `ArrayType` list: we can't enumerate the
640+
// trailing entries, so anchor the lower bound with
641+
// `HasOffsetValueType` accessories (skipping offset 0 — already
642+
// implied by `NonEmptyArrayType`).
643+
$intersection = [$this, new NonEmptyArrayType()];
644+
$zero = new ConstantIntegerType(0);
645+
$added = 0;
646+
for ($i = 0; $i < $min; $i++) {
647+
$offsetType = new ConstantIntegerType($i);
648+
if ($zero->isSuperTypeOf($offsetType)->yes()) {
649+
continue;
650+
}
651+
if ($added > self::TRUNCATE_ACCESSORIES_LIMIT) {
652+
break;
653+
}
654+
655+
$intersection[] = new HasOffsetValueType($offsetType, $this->getOffsetValueType($offsetType));
656+
$added++;
657+
}
658+
659+
return TypeCombinator::intersect(...$intersection);
660+
}
661+
592662
public function mapValueType(callable $cb): Type
593663
{
594664
return $this->withTypes($this->keyType, $cb($this->getItemType()));

src/Type/Constant/ConstantArrayType.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,99 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
12921292
return TypeCombinator::union(...$types);
12931293
}
12941294

1295+
public function truncateListToSize(Type $sizeType): Type
1296+
{
1297+
[$min, $max] = self::extractTruncateListBounds($sizeType);
1298+
1299+
// `getMin() === null` ↔ unbounded below; the narrowing has no anchor
1300+
// to start from. Also bail out when the required prefix would exceed
1301+
// the array-shape limit — we can't enumerate that many keys.
1302+
// `isList()` is intentionally NOT checked here: the call site
1303+
// (`TypeSpecifier`) only invokes this when the *outer* aggregate is
1304+
// already a list, but a CAT inside a `non-empty-list` intersection
1305+
// may have its own `isList()` weakened to `Maybe`.
1306+
if (
1307+
$min === null
1308+
|| $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
1309+
|| !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes()
1310+
) {
1311+
return TypeCombinator::intersect($this, new NonEmptyArrayType());
1312+
}
1313+
1314+
// Required prefix `[0, $min)`: every value definitely present.
1315+
$builderData = [];
1316+
for ($i = 0; $i < $min; $i++) {
1317+
$offsetType = new ConstantIntegerType($i);
1318+
$builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), false];
1319+
}
1320+
1321+
if ($max !== null) {
1322+
// Optional middle `[$min, $max)`.
1323+
if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
1324+
return TypeCombinator::intersect($this, new NonEmptyArrayType());
1325+
}
1326+
for ($i = $min; $i < $max; $i++) {
1327+
$offsetType = new ConstantIntegerType($i);
1328+
$builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), true];
1329+
}
1330+
} else {
1331+
// Unbounded max: probe explicit keys from `$min` onward until
1332+
// `hasOffsetValueType` answers `no`. Each probe contributes one
1333+
// optional (or required, when `hasOffsetValueType` is `yes`) slot.
1334+
for ($i = $min;; $i++) {
1335+
$offsetType = new ConstantIntegerType($i);
1336+
$hasOffset = $this->hasOffsetValueType($offsetType);
1337+
if ($hasOffset->no()) {
1338+
break;
1339+
}
1340+
$builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), !$hasOffset->yes()];
1341+
}
1342+
}
1343+
1344+
if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
1345+
return TypeCombinator::intersect($this, new NonEmptyArrayType());
1346+
}
1347+
1348+
$builder = ConstantArrayTypeBuilder::createEmpty();
1349+
foreach ($builderData as [$offsetType, $valueType, $optional]) {
1350+
$builder->setOffsetValueType($offsetType, $valueType, $optional);
1351+
}
1352+
1353+
$builtArray = $builder->getArray();
1354+
// `setOffsetValueType` on a brand-new builder produces a list when
1355+
// the resulting offsets are sequential ints — but it may not preserve
1356+
// list-ness in every shape. Reattach it for the single-CAT case.
1357+
if (!$builder->isList()) {
1358+
$constantArrays = $builtArray->getConstantArrays();
1359+
if (count($constantArrays) === 1) {
1360+
$builtArray = $constantArrays[0]->makeList();
1361+
}
1362+
}
1363+
1364+
return $builtArray;
1365+
}
1366+
1367+
/**
1368+
* Extracts (min, max) bounds from a size type for `truncateListToSize`.
1369+
* `ConstantIntegerType(N)` → `[N, N]`. `IntegerRangeType` →
1370+
* `[$min, $max]`. Anything else returns `[null, null]` and the caller
1371+
* falls back to the non-precise path.
1372+
*
1373+
* @return array{?int, ?int}
1374+
*/
1375+
public static function extractTruncateListBounds(Type $sizeType): array
1376+
{
1377+
if ($sizeType instanceof ConstantIntegerType) {
1378+
return [$sizeType->getValue(), $sizeType->getValue()];
1379+
}
1380+
1381+
if ($sizeType instanceof IntegerRangeType) {
1382+
return [$sizeType->getMin(), $sizeType->getMax()];
1383+
}
1384+
1385+
return [null, null];
1386+
}
1387+
12951388
public function isIterableAtLeastOnce(): TrinaryLogic
12961389
{
12971390
$keysCount = count($this->keyTypes);

src/Type/IntersectionType.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
12281228
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType));
12291229
}
12301230

1231+
public function truncateListToSize(Type $sizeType): Type
1232+
{
1233+
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->truncateListToSize($sizeType));
1234+
}
1235+
12311236
public function makeListMaybe(): Type
12321237
{
12331238
return $this->intersectTypes(static fn (Type $type): Type => $type->makeListMaybe());

0 commit comments

Comments
 (0)