Skip to content

Commit 229df9b

Browse files
committed
ArrayDimFetch: assign vs. narrowing a type
1 parent b2a077a commit 229df9b

File tree

11 files changed

+207
-21
lines changed

11 files changed

+207
-21
lines changed

src/Type/Accessory/HasOffsetValueType.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Type\Accessory;
44

5+
use PHPStan\ShouldNotHappenException;
56
use PHPStan\TrinaryLogic;
67
use PHPStan\Type\CompoundType;
78
use PHPStan\Type\Constant\ConstantIntegerType;
@@ -39,7 +40,7 @@ public function __construct(private ConstantStringType|ConstantIntegerType $offs
3940
{
4041
}
4142

42-
public function getOffsetType(): Type
43+
public function getOffsetType(): ConstantStringType|ConstantIntegerType
4344
{
4445
return $this->offsetType;
4546
}
@@ -129,7 +130,19 @@ public function getOffsetValueType(Type $offsetType): Type
129130

130131
public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type
131132
{
132-
return $this;
133+
if ($offsetType === null) {
134+
return $this;
135+
}
136+
137+
if (!$offsetType->equals($this->offsetType)) {
138+
return $this;
139+
}
140+
141+
if (!$offsetType instanceof ConstantIntegerType && !$offsetType instanceof ConstantStringType) {
142+
throw new ShouldNotHappenException();
143+
}
144+
145+
return new self($offsetType, $valueType);
133146
}
134147

135148
public function unsetOffset(Type $offsetType): Type

src/Type/ArrayType.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
266266
{
267267
if ($offsetType === null) {
268268
$offsetType = new IntegerType();
269+
} else {
270+
$offsetType = self::castToArrayKeyType($offsetType);
269271
}
270272

271273
if (
@@ -277,10 +279,15 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
277279
return $builder->getArray();
278280
}
279281

280-
return TypeCombinator::intersect(new self(
281-
TypeCombinator::union($this->keyType, self::castToArrayKeyType($offsetType)),
282+
$array = new self(
283+
TypeCombinator::union($this->keyType, $offsetType),
282284
$unionValues ? TypeCombinator::union($this->itemType, $valueType) : $valueType,
283-
), new NonEmptyArrayType());
285+
);
286+
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
287+
return TypeCombinator::intersect($array, new HasOffsetValueType($offsetType, $valueType), new NonEmptyArrayType());
288+
}
289+
290+
return TypeCombinator::intersect($array, new NonEmptyArrayType());
284291
}
285292

286293
public function unsetOffset(Type $offsetType): Type

src/Type/TypeCombinator.php

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use PHPStan\Type\Generic\TemplateUnionType;
2121
use function array_intersect_key;
2222
use function array_key_exists;
23+
use function array_keys;
2324
use function array_map;
2425
use function array_merge;
2526
use function array_slice;
@@ -29,6 +30,7 @@
2930
use function get_class;
3031
use function is_int;
3132
use function md5;
33+
use function sprintf;
3234
use function usort;
3335

3436
/** @api */
@@ -171,7 +173,12 @@ public static function union(Type ...$types): Type
171173
continue;
172174
}
173175
if ($innerType instanceof AccessoryType || $innerType instanceof CallableType) {
174-
$intermediateAccessoryTypes[$innerType->describe(VerbosityLevel::cache())] = $innerType;
176+
if ($innerType instanceof HasOffsetValueType) {
177+
$intermediateAccessoryTypes[sprintf('hasOffsetValue(%s)', $innerType->getOffsetType()->describe(VerbosityLevel::cache()))][] = $innerType;
178+
continue;
179+
}
180+
181+
$intermediateAccessoryTypes[$innerType->describe(VerbosityLevel::cache())][] = $innerType;
175182
continue;
176183
}
177184
}
@@ -191,7 +198,7 @@ public static function union(Type ...$types): Type
191198

192199
if ($types[$i]->isIterableAtLeastOnce()->yes()) {
193200
$nonEmpty = new NonEmptyArrayType();
194-
$arrayAccessoryTypes[] = [$nonEmpty->describe(VerbosityLevel::cache()) => $nonEmpty];
201+
$arrayAccessoryTypes[] = [$nonEmpty->describe(VerbosityLevel::cache()) => [$nonEmpty]];
195202
} else {
196203
$arrayAccessoryTypes[] = [];
197204
}
@@ -205,11 +212,22 @@ public static function union(Type ...$types): Type
205212
/** @var ArrayType[] $arrayTypes */
206213
$arrayTypes = $arrayTypes;
207214

208-
$arrayAccessoryTypesToProcess = [];
215+
$commonArrayAccessoryTypesKeys = [];
209216
if (count($arrayAccessoryTypes) > 1) {
210-
$arrayAccessoryTypesToProcess = array_values(array_intersect_key(...$arrayAccessoryTypes));
217+
$commonArrayAccessoryTypesKeys = array_keys(array_intersect_key(...$arrayAccessoryTypes));
211218
} elseif (count($arrayAccessoryTypes) > 0) {
212-
$arrayAccessoryTypesToProcess = array_values($arrayAccessoryTypes[0]);
219+
$commonArrayAccessoryTypesKeys = array_keys($arrayAccessoryTypes[0]);
220+
}
221+
222+
$arrayAccessoryTypesToProcess = [];
223+
foreach ($commonArrayAccessoryTypesKeys as $commonKey) {
224+
$typesToUnion = [];
225+
foreach ($arrayAccessoryTypes as $array) {
226+
foreach ($array[$commonKey] as $arrayAccessoryType) {
227+
$typesToUnion[] = $arrayAccessoryType;
228+
}
229+
}
230+
$arrayAccessoryTypesToProcess[] = self::union(...$typesToUnion);
213231
}
214232

215233
$types = array_values(
@@ -366,6 +384,11 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array
366384
if ($a instanceof IntegerRangeType && $b instanceof IntegerRangeType) {
367385
return null;
368386
}
387+
if ($a instanceof HasOffsetValueType && $b instanceof HasOffsetValueType) {
388+
if ($a->getOffsetType()->equals($b->getOffsetType())) {
389+
return [new HasOffsetValueType($a->getOffsetType(), self::union($a->getValueType(), $b->getValueType())), null];
390+
}
391+
}
369392

370393
if ($a instanceof SubtractableType) {
371394
$typeWithoutSubtractedTypeA = $a->getTypeWithoutSubtractedType();

tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7940,7 +7940,7 @@ public function dataArrayKeysInBranches(): array
79407940
'$array',
79417941
],
79427942
[
7943-
'array&hasOffsetValue(\'key\', mixed~null)',
7943+
'array&hasOffsetValue(\'key\', mixed)',
79447944
'$generalArray',
79457945
],
79467946
[

tests/PHPStan/Analyser/data/bug-2911.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private function getResultSettings(array $settings): array
7474

7575
$settings['limit'] = (int) $settings['limit'];
7676

77-
assertType("non-empty-array<string, mixed>&hasOffsetValue('limit', float|int<1, max>|numeric-string)&hasOffsetValue('limit', int)&hasOffsetValue('remove', 'all'|'first'|'last')", $settings);
77+
assertType("non-empty-array<string, mixed>&hasOffsetValue('limit', int)&hasOffsetValue('remove', 'all'|'first'|'last')", $settings);
7878

7979
return $settings;
8080
}

tests/PHPStan/Analyser/data/bug-4708.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,25 @@ function GetASCConfig()
5959
{
6060
assertType("array<string>&hasOffsetValue('bsw', string)", $result);
6161
$result['bsw'] = (int) $result['bsw'];
62-
assertType('*NEVER*', $result); // should be non-empty-array<string|int>&hasOffsetValue('bsw', int)
62+
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', int)", $result);
6363
}
6464

65-
assertType("non-empty-array<1|string>&hasOffsetValue('bsw', 1)", $result); // should be non-empty-array<1|string>&hasOffsetValue('bsw', int)
65+
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', int)", $result);
6666

6767
if (!isset($result['bew']))
6868
{
69+
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', int)", $result);
6970
$result['bew'] = 5;
71+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', 5)&hasOffsetValue('bsw', int)", $result);
7072
}
7173
else
7274
{
75+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int|string)&hasOffsetValue('bsw', int)", $result);
7376
$result['bew'] = (int) $result['bew'];
77+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result);
7478
}
7579

76-
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', 1)", $result); // should be non-empty-array<int|string>&hasOffsetValue('bsw', int)&hasOffsetValue('bew', int)
80+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result);
7781

7882
foreach (['utc', 'ssi'] as $field)
7983
{
@@ -83,7 +87,7 @@ function GetASCConfig()
8387
}
8488
}
8589

86-
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', 1)", $result); // should be non-empty-array<int|string>&hasOffsetValue('bsw', int)&hasOffsetValue('bew', int)
90+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result);
8791
}
8892

8993
assertType('non-empty-array<int|string|false>', $result);

tests/PHPStan/Analyser/data/has-offset-type-bug.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,53 @@ public function doBar(\SimpleXMLElement $xml)
111111
}
112112

113113
}
114+
115+
116+
class AssignVsNarrow
117+
{
118+
119+
/**
120+
* @param array{a: string} $a
121+
* @return void
122+
*/
123+
public function doFoo(array $a)
124+
{
125+
if (is_int($a['a'])) {
126+
assertType('array{a: *NEVER*}', $a);
127+
}
128+
}
129+
130+
/**
131+
* @param array{a: string} $a
132+
* @return void
133+
*/
134+
public function doBar(array $a, int $i)
135+
{
136+
$a['a'] = $i;
137+
assertType('array{a: int}', $a);
138+
}
139+
140+
/**
141+
* @param array<string, string> $a
142+
* @return void
143+
*/
144+
public function doFoo2(array $a)
145+
{
146+
if (is_int($a['a'])) {
147+
assertType("array<string, string>&hasOffsetValue('a', *NEVER*)", $a);
148+
}
149+
}
150+
151+
/**
152+
* @param array<string, string> $a
153+
* @return void
154+
*/
155+
public function doBar2(array $a, int $i, string $s)
156+
{
157+
$a['a'] = $i;
158+
assertType('non-empty-array<string, int|string>&hasOffsetValue(\'a\', int)', $a);
159+
$a['a'] = $s;
160+
assertType('non-empty-array<string, int|string>&hasOffsetValue(\'a\', string)', $a);
161+
}
162+
163+
}

tests/PHPStan/Rules/Arrays/data/bug-7469.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function doFoo() {
3939
$data['radio'] = normalizePrice($data['radio']);
4040

4141
$data['invoicing'] = $data['invoicing'] === 'ANO';
42-
assertType("non-empty-array<'address'|'bankAccount'|'birthDate'|'email'|'firstName'|'ic'|'invoicing'|'invoicingAddress'|'languages'|'lastName'|'note'|'phone'|'radio'|'videoOnline'|'videoTvc'|'voiceExample', mixed>&hasOffsetValue('invoicing', bool)&hasOffsetValue('languages', non-empty-array<int, string>)", $data);
42+
assertType("non-empty-array<'address'|'bankAccount'|'birthDate'|'email'|'firstName'|'ic'|'invoicing'|'invoicingAddress'|'languages'|'lastName'|'note'|'phone'|'radio'|'videoOnline'|'videoTvc'|'voiceExample', mixed>&hasOffsetValue('invoicing', bool)&hasOffsetValue('languages', non-empty-array<int, string>)&hasOffsetValue('radio', mixed)&hasOffsetValue('videoOnline', mixed)&hasOffsetValue('videoTvc', mixed)", $data);
4343
}
4444

4545
function normalizePrice($value)

tests/PHPStan/Rules/Comparison/data/bug-4708.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,25 @@ function GetASCConfig()
5959
{
6060
assertType('array<string>&hasOffsetValue(\'bsw\', string)', $result);
6161
$result['bsw'] = (int) $result['bsw'];
62-
assertType('*NEVER*', $result); // should be non-empty-array<string|int>&hasOffsetValue('bsw', int)
62+
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', int)", $result);
6363
}
6464

65-
assertType("non-empty-array<1|string>&hasOffsetValue('bsw', 1)", $result); // should have an int
65+
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', int)", $result);
6666

6767
if (!isset($result['bew']))
6868
{
69+
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', int)", $result);
6970
$result['bew'] = 5;
71+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', 5)&hasOffsetValue('bsw', int)", $result);
7072
}
7173
else
7274
{
75+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int|string)&hasOffsetValue('bsw', int)", $result);
7376
$result['bew'] = (int) $result['bew'];
77+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result);
7478
}
7579

76-
assertType("non-empty-array<int|string>&hasOffsetValue('bsw', 1)", $result); // missing bsw key
80+
assertType("non-empty-array<int|string>&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result);
7781

7882
foreach (['utc', 'ssi'] as $field)
7983
{

tests/PHPStan/Rules/Variables/data/bug-7417.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function doFoo() {
2020
// in core.extension so this will come before it's base theme.
2121
$extensions['theme']['test_subtheme'] = 0;
2222
$extensions['theme']['test_subsubtheme'] = 0;
23-
assertType('non-empty-array', $extensions); // could be more precise
23+
assertType("hasOffsetValue('theme', mixed)&non-empty-array", $extensions);
2424
unset($extensions['theme']['test_basetheme']);
2525
unset($extensions['theme']['test_subsubtheme']);
2626
unset($extensions['theme']['test_subtheme']);

0 commit comments

Comments
 (0)