Skip to content

Commit f39a922

Browse files
staabmphpstan-bot
authored andcommitted
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 1d79eae commit f39a922

5 files changed

Lines changed: 234 additions & 28 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,90 @@ public function specifyTypesInCondition(
876876
}
877877
}
878878

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

936-
if ($context->true()) {
937-
// infer $arr[$key] after $key = array_search($needle, $arr)
938-
if (
939-
$expr->expr instanceof FuncCall
940-
&& $expr->expr->name instanceof Name
941-
&& $expr->expr->name->toLowerString() === 'array_search'
942-
&& count($expr->expr->getArgs()) >= 2
943-
) {
944-
$arrayArg = $expr->expr->getArgs()[1]->value;
945-
$arrayType = $scope->getType($arrayArg);
946-
947-
if ($arrayType->isArray()->yes()) {
948-
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
949-
$iterableValueType = $arrayType->getIterableValueType();
950-
951-
return $specifiedTypes->unionWith(
952-
$this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope),
953-
);
954-
}
955-
}
956-
}
9571020
return $specifiedTypes;
9581021
} elseif (
9591022
$expr instanceof Expr\Isset_
@@ -3016,10 +3079,11 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
30163079

30173080
// array_key_first($a) !== null
30183081
// array_key_last($a) !== null
3082+
// array_find_key($a, $cb) !== null
30193083
if (
30203084
$unwrappedLeftExpr instanceof FuncCall
30213085
&& $unwrappedLeftExpr->name instanceof Name
3022-
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true)
3086+
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_find_key'], true)
30233087
&& isset($unwrappedLeftExpr->getArgs()[0])
30243088
&& $rightType->isNull()->yes()
30253089
) {
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)