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
15 changes: 14 additions & 1 deletion src/Type/ArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,21 @@ 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 constant array whose offending keys/values are all optional
// still admits the empty array, which is a subtype of every
// array type — so the relationship is `maybe`, not `no`.
return IsSuperTypeOfResult::createMaybe();
}

return $result;
}

if ($type instanceof CompoundType) {
Expand Down
80 changes: 78 additions & 2 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,57 @@
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,24 @@
/** @var list<ConstantIntegerType|ConstantStringType> $keyTypes */
$keyTypes = $keyTypes;

// Merging widens keys present in only one side into optional keys, so the
// result can admit list realizations that neither input did. When the merged
// extras are the explicit-never sentinel (i.e. no real extras), the result is
// sealed and its list-ness follows purely from the merged shape. Two pure
// lists merge into a list (their optional keys are suffix-constrained), so
// keep `yes` in that case rather than degrading it from the shape.
$naiveIsList = $this->isList->and($otherArray->isList);
$mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit();
$isList = $mergedIsSealed && !$naiveIsList->yes()

Check warning on line 3094 in src/Type/Constant/ConstantArrayType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // keep `yes` in that case rather than degrading it from the shape. $naiveIsList = $this->isList->and($otherArray->isList); $mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit(); - $isList = $mergedIsSealed && !$naiveIsList->yes() + $isList = $mergedIsSealed && $naiveIsList->no() ? self::inferIsListFromShape($keyTypes, $optionalKeys) : $naiveIsList;
? self::inferIsListFromShape($keyTypes, $optionalKeys)
: $naiveIsList;

return $this->recreate(
$keyTypes,
$valueTypes,
$nextAutoIndexes,
$optionalKeys,
$this->isList->and($otherArray->isList),
$isList,
$resultUnsealed,
);
}
Expand All @@ -3064,7 +3127,20 @@
$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);
// Merging widens keys present in only one side into optional keys, so the
// result can admit list realizations that neither input did (e.g. the empty
// array). When the result carries no real extras it is sealed and its
// list-ness follows purely from the merged shape, instead of the too-strict
// `$this->isList->and($otherArray->isList)`. Two pure lists merge into a list
// (their optional keys are suffix-constrained), so keep `yes` in that case.
$naiveIsList = $this->isList->and($otherArray->isList);
$mergedIsSealed = $this->unsealed === null
|| ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit());
$isList = $mergedIsSealed && !$naiveIsList->yes()

Check warning on line 3139 in src/Type/Constant/ConstantArrayType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $naiveIsList = $this->isList->and($otherArray->isList); $mergedIsSealed = $this->unsealed === null || ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit()); - $isList = $mergedIsSealed && !$naiveIsList->yes() + $isList = $mergedIsSealed && $naiveIsList->no() ? self::inferIsListFromShape($this->keyTypes, $optionalKeys) : $naiveIsList;

Check warning on line 3139 in src/Type/Constant/ConstantArrayType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $naiveIsList = $this->isList->and($otherArray->isList); $mergedIsSealed = $this->unsealed === null || ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit()); - $isList = $mergedIsSealed && !$naiveIsList->yes() + $isList = $mergedIsSealed && $naiveIsList->no() ? self::inferIsListFromShape($this->keyTypes, $optionalKeys) : $naiveIsList;
? self::inferIsListFromShape($this->keyTypes, $optionalKeys)
: $naiveIsList;

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

/**
Expand Down
8 changes: 4 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->isList = $optional ? $this->isList->and(TrinaryLogic::createMaybe()) : TrinaryLogic::createNo();
}
}
} else {
$this->isList = TrinaryLogic::createNo();
$this->isList = $optional ? $this->isList->and(TrinaryLogic::createMaybe()) : TrinaryLogic::createNo();
}

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

if ($optional) {
Expand Down
4 changes: 2 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,8 @@ 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);
assertType('list{0: string, 1: int, 2?: string, 4?: string}', $invalid1);
assertType('list{0: string, 1: int, 2?: string, foo?: string}', $invalid2);
}

}
91 changes: 91 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14938.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php declare(strict_types = 1);

namespace Bug14938;

use function PHPStan\Testing\assertType;

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

/** @param array{0: int, a?: string} $b */
function withOptionalExtraKey(array $b): void
{
if (array_is_list($b)) {
assertType('list{int}', $b);
} else {
assertType('array{0: int, a?: string}', $b);
}
}

/** @param array{a: string} $c */
function withMandatoryStringKey(array $c): void
{
if (array_is_list($c)) {
assertType('*NEVER*', $c);
} else {
assertType('array{a: string}', $c);
}
}

/** @param array{1: string} $d */
function withMandatoryGapKey(array $d): void
{
if (array_is_list($d)) {
assertType('*NEVER*', $d);
} else {
assertType('array{1: string}', $d);
}
}

/** @param array{5?: string} $e */
function withOptionalGapIntKey(array $e): void
{
if (array_is_list($e)) {
assertType('array{5?: string}', $e);
} else {
assertType('array{5?: string}', $e);
}
}

/** @param array{-1?: string} $f */
function withOptionalNegativeIntKey(array $f): void
{
if (array_is_list($f)) {
assertType('list{-1?: string}&list', $f);
} else {
assertType('array{-1?: string}', $f);
}
}

function builtViaConditionalAssignment(): void
{
$b = [0 => 'z'];
if (rand(0, 1)) {
$b['y'] = 1;
}
assertType("array{0: 'z', y?: 1}", $b);
assertType('bool', array_is_list($b));

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

// Merging shapes disjoint save for the empty array still admits the empty list.
$d = [];
if (rand(0, 1)) {
$d['a'] = 1;
}
assertType('array{}|array{a: 1}', $d);
assertType('bool', array_is_list($d));
}
Loading