Skip to content

Commit b9703b0

Browse files
phpstan-botstaabmclaude
authored
Track $arr[$key] existence across array_search/array_find_key via conditional expression holders (#5552)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Markus Staab <maggus.staab@googlemail.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de>
1 parent 6855944 commit b9703b0

4 files changed

Lines changed: 247 additions & 46 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -849,34 +849,77 @@ public function specifyTypesInCondition(
849849

850850
if ($isNonEmpty) {
851851
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
852-
853852
$specifiedTypes = $specifiedTypes->unionWith(
854853
$this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope),
855854
);
856855
} elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
857-
// The array might be empty here, so we cannot register
858-
// $arr[$key] unconditionally. Attach a conditional holder
859-
// that fires once the user narrows $key to non-null
860-
// (e.g. `if ($key !== null)`), giving the deep-write
861-
// path the same shape information `isset($arr[$key])`
862-
// would have provided.
863856
$keyType = $scope->getType($expr->expr);
864857
$nonNullKeyType = TypeCombinator::removeNull($keyType);
865-
if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) {
866-
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
867-
$dimFetchString = $this->exprPrinter->printExpr($dimFetch);
868-
$keyExprString = $this->exprPrinter->printExpr($expr->var);
858+
if (!$nonNullKeyType instanceof NeverType) {
859+
$specifiedTypes = $specifiedTypes->unionWith(
860+
$this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()),
861+
);
862+
}
863+
}
864+
}
865+
}
866+
867+
// infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback)
868+
if (
869+
$expr->expr instanceof FuncCall
870+
&& $expr->expr->name instanceof Name
871+
&& !$expr->expr->isFirstClassCallable()
872+
&& count($expr->expr->getArgs()) >= 2
873+
) {
874+
$funcName = $expr->expr->name->toLowerString();
875+
$arrayArg = null;
876+
$sentinelType = null;
877+
$isStrictArraySearch = false;
878+
879+
if ($funcName === 'array_search') {
880+
$arrayArg = $expr->expr->getArgs()[1]->value;
881+
$sentinelType = new ConstantBooleanType(false);
882+
$isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes();
883+
} elseif ($funcName === 'array_find_key') {
884+
$arrayArg = $expr->expr->getArgs()[0]->value;
885+
$sentinelType = new NullType();
886+
}
869887

870-
$holder = new ConditionalExpressionHolder(
871-
[$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)],
872-
ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()),
888+
if ($arrayArg !== null) {
889+
$arrayType = $scope->getType($arrayArg);
890+
891+
if ($arrayType->isArray()->yes()) {
892+
if ($context->true()) {
893+
$specifiedTypes = $specifiedTypes->unionWith(
894+
$this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope),
873895
);
874896

897+
$dimFetch = new ArrayDimFetch($arrayArg, $expr->var);
898+
899+
if ($isStrictArraySearch) {
900+
$needleType = $scope->getType($expr->expr->getArgs()[0]->value);
901+
$dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType());
902+
} else {
903+
$dimFetchType = $arrayType->getIterableValueType();
904+
}
905+
875906
$specifiedTypes = $specifiedTypes->unionWith(
876-
(new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([
877-
$dimFetchString => [$holder->getKey() => $holder],
878-
]),
907+
$this->create($dimFetch, $dimFetchType, TypeSpecifierContext::createTrue(), $scope),
879908
);
909+
} elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
910+
$keyType = $scope->getType($expr->expr);
911+
$narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType);
912+
if (!$narrowedKeyType instanceof NeverType) {
913+
if ($isStrictArraySearch) {
914+
$needleType = $scope->getType($expr->expr->getArgs()[0]->value);
915+
$dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType());
916+
} else {
917+
$dimFetchType = $arrayType->getIterableValueType();
918+
}
919+
$specifiedTypes = $specifiedTypes->unionWith(
920+
$this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType),
921+
);
922+
}
880923
}
881924
}
882925
}
@@ -941,28 +984,6 @@ public function specifyTypesInCondition(
941984
return $specifiedTypes;
942985
}
943986

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-
}
966987
return $specifiedTypes;
967988
} elseif (
968989
$expr instanceof Expr\Isset_
@@ -2404,6 +2425,27 @@ public function create(
24042425
return $types;
24052426
}
24062427

2428+
private function createArrayDimFetchConditionalExpressionHolder(
2429+
Expr\Variable $keyVar,
2430+
Expr $arrayArg,
2431+
Type $narrowedKeyType,
2432+
Type $dimFetchType,
2433+
): SpecifiedTypes
2434+
{
2435+
$dimFetch = new ArrayDimFetch($arrayArg, $keyVar);
2436+
$dimFetchString = $this->exprPrinter->printExpr($dimFetch);
2437+
$keyExprString = $this->exprPrinter->printExpr($keyVar);
2438+
2439+
$holder = new ConditionalExpressionHolder(
2440+
[$keyExprString => ExpressionTypeHolder::createYes($keyVar, $narrowedKeyType)],
2441+
ExpressionTypeHolder::createYes($dimFetch, $dimFetchType),
2442+
);
2443+
2444+
return (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([
2445+
$dimFetchString => [$holder->getKey() => $holder],
2446+
]);
2447+
}
2448+
24072449
private function createForExpr(
24082450
Expr $expr,
24092451
Type $type,
@@ -3032,11 +3074,12 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
30323074

30333075
// array_key_first($a) !== null
30343076
// array_key_last($a) !== null
3077+
// array_find_key($a, $cb) !== null
30353078
if (
30363079
$unwrappedLeftExpr instanceof FuncCall
30373080
&& $unwrappedLeftExpr->name instanceof Name
30383081
&& !$unwrappedLeftExpr->isFirstClassCallable()
3039-
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true)
3082+
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_find_key'], true)
30403083
&& isset($unwrappedLeftExpr->getArgs()[0])
30413084
&& $rightType->isNull()->yes()
30423085
) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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('int<0, max>', $key);
18+
assertType('string', $list[$key]);
19+
} else {
20+
assertType('array{}', $list);
21+
assertType('null', $key);
22+
assertType('*ERROR*', $list[$key]);
23+
}
24+
assertType('list<string>', $list);
25+
assertType('int<0, max>|null', $key);
26+
assertType('string', $list[$key]);
27+
}
28+
29+
/**
30+
* @param array<string, int> $map
31+
*/
32+
function arrayFindKeyStringKey(array $map): void
33+
{
34+
$key = array_find_key($map, fn (int $v) => $v > 10);
35+
if ($key !== null) {
36+
assertType('int', $map[$key]);
37+
}
38+
}
39+
40+
/**
41+
* @param list<string> $list
42+
*/
43+
function arrayFindKeyReversedComparison(array $list, string $s): void
44+
{
45+
$key = array_find_key($list, fn (string $v) => $v === $s);
46+
if (null !== $key) {
47+
assertType('string', $list[$key]);
48+
}
49+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 arraySearchReversedComparison(array $list, string $s): void
34+
{
35+
$key = array_search($s, $list);
36+
if (false !== $key) {
37+
assertType('string', $list[$key]);
38+
}
39+
}
40+
41+
/**
42+
* @param array<string, int|string> $arr
43+
*/
44+
function arraySearchStrictNarrowsToNeedle(array $arr, int $needle): void
45+
{
46+
$key = array_search($needle, $arr, true);
47+
if ($key !== false) {
48+
assertType('non-empty-array<string, int|string>', $arr);
49+
assertType('string', $key);
50+
assertType('int', $arr[$key]);
51+
} else {
52+
assertType('array<string, int|string>', $arr);
53+
assertType('false', $key);
54+
assertType('*ERROR*', $arr[$key]);
55+
}
56+
assertType('array<string, int|string>', $arr);
57+
assertType('string|false', $key);
58+
assertType('int|string', $arr[$key]);
59+
60+
}
61+
62+
/**
63+
* @param array<string, int|string> $arr
64+
*/
65+
function arraySearchLooseKeepsValueType(array $arr, int $needle): void
66+
{
67+
$key = array_search($needle, $arr);
68+
if ($key !== false) {
69+
assertType('int|string', $arr[$key]);
70+
}
71+
}
72+
73+
/**
74+
* @param array<string, int|string> $arr
75+
*/
76+
function arraySearchStrictInlineAssign(array $arr, int $needle): void
77+
{
78+
if (($key = array_search($needle, $arr, true)) !== false) {
79+
assertType('int', $arr[$key]);
80+
}
81+
}

tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php

Lines changed: 34 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,37 @@ public function testBug11218(): void
13101305
$this->analyse([__DIR__ . '/data/bug-11218.php'], []);
13111306
}
13121307

1308+
public function testArraySearchExisting(): void
1309+
{
1310+
$this->reportPossiblyNonexistentGeneralArrayOffset = true;
1311+
1312+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/array-search-existing.php'], [
1313+
[
1314+
'Offset false does not exist on array<string, int|string>.',
1315+
54,
1316+
],
1317+
[
1318+
'Offset string|false might not exist on array<string, int|string>.',
1319+
58,
1320+
],
1321+
]);
1322+
}
1323+
1324+
#[RequiresPhp('>= 8.4.0')]
1325+
public function testArrayFindKeyExisting(): void
1326+
{
1327+
$this->reportPossiblyNonexistentGeneralArrayOffset = true;
1328+
1329+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/array-find-key-existing.php'], [
1330+
[
1331+
'Offset null does not exist on array{}.',
1332+
22,
1333+
],
1334+
[
1335+
'Offset int<0, max>|null might not exist on list<string>.',
1336+
26,
1337+
],
1338+
]);
1339+
}
1340+
13131341
}

0 commit comments

Comments
 (0)