Skip to content

Commit 25d0b66

Browse files
committed
Track $arr[$key] existence across array_search/array_find_key via conditional expression holders
- Add conditional expression holders in TypeSpecifier for `$key = array_search($needle, $arr)` that fire when `$key !== false`, registering `$arr[$key]` as existing - Add conditional expression holders for `$key = array_find_key($arr, $cb)` that fire when `$key !== null`, registering `$arr[$key]` as existing - Add `array_find_key` to the existing `array_key_first/last !== null` comparison handler to narrow array to non-empty - Move `array_search` true-context handling from standalone block into unified handler alongside the conditional holder logic - Update existing test that was asserting the buggy behavior (separate assignment `$key = array_search(...)` followed by `if ($key !== false)` was reporting "Offset might not exist")
1 parent b6a75c6 commit 25d0b66

5 files changed

Lines changed: 234 additions & 29 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,90 @@ public function specifyTypesInCondition(
882882
}
883883
}
884884

885+
// infer $arr[$key] after $key = array_search($needle, $arr)
886+
if (
887+
$expr->expr instanceof FuncCall
888+
&& $expr->expr->name instanceof Name
889+
&& $expr->expr->name->toLowerString() === 'array_search'
890+
&& count($expr->expr->getArgs()) >= 2
891+
) {
892+
$arrayArg = $expr->expr->getArgs()[1]->value;
893+
$arrayType = $scope->getType($arrayArg);
894+
895+
if ($arrayType->isArray()->yes()) {
896+
if ($context->true()) {
897+
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
898+
899+
$specifiedTypes = $specifiedTypes->unionWith(
900+
$this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope),
901+
);
902+
} elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
903+
$keyType = $scope->getType($expr->expr);
904+
$nonFalseKeyType = TypeCombinator::remove($keyType, new ConstantBooleanType(false));
905+
if (!$nonFalseKeyType instanceof NeverType && !$keyType->isFalse()->yes()) {
906+
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
907+
$dimFetchString = $this->exprPrinter->printExpr($dimFetch);
908+
$keyExprString = $this->exprPrinter->printExpr($expr->var);
909+
910+
$holder = new ConditionalExpressionHolder(
911+
[$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonFalseKeyType)],
912+
ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()),
913+
);
914+
915+
$specifiedTypes = $specifiedTypes->unionWith(
916+
(new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([
917+
$dimFetchString => [$holder->getKey() => $holder],
918+
]),
919+
);
920+
}
921+
}
922+
}
923+
}
924+
925+
// infer $arr[$key] after $key = array_find_key($arr, $callback)
926+
if (
927+
$expr->expr instanceof FuncCall
928+
&& $expr->expr->name instanceof Name
929+
&& $expr->expr->name->toLowerString() === 'array_find_key'
930+
&& count($expr->expr->getArgs()) >= 2
931+
) {
932+
$arrayArg = $expr->expr->getArgs()[0]->value;
933+
$arrayType = $scope->getType($arrayArg);
934+
935+
if ($arrayType->isArray()->yes()) {
936+
if ($context->true()) {
937+
$specifiedTypes = $specifiedTypes->unionWith(
938+
$this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope),
939+
);
940+
941+
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
942+
943+
$specifiedTypes = $specifiedTypes->unionWith(
944+
$this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope),
945+
);
946+
} elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
947+
$keyType = $scope->getType($expr->expr);
948+
$nonNullKeyType = TypeCombinator::removeNull($keyType);
949+
if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) {
950+
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
951+
$dimFetchString = $this->exprPrinter->printExpr($dimFetch);
952+
$keyExprString = $this->exprPrinter->printExpr($expr->var);
953+
954+
$holder = new ConditionalExpressionHolder(
955+
[$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)],
956+
ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()),
957+
);
958+
959+
$specifiedTypes = $specifiedTypes->unionWith(
960+
(new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([
961+
$dimFetchString => [$holder->getKey() => $holder],
962+
]),
963+
);
964+
}
965+
}
966+
}
967+
}
968+
885969
if ($context->null()) {
886970
// infer $arr[$key] after $key = array_rand($arr)
887971
if (
@@ -941,28 +1025,6 @@ public function specifyTypesInCondition(
9411025
return $specifiedTypes;
9421026
}
9431027

944-
if ($context->true()) {
945-
// infer $arr[$key] after $key = array_search($needle, $arr)
946-
if (
947-
$expr->expr instanceof FuncCall
948-
&& $expr->expr->name instanceof Name
949-
&& !$expr->expr->isFirstClassCallable()
950-
&& $expr->expr->name->toLowerString() === 'array_search'
951-
&& count($expr->expr->getArgs()) >= 2
952-
) {
953-
$arrayArg = $expr->expr->getArgs()[1]->value;
954-
$arrayType = $scope->getType($arrayArg);
955-
956-
if ($arrayType->isArray()->yes()) {
957-
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
958-
$iterableValueType = $arrayType->getIterableValueType();
959-
960-
return $specifiedTypes->unionWith(
961-
$this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope),
962-
);
963-
}
964-
}
965-
}
9661028
return $specifiedTypes;
9671029
} elseif (
9681030
$expr instanceof Expr\Isset_
@@ -3032,11 +3094,12 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
30323094

30333095
// array_key_first($a) !== null
30343096
// array_key_last($a) !== null
3097+
// array_find_key($a, $cb) !== null
30353098
if (
30363099
$unwrappedLeftExpr instanceof FuncCall
30373100
&& $unwrappedLeftExpr->name instanceof Name
30383101
&& !$unwrappedLeftExpr->isFirstClassCallable()
3039-
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true)
3102+
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_find_key'], true)
30403103
&& isset($unwrappedLeftExpr->getArgs()[0])
30413104
&& $rightType->isNull()->yes()
30423105
) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php // lint >= 8.4
2+
3+
declare(strict_types=1);
4+
5+
namespace ArrayFindKeyExisting;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @param list<string> $list
11+
*/
12+
function arrayFindKeyNotNull(array $list, string $s): void
13+
{
14+
$key = array_find_key($list, fn (string $v) => $v === $s);
15+
if ($key !== null) {
16+
assertType('non-empty-list<string>', $list);
17+
assertType('string', $list[$key]);
18+
}
19+
}
20+
21+
/**
22+
* @param array<string, int> $map
23+
*/
24+
function arrayFindKeyStringKey(array $map): void
25+
{
26+
$key = array_find_key($map, fn (int $v) => $v > 10);
27+
if ($key !== null) {
28+
assertType('int', $map[$key]);
29+
}
30+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace ArraySearchExisting;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @param list<string> $list
9+
*/
10+
function arraySearchNotFalse(array $list, string $s): void
11+
{
12+
$key = array_search($s, $list);
13+
if ($key !== false) {
14+
assertType('non-empty-list<string>', $list);
15+
assertType('string', $list[$key]);
16+
}
17+
}
18+
19+
/**
20+
* @param array<string, int> $map
21+
*/
22+
function arraySearchStringKey(array $map, int $needle): void
23+
{
24+
$key = array_search($needle, $map);
25+
if ($key !== false) {
26+
assertType('int', $map[$key]);
27+
}
28+
}
29+
30+
/**
31+
* @param list<string> $list
32+
*/
33+
function arraySearchDeepWrite(array $list, string $s): void
34+
{
35+
$key = array_search($s, $list);
36+
if ($key !== false) {
37+
assertType('string', $list[$key]);
38+
}
39+
}

tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -873,12 +873,7 @@ public function testArrayDimFetchAfterArraySearch(): void
873873
{
874874
$this->reportPossiblyNonexistentGeneralArrayOffset = true;
875875

876-
$this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], [
877-
[
878-
'Offset int|string might not exist on non-empty-array.',
879-
20,
880-
],
881-
]);
876+
$this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], []);
882877
}
883878

884879
public function testArrayDimFetchOnArrayKeyFirsOrLastOrCount(): void
@@ -1310,4 +1305,11 @@ public function testBug11218(): void
13101305
$this->analyse([__DIR__ . '/data/bug-11218.php'], []);
13111306
}
13121307

1308+
public function testBug14537(): void
1309+
{
1310+
$this->reportPossiblyNonexistentGeneralArrayOffset = true;
1311+
1312+
$this->analyse([__DIR__ . '/data/bug-14537.php'], []);
1313+
}
1314+
13131315
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php // lint >= 8.4
2+
3+
declare(strict_types=1);
4+
5+
namespace Bug14537;
6+
7+
/**
8+
* @param list<string> $list
9+
*/
10+
function arraySearchNotFalse(array $list, string $s): void
11+
{
12+
$key = array_search($s, $list);
13+
if ($key !== false) {
14+
echo $list[$key];
15+
}
16+
}
17+
18+
/**
19+
* @param array<string, int> $map
20+
*/
21+
function arraySearchStringKey(array $map, int $needle): void
22+
{
23+
$key = array_search($needle, $map);
24+
if ($key !== false) {
25+
echo $map[$key];
26+
}
27+
}
28+
29+
/**
30+
* @param list<string> $list
31+
*/
32+
function arraySearchReversedComparison(array $list, string $s): void
33+
{
34+
$key = array_search($s, $list);
35+
if (false !== $key) {
36+
echo $list[$key];
37+
}
38+
}
39+
40+
/**
41+
* @param list<string> $list
42+
*/
43+
function arrayFindKeyNotNull(array $list, string $s): void
44+
{
45+
$key = array_find_key($list, fn (string $v) => $v === $s);
46+
if ($key !== null) {
47+
echo $list[$key];
48+
}
49+
}
50+
51+
/**
52+
* @param array<string, int> $map
53+
*/
54+
function arrayFindKeyStringKey(array $map): void
55+
{
56+
$key = array_find_key($map, fn (int $v) => $v > 10);
57+
if ($key !== null) {
58+
echo $map[$key];
59+
}
60+
}
61+
62+
/**
63+
* @param list<string> $list
64+
*/
65+
function arrayFindKeyReversedComparison(array $list, string $s): void
66+
{
67+
$key = array_find_key($list, fn (string $v) => $v === $s);
68+
if (null !== $key) {
69+
echo $list[$key];
70+
}
71+
}

0 commit comments

Comments
 (0)