Skip to content
Open
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
13 changes: 12 additions & 1 deletion src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,19 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
{
if ($type instanceof self || $type instanceof ConstantArrayType) {
return $this->getItemType()->isSuperTypeOf($type->getItemType())
$result = $this->getItemType()->isSuperTypeOf($type->getItemType())
->and($this->getIterableKeyType()->isSuperTypeOf($type->getIterableKeyType()));
if (
$result->no()
&& $type->isConstantArray()->yes()
&& !$type->isIterableAtLeastOnce()->yes()
) {
// A possibly-empty constant array always admits the empty array,
// which is a subtype of every array type, so the relationship can
// only be "maybe", never a definite "no".
return IsSuperTypeOfResult::createMaybe();
}
return $result;
}

if ($type instanceof CompoundType) {
Expand Down
112 changes: 110 additions & 2 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,57 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals
return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed);
}

/**
* Compute the list-ness trinary of a sealed array shape purely from its keys
* and their optionality: `yes` if every realization (choice of which optional
* keys are present) is a list, `no` if none is, `maybe` otherwise. An optional
* key that breaks list-ness only degrades the answer to `maybe`, because the
* realization where that key is absent may still be a list.
*
* @param list<ConstantIntegerType|ConstantStringType> $keyTypes
* @param int[] $optionalKeys
*/
private static function inferIsListFromShape(array $keyTypes, array $optionalKeys): TrinaryLogic
{
$optional = [];
foreach ($optionalKeys as $optionalKey) {
$optional[$optionalKey] = true;
}

// Prefix lengths reachable by realizations that are still a valid list.
$validLengths = [0 => true];
$existsInvalid = false;

foreach ($keyTypes as $i => $keyType) {
$isOptional = array_key_exists($i, $optional);
$value = $keyType instanceof ConstantIntegerType ? $keyType->getValue() : null;

$newValidLengths = [];
foreach (array_keys($validLengths) as $length) {
if ($isOptional) {
// Skipping the key keeps the realization a valid list prefix.
$newValidLengths[$length] = true;
}

if ($value === $length) {
// Including the key extends the list into the next slot.
$newValidLengths[$length + 1] = true;
} else {
// Including a non-sequential key yields a non-list realization.
$existsInvalid = true;
}
}

$validLengths = $newValidLengths;
if ($validLengths === []) {
// No realization can be a list from here on.
return TrinaryLogic::createNo();
}
}

return $existsInvalid ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes();
}

/**
* When we're unsetting something not on the array, it will be untouched,
* So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition.
Expand Down Expand Up @@ -3032,12 +3083,23 @@ public function mergeWith(self $otherArray): self
/** @var list<ConstantIntegerType|ConstantStringType> $keyTypes */
$keyTypes = $keyTypes;

// A key present on only one side becomes optional, so a sealed result may gain
// list realizations (e.g. the empty array) the naive `and` misses; fold in the
// list-ness recomputed from the merged shape. `or` keeps a naive `yes` (two
// lists merge into a list even where the shape's independent-optional reading
// would say maybe) while letting the shape lift a `no`/`maybe`.
$naiveIsList = $this->isList->and($otherArray->isList);
$mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit();
$isList = $mergedIsSealed
? $naiveIsList->or(self::inferIsListFromShape($keyTypes, $optionalKeys))
: $naiveIsList;

return $this->recreate(
$keyTypes,
$valueTypes,
$nextAutoIndexes,
$optionalKeys,
$this->isList->and($otherArray->isList),
$isList,
$resultUnsealed,
);
}
Expand All @@ -3064,7 +3126,20 @@ private function legacyMergeWith(self $otherArray): self
$nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes)));
sort($nextAutoIndexes);

return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed);
// This path keeps only `$this`'s keys, widening those missing from the other
// side to optional, so a sealed result may gain list realizations the naive
// `and` misses — including lifting a `maybe` back to `yes` when the widened
// keys form a valid list suffix. Fold in the list-ness of the merged shape;
// `or` keeps a naive `yes` unchanged, and an unsealed result can't be
// re-derived from the known keys alone.
$naiveIsList = $this->isList->and($otherArray->isList);
$mergedIsSealed = $this->unsealed === null
|| ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit());
$isList = $mergedIsSealed
? $naiveIsList->or(self::inferIsListFromShape($this->keyTypes, $optionalKeys))
: $naiveIsList;

return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $this->unsealed);
}

/**
Expand Down Expand Up @@ -3168,6 +3243,39 @@ public function makeList(): Type
return new NeverType();
}

// isList is Maybe: some values are lists, some are not. When the shape
// is sealed, a key sitting past a gap in the 0..n sequence (or any
// non-integer key) can never appear in a list, so keep only the
// contiguous 0..m prefix. Unsealed extras may fill such gaps, so there
// every key is kept.
if ($this->isUnsealed()->no()) {
$positionByIndex = [];
foreach ($this->keyTypes as $position => $keyType) {
if (!$keyType instanceof ConstantIntegerType) {
continue;
}
$positionByIndex[$keyType->getValue()] = $position;
}

$keptPositions = [];
for ($index = 0; array_key_exists($index, $positionByIndex); $index++) {
$keptPositions[] = $positionByIndex[$index];
}

if (count($keptPositions) < count($this->keyTypes)) {
$builder = ConstantArrayTypeBuilder::createEmpty();
foreach ($keptPositions as $position) {
$builder->setOffsetValueType(
$this->keyTypes[$position],
$this->valueTypes[$position],
$this->isOptionalKey($position),
);
}

return $builder->getArray();
}
}

return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed);
}

Expand Down
22 changes: 18 additions & 4 deletions src/Type/Constant/ConstantArrayTypeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,11 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt
if ($offsetValue <= $max) {
$this->isList = $this->isList->and(TrinaryLogic::createMaybe());
} else {
$this->isList = TrinaryLogic::createNo();
$this->markNonListKey($optional);
}
}
} else {
$this->isList = TrinaryLogic::createNo();
$this->markNonListKey($optional);
}

if ($offsetValue >= $max) {
Expand All @@ -245,10 +245,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt
}
}
} else {
$this->isList = TrinaryLogic::createNo();
$this->markNonListKey($optional);
}
} else {
$this->isList = TrinaryLogic::createNo();
$this->markNonListKey($optional);
}

if ($optional) {
Expand Down Expand Up @@ -410,6 +410,20 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt
$this->degradeToGeneralArray = true;
}

/**
* Record that a key incompatible with list ordering was added.
*
* A required key definitely breaks list-ness. An optional key only might
* be present, so the array is still a list whenever the key is absent —
* degrade Yes to Maybe rather than to No (No stays No).
*/
private function markNonListKey(bool $optional): void
{
$this->isList = $optional
? $this->isList->and(TrinaryLogic::createMaybe())
: TrinaryLogic::createNo();
}

public function degradeToGeneralArray(bool $oversized = false): void
{
if ($this->disableArrayDegradation) {
Expand Down
7 changes: 5 additions & 2 deletions tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ public function doFoo(
assertType('list{0: string, 1: int, 2?: string, 3?: string}', $valid1);
assertType('list{0: string, 1?: int, 2?: string, 3?: string}', $valid2);
assertType('non-empty-array{0?: string, 1?: int, 2?: string, 3?: string}', $valid3);
assertType('*NEVER*', $invalid1);
assertType('*NEVER*', $invalid2);
// The trailing keys can never appear in a list (4 sits past the gap at
// 3; foo is not an integer), so they are dropped, leaving the valid
// list projection rather than an empty *NEVER*.
assertType('array{0: string, 1: int, 2?: string}', $invalid1);
assertType('array{0: string, 1: int, 2?: string}', $invalid2);
}

}
86 changes: 86 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14938.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace Bug14938;

use function PHPStan\Testing\assertType;

class Foo
{

/**
* @param array{a?: string} $optStr
* @param array{0: int, a?: string} $listPlusOptStr
* @param array{-1?: string} $optNeg
* @param array{0: int, 5?: string} $listPlusGap
* @param array{a: string} $reqStr
* @param array{1: string} $gapReq
* @param array{0?: string} $optZero
*/
public function doFoo(
array $optStr,
array $listPlusOptStr,
array $optNeg,
array $listPlusGap,
array $reqStr,
array $gapReq,
array $optZero,
): void
{
// An optional non-list key might be absent, so the array can still be
// a list ([] / the list prefix) — array_is_list() is not decidable.
assertType('bool', array_is_list($optStr));
assertType('bool', array_is_list($listPlusOptStr));
assertType('bool', array_is_list($optNeg));
assertType('bool', array_is_list($listPlusGap));

// A required non-list key is always present, so it is never a list.
assertType('false', array_is_list($reqStr));
assertType('false', array_is_list($gapReq));

// Only an optional key 0 keeps it a guaranteed list ([] and [v]).
assertType('true', array_is_list($optZero));
}

/**
* @param array{a?: string} $optStr
*/
public function narrowing(array $optStr): void
{
if (array_is_list($optStr)) {
assertType('list{}&list', $optStr);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this fix array{a?: string} had isList = No, so this branch was *NEVER* (unreachable). With isList now Maybe, the empty-array realisation survives the is list intersection, so the branch is reachable and narrows to the empty list.

} else {
// array_is_list()'s false branch is not narrowed, so `a` stays optional
// here rather than being refined to array{a: string}.
assertType('array{a?: string}', $optStr);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The a? here (rather than array{a: string}) is pre-existing and unrelated to this fix: array_is_list()'s false branch is not refined. It stays a? for sealed shapes too — e.g. even array{0: string, a?: string} keeps a? in the false branch on current stable.

}
}

public function mergedShapes(): void
{
// Merging a list with a shape that adds a string key: the empty-of-the-extra
// realization is still a list, so array_is_list() is not decidable.
$a = [0 => 'z'];
if (rand(0, 1)) {
$a['y'] = 1;
}
assertType("array{0: 'z', y?: 1}", $a);
assertType('bool', array_is_list($a));

// Two pure lists merge into a list (optional keys stay a suffix).
$b = [0 => 'z'];
if (rand(0, 1)) {
$b[1] = 'w';
}
assertType("array{0: 'z', 1?: 'w'}", $b);
assertType('true', array_is_list($b));

// Shapes disjoint except for the empty array still admit the empty list.
$c = [];
if (rand(0, 1)) {
$c['a'] = 1;
}
assertType('array{}|array{a: 1}', $c);
assertType('bool', array_is_list($c));
}

}
71 changes: 71 additions & 0 deletions tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,77 @@ public function testSealedness(): void
});
}

/**
* @param list<array{int|string, Type, bool}> $items
*/
private function buildShape(array $items): ConstantArrayType
{
$builder = ConstantArrayTypeBuilder::createEmpty();
foreach ($items as [$key, $valueType, $optional]) {
$keyType = is_string($key) ? new ConstantStringType($key) : new ConstantIntegerType($key);
$builder->setOffsetValueType($keyType, $valueType, $optional);
}
$array = $builder->getArray();
$this->assertInstanceOf(ConstantArrayType::class, $array);

return $array;
}

public function testMakeListProjectsSealedShapeButKeepsUnsealedKeys(): void
{
// A sealed shape with an optional key past the gap at 1 (2? here): key 2 can
// never appear in a list, so makeList() drops it and projects to the prefix.
BleedingEdgeToggle::withBleedingEdge(true, function (): void {
$array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe());
$this->assertSame('array{int}', $array->makeList()->describe(VerbosityLevel::precise()));
});

// Unsealed: extras may fill the gap, so key 2 is reachable and every key is kept.
BleedingEdgeToggle::withBleedingEdge(false, function (): void {
$array = $this->buildShape([[0, new IntegerType(), false], [2, new StringType(), true]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isList()->describe());
$this->assertSame('array{0: int, 2?: string}', $array->makeList()->describe(VerbosityLevel::precise()));
});
}

public function testMergeWithRecomputesListnessOfSealedShape(): void
{
// main mergeWith() path (bleeding edge: shapes carry real unsealed markers)
BleedingEdgeToggle::withBleedingEdge(true, function (): void {
// Two pure lists merge into a list, even though the merged shape read with
// independent optional keys would over-approximate to maybe.
$x = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), true]]);
$y = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), false], [2, new IntegerType(), true]]);
$this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe());

// A mandatory non-list key that becomes optional through merging turns the
// naive `no` into `maybe` (the merged shape now admits list realizations).
$x2 = $this->buildShape([[0, new IntegerType(), false], ['a', new StringType(), false]]);
$y2 = $this->buildShape([[0, new IntegerType(), false]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $x2->mergeWith($y2)->isList()->describe());
});

// legacyMergeWith() path (unsealed === null)
BleedingEdgeToggle::withBleedingEdge(false, function (): void {
$x = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), false], [2, new StringType(), false]]);
$y = $this->buildShape([[0, new IntegerType(), false]]);
$this->assertSame(TrinaryLogic::createYes()->describe(), $x->mergeWith($y)->isList()->describe());

$x2 = $this->buildShape([[0, new IntegerType(), false], ['a', new StringType(), false]]);
$y2 = $this->buildShape([[0, new IntegerType(), false]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $x2->mergeWith($y2)->isList()->describe());

// Legacy merge keeps only $this's keys, so a naive `maybe` (the other side
// has a gap key) sharpens back to `yes`: the surviving keys form a list once
// the key absent from the other side is widened to a trailing optional.
$x3 = $this->buildShape([[0, new IntegerType(), false], [1, new StringType(), false]]);
$y3 = $this->buildShape([[0, new IntegerType(), false], [5, new StringType(), true]]);
$this->assertSame(TrinaryLogic::createMaybe()->describe(), $y3->isList()->describe());
$this->assertSame(TrinaryLogic::createYes()->describe(), $x3->mergeWith($y3)->isList()->describe());
});
}

public static function dataGetArraySize(): iterable
{
$cases = [];
Expand Down
Loading