Skip to content

Commit 9dcba70

Browse files
VincentLangletphpstan-bot
authored andcommitted
Improve intersection of ConstantArray and AccessoryIsList (phpstan#5067)
1 parent e45df95 commit 9dcba70

18 files changed

+215
-53
lines changed

phpstan-baseline.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1692,7 +1692,7 @@ parameters:
16921692
-
16931693
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
16941694
identifier: phpstanApi.instanceofType
1695-
count: 16
1695+
count: 18
16961696
path: src/Type/TypeCombinator.php
16971697

16981698
-

src/Type/Constant/ConstantArrayType.php

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,15 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
715715
return $builder->getArray();
716716
}
717717

718-
public function unsetOffset(Type $offsetType): Type
718+
/**
719+
* Removes or marks as optional the key(s) matching the given offset type from this constant array.
720+
*
721+
* By default, the method assumes an actual `unset()` call was made, which actively modifies the
722+
* array and weakens its list certainty to "maybe". However, in some contexts, such as the else
723+
* branch of an array_key_exists() check, the key is statically known to be absent without any
724+
* modification, so list certainty should be preserved as-is.
725+
*/
726+
public function unsetOffset(Type $offsetType, bool $preserveListCertainty = false): Type
719727
{
720728
$offsetType = $offsetType->toArrayKey();
721729
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
@@ -749,6 +757,11 @@ public function unsetOffset(Type $offsetType): Type
749757
$this->isList,
750758
in_array($i, $this->optionalKeys, true),
751759
);
760+
if (!$preserveListCertainty) {
761+
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
762+
} elseif ($this->isList->yes() && $newIsList->no()) {
763+
return new NeverType();
764+
}
752765

753766
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
754767
}
@@ -791,6 +804,11 @@ public function unsetOffset(Type $offsetType): Type
791804
$this->isList,
792805
count($optionalKeys) === count($this->optionalKeys),
793806
);
807+
if (!$preserveListCertainty) {
808+
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
809+
} elseif ($this->isList->yes() && $newIsList->no()) {
810+
return new NeverType();
811+
}
794812

795813
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
796814
}
@@ -816,6 +834,11 @@ public function unsetOffset(Type $offsetType): Type
816834
$this->isList,
817835
count($optionalKeys) === count($this->optionalKeys),
818836
);
837+
if (!$preserveListCertainty) {
838+
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
839+
} elseif ($this->isList->yes() && $newIsList->no()) {
840+
return new NeverType();
841+
}
819842

820843
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
821844
}
@@ -851,7 +874,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK
851874
}
852875
}
853876

854-
return TrinaryLogic::createMaybe();
877+
return $arrayIsList;
855878
}
856879

857880
public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
@@ -1531,7 +1554,9 @@ private function getKeysOrValuesArray(array $types): self
15311554

15321555
public function describe(VerbosityLevel $level): string
15331556
{
1534-
$describeValue = function (bool $truncate) use ($level): string {
1557+
$arrayName = $this->shouldBeDescribedAsAList() ? 'list' : 'array';
1558+
1559+
$describeValue = function (bool $truncate) use ($level, $arrayName): string {
15351560
$items = [];
15361561
$values = [];
15371562
$exportValuesOnly = true;
@@ -1570,18 +1595,36 @@ public function describe(VerbosityLevel $level): string
15701595
}
15711596

15721597
return sprintf(
1573-
'array{%s%s}',
1598+
'%s{%s%s}',
1599+
$arrayName,
15741600
implode(', ', $exportValuesOnly ? $values : $items),
15751601
$append,
15761602
);
15771603
};
15781604
return $level->handle(
1579-
fn (): string => $this->isIterableAtLeastOnce()->no() ? 'array' : sprintf('array<%s, %s>', $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
1605+
fn (): string => $this->isIterableAtLeastOnce()->no() ? $arrayName : sprintf('%s<%s, %s>', $arrayName, $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
15801606
static fn (): string => $describeValue(true),
15811607
static fn (): string => $describeValue(false),
15821608
);
15831609
}
15841610

1611+
private function shouldBeDescribedAsAList(): bool
1612+
{
1613+
if (!$this->isList->yes()) {
1614+
return false;
1615+
}
1616+
1617+
if (count($this->optionalKeys) === 0) {
1618+
return false;
1619+
}
1620+
1621+
if (count($this->optionalKeys) > 1) {
1622+
return true;
1623+
}
1624+
1625+
return $this->optionalKeys[0] !== count($this->keyTypes) - 1;
1626+
}
1627+
15851628
public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
15861629
{
15871630
if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) {
@@ -1643,11 +1686,11 @@ public function tryRemove(Type $typeToRemove): ?Type
16431686
}
16441687

16451688
if ($typeToRemove instanceof HasOffsetType) {
1646-
return $this->unsetOffset($typeToRemove->getOffsetType());
1689+
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
16471690
}
16481691

16491692
if ($typeToRemove instanceof HasOffsetValueType) {
1650-
return $this->unsetOffset($typeToRemove->getOffsetType());
1693+
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
16511694
}
16521695

16531696
return null;
@@ -1823,6 +1866,19 @@ public function makeOffsetRequired(Type $offsetType): self
18231866
return $this;
18241867
}
18251868

1869+
public function makeList(): Type
1870+
{
1871+
if ($this->isList->yes()) {
1872+
return $this;
1873+
}
1874+
1875+
if ($this->isList->no()) {
1876+
return new NeverType();
1877+
}
1878+
1879+
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes());
1880+
}
1881+
18261882
public function toPhpDocNode(): TypeNode
18271883
{
18281884
$items = [];
@@ -1863,7 +1919,10 @@ public function toPhpDocNode(): TypeNode
18631919
);
18641920
}
18651921

1866-
return ArrayShapeNode::createSealed($exportValuesOnly ? $values : $items);
1922+
return ArrayShapeNode::createSealed(
1923+
$exportValuesOnly ? $values : $items,
1924+
$this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
1925+
);
18671926
}
18681927

18691928
public static function isValidIdentifier(string $value): bool

src/Type/IntersectionType.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
use function is_int;
5656
use function ksort;
5757
use function sprintf;
58+
use function str_starts_with;
5859
use function strcasecmp;
5960
use function strlen;
6061
use function substr;
@@ -456,7 +457,8 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
456457
continue;
457458
} elseif ($type instanceof ConstantArrayType) {
458459
$description = $type->describe($level);
459-
$descriptionWithoutKind = substr($description, strlen('array'));
460+
$kind = str_starts_with($description, 'list') ? 'list' : 'array';
461+
$descriptionWithoutKind = substr($description, strlen($kind));
460462
$begin = $isList ? 'list' : 'array';
461463
if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) {
462464
$begin = 'non-empty-' . $begin;

src/Type/TypeCombinator.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,20 @@ public static function intersect(Type ...$types): Type
13331333
continue 2;
13341334
}
13351335

1336+
if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof AccessoryArrayListType) {
1337+
$types[$i] = $types[$i]->makeList();
1338+
array_splice($types, $j--, 1);
1339+
$typesCount--;
1340+
continue;
1341+
}
1342+
1343+
if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof AccessoryArrayListType) {
1344+
$types[$j] = $types[$j]->makeList();
1345+
array_splice($types, $i--, 1);
1346+
$typesCount--;
1347+
continue 2;
1348+
}
1349+
13361350
if (
13371351
$types[$i] instanceof ConstantArrayType
13381352
&& count($types[$i]->getKeyTypes()) === 1

tests/PHPStan/Analyser/nsrt/array-chunk.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ public function constantArraysWithOptionalKeys(array $arr): void
4949
*/
5050
public function chunkUnionTypeLength(array $arr, $positiveRange, $positiveUnion) {
5151
/** @var array{a: 0, b?: 1, c: 2} $arr */
52-
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange));
52+
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange));
5353
assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveRange, true));
54-
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion));
54+
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion));
5555
assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveUnion, true));
5656
}
5757

@@ -70,7 +70,7 @@ public function lengthIntRanges(array $arr, int $positiveInt, int $bigger50) {
7070
*/
7171
function testLimits(array $arr, int $oneToFour, int $tooBig) {
7272
/** @var array{a: 0, b?: 1, c: 2, d: 3} $arr */
73-
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: array{0?: 2|3, 1?: 3}}|array{array{0}, array{0?: 1|2, 1?: 2}, array{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour));
73+
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2|3, 3?: 3}, 1?: list{0?: 2|3, 1?: 3}}|array{array{0}, list{0?: 1|2, 1?: 2}, list{0?: 2|3, 1?: 3}, array{0?: 3}}', array_chunk($arr, $oneToFour));
7474
assertType('non-empty-list<non-empty-list<0|1|2|3>>', array_chunk($arr, $tooBig));
7575
}
7676

tests/PHPStan/Analyser/nsrt/array-column.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ public function testConstantArray12(array $array): void
158158
/** @param array{0?: array{column: 'foo1', key: 'bar1'}, 1?: array{column: 'foo2', key: 'bar2'}} $array */
159159
public function testConstantArray13(array $array): void
160160
{
161-
assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column'));
162-
assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null));
161+
assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column'));
162+
assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null));
163163
assertType("array{bar1?: 'foo1', bar2?: 'foo2'}", array_column($array, 'column', 'key'));
164164
}
165165

tests/PHPStan/Analyser/nsrt/array-reverse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public function constantArrays(array $a, array $b, array $c): void
4949
assertType('array{\'bar\', \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b));
5050
assertType('array{19: \'bar\', 17: \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b, true));
5151

52-
assertType("array{0: 'A'|'B'|'C', 1?: 'A'|'B', 2?: 'A'}", array_reverse($c));
52+
assertType("list{0: 'A'|'B'|'C', 1?: 'A'|'B', 2?: 'A'}", array_reverse($c));
5353
assertType("array{2?: 'C', 1?: 'B', 0: 'A'}", array_reverse($c, true));
5454
}
5555

tests/PHPStan/Analyser/nsrt/array_keys.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ public function constantArrayType(): void
2222
[1 => 'a', 2 => 'b', 3 => 'c'],
2323
static fn ($value) => mt_rand(0, 1) === 0,
2424
);
25-
assertType("array{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers));
25+
assertType("list{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers));
2626
}
2727
}

tests/PHPStan/Analyser/nsrt/array_values.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public function constantArrayType(): void
3535
[1 => 'a', 2 => 'b', 3 => 'c'],
3636
static fn ($value) => mt_rand(0, 1) === 0,
3737
);
38-
assertType("array{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers));
38+
assertType("list{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers));
3939
}
4040

4141
/**

tests/PHPStan/Analyser/nsrt/bug-14177.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public function testList(array $b): void
1414
if (array_key_exists(3, $b)) {
1515
assertType('list{0: string, 1: string, 2?: string, 3: string}', $b);
1616
} else {
17-
assertType('list{0: string, 1: string, 2?: string}', $b);
17+
assertType('array{0: string, 1: string, 2?: string}', $b);
1818
}
1919
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
2020
}
@@ -200,4 +200,25 @@ public function testUnsetInt(array $a, array $b, array $c, int $int): void
200200
assertType('bool', array_is_list($a));
201201
assertType('false', array_is_list($b));
202202
}
203+
204+
/**
205+
* @param list{0?: string, 1?: string, 2?: string} $l
206+
*/
207+
public function testFoo($l): void
208+
{
209+
if (array_key_exists(2, $l, true)) {
210+
assertType('true', array_is_list($l));
211+
assertType('list{0?: string, 1?: string, 2: string}', $l);
212+
if (array_key_exists(1, $l, true)) {
213+
assertType('true', array_is_list($l));
214+
assertType('list{0?: string, 1: string, 2: string}', $l);
215+
} else {
216+
assertType('true', array_is_list($l));
217+
assertType('*NEVER*', $l);
218+
}
219+
} else {
220+
assertType('true', array_is_list($l));
221+
assertType('list{0?: string, 1?: string}', $l);
222+
}
223+
}
203224
}

0 commit comments

Comments
 (0)