Skip to content

Commit 252390f

Browse files
authored
Fixed HasOffsetValueType accessory missing main type
1 parent a817abf commit 252390f

File tree

9 files changed

+197
-16
lines changed

9 files changed

+197
-16
lines changed

src/Analyser/NodeScopeResolver.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@
181181
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
182182
use PHPStan\Type\IntegerType;
183183
use PHPStan\Type\IntersectionType;
184+
use PHPStan\Type\IterableType;
184185
use PHPStan\Type\MixedType;
185186
use PHPStan\Type\NeverType;
186187
use PHPStan\Type\NullType;
@@ -1250,6 +1251,19 @@ private function processStmtNode(
12501251
$exprType = $scope->getType($stmt->expr);
12511252
$isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce();
12521253
if ($exprType->isIterable()->no() || $isIterableAtLeastOnce->maybe()) {
1254+
$foreachType = $this->getForeachIterateeType();
1255+
if (
1256+
!$foreachType->isSuperTypeOf($exprType)->yes()
1257+
&& $finalScope->getType($stmt->expr)->equals($foreachType)
1258+
) {
1259+
// restore iteratee type, in case the type was narrowed while entering the foreach
1260+
$finalScope = $finalScope->assignExpression(
1261+
$stmt->expr,
1262+
$exprType,
1263+
$scope->getNativeType($stmt->expr),
1264+
);
1265+
}
1266+
12531267
$finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr(
12541268
new BinaryOp\Identical(
12551269
$stmt->expr,
@@ -6313,11 +6327,32 @@ private function processVarAnnotation(MutatingScope $scope, array $variableNames
63136327
return $scope;
63146328
}
63156329

6330+
private function getForeachIterateeType(): Type
6331+
{
6332+
return new IterableType(new MixedType(), new MixedType());
6333+
}
6334+
63166335
private function enterForeach(MutatingScope $scope, MutatingScope $originalScope, Foreach_ $stmt): MutatingScope
63176336
{
63186337
if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) {
63196338
$scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt);
63206339
}
6340+
6341+
// narrow the iteratee type to those supported by foreach
6342+
$foreachType = $this->getForeachIterateeType();
6343+
$scope = $scope->specifyExpressionType(
6344+
$stmt->expr,
6345+
TypeCombinator::intersect(
6346+
$scope->getType($stmt->expr),
6347+
$foreachType,
6348+
),
6349+
TypeCombinator::intersect(
6350+
$scope->getNativeType($stmt->expr),
6351+
$foreachType,
6352+
),
6353+
TrinaryLogic::createYes(),
6354+
);
6355+
63216356
$iterateeType = $originalScope->getType($stmt->expr);
63226357
if (
63236358
($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name))

src/Analyser/ResultCache/ResultCacheManager.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1131,7 +1131,7 @@ private function getComposerLocks(): array
11311131
}
11321132

11331133
/**
1134-
* @return array<string, string>
1134+
* @return array<string, array<mixed>>
11351135
*/
11361136
private function getComposerInstalled(): array
11371137
{
@@ -1149,6 +1149,10 @@ private function getComposerInstalled(): array
11491149
}
11501150

11511151
$installed = require $filePath;
1152+
if (!is_array($installed)) {
1153+
throw new ShouldNotHappenException();
1154+
}
1155+
11521156
$rootName = $installed['root']['name'];
11531157
unset($installed['root']);
11541158
unset($installed['versions'][$rootName]);

src/Type/MixedType.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,14 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
169169

170170
public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type
171171
{
172-
return new self($this->isExplicitMixed);
172+
$types = [
173+
new ArrayType(new MixedType(), new MixedType()),
174+
new ObjectType(ArrayAccess::class),
175+
];
176+
if (!$offsetType->isInteger()->no()) {
177+
$types[] = new StringType();
178+
}
179+
return TypeCombinator::union(...$types);
173180
}
174181

175182
public function unsetOffset(Type $offsetType): Type
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug13270a;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
final class HelloWorld
10+
{
11+
/**
12+
* @param array<mixed> $data
13+
*/
14+
public function test(array $data): void
15+
{
16+
foreach($data as $k => $v) {
17+
assertType('non-empty-array<mixed>', $data);
18+
$data[$k]['a'] = true;
19+
assertType("non-empty-array<(non-empty-array&hasOffsetValue('a', true))|(ArrayAccess&hasOffsetValue('a', true))>", $data);
20+
foreach($data[$k] as $val) {
21+
}
22+
}
23+
}
24+
25+
public function doFoo(
26+
mixed $mixed,
27+
mixed $mixed2,
28+
mixed $mixed3,
29+
mixed $mixed4,
30+
int $i,
31+
int $i2,
32+
string|int $stringOrInt
33+
): void
34+
{
35+
$mixed[$i]['a'] = true;
36+
assertType('mixed', $mixed);
37+
38+
$mixed2[$stringOrInt]['a'] = true;
39+
assertType('mixed', $mixed2);
40+
41+
$mixed3[$i][$stringOrInt] = true;
42+
assertType('mixed', $mixed3);
43+
44+
$mixed4['a'][$stringOrInt] = true;
45+
assertType('mixed', $mixed4);
46+
47+
$null = null;
48+
$null[$i]['a'] = true;
49+
assertType('non-empty-array<int, array{a: true}>', $null);
50+
51+
$i2['a'] = true;
52+
assertType('*ERROR*', $i2);
53+
}
54+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bug13270b;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Test
8+
{
9+
/**
10+
* @param mixed[] $data
11+
* @return mixed[]
12+
*/
13+
public function parseData(array $data): array
14+
{
15+
if (isset($data['price'])) {
16+
assertType('mixed~null', $data['price']);
17+
if (!array_key_exists('priceWithVat', $data['price'])) {
18+
$data['price']['priceWithVat'] = null;
19+
}
20+
assertType("(non-empty-array&hasOffsetValue('priceWithVat', mixed))|(ArrayAccess&hasOffsetValue('priceWithVat', null))", $data['price']);
21+
if (!array_key_exists('priceWithoutVat', $data['price'])) {
22+
$data['price']['priceWithoutVat'] = null;
23+
}
24+
}
25+
return $data;
26+
}
27+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php // lint >= 8.0
2+
3+
namespace Bug13312;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function fooArr(array $arr): void {
8+
assertType('array', $arr);
9+
foreach ($arr as $v) {
10+
assertType('non-empty-array', $arr);
11+
}
12+
assertType('array', $arr);
13+
14+
for ($i = 0; $i < count($arr); ++$i) {
15+
assertType('non-empty-array', $arr);
16+
}
17+
assertType('array', $arr);
18+
}
19+
20+
/** @param list<mixed> $arr */
21+
function foo(array $arr): void {
22+
assertType('list<mixed>', $arr);
23+
foreach ($arr as $v) {
24+
assertType('non-empty-list<mixed>', $arr);
25+
}
26+
assertType('list<mixed>', $arr);
27+
28+
for ($i = 0; $i < count($arr); ++$i) {
29+
assertType('non-empty-list<mixed>', $arr);
30+
}
31+
assertType('list<mixed>', $arr);
32+
}
33+
34+
35+
function fooBar(mixed $mixed): void {
36+
assertType('mixed', $mixed);
37+
foreach ($mixed as $v) {
38+
assertType('iterable', $mixed); // could be non-empty-array|Traversable
39+
}
40+
assertType('mixed', $mixed);
41+
42+
foreach ($mixed as $v) {}
43+
44+
assertType('mixed', $mixed);
45+
}

tests/PHPStan/Analyser/nsrt/composer-array-bug.php

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,28 @@ class Foo
1616
public function doFoo(): void
1717
{
1818
if (!empty($this->config['authors'])) {
19+
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
1920
foreach ($this->config['authors'] as $key => $author) {
21+
assertType("iterable", $this->config['authors']);
22+
2023
if (!is_array($author)) {
2124
$this->errors[] = 'authors.'.$key.' : should be an array, '.gettype($author).' given';
22-
assertType("mixed", $this->config['authors']);
25+
assertType("iterable", $this->config['authors']);
2326
unset($this->config['authors'][$key]);
24-
assertType("mixed", $this->config['authors']);
27+
assertType("iterable", $this->config['authors']);
2528
continue;
2629
}
27-
assertType("mixed", $this->config['authors']);
30+
assertType("iterable", $this->config['authors']);
2831
foreach (['homepage', 'email', 'name', 'role'] as $authorData) {
2932
if (isset($author[$authorData]) && !is_string($author[$authorData])) {
3033
$this->errors[] = 'authors.'.$key.'.'.$authorData.' : invalid value, must be a string';
3134
unset($this->config['authors'][$key][$authorData]);
3235
}
3336
}
3437
if (isset($author['homepage'])) {
35-
assertType("mixed", $this->config['authors']);
38+
assertType("iterable", $this->config['authors']);
3639
unset($this->config['authors'][$key]['homepage']);
37-
assertType("mixed", $this->config['authors']);
40+
assertType("iterable", $this->config['authors']);
3841
}
3942
if (isset($author['email']) && !filter_var($author['email'], FILTER_VALIDATE_EMAIL)) {
4043
unset($this->config['authors'][$key]['email']);
@@ -44,8 +47,8 @@ public function doFoo(): void
4447
}
4548
}
4649

47-
assertType("non-empty-array&hasOffsetValue('authors', mixed)", $this->config);
48-
assertType("mixed", $this->config['authors']);
50+
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
51+
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
4952

5053
if (empty($this->config['authors'])) {
5154
unset($this->config['authors']);
@@ -54,7 +57,7 @@ public function doFoo(): void
5457
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
5558
}
5659

57-
assertType('array', $this->config);
60+
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
5861
}
5962
}
6063

tests/PHPStan/Analyser/nsrt/composer-non-empty-array-after-unset.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ public function doFoo()
1515
if (!empty($this->config['authors'])) {
1616
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
1717
foreach ($this->config['authors'] as $key => $author) {
18-
assertType("mixed", $this->config['authors']);
18+
assertType("iterable", $this->config['authors']);
1919
if (!is_array($author)) {
2020
unset($this->config['authors'][$key]);
21-
assertType("mixed", $this->config['authors']);
21+
assertType("iterable", $this->config['authors']);
2222
continue;
2323
}
2424
foreach (['homepage', 'email', 'name', 'role'] as $authorData) {
@@ -33,13 +33,13 @@ public function doFoo()
3333
unset($this->config['authors'][$key]['email']);
3434
}
3535
if (empty($this->config['authors'][$key])) {
36-
assertType("mixed", $this->config['authors']);
36+
assertType("iterable", $this->config['authors']);
3737
unset($this->config['authors'][$key]);
38-
assertType("mixed", $this->config['authors']);
38+
assertType("iterable", $this->config['authors']);
3939
}
40-
assertType("mixed", $this->config['authors']);
40+
assertType("iterable", $this->config['authors']);
4141
}
42-
assertType("mixed", $this->config['authors']);
42+
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
4343
}
4444
}
4545

tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,10 @@ public function testMixed(bool $checkExplicitMixed, bool $checkImplicitMixed, ar
139139
$this->analyse([__DIR__ . '/data/foreach-mixed.php'], $errors);
140140
}
141141

142+
#[RequiresPhp('>= 8.0')]
143+
public function testBug13312(): void
144+
{
145+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13312.php'], []);
146+
}
147+
142148
}

0 commit comments

Comments
 (0)