Skip to content

Commit a269f2e

Browse files
phpstan-botondrejmirtes
authored andcommitted
Advance the implicit index by an unpacked item's integer keys in array literals
- Add `PHPStan\Type\ArrayUnpackingHelper`, which resolves how many implicit indices an unpacked item (`...$a`) takes up and which keys it contributes: integer keys are always renumbered, string keys keep their name since PHP 8.1. - `AssignHandler::processArrayByRefItems()` counts an unpacked item's integer keys through the helper, replacing `advanceImplicitIndexByUnpackedArray()`. Besides a single sealed constant array, this also keeps the implicit index known after a union of constant arrays with the same integer key count and after an array with only string keys, which takes up no index. The index still stops at `PHP_INT_MAX` through `advanceImplicitIndex()`. - By-reference items inside an unpacked array literal whose items are all keyless (`[...[&$x], &$y]`) are flattened into the surrounding literal, so `$x` is linked to `$a[0]` instead of losing its link: `$x = 10` now gives `array{10, int}`. - `DuplicateKeysInLiteralArraysRule` no longer stops tracking keys after every unpacked item. It asks the helper for the keys the unpacked item really contributes, so `[...[1, 2], 0 => 'x']` and `['a' => 1, ...['a' => 2]]` are reported, and stops tracking the auto-generated index only when they can't be determined or would pass `PHP_INT_MAX`. - In the same rule, an explicit integer key no longer resurrects the auto-generated index after it became unknown (`max(false, 5)` used to evaluate to `5`), which fixes false positives like `[$k => 'z', 5 => 'a', 'b', 6 => 'c']` and `[...$list, 5 => 'a', 'b', 6 => 'c']`. - String keys contributed by an unpacked item are printed with quotes in the rule's error message. - `testBug15244` now requires PHP 8.1 like its `// lint >= 8.1` data file: before 8.1 the string-keyed spread in it renumbers, which the rule now reports.
1 parent 8f89e5f commit a269f2e

9 files changed

Lines changed: 574 additions & 103 deletions

File tree

src/Analyser/ExprHandler/AssignHandler.php

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
use PHPStan\Type\Accessory\AccessoryArrayListType;
6464
use PHPStan\Type\Accessory\HasOffsetValueType;
6565
use PHPStan\Type\Accessory\NonEmptyArrayType;
66+
use PHPStan\Type\ArrayUnpackingHelper;
6667
use PHPStan\Type\Constant\ConstantArrayType;
6768
use PHPStan\Type\Constant\ConstantBooleanType;
6869
use PHPStan\Type\Constant\ConstantIntegerType;
@@ -121,6 +122,7 @@ public function __construct(
121122
private StaticPropertyFetchHandler $staticPropertyFetchHandler,
122123
private MethodThrowPointHelper $methodThrowPointHelper,
123124
private PropertyHookThrowPointsResolver $propertyHookThrowPointsResolver,
125+
private ArrayUnpackingHelper $arrayUnpackingHelper,
124126
)
125127
{
126128
}
@@ -1935,15 +1937,38 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr
19351937

19361938
private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope
19371939
{
1938-
$implicitIndex = 0;
1940+
[$scope] = $this->processArrayByRefItemsWithImplicitIndex($scope, $rootVarName, $arrayExpr, $parentExpr, 0);
1941+
1942+
return $scope;
1943+
}
1944+
1945+
/**
1946+
* @param int|null $implicitIndex Next implicit index, or null when it cannot be determined
1947+
*
1948+
* @return array{MutatingScope, int|null}
1949+
*/
1950+
private function processArrayByRefItemsWithImplicitIndex(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr, ?int $implicitIndex): array
1951+
{
19391952
foreach ($arrayExpr->items as $arrayItem) {
19401953
if ($arrayItem->unpack) {
1941-
// An unpacked item adds an unknown number of elements, so the
1942-
// following implicit indices are only predictable when the
1943-
// unpacked array has a known shape.
1954+
// Unpacked items are flattened into the surrounding array, so they take up
1955+
// as many implicit indices as the unpacked value has integer keys.
1956+
if ($arrayItem->value instanceof Expr\Array_ && $this->isFlattenableUnpackedArray($arrayItem->value)) {
1957+
[$scope, $implicitIndex] = $this->processArrayByRefItemsWithImplicitIndex($scope, $rootVarName, $arrayItem->value, $parentExpr, $implicitIndex);
1958+
continue;
1959+
}
1960+
19441961
if ($implicitIndex !== null) {
1945-
$implicitIndex = $this->advanceImplicitIndexByUnpackedArray($implicitIndex, $scope->getType($arrayItem->value));
1962+
$unpackedIntegerKeysCount = $this->arrayUnpackingHelper->getImplicitIndexCount($scope->getType($arrayItem->value));
1963+
if ($unpackedIntegerKeysCount === null) {
1964+
$implicitIndex = null;
1965+
} else {
1966+
for ($i = 0; $i < $unpackedIntegerKeysCount && $implicitIndex !== null; $i++) {
1967+
$implicitIndex = $this->advanceImplicitIndex($implicitIndex);
1968+
}
1969+
}
19461970
}
1971+
19471972
continue;
19481973
}
19491974

@@ -1974,7 +1999,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
19741999

19752000
if ($arrayItem->value instanceof Expr\Array_) {
19762001
$dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr);
1977-
$scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr);
2002+
[$scope] = $this->processArrayByRefItemsWithImplicitIndex($scope, $rootVarName, $arrayItem->value, $dimFetchExpr, 0);
19782003
}
19792004

19802005
if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) {
@@ -2001,7 +2026,22 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
20012026
);
20022027
}
20032028

2004-
return $scope;
2029+
return [$scope, $implicitIndex];
2030+
}
2031+
2032+
/**
2033+
* Unpacking renumbers integer keys, so a by-reference item inside the unpacked array
2034+
* literal only stays at the key it's written with if all of its items are keyless.
2035+
*/
2036+
private function isFlattenableUnpackedArray(Expr\Array_ $arrayExpr): bool
2037+
{
2038+
foreach ($arrayExpr->items as $arrayItem) {
2039+
if ($arrayItem->key !== null) {
2040+
return false;
2041+
}
2042+
}
2043+
2044+
return true;
20052045
}
20062046

20072047
private const ARRAY_DIM_FETCH_WRITE_DEPTH_LIMIT = 5;
@@ -2016,39 +2056,6 @@ private function advanceImplicitIndex(int $index): ?int
20162056
return $index === PHP_INT_MAX ? null : $index + 1;
20172057
}
20182058

2019-
private function advanceImplicitIndexByUnpackedArray(int $implicitIndex, Type $unpackedType): ?int
2020-
{
2021-
$constantArrays = $unpackedType->getConstantArrays();
2022-
if (count($constantArrays) !== 1) {
2023-
return null;
2024-
}
2025-
2026-
$constantArray = $constantArrays[0];
2027-
if (count($constantArray->getOptionalKeys()) > 0 || !$constantArray->isUnsealed()->no()) {
2028-
return null;
2029-
}
2030-
2031-
foreach ($constantArray->getKeyTypes() as $keyType) {
2032-
// String keys are preserved by unpacking, only integer keys are renumbered
2033-
if ($keyType->isString()->yes()) {
2034-
continue;
2035-
}
2036-
2037-
if (!$keyType->isInteger()->yes()) {
2038-
return null;
2039-
}
2040-
2041-
$nextIndex = $this->advanceImplicitIndex($implicitIndex);
2042-
if ($nextIndex === null) {
2043-
return null;
2044-
}
2045-
2046-
$implicitIndex = $nextIndex;
2047-
}
2048-
2049-
return $implicitIndex;
2050-
}
2051-
20522059
/**
20532060
* @param non-empty-list<ArrayDimFetch> $dimFetchStack
20542061
* @param non-empty-list<array{Type|null, ArrayDimFetch}> $offsetTypes

src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php

Lines changed: 96 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\Node\Printer\ExprPrinter;
1010
use PHPStan\Rules\Rule;
1111
use PHPStan\Rules\RuleErrorBuilder;
12+
use PHPStan\Type\ArrayUnpackingHelper;
1213
use PHPStan\Type\Constant\ConstantIntegerType;
1314
use function array_key_first;
1415
use function array_keys;
@@ -18,6 +19,7 @@
1819
use function is_bool;
1920
use function is_float;
2021
use function is_int;
22+
use function is_string;
2123
use function max;
2224
use function sprintf;
2325
use function var_export;
@@ -32,6 +34,7 @@ final class DuplicateKeysInLiteralArraysRule implements Rule
3234

3335
public function __construct(
3436
private ExprPrinter $exprPrinter,
37+
private ArrayUnpackingHelper $arrayUnpackingHelper,
3538
)
3639
{
3740
}
@@ -64,106 +67,138 @@ public function processNode(Node $node, Scope $scope): array
6467
continue;
6568
}
6669

70+
$key = $item->key;
6771
if ($item->unpack) {
68-
// An unpacked array contributes an unknown number of renumbered
69-
// integer keys, so the following implicit keys are unpredictable.
70-
$autoGeneratedIndex = false;
71-
continue;
72-
}
72+
if ($autoGeneratedIndex === false) {
73+
continue;
74+
}
7375

74-
$key = $item->key;
75-
if ($key === null) {
76+
if ($autoGeneratedIndex === null) {
77+
$nextImplicitIndex = 0;
78+
} elseif ($autoGeneratedIndex === PHP_INT_MAX) {
79+
$nextImplicitIndex = null;
80+
} else {
81+
$nextImplicitIndex = $autoGeneratedIndex + 1;
82+
}
83+
84+
// an unpacked item contributes all of its own keys, with its integer
85+
// keys renumbered starting from the current implicit index
86+
$keyTypes = $this->arrayUnpackingHelper->getKeyTypes(
87+
$itemNode->getScope()->getType($item->value),
88+
$nextImplicitIndex,
89+
);
90+
if ($keyTypes === null) {
91+
$autoGeneratedIndex = false;
92+
continue;
93+
}
94+
95+
foreach ($keyTypes as $unpackedKeyType) {
96+
if (!$unpackedKeyType instanceof ConstantIntegerType) {
97+
continue;
98+
}
99+
100+
$autoGeneratedIndex = $autoGeneratedIndex === null
101+
? $unpackedKeyType->getValue()
102+
: max($autoGeneratedIndex, $unpackedKeyType->getValue());
103+
}
104+
} elseif ($key === null) {
76105
if ($autoGeneratedIndex === false) {
77106
continue;
78107
}
79108

80109
if ($autoGeneratedIndex === null) {
81110
$autoGeneratedIndex = 0;
82-
$keyType = new ConstantIntegerType(0);
111+
$keyTypes = [new ConstantIntegerType(0)];
83112
} elseif ($autoGeneratedIndex === PHP_INT_MAX) {
84113
// PHP throws "Cannot add element to the array as the next
85114
// element is already occupied" instead of wrapping around.
86115
$autoGeneratedIndex = false;
87116
continue;
88117
} else {
89-
$keyType = new ConstantIntegerType(++$autoGeneratedIndex);
118+
$keyTypes = [new ConstantIntegerType(++$autoGeneratedIndex)];
90119
}
91120
} else {
92121
$keyType = $itemNode->getScope()->getType($key);
93122
$arrayKeyValues = $keyType->toArrayKey()->getConstantScalarValues();
94-
if (count($arrayKeyValues) === 1 && is_int($arrayKeyValues[0])) {
123+
// once the implicit index is unknown an explicit key can't bring it back -
124+
// the forgotten items might have taken up a higher index than this one
125+
if ($autoGeneratedIndex !== false && count($arrayKeyValues) === 1 && is_int($arrayKeyValues[0])) {
95126
$autoGeneratedIndex = $autoGeneratedIndex === null
96127
? $arrayKeyValues[0]
97128
: max($autoGeneratedIndex, $arrayKeyValues[0]);
98129
}
99-
}
100130

101-
$keyValues = $keyType->toArrayKey()->getConstantScalarValues();
102-
if (count($keyValues) === 0) {
103-
$autoGeneratedIndex = false;
104-
continue;
131+
$keyTypes = [$keyType];
105132
}
106133

107-
$duplicate = false;
108-
$newValues = $keyValues;
109-
foreach ($newValues as $k => $newValue) {
110-
if (array_search($newValue, $seenKeys, true) !== false) {
111-
unset($newValues[$k]);
134+
foreach ($keyTypes as $keyType) {
135+
$keyValues = $keyType->toArrayKey()->getConstantScalarValues();
136+
if (count($keyValues) === 0) {
137+
$autoGeneratedIndex = false;
138+
continue;
112139
}
113140

114-
if ($newValues === []) {
115-
$duplicate = true;
116-
break;
141+
$duplicate = false;
142+
$newValues = $keyValues;
143+
foreach ($newValues as $k => $newValue) {
144+
if (array_search($newValue, $seenKeys, true) !== false) {
145+
unset($newValues[$k]);
146+
}
147+
148+
if ($newValues === []) {
149+
$duplicate = true;
150+
break;
151+
}
117152
}
118-
}
119153

120-
if ($newValues !== []) {
121-
if (count($newValues) === 1) {
122-
$newValue = $newValues[array_key_first($newValues)];
123-
foreach ($seenUnions as $k => $union) {
124-
$offset = array_search($newValue, $union, true);
125-
if ($offset === false) {
126-
continue;
127-
}
154+
if ($newValues !== []) {
155+
if (count($newValues) === 1) {
156+
$newValue = $newValues[array_key_first($newValues)];
157+
foreach ($seenUnions as $k => $union) {
158+
$offset = array_search($newValue, $union, true);
159+
if ($offset === false) {
160+
continue;
161+
}
128162

129-
unset($seenUnions[$k][$offset]);
163+
unset($seenUnions[$k][$offset]);
130164

131-
// turn a union into a seen key, when all its elements have been seen
132-
if (count($seenUnions[$k]) !== 1) {
133-
continue;
134-
}
165+
// turn a union into a seen key, when all its elements have been seen
166+
if (count($seenUnions[$k]) !== 1) {
167+
continue;
168+
}
135169

136-
$seenKeys[] = $seenUnions[$k][array_key_first($seenUnions[$k])];
137-
unset($seenUnions[$k]);
170+
$seenKeys[] = $seenUnions[$k][array_key_first($seenUnions[$k])];
171+
unset($seenUnions[$k]);
172+
}
173+
$seenKeys[] = $newValue;
174+
} else {
175+
$seenUnions[] = $newValues;
138176
}
139-
$seenKeys[] = $newValue;
140-
} else {
141-
$seenUnions[] = $newValues;
142177
}
143-
}
144178

145-
foreach ($keyValues as $value) {
146-
// Prevent php warning by manually casting array keys
147-
if (is_bool($value) || is_float($value)) {
148-
$value = (int) $value;
149-
} elseif ($value === null) {
150-
$value = (string) $value;
151-
}
179+
foreach ($keyValues as $value) {
180+
// Prevent php warning by manually casting array keys
181+
if (is_bool($value) || is_float($value)) {
182+
$value = (int) $value;
183+
} elseif ($value === null) {
184+
$value = (string) $value;
185+
}
152186

153-
$printedValue = $key !== null
154-
? $this->exprPrinter->printExpr($key)
155-
: $value;
156-
$printedValues[$value][] = $printedValue;
187+
$printedValue = $key !== null
188+
? $this->exprPrinter->printExpr($key)
189+
: (is_string($value) ? var_export($value, true) : $value);
190+
$printedValues[$value][] = $printedValue;
157191

158-
if (!isset($valueLines[$value])) {
159-
$valueLines[$value] = $item->getStartLine();
160-
}
192+
if (!isset($valueLines[$value])) {
193+
$valueLines[$value] = $item->getStartLine();
194+
}
161195

162-
if (!$duplicate) {
163-
continue;
164-
}
196+
if (!$duplicate) {
197+
continue;
198+
}
165199

166-
$duplicateKeys[$value] = true;
200+
$duplicateKeys[$value] = true;
201+
}
167202
}
168203
}
169204

0 commit comments

Comments
 (0)