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
106 changes: 9 additions & 97 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
use PHPStan\Type\BooleanType;
use PHPStan\Type\ConditionalTypeForParameter;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantFloatType;
use PHPStan\Type\Constant\ConstantIntegerType;
Expand Down Expand Up @@ -100,8 +99,6 @@
final class TypeSpecifier
{

private const MAX_ACCESSORIES_LIMIT = 8;

private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4;

/** @var MethodTypeSpecifyingExtension[][]|null */
Expand Down Expand Up @@ -1432,100 +1429,15 @@ private function specifyTypesForCountFuncCall(
continue;
}

if (
$sizeType instanceof ConstantIntegerType
&& $sizeType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
&& $isList->yes()
&& $arrayType->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, $sizeType->getValue() - 1))->yes()
) {
// turn optional offsets non-optional
$valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty();
for ($i = 0; $i < $sizeType->getValue(); $i++) {
$offsetType = new ConstantIntegerType($i);
$valueTypesBuilder->setOffsetValueType($offsetType, $arrayType->getOffsetValueType($offsetType));
}
$resultTypes[] = $valueTypesBuilder->getArray();
continue;
}

if (
$sizeType instanceof IntegerRangeType
&& $sizeType->getMin() !== null
&& $sizeType->getMin() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
&& $isList->yes()
&& $arrayType->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($sizeType->getMax() ?? $sizeType->getMin()) - 1))->yes()
) {
$builderData = [];
// turn optional offsets non-optional
for ($i = 0; $i < $sizeType->getMin(); $i++) {
$offsetType = new ConstantIntegerType($i);
$builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), false];
}
if ($sizeType->getMax() !== null) {
if ($sizeType->getMax() - $sizeType->getMin() > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
$resultTypes[] = $arrayType;
continue;
}
for ($i = $sizeType->getMin(); $i < $sizeType->getMax(); $i++) {
$offsetType = new ConstantIntegerType($i);
$builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), true];
}
} elseif ($arrayType->isConstantArray()->yes()) {
for ($i = $sizeType->getMin();; $i++) {
$offsetType = new ConstantIntegerType($i);
$hasOffset = $arrayType->hasOffsetValueType($offsetType);
if ($hasOffset->no()) {
break;
}
$builderData[] = [$offsetType, $arrayType->getOffsetValueType($offsetType), !$hasOffset->yes()];
}
} else {
$intersection = [];
$intersection[] = $arrayType;
$intersection[] = new NonEmptyArrayType();

$zero = new ConstantIntegerType(0);
$i = 0;
foreach ($builderData as [$offsetType, $valueType]) {
// non-empty-list already implies the offset 0
if ($zero->isSuperTypeOf($offsetType)->yes()) {
continue;
}

if ($i > self::MAX_ACCESSORIES_LIMIT) {
break;
}

$intersection[] = new HasOffsetValueType($offsetType, $valueType);
$i++;
}

$resultTypes[] = TypeCombinator::intersect(...$intersection);
continue;
}

if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
$resultTypes[] = $arrayType;
continue;
}

$builder = ConstantArrayTypeBuilder::createEmpty();
foreach ($builderData as [$offsetType, $valueType, $optional]) {
$builder->setOffsetValueType($offsetType, $valueType, $optional);
}

$builtArray = $builder->getArray();
if ($isList->yes() && !$builder->isList()) {
$constantArrays = $builtArray->getConstantArrays();
if (count($constantArrays) === 1) {
$builtArray = $constantArrays[0]->makeList();
}
}
$resultTypes[] = $builtArray;
continue;
}

$resultTypes[] = TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
// `truncateListToSize` rebuilds the inner array as a list shape
// — that's only sound when the *outer* type is definitely a
// list. The inner array alone may have `isList()` answer `Maybe`
// (e.g. `ArrayType<int<0, max>, T>` inside a
// `non-empty-list<T>` intersection), so the gate has to live
// here, not on the per-array method.
$resultTypes[] = $isList->yes()
? $arrayType->truncateListToSize($sizeType)
: TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
}

if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) {
Expand Down
7 changes: 7 additions & 0 deletions src/Type/Accessory/AccessoryArrayListType.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return $this;
}

public function truncateListToSize(Type $sizeType): Type
{
// List-ness survives a count narrowing — the resulting array is
// still a list, just of a constrained size.
return $this;
}

public function makeListMaybe(): Type
{
// This accessory is the list assertion itself; weakening the
Expand Down
6 changes: 6 additions & 0 deletions src/Type/Accessory/HasOffsetType.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return new MixedType();
}

public function truncateListToSize(Type $sizeType): Type
{
// Having a specific offset is independent of the array's size bound.
return $this;
}

public function makeListMaybe(): Type
{
// Having an offset doesn't conflict with list-being-maybe.
Expand Down
7 changes: 7 additions & 0 deletions src/Type/Accessory/HasOffsetValueType.php
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return new MixedType();
}

public function truncateListToSize(Type $sizeType): Type
{
// `HasOffsetValueType` is metadata about a specific key — independent
// of the array's overall size constraint.
return $this;
}

public function makeListMaybe(): Type
{
// Knowing a specific offset/value is independent of list-ness.
Expand Down
7 changes: 7 additions & 0 deletions src/Type/Accessory/NonEmptyArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,13 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return new MixedType();
}

public function truncateListToSize(Type $sizeType): Type
{
// The accessory only asserts "this array is non-empty" — truncating
// to a positive size leaves that property in place.
return $this;
}

public function makeListMaybe(): Type
{
// Non-emptiness is independent of list-ness; weaken-list keeps it.
Expand Down
5 changes: 5 additions & 0 deletions src/Type/Accessory/OversizedArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return $this;
}

public function truncateListToSize(Type $sizeType): Type
{
return $this;
}

public function makeListMaybe(): Type
{
return $this;
Expand Down
70 changes: 70 additions & 0 deletions src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class ArrayType implements Type
use UndecidedComparisonTypeTrait;
use NonGeneralizableTypeTrait;

private const TRUNCATE_ACCESSORIES_LIMIT = 8;

private Type $keyType;

private ?TrinaryLogic $isList = null;
Expand Down Expand Up @@ -589,6 +591,74 @@ public function makeListMaybe(): Type
return $this;
}

public function truncateListToSize(Type $sizeType): Type
{
[$min, $max] = ConstantArrayType::extractTruncateListBounds($sizeType);

// `isList()` is deliberately NOT checked here — see the matching
// note on `ConstantArrayType::truncateListToSize`. The call site
// has already established outer list-ness.
if (
$min === null
|| $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
|| !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes()
) {
return TypeCombinator::intersect($this, new NonEmptyArrayType());
}

if ($max !== null) {
// Bounded range — `ArrayType` doesn't carry per-offset types, so
// rebuild via the same CAT builder logic as `ConstantArrayType`.
// The values come from `$this->getOffsetValueType()` (which on a
// general `ArrayType` collapses to the iterable value type).
if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
return TypeCombinator::intersect($this, new NonEmptyArrayType());
}

$builder = ConstantArrayTypeBuilder::createEmpty();
for ($i = 0; $i < $min; $i++) {
$offsetType = new ConstantIntegerType($i);
$builder->setOffsetValueType($offsetType, $this->getOffsetValueType($offsetType), false);
}
for ($i = $min; $i < $max; $i++) {
$offsetType = new ConstantIntegerType($i);
$builder->setOffsetValueType($offsetType, $this->getOffsetValueType($offsetType), true);
}

$builtArray = $builder->getArray();
if (!$builder->isList()) {
$constantArrays = $builtArray->getConstantArrays();
if (count($constantArrays) === 1) {
$builtArray = $constantArrays[0]->makeList();
}
}

return $builtArray;
}

// Unbounded max on a general `ArrayType` list: we can't enumerate the
// trailing entries, so anchor the lower bound with
// `HasOffsetValueType` accessories (skipping offset 0 — already
// implied by `NonEmptyArrayType`).
$intersection = [$this, new NonEmptyArrayType()];
$zero = new ConstantIntegerType(0);
$added = 0;
for ($i = 0; $i < $min; $i++) {
$offsetType = new ConstantIntegerType($i);
if ($zero->isSuperTypeOf($offsetType)->yes()) {
continue;
}
if ($added > self::TRUNCATE_ACCESSORIES_LIMIT) {
break;
}

$intersection[] = new HasOffsetValueType($offsetType, $this->getOffsetValueType($offsetType));
$added++;
}

return TypeCombinator::intersect(...$intersection);
}

public function mapValueType(callable $cb): Type
{
return $this->withTypes($this->keyType, $cb($this->getItemType()));
Expand Down
93 changes: 93 additions & 0 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,99 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return TypeCombinator::union(...$types);
}

public function truncateListToSize(Type $sizeType): Type
{
[$min, $max] = self::extractTruncateListBounds($sizeType);

// `getMin() === null` ↔ unbounded below; the narrowing has no anchor
// to start from. Also bail out when the required prefix would exceed
// the array-shape limit — we can't enumerate that many keys.
// `isList()` is intentionally NOT checked here: the call site
// (`TypeSpecifier`) only invokes this when the *outer* aggregate is
// already a list, but a CAT inside a `non-empty-list` intersection
// may have its own `isList()` weakened to `Maybe`.
if (
$min === null
|| $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
|| !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes()
) {
return TypeCombinator::intersect($this, new NonEmptyArrayType());
}

// Required prefix `[0, $min)`: every value definitely present.
$builderData = [];
for ($i = 0; $i < $min; $i++) {
$offsetType = new ConstantIntegerType($i);
$builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), false];
}

if ($max !== null) {
// Optional middle `[$min, $max)`.
if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
return TypeCombinator::intersect($this, new NonEmptyArrayType());
}
for ($i = $min; $i < $max; $i++) {
$offsetType = new ConstantIntegerType($i);
$builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), true];
}
} else {
// Unbounded max: probe explicit keys from `$min` onward until
// `hasOffsetValueType` answers `no`. Each probe contributes one
// optional (or required, when `hasOffsetValueType` is `yes`) slot.
for ($i = $min;; $i++) {
$offsetType = new ConstantIntegerType($i);
$hasOffset = $this->hasOffsetValueType($offsetType);
if ($hasOffset->no()) {
break;
}
$builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), !$hasOffset->yes()];
}
}

if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
return TypeCombinator::intersect($this, new NonEmptyArrayType());
}

$builder = ConstantArrayTypeBuilder::createEmpty();
foreach ($builderData as [$offsetType, $valueType, $optional]) {
$builder->setOffsetValueType($offsetType, $valueType, $optional);
}

$builtArray = $builder->getArray();
// `setOffsetValueType` on a brand-new builder produces a list when
// the resulting offsets are sequential ints — but it may not preserve
// list-ness in every shape. Reattach it for the single-CAT case.
if (!$builder->isList()) {
$constantArrays = $builtArray->getConstantArrays();
if (count($constantArrays) === 1) {
$builtArray = $constantArrays[0]->makeList();
}
}

return $builtArray;
}

/**
* Extracts (min, max) bounds from a size type for `truncateListToSize`.
* `ConstantIntegerType(N)` → `[N, N]`. `IntegerRangeType` →
* `[$min, $max]`. Anything else returns `[null, null]` and the caller
* falls back to the non-precise path.
*
* @return array{?int, ?int}
*/
public static function extractTruncateListBounds(Type $sizeType): array
{
if ($sizeType instanceof ConstantIntegerType) {
return [$sizeType->getValue(), $sizeType->getValue()];
}

if ($sizeType instanceof IntegerRangeType) {
return [$sizeType->getMin(), $sizeType->getMax()];
}

return [null, null];
}

public function isIterableAtLeastOnce(): TrinaryLogic
{
$keysCount = count($this->keyTypes);
Expand Down
5 changes: 5 additions & 0 deletions src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,11 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->spliceArray($offsetType, $lengthType, $replacementType));
}

public function truncateListToSize(Type $sizeType): Type
{
return $this->intersectTypesPreserveTemplateType(static fn (Type $type): Type => $type->truncateListToSize($sizeType));
}

public function makeListMaybe(): Type
{
return $this->intersectTypes(static fn (Type $type): Type => $type->makeListMaybe());
Expand Down
Loading
Loading