Skip to content

Commit 49e476f

Browse files
Improve intersection of list and constantArrayType
1 parent 5b79cca commit 49e476f

File tree

12 files changed

+83
-29
lines changed

12 files changed

+83
-29
lines changed

src/Type/Constant/ConstantArrayType.php

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

718-
public function unsetOffset(Type $offsetType): Type
718+
public function unsetOffset(Type $offsetType, bool $preserveListCertainty = false): Type
719719
{
720720
$offsetType = $offsetType->toArrayKey();
721721
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
@@ -748,6 +748,7 @@ public function unsetOffset(Type $offsetType): Type
748748
$newOptionalKeys,
749749
$this->isList,
750750
in_array($i, $this->optionalKeys, true),
751+
$preserveListCertainty,
751752
);
752753

753754
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
@@ -790,6 +791,7 @@ public function unsetOffset(Type $offsetType): Type
790791
$optionalKeys,
791792
$this->isList,
792793
count($optionalKeys) === count($this->optionalKeys),
794+
$preserveListCertainty,
793795
);
794796

795797
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
@@ -815,6 +817,7 @@ public function unsetOffset(Type $offsetType): Type
815817
$optionalKeys,
816818
$this->isList,
817819
count($optionalKeys) === count($this->optionalKeys),
820+
$preserveListCertainty,
818821
);
819822

820823
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
@@ -827,7 +830,7 @@ public function unsetOffset(Type $offsetType): Type
827830
* @param list<ConstantIntegerType|ConstantStringType> $newKeyTypes
828831
* @param int[] $newOptionalKeys
829832
*/
830-
private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
833+
private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey, bool $preserveListCertainty): TrinaryLogic
831834
{
832835
if (!$unsetOptionalKey || $arrayIsList->no()) {
833836
return TrinaryLogic::createNo();
@@ -851,7 +854,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK
851854
}
852855
}
853856

854-
return TrinaryLogic::createMaybe();
857+
return $preserveListCertainty ? $arrayIsList : TrinaryLogic::createMaybe();
855858
}
856859

857860
public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
@@ -1531,7 +1534,9 @@ private function getKeysOrValuesArray(array $types): self
15311534

15321535
public function describe(VerbosityLevel $level): string
15331536
{
1534-
$describeValue = function (bool $truncate) use ($level): string {
1537+
$arrayName = $this->shouldBeDescribedAsAList() ? 'list' : 'array';
1538+
1539+
$describeValue = function (bool $truncate) use ($level, $arrayName): string {
15351540
$items = [];
15361541
$values = [];
15371542
$exportValuesOnly = true;
@@ -1570,18 +1575,36 @@ public function describe(VerbosityLevel $level): string
15701575
}
15711576

15721577
return sprintf(
1573-
'array{%s%s}',
1578+
'%s{%s%s}',
1579+
$arrayName,
15741580
implode(', ', $exportValuesOnly ? $values : $items),
15751581
$append,
15761582
);
15771583
};
15781584
return $level->handle(
1579-
fn (): string => $this->isIterableAtLeastOnce()->no() ? 'array' : sprintf('array<%s, %s>', $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
1585+
fn (): string => $this->isIterableAtLeastOnce()->no() ? $arrayName : sprintf('%s<%s, %s>', $arrayName, $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
15801586
static fn (): string => $describeValue(true),
15811587
static fn (): string => $describeValue(false),
15821588
);
15831589
}
15841590

1591+
private function shouldBeDescribedAsAList(): bool
1592+
{
1593+
if (!$this->isList->yes()) {
1594+
return false;
1595+
}
1596+
1597+
if (count($this->optionalKeys) === 0) {
1598+
return false;
1599+
}
1600+
1601+
if (count($this->optionalKeys) === 2) {
1602+
return true;
1603+
}
1604+
1605+
return $this->optionalKeys[0] !== count($this->keyTypes) - 1;
1606+
}
1607+
15851608
public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
15861609
{
15871610
if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) {
@@ -1643,11 +1666,11 @@ public function tryRemove(Type $typeToRemove): ?Type
16431666
}
16441667

16451668
if ($typeToRemove instanceof HasOffsetType) {
1646-
return $this->unsetOffset($typeToRemove->getOffsetType());
1669+
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
16471670
}
16481671

16491672
if ($typeToRemove instanceof HasOffsetValueType) {
1650-
return $this->unsetOffset($typeToRemove->getOffsetType());
1673+
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
16511674
}
16521675

16531676
return null;
@@ -1823,6 +1846,19 @@ public function makeOffsetRequired(Type $offsetType): self
18231846
return $this;
18241847
}
18251848

1849+
public function makeList(): Type
1850+
{
1851+
if ($this->isList->yes()) {
1852+
return $this;
1853+
}
1854+
1855+
if ($this->isList->no()) {
1856+
return new NeverType();
1857+
}
1858+
1859+
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes());
1860+
}
1861+
18261862
public function toPhpDocNode(): TypeNode
18271863
{
18281864
$items = [];
@@ -1863,7 +1899,10 @@ public function toPhpDocNode(): TypeNode
18631899
);
18641900
}
18651901

1866-
return ArrayShapeNode::createSealed($exportValuesOnly ? $values : $items);
1902+
return ArrayShapeNode::createSealed(
1903+
$exportValuesOnly ? $values : $items,
1904+
$this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
1905+
);
18671906
}
18681907

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

src/Type/IntersectionType.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,8 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
448448
continue;
449449
} elseif ($type instanceof ConstantArrayType) {
450450
$description = $type->describe($level);
451-
$descriptionWithoutKind = substr($description, strlen('array'));
451+
$kind = str_starts_with($description, 'list') ? 'list' : 'array';
452+
$descriptionWithoutKind = substr($description, strlen($kind));
452453
$begin = $isList ? 'list' : 'array';
453454
if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) {
454455
$begin = 'non-empty-' . $begin;

src/Type/TypeCombinator.php

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

1334+
if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof AccessoryArrayListType) {
1335+
$types[$i] = $types[$i]->makeList();
1336+
array_splice($types, $j--, 1);
1337+
$typesCount--;
1338+
continue;
1339+
}
1340+
1341+
if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof AccessoryArrayListType) {
1342+
$types[$j] = $types[$j]->makeList();
1343+
array_splice($types, $i--, 1);
1344+
$typesCount--;
1345+
continue 2;
1346+
}
1347+
13341348
if (
13351349
$types[$i] instanceof ConstantArrayType
13361350
&& 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-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: 1 addition & 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
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function(array $array, int $count): void {
1919
if (isset($array['e'])) $a[] = $array['e'];
2020
if (count($a) >= $count) {
2121
assertType('int<1, 5>', count($a));
22-
assertType('array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
22+
assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
2323
} else {
2424
assertType('0', count($a));
2525
assertType('array{}', $a);
@@ -44,6 +44,6 @@ function(array $array, int $count): void {
4444
assertType('list{0: mixed~null, 1: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
4545
} else {
4646
assertType('int<0, 5>', count($a)); // Could be int<0, 1>
47-
assertType('array{}|array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); // Could be array{}|array{0: mixed~null}
47+
assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); // Could be array{}|array{0: mixed~null}
4848
}
4949
};

tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ public function doFoo()
1717
if (rand(0, 1)) {
1818
$a[] = 3;
1919
}
20-
assertType('array{0: 1, 1?: 2|3, 2?: 3}', $a);
20+
assertType('list{0: 1, 1?: 2|3, 2?: 3}', $a);
2121
if (rand(0, 1)) {
2222
$a[] = 4;
2323
}
24-
assertType('array{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a);
24+
assertType('list{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a);
2525
if (rand(0, 1)) {
2626
$a[] = 5;
2727
}
28-
assertType('array{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a);
28+
assertType('list{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a);
2929
}
3030

3131
public function doBar()

0 commit comments

Comments
 (0)