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
87 changes: 85 additions & 2 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,61 @@
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.
*
* Keys are normalized with `toArrayKey()` first, so an integer-like string key
* such as `'1'` counts as the integer key `1` it becomes at runtime.
*
* @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);
$arrayKeyType = $keyType->toArrayKey();
$value = $arrayKeyType instanceof ConstantIntegerType ? $arrayKeyType->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 All @@ -1452,6 +1507,9 @@

$isListOnlyIfKeysAreOptional = false;
foreach ($newKeyTypes as $k2 => $newKeyType2) {
// An integer-like string key such as '1' is the integer key it becomes at
// runtime, so normalize before deciding whether it continues the list.
$newKeyType2 = $newKeyType2->toArrayKey();
if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
// We found a non-optional key that implies that the array is never a list.
if (!in_array($k2, $newOptionalKeys, true)) {
Expand Down Expand Up @@ -3032,12 +3090,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()
? self::inferIsListFromShape($keyTypes, $optionalKeys)
: $naiveIsList;

Check warning on line 3103 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;

return $this->recreate(
$keyTypes,
$valueTypes,
$nextAutoIndexes,
$optionalKeys,
$this->isList->and($otherArray->isList),
$isList,
$resultUnsealed,
);
}
Expand All @@ -3064,7 +3134,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()
? self::inferIsListFromShape($this->keyTypes, $optionalKeys)
: $naiveIsList;

Check warning on line 3148 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;

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);
}

}
136 changes: 136 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14938.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?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);
}
}

/**
* Integer-like string keys are the integer keys they become at runtime,
* so they must be judged as such, not as list-breaking string keys.
*
* @param array{'1'?: string} $g
* @param array{'0': string, '1'?: int} $h
* @param array{'01'?: string} $i
*/
function withNumericStringKeys(array $g, array $h, array $i): void
{
assertType('array{1?: string}', $g);
assertType('bool', array_is_list($g));

assertType('array{0: string, 1?: int}', $h);
assertType('true', array_is_list($h));

// '01' is not a canonical integer key, so it stays a string key.
assertType("array{'01'?: string}", $i);
assertType('bool', array_is_list($i));
}

function builtViaConditionalAssignmentWithNumericStringKeys(): void
{
$a = [0 => 'z'];
if (rand(0, 1)) {
$a['1'] = 'w';
}
assertType("array{0: 'z', 1?: 'w'}", $a);
assertType('true', array_is_list($a));

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

$c = [0 => 'z'];
if (rand(0, 1)) {
$c['01'] = 'w';
}
assertType("array{0: 'z', '01'?: 'w'}", $c);
assertType('bool', array_is_list($c));
}

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
Loading