Skip to content
Draft
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
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1680,7 +1680,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
identifier: phpstanApi.instanceofType
count: 16
count: 18
path: src/Type/TypeCombinator.php

-
Expand Down
84 changes: 74 additions & 10 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
return $builder->getArray();
}

public function unsetOffset(Type $offsetType): Type
public function unsetOffset(Type $offsetType, bool $preserveListCertainty = false): Type
{
$offsetType = $offsetType->toArrayKey();
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
Expand Down Expand Up @@ -749,6 +749,11 @@ public function unsetOffset(Type $offsetType): Type
$this->isList,
in_array($i, $this->optionalKeys, true),
);
if (!$preserveListCertainty) {
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
} elseif ($this->isList->yes() && $newIsList->no()) {
return new NeverType();
}

return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
}
Expand Down Expand Up @@ -791,6 +796,11 @@ public function unsetOffset(Type $offsetType): Type
$this->isList,
count($optionalKeys) === count($this->optionalKeys),
);
if (!$preserveListCertainty) {
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
} elseif ($this->isList->yes() && $newIsList->no()) {
return new NeverType();
}

return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
}
Expand All @@ -816,6 +826,11 @@ public function unsetOffset(Type $offsetType): Type
$this->isList,
count($optionalKeys) === count($this->optionalKeys),
);
if (!$preserveListCertainty) {
$newIsList = $newIsList->and(TrinaryLogic::createMaybe());
} elseif ($this->isList->yes() && $newIsList->no()) {
return new NeverType();
}

return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
}
Expand Down Expand Up @@ -851,7 +866,7 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK
}
}

return TrinaryLogic::createMaybe();
return $arrayIsList;
}

public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
Expand Down Expand Up @@ -1531,7 +1546,9 @@ private function getKeysOrValuesArray(array $types): self

public function describe(VerbosityLevel $level): string
{
$describeValue = function (bool $truncate) use ($level): string {
$arrayName = $this->shouldBeDescribedAsAList() ? 'list' : 'array';

$describeValue = function (bool $truncate) use ($level, $arrayName): string {
$items = [];
$values = [];
$exportValuesOnly = true;
Expand Down Expand Up @@ -1570,18 +1587,36 @@ public function describe(VerbosityLevel $level): string
}

return sprintf(
'array{%s%s}',
'%s{%s%s}',
$arrayName,
implode(', ', $exportValuesOnly ? $values : $items),
$append,
);
};
return $level->handle(
fn (): string => $this->isIterableAtLeastOnce()->no() ? 'array' : sprintf('array<%s, %s>', $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
fn (): string => $this->isIterableAtLeastOnce()->no() ? $arrayName : sprintf('%s<%s, %s>', $arrayName, $this->getIterableKeyType()->describe($level), $this->getIterableValueType()->describe($level)),
static fn (): string => $describeValue(true),
static fn (): string => $describeValue(false),
);
}

private function shouldBeDescribedAsAList(): bool
{
if (!$this->isList->yes()) {
return false;
}

if (count($this->optionalKeys) === 0) {
return false;
}

if (count($this->optionalKeys) > 1) {
return true;
}

return $this->optionalKeys[0] !== count($this->keyTypes) - 1;
}

public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
{
if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) {
Expand Down Expand Up @@ -1643,11 +1678,11 @@ public function tryRemove(Type $typeToRemove): ?Type
}

if ($typeToRemove instanceof HasOffsetType) {
return $this->unsetOffset($typeToRemove->getOffsetType());
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
}

if ($typeToRemove instanceof HasOffsetValueType) {
return $this->unsetOffset($typeToRemove->getOffsetType());
return $this->unsetOffset($typeToRemove->getOffsetType(), true);
}

return null;
Expand Down Expand Up @@ -1805,24 +1840,50 @@ public function makeOffsetRequired(Type $offsetType): self
{
$offsetType = $offsetType->toArrayKey();
$optionalKeys = $this->optionalKeys;
$isList = $this->isList->yes();
foreach ($this->keyTypes as $i => $keyType) {
if (!$keyType->equals($offsetType)) {
continue;
}

$keyValue = $keyType->getValue();
foreach ($optionalKeys as $j => $key) {
if ($i === $key) {
if (
$i === $key
|| (
$isList
&& is_int($keyValue)
&& is_int($this->keyTypes[$key]->getValue())
&& $this->keyTypes[$key]->getValue() < $keyValue
)
) {
unset($optionalKeys[$j]);
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList);
}
}

if (count($this->optionalKeys) !== count($optionalKeys)) {
return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList);
}

break;
}

return $this;
}

public function makeList(): Type
{
if ($this->isList->yes()) {
return $this;
}

if ($this->isList->no()) {
return new NeverType();
}

return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes());
}

public function toPhpDocNode(): TypeNode
{
$items = [];
Expand Down Expand Up @@ -1863,7 +1924,10 @@ public function toPhpDocNode(): TypeNode
);
}

return ArrayShapeNode::createSealed($exportValuesOnly ? $values : $items);
return ArrayShapeNode::createSealed(
$exportValuesOnly ? $values : $items,
$this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
);
}

public static function isValidIdentifier(string $value): bool
Expand Down
4 changes: 3 additions & 1 deletion src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use function is_int;
use function ksort;
use function sprintf;
use function str_starts_with;
use function strcasecmp;
use function strlen;
use function substr;
Expand Down Expand Up @@ -448,7 +449,8 @@ private function describeItself(VerbosityLevel $level, bool $skipAccessoryTypes)
continue;
} elseif ($type instanceof ConstantArrayType) {
$description = $type->describe($level);
$descriptionWithoutKind = substr($description, strlen('array'));
$kind = str_starts_with($description, 'list') ? 'list' : 'array';
$descriptionWithoutKind = substr($description, strlen($kind));
$begin = $isList ? 'list' : 'array';
if ($isNonEmptyArray && !$type->isIterableAtLeastOnce()->yes()) {
$begin = 'non-empty-' . $begin;
Expand Down
14 changes: 14 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,20 @@ public static function intersect(Type ...$types): Type
continue 2;
}

if ($types[$i] instanceof ConstantArrayType && $types[$j] instanceof AccessoryArrayListType) {
$types[$i] = $types[$i]->makeList();
array_splice($types, $j--, 1);
$typesCount--;
continue;
}

if ($types[$j] instanceof ConstantArrayType && $types[$i] instanceof AccessoryArrayListType) {
$types[$j] = $types[$j]->makeList();
array_splice($types, $i--, 1);
$typesCount--;
continue 2;
}

if (
$types[$i] instanceof ConstantArrayType
&& count($types[$i]->getKeyTypes()) === 1
Expand Down
6 changes: 3 additions & 3 deletions tests/PHPStan/Analyser/nsrt/array-chunk.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ public function constantArraysWithOptionalKeys(array $arr): void
*/
public function chunkUnionTypeLength(array $arr, $positiveRange, $positiveUnion) {
/** @var array{a: 0, b?: 1, c: 2} $arr */
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange));
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveRange));
assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveRange, true));
assertType('array{0: array{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion));
assertType('array{0: list{0: 0, 1?: 1|2, 2?: 2}, 1?: array{0?: 2}}', array_chunk($arr, $positiveUnion));
assertType('array{0: array{a: 0, b?: 1, c?: 2}, 1?: array{c?: 2}}', array_chunk($arr, $positiveUnion, true));
}

Expand All @@ -70,7 +70,7 @@ public function lengthIntRanges(array $arr, int $positiveInt, int $bigger50) {
*/
function testLimits(array $arr, int $oneToFour, int $tooBig) {
/** @var array{a: 0, b?: 1, c: 2, d: 3} $arr */
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));
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));
assertType('non-empty-list<non-empty-list<0|1|2|3>>', array_chunk($arr, $tooBig));
}

Expand Down
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/array-column.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ public function testConstantArray12(array $array): void
/** @param array{0?: array{column: 'foo1', key: 'bar1'}, 1?: array{column: 'foo2', key: 'bar2'}} $array */
public function testConstantArray13(array $array): void
{
assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column'));
assertType("array{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null));
assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column'));
assertType("list{0?: 'foo1'|'foo2', 1?: 'foo2'}", array_column($array, 'column', null));
assertType("array{bar1?: 'foo1', bar2?: 'foo2'}", array_column($array, 'column', 'key'));
}

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array-reverse.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function constantArrays(array $a, array $b, array $c): void
assertType('array{\'bar\', \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b));
assertType('array{19: \'bar\', 17: \'foo\'}|array{bar: 19, foo: 17}', array_reverse($b, true));

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

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array_keys.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ public function constantArrayType(): void
[1 => 'a', 2 => 'b', 3 => 'c'],
static fn ($value) => mt_rand(0, 1) === 0,
);
assertType("array{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers));
assertType("list{0?: 1|2|3, 1?: 2|3, 2?: 3}", array_keys($numbers));
}
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/array_values.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function constantArrayType(): void
[1 => 'a', 2 => 'b', 3 => 'c'],
static fn ($value) => mt_rand(0, 1) === 0,
);
assertType("array{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers));
assertType("list{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers));
}

/**
Expand Down
25 changes: 23 additions & 2 deletions tests/PHPStan/Analyser/nsrt/bug-14177.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ class HelloWorld
public function testList(array $b): void
{
if (array_key_exists(3, $b)) {
assertType('list{0: string, 1: string, 2?: string, 3: string}', $b);
assertType('array{string, string, string, string}', $b);
} else {
assertType('list{0: string, 1: string, 2?: string}', $b);
assertType('array{0: string, 1: string, 2?: string}', $b);
}
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
}
Expand Down Expand Up @@ -200,4 +200,25 @@ public function testUnsetInt(array $a, array $b, array $c, int $int): void
assertType('bool', array_is_list($a));
assertType('false', array_is_list($b));
}

/**
* @param list{0?: string, 1?: string, 2?: string} $l
*/
public function testFoo($l): void
{
if (array_key_exists(2, $l, true)) {
assertType('true', array_is_list($l));
assertType('list{0?: string, 1?: string, 2: string}', $l);
if (array_key_exists(1, $l, true)) {
assertType('true', array_is_list($l));
assertType('list{0?: string, 1: string, 2: string}', $l);
} else {
assertType('true', array_is_list($l));
assertType('*NEVER*', $l);
}
} else {
assertType('true', array_is_list($l));
assertType('list{0?: string, 1?: string}', $l);
}
}
}
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/bug-4700.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function(array $array, int $count): void {
if (isset($array['e'])) $a[] = $array['e'];
if (count($a) >= $count) {
assertType('int<1, 5>', count($a));
assertType('array{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
} else {
assertType('0', count($a));
assertType('array{}', $a);
Expand All @@ -44,6 +44,6 @@ function(array $array, int $count): void {
assertType('list{0: mixed~null, 1: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
} else {
assertType('int<0, 5>', count($a)); // Could be int<0, 1>
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}
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}
}
};
6 changes: 3 additions & 3 deletions tests/PHPStan/Analyser/nsrt/constant-array-optional-set.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ public function doFoo()
if (rand(0, 1)) {
$a[] = 3;
}
assertType('array{0: 1, 1?: 2|3, 2?: 3}', $a);
assertType('list{0: 1, 1?: 2|3, 2?: 3}', $a);
if (rand(0, 1)) {
$a[] = 4;
}
assertType('array{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a);
assertType('list{0: 1, 1?: 2|3|4, 2?: 3|4, 3?: 4}', $a);
if (rand(0, 1)) {
$a[] = 5;
}
assertType('array{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a);
assertType('list{0: 1, 1?: 2|3|4|5, 2?: 3|4|5, 3?: 4|5, 4?: 5}', $a);
}

public function doBar()
Expand Down
8 changes: 4 additions & 4 deletions tests/PHPStan/Analyser/nsrt/preg_match_shapes.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ function doFoo(string $row): void
assertType("array{0: non-falsy-string, 1: non-falsy-string, 2?: 'b'}", $matches);
}
if (preg_match('~^(a(b)?)?$~', $row, $matches) === 1) {
assertType("array{0: string, 1?: non-falsy-string, 2?: 'b'}", $matches);
assertType("list{0: string, 1?: non-falsy-string, 2?: 'b'}", $matches);
}
}

Expand Down Expand Up @@ -286,7 +286,7 @@ function (string $size): void {
if (preg_match('~^a\.b(c(\d+)?)?d~', $size, $matches) !== 1) {
throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size));
}
assertType('array{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches);
assertType('list{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches);
};

function (string $size): void {
Expand Down Expand Up @@ -346,11 +346,11 @@ function bug11277b(string $value): void
// https://3v4l.org/09qdT
function bug11291(string $s): void {
if (preg_match('/(?|(a)|(b)(c)|(d)(e)(f))/', $s, $matches)) {
assertType('array{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
assertType('list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
} else {
assertType('array{}', $matches);
}
assertType('array{}|array{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
assertType('array{}|list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches);
}

function bug11323a(string $s): void
Expand Down
Loading
Loading