Skip to content

Commit e66e5fb

Browse files
committed
Fix phpstan/phpstan#14407: !in_array with enum values incorrectly narrows to *NEVER*
- In false context (!in_array), compute guaranteed array value type by intersecting value types across union members instead of using the full union of all possible values - For array{A}|array{B}, neither A nor B is guaranteed in every variant, so no narrowing should occur - For array{A, B}, both A and B are guaranteed, so narrowing to *NEVER* remains correct - New regression test in tests/PHPStan/Analyser/nsrt/bug-14407.php
1 parent c36922b commit e66e5fb

File tree

2 files changed

+100
-3
lines changed

2 files changed

+100
-3
lines changed

src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use PHPStan\Type\FunctionTypeSpecifyingExtension;
2020
use PHPStan\Type\MixedType;
2121
use PHPStan\Type\TypeCombinator;
22+
use PHPStan\Type\UnionType;
2223
use function count;
2324
use function strtolower;
2425

@@ -113,25 +114,45 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
113114
}
114115

115116
$specifiedTypes = new SpecifiedTypes();
117+
$narrowingValueType = $arrayValueType;
118+
if ($context->false()) {
119+
// In false context (!in_array), we can only remove values guaranteed
120+
// to be in every possible array variant. For union types like
121+
// array{A}|array{B}, getIterableValueType() returns A|B but neither
122+
// value is guaranteed to be in every variant.
123+
$innerTypes = $arrayType instanceof UnionType ? $arrayType->getTypes() : [$arrayType];
124+
$guaranteedValueType = null;
125+
foreach ($innerTypes as $innerType) {
126+
$innerValueType = $innerType->getIterableValueType();
127+
if ($guaranteedValueType === null) {
128+
$guaranteedValueType = $innerValueType;
129+
} else {
130+
$guaranteedValueType = TypeCombinator::intersect($guaranteedValueType, $innerValueType);
131+
}
132+
}
133+
if ($guaranteedValueType !== null) {
134+
$narrowingValueType = $guaranteedValueType;
135+
}
136+
}
116137
if (
117138
$context->true()
118139
|| (
119140
$context->false()
120-
&& count($arrayValueType->getFiniteTypes()) > 0
141+
&& count($narrowingValueType->getFiniteTypes()) > 0
121142
&& count($needleType->getFiniteTypes()) > 0
122143
&& $arrayType->isIterableAtLeastOnce()->yes()
123144
)
124145
) {
125146
$specifiedTypes = $this->typeSpecifier->create(
126147
$needleExpr,
127-
$arrayValueType,
148+
$narrowingValueType,
128149
$context,
129150
$scope,
130151
);
131152
if ($needleExpr instanceof AlwaysRememberedExpr) {
132153
$specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create(
133154
$needleExpr->getExpr(),
134-
$arrayValueType,
155+
$narrowingValueType,
135156
$context,
136157
$scope,
137158
));
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14407;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
enum SomeEnum {
10+
case A;
11+
case B;
12+
}
13+
14+
function () {
15+
$arr = [];
16+
17+
if (rand(0, 1) === 0) {
18+
$arr[] = SomeEnum::A;
19+
} else {
20+
$arr[] = SomeEnum::B;
21+
}
22+
23+
if (rand(0, 1) === 0) {
24+
$x = SomeEnum::A;
25+
} else {
26+
$x = SomeEnum::B;
27+
}
28+
29+
if (!in_array($x, $arr)) {
30+
// either $x=A, $arr=[B] or $x=B, $arr=[A]
31+
assertType('Bug14407\SomeEnum::A|Bug14407\SomeEnum::B', $x);
32+
}
33+
34+
if (!in_array($x, $arr) && $x === SomeEnum::A) {
35+
// $x=A, $arr=[B]
36+
assertType('Bug14407\SomeEnum::A', $x);
37+
}
38+
};
39+
40+
function () {
41+
$arr = [SomeEnum::A, SomeEnum::B];
42+
43+
if (rand(0, 1) === 0) {
44+
$x = SomeEnum::A;
45+
} else {
46+
$x = SomeEnum::B;
47+
}
48+
49+
if (!in_array($x, $arr)) {
50+
// array always contains both A and B, so this is correctly *NEVER*
51+
assertType('*NEVER*', $x);
52+
}
53+
};
54+
55+
function () {
56+
$arr = [];
57+
58+
$r = rand(0, 2);
59+
60+
if ($r === 0) {
61+
$arr[] = SomeEnum::A;
62+
} elseif ($r === 1) {
63+
$arr[] = SomeEnum::B;
64+
}
65+
66+
if (rand(0, 1) === 0) {
67+
$x = SomeEnum::A;
68+
} else {
69+
$x = SomeEnum::B;
70+
}
71+
72+
// arr might be empty, so no narrowing possible
73+
if (!in_array($x, $arr) && $x === SomeEnum::A) {
74+
assertType('Bug14407\SomeEnum::A', $x);
75+
}
76+
};

0 commit comments

Comments
 (0)