diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index a951f13c24..895f07ae6f 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -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) { diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 7eca2eaa58..272cbec8da 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -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 $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. @@ -3032,12 +3083,23 @@ public function mergeWith(self $otherArray): self /** @var list $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, ); } @@ -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); } /** @@ -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); } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index e81a4d694e..aa24969c83 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -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) { @@ -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) { @@ -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) { diff --git a/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php b/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php index 156ad93ed2..9acad79f69 100644 --- a/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php +++ b/tests/PHPStan/Analyser/nsrt/array-shape-list-optional.php @@ -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); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14938.php b/tests/PHPStan/Analyser/nsrt/bug-14938.php new file mode 100644 index 0000000000..882b70b8ac --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14938.php @@ -0,0 +1,86 @@ + '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)); + } + +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 5c85f86026..6e78df2e6d 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1639,6 +1639,77 @@ public function testSealedness(): void }); } + /** + * @param list $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 = [];