From b90c20e948ccafddb1ded5acc3833d5875b86c72 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:13:28 +0000 Subject: [PATCH 1/9] 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") --- src/Analyser/TypeSpecifier.php | 109 ++++++++++++++---- .../Analyser/nsrt/array-find-key-existing.php | 30 +++++ .../Analyser/nsrt/array-search-existing.php | 39 +++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 14 ++- tests/PHPStan/Rules/Arrays/data/bug-14537.php | 71 ++++++++++++ 5 files changed, 234 insertions(+), 29 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-find-key-existing.php create mode 100644 tests/PHPStan/Analyser/nsrt/array-search-existing.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14537.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 1eacf25dd4a..8ebda0b51a7 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -882,6 +882,90 @@ public function specifyTypesInCondition( } } + // infer $arr[$key] after $key = array_search($needle, $arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && $expr->expr->name->toLowerString() === 'array_search' + && count($expr->expr->getArgs()) >= 2 + ) { + $arrayArg = $expr->expr->getArgs()[1]->value; + $arrayType = $scope->getType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), + ); + } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $keyType = $scope->getType($expr->expr); + $nonFalseKeyType = TypeCombinator::remove($keyType, new ConstantBooleanType(false)); + if (!$nonFalseKeyType instanceof NeverType && !$keyType->isFalse()->yes()) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $dimFetchString = $this->exprPrinter->printExpr($dimFetch); + $keyExprString = $this->exprPrinter->printExpr($expr->var); + + $holder = new ConditionalExpressionHolder( + [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonFalseKeyType)], + ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ); + + $specifiedTypes = $specifiedTypes->unionWith( + (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ + $dimFetchString => [$holder->getKey() => $holder], + ]), + ); + } + } + } + } + + // infer $arr[$key] after $key = array_find_key($arr, $callback) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && $expr->expr->name->toLowerString() === 'array_find_key' + && count($expr->expr->getArgs()) >= 2 + ) { + $arrayArg = $expr->expr->getArgs()[0]->value; + $arrayType = $scope->getType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + ); + + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), + ); + } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $keyType = $scope->getType($expr->expr); + $nonNullKeyType = TypeCombinator::removeNull($keyType); + if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $dimFetchString = $this->exprPrinter->printExpr($dimFetch); + $keyExprString = $this->exprPrinter->printExpr($expr->var); + + $holder = new ConditionalExpressionHolder( + [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)], + ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ); + + $specifiedTypes = $specifiedTypes->unionWith( + (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ + $dimFetchString => [$holder->getKey() => $holder], + ]), + ); + } + } + } + } + if ($context->null()) { // infer $arr[$key] after $key = array_rand($arr) if ( @@ -941,28 +1025,6 @@ public function specifyTypesInCondition( return $specifiedTypes; } - if ($context->true()) { - // infer $arr[$key] after $key = array_search($needle, $arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && $expr->expr->name->toLowerString() === 'array_search' - && count($expr->expr->getArgs()) >= 2 - ) { - $arrayArg = $expr->expr->getArgs()[1]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $iterableValueType = $arrayType->getIterableValueType(); - - return $specifiedTypes->unionWith( - $this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope), - ); - } - } - } return $specifiedTypes; } elseif ( $expr instanceof Expr\Isset_ @@ -3032,11 +3094,12 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope // array_key_first($a) !== null // array_key_last($a) !== null + // array_find_key($a, $cb) !== null if ( $unwrappedLeftExpr instanceof FuncCall && $unwrappedLeftExpr->name instanceof Name && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) + && in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_find_key'], true) && isset($unwrappedLeftExpr->getArgs()[0]) && $rightType->isNull()->yes() ) { diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php new file mode 100644 index 00000000000..1f68cc067f6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php @@ -0,0 +1,30 @@ += 8.4 + +declare(strict_types=1); + +namespace ArrayFindKeyExisting; + +use function PHPStan\Testing\assertType; + +/** + * @param list $list + */ +function arrayFindKeyNotNull(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if ($key !== null) { + assertType('non-empty-list', $list); + assertType('string', $list[$key]); + } +} + +/** + * @param array $map + */ +function arrayFindKeyStringKey(array $map): void +{ + $key = array_find_key($map, fn (int $v) => $v > 10); + if ($key !== null) { + assertType('int', $map[$key]); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/array-search-existing.php b/tests/PHPStan/Analyser/nsrt/array-search-existing.php new file mode 100644 index 00000000000..9467939eb4d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-search-existing.php @@ -0,0 +1,39 @@ + $list + */ +function arraySearchNotFalse(array $list, string $s): void +{ + $key = array_search($s, $list); + if ($key !== false) { + assertType('non-empty-list', $list); + assertType('string', $list[$key]); + } +} + +/** + * @param array $map + */ +function arraySearchStringKey(array $map, int $needle): void +{ + $key = array_search($needle, $map); + if ($key !== false) { + assertType('int', $map[$key]); + } +} + +/** + * @param list $list + */ +function arraySearchDeepWrite(array $list, string $s): void +{ + $key = array_search($s, $list); + if ($key !== false) { + assertType('string', $list[$key]); + } +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 51c32f370af..ee45da29ce1 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -873,12 +873,7 @@ public function testArrayDimFetchAfterArraySearch(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], [ - [ - 'Offset int|string might not exist on non-empty-array.', - 20, - ], - ]); + $this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], []); } public function testArrayDimFetchOnArrayKeyFirsOrLastOrCount(): void @@ -1310,4 +1305,11 @@ public function testBug11218(): void $this->analyse([__DIR__ . '/data/bug-11218.php'], []); } + public function testBug14537(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-14537.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14537.php b/tests/PHPStan/Rules/Arrays/data/bug-14537.php new file mode 100644 index 00000000000..b379e332491 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14537.php @@ -0,0 +1,71 @@ += 8.4 + +declare(strict_types=1); + +namespace Bug14537; + +/** + * @param list $list + */ +function arraySearchNotFalse(array $list, string $s): void +{ + $key = array_search($s, $list); + if ($key !== false) { + echo $list[$key]; + } +} + +/** + * @param array $map + */ +function arraySearchStringKey(array $map, int $needle): void +{ + $key = array_search($needle, $map); + if ($key !== false) { + echo $map[$key]; + } +} + +/** + * @param list $list + */ +function arraySearchReversedComparison(array $list, string $s): void +{ + $key = array_search($s, $list); + if (false !== $key) { + echo $list[$key]; + } +} + +/** + * @param list $list + */ +function arrayFindKeyNotNull(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if ($key !== null) { + echo $list[$key]; + } +} + +/** + * @param array $map + */ +function arrayFindKeyStringKey(array $map): void +{ + $key = array_find_key($map, fn (int $v) => $v > 10); + if ($key !== null) { + echo $map[$key]; + } +} + +/** + * @param list $list + */ +function arrayFindKeyReversedComparison(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if (null !== $key) { + echo $list[$key]; + } +} From 3accc8d0b798240befbf063c0f9a92e4d5ecf06c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 08:26:43 +0000 Subject: [PATCH 2/9] De-duplicate ConditionalExpressionHolder creation for array dim fetch tracking Extract createArrayDimFetchConditionalExpressionHolder() helper method and merge the separate array_search/array_find_key blocks into one, reducing three instances of the same ~12-line pattern to single-line calls. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 144 +++++++++++++-------------------- 1 file changed, 57 insertions(+), 87 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 8ebda0b51a7..bf8f2bc0c7e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -854,113 +854,62 @@ public function specifyTypesInCondition( $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), ); } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - // The array might be empty here, so we cannot register - // $arr[$key] unconditionally. Attach a conditional holder - // that fires once the user narrows $key to non-null - // (e.g. `if ($key !== null)`), giving the deep-write - // path the same shape information `isset($arr[$key])` - // would have provided. $keyType = $scope->getType($expr->expr); $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchString = $this->exprPrinter->printExpr($dimFetch); - $keyExprString = $this->exprPrinter->printExpr($expr->var); - - $holder = new ConditionalExpressionHolder( - [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), - ); - - $specifiedTypes = $specifiedTypes->unionWith( - (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ - $dimFetchString => [$holder->getKey() => $holder], - ]), - ); - } - } - } - } - - // infer $arr[$key] after $key = array_search($needle, $arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && $expr->expr->name->toLowerString() === 'array_search' - && count($expr->expr->getArgs()) >= 2 - ) { - $arrayArg = $expr->expr->getArgs()[1]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonFalseKeyType = TypeCombinator::remove($keyType, new ConstantBooleanType(false)); - if (!$nonFalseKeyType instanceof NeverType && !$keyType->isFalse()->yes()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchString = $this->exprPrinter->printExpr($dimFetch); - $keyExprString = $this->exprPrinter->printExpr($expr->var); - - $holder = new ConditionalExpressionHolder( - [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonFalseKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), - ); - + if (!$nonNullKeyType instanceof NeverType) { $specifiedTypes = $specifiedTypes->unionWith( - (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ - $dimFetchString => [$holder->getKey() => $holder], - ]), + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $nonNullKeyType), ); } } } } - // infer $arr[$key] after $key = array_find_key($arr, $callback) + // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) if ( $expr->expr instanceof FuncCall && $expr->expr->name instanceof Name - && $expr->expr->name->toLowerString() === 'array_find_key' && count($expr->expr->getArgs()) >= 2 ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); + $funcName = $expr->expr->name->toLowerString(); + $arrayArgIndex = null; + $sentinelType = null; + $narrowToNonEmpty = false; + + if ($funcName === 'array_search') { + $arrayArgIndex = 1; + $sentinelType = new ConstantBooleanType(false); + } elseif ($funcName === 'array_find_key') { + $arrayArgIndex = 0; + $sentinelType = new NullType(); + $narrowToNonEmpty = true; + } + + if ($arrayArgIndex !== null && $sentinelType !== null) { + $arrayArg = $expr->expr->getArgs()[$arrayArgIndex]->value; + $arrayType = $scope->getType($arrayArg); - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + if ($narrowToNonEmpty) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + ); + } - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchString = $this->exprPrinter->printExpr($dimFetch); - $keyExprString = $this->exprPrinter->printExpr($expr->var); - - $holder = new ConditionalExpressionHolder( - [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), - ); $specifiedTypes = $specifiedTypes->unionWith( - (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ - $dimFetchString => [$holder->getKey() => $holder], - ]), + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), ); + } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $keyType = $scope->getType($expr->expr); + $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); + if (!$narrowedKeyType instanceof NeverType) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $narrowedKeyType), + ); + } } } } @@ -2466,6 +2415,27 @@ public function create( return $types; } + private function createArrayDimFetchConditionalExpressionHolder( + Expr\Variable $keyVar, + Expr $arrayArg, + Type $arrayType, + Type $narrowedKeyType, + ): SpecifiedTypes + { + $dimFetch = new ArrayDimFetch($arrayArg, $keyVar); + $dimFetchString = $this->exprPrinter->printExpr($dimFetch); + $keyExprString = $this->exprPrinter->printExpr($keyVar); + + $holder = new ConditionalExpressionHolder( + [$keyExprString => ExpressionTypeHolder::createYes($keyVar, $narrowedKeyType)], + ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ); + + return (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ + $dimFetchString => [$holder->getKey() => $holder], + ]); + } + private function createForExpr( Expr $expr, Type $type, From f3f8508e32ec162426d853416419d298778ea103 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 29 Apr 2026 07:15:28 +0200 Subject: [PATCH 3/9] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index bf8f2bc0c7e..2b6adaaf101 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -869,6 +869,7 @@ public function specifyTypesInCondition( if ( $expr->expr instanceof FuncCall && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() && count($expr->expr->getArgs()) >= 2 ) { $funcName = $expr->expr->name->toLowerString(); From ca44a179e03925d7bfef9d2f519ed68f94936197 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 29 Apr 2026 12:35:32 +0200 Subject: [PATCH 4/9] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 2b6adaaf101..3b8cca1f744 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -886,7 +886,7 @@ public function specifyTypesInCondition( $narrowToNonEmpty = true; } - if ($arrayArgIndex !== null && $sentinelType !== null) { + if ($arrayArgIndex !== null) { $arrayArg = $expr->expr->getArgs()[$arrayArgIndex]->value; $arrayType = $scope->getType($arrayArg); From 09d59ca9c85115e785fa46bef4d212c239a64185 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 11:04:20 +0000 Subject: [PATCH 5/9] Reference NSRT test files from NonexistentOffsetInArrayDimFetchRuleTest instead of duplicating Co-Authored-By: Claude Opus 4.6 --- .../Analyser/nsrt/array-find-key-existing.php | 11 +++ .../Analyser/nsrt/array-search-existing.php | 4 +- ...nexistentOffsetInArrayDimFetchRuleTest.php | 12 +++- tests/PHPStan/Rules/Arrays/data/bug-14537.php | 71 ------------------- 4 files changed, 23 insertions(+), 75 deletions(-) delete mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14537.php diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php index 1f68cc067f6..5601020a392 100644 --- a/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php +++ b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php @@ -28,3 +28,14 @@ function arrayFindKeyStringKey(array $map): void assertType('int', $map[$key]); } } + +/** + * @param list $list + */ +function arrayFindKeyReversedComparison(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if (null !== $key) { + assertType('string', $list[$key]); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/array-search-existing.php b/tests/PHPStan/Analyser/nsrt/array-search-existing.php index 9467939eb4d..8d16bcd9d6e 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-existing.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-existing.php @@ -30,10 +30,10 @@ function arraySearchStringKey(array $map, int $needle): void /** * @param list $list */ -function arraySearchDeepWrite(array $list, string $s): void +function arraySearchReversedComparison(array $list, string $s): void { $key = array_search($s, $list); - if ($key !== false) { + if (false !== $key) { assertType('string', $list[$key]); } } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index ee45da29ce1..f0d10b4820a 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1305,11 +1305,19 @@ public function testBug11218(): void $this->analyse([__DIR__ . '/data/bug-11218.php'], []); } - public function testBug14537(): void + public function testArraySearchExisting(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/data/bug-14537.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-search-existing.php'], []); + } + + #[RequiresPhp('>= 8.4.0')] + public function testArrayFindKeyExisting(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-find-key-existing.php'], []); } } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14537.php b/tests/PHPStan/Rules/Arrays/data/bug-14537.php deleted file mode 100644 index b379e332491..00000000000 --- a/tests/PHPStan/Rules/Arrays/data/bug-14537.php +++ /dev/null @@ -1,71 +0,0 @@ -= 8.4 - -declare(strict_types=1); - -namespace Bug14537; - -/** - * @param list $list - */ -function arraySearchNotFalse(array $list, string $s): void -{ - $key = array_search($s, $list); - if ($key !== false) { - echo $list[$key]; - } -} - -/** - * @param array $map - */ -function arraySearchStringKey(array $map, int $needle): void -{ - $key = array_search($needle, $map); - if ($key !== false) { - echo $map[$key]; - } -} - -/** - * @param list $list - */ -function arraySearchReversedComparison(array $list, string $s): void -{ - $key = array_search($s, $list); - if (false !== $key) { - echo $list[$key]; - } -} - -/** - * @param list $list - */ -function arrayFindKeyNotNull(array $list, string $s): void -{ - $key = array_find_key($list, fn (string $v) => $v === $s); - if ($key !== null) { - echo $list[$key]; - } -} - -/** - * @param array $map - */ -function arrayFindKeyStringKey(array $map): void -{ - $key = array_find_key($map, fn (int $v) => $v > 10); - if ($key !== null) { - echo $map[$key]; - } -} - -/** - * @param list $list - */ -function arrayFindKeyReversedComparison(array $list, string $s): void -{ - $key = array_find_key($list, fn (string $v) => $v === $s); - if (null !== $key) { - echo $list[$key]; - } -} From 1ce0e7213e9a0dab4e4225a23d62d85c018a50ec Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 18:53:08 +0000 Subject: [PATCH 6/9] Narrow array to non-empty for array_search, narrow $arr[$key] to needle type for strict array_search Both array_search and array_find_key now narrow the array to non-empty when the result is not the sentinel value (false/null respectively). For array_search with strict mode (third argument true), $arr[$key] is narrowed to the intersection of the needle type and the value type, since strict comparison guarantees the found value has the same type as the needle. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 38 ++++++++++++++----- .../Analyser/nsrt/array-search-existing.php | 32 ++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3b8cca1f744..9244575fdc9 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -875,15 +875,15 @@ public function specifyTypesInCondition( $funcName = $expr->expr->name->toLowerString(); $arrayArgIndex = null; $sentinelType = null; - $narrowToNonEmpty = false; + $needleArgIndex = null; if ($funcName === 'array_search') { $arrayArgIndex = 1; $sentinelType = new ConstantBooleanType(false); + $needleArgIndex = 0; } elseif ($funcName === 'array_find_key') { $arrayArgIndex = 0; $sentinelType = new NullType(); - $narrowToNonEmpty = true; } if ($arrayArgIndex !== null) { @@ -892,23 +892,40 @@ public function specifyTypesInCondition( if ($arrayType->isArray()->yes()) { if ($context->true()) { - if ($narrowToNonEmpty) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - } + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + ); $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $dimFetchType = $arrayType->getIterableValueType(); + if ( + $needleArgIndex !== null + && count($expr->expr->getArgs()) >= 3 + && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes() + ) { + $needleType = $scope->getType($expr->expr->getArgs()[$needleArgIndex]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $dimFetchType); + } + $specifiedTypes = $specifiedTypes->unionWith( - $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), + $this->create($dimFetch, $dimFetchType, TypeSpecifierContext::createTrue(), $scope), ); } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { $keyType = $scope->getType($expr->expr); $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); if (!$narrowedKeyType instanceof NeverType) { + $dimFetchType = null; + if ( + $needleArgIndex !== null + && count($expr->expr->getArgs()) >= 3 + && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes() + ) { + $needleType = $scope->getType($expr->expr->getArgs()[$needleArgIndex]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $narrowedKeyType), + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $narrowedKeyType, $dimFetchType), ); } } @@ -2421,6 +2438,7 @@ private function createArrayDimFetchConditionalExpressionHolder( Expr $arrayArg, Type $arrayType, Type $narrowedKeyType, + ?Type $dimFetchType = null, ): SpecifiedTypes { $dimFetch = new ArrayDimFetch($arrayArg, $keyVar); @@ -2429,7 +2447,7 @@ private function createArrayDimFetchConditionalExpressionHolder( $holder = new ConditionalExpressionHolder( [$keyExprString => ExpressionTypeHolder::createYes($keyVar, $narrowedKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ExpressionTypeHolder::createYes($dimFetch, $dimFetchType ?? $arrayType->getIterableValueType()), ); return (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ diff --git a/tests/PHPStan/Analyser/nsrt/array-search-existing.php b/tests/PHPStan/Analyser/nsrt/array-search-existing.php index 8d16bcd9d6e..9cc2b9284bb 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-existing.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-existing.php @@ -37,3 +37,35 @@ function arraySearchReversedComparison(array $list, string $s): void assertType('string', $list[$key]); } } + +/** + * @param array $arr + */ +function arraySearchStrictNarrowsToNeedle(array $arr, int $needle): void +{ + $key = array_search($needle, $arr, true); + if ($key !== false) { + assertType('int', $arr[$key]); + } +} + +/** + * @param array $arr + */ +function arraySearchLooseKeepsValueType(array $arr, int $needle): void +{ + $key = array_search($needle, $arr); + if ($key !== false) { + assertType('int|string', $arr[$key]); + } +} + +/** + * @param array $arr + */ +function arraySearchStrictInlineAssign(array $arr, int $needle): void +{ + if (($key = array_search($needle, $arr, true)) !== false) { + assertType('int', $arr[$key]); + } +} From b8de41583bf6c654cfbffac398eb0744e720dfb9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 30 Apr 2026 07:26:55 +0200 Subject: [PATCH 7/9] refactor --- src/Analyser/TypeSpecifier.php | 38 ++++++++++++++-------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9244575fdc9..902219dd764 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -849,7 +849,6 @@ public function specifyTypesInCondition( if ($isNonEmpty) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $specifiedTypes = $specifiedTypes->unionWith( $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), ); @@ -858,7 +857,7 @@ public function specifyTypesInCondition( $nonNullKeyType = TypeCombinator::removeNull($keyType); if (!$nonNullKeyType instanceof NeverType) { $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $nonNullKeyType), + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), ); } } @@ -875,12 +874,12 @@ public function specifyTypesInCondition( $funcName = $expr->expr->name->toLowerString(); $arrayArgIndex = null; $sentinelType = null; - $needleArgIndex = null; + $isStrictArraySearch = false; if ($funcName === 'array_search') { $arrayArgIndex = 1; $sentinelType = new ConstantBooleanType(false); - $needleArgIndex = 0; + $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); } elseif ($funcName === 'array_find_key') { $arrayArgIndex = 0; $sentinelType = new NullType(); @@ -898,14 +897,11 @@ public function specifyTypesInCondition( $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchType = $arrayType->getIterableValueType(); - if ( - $needleArgIndex !== null - && count($expr->expr->getArgs()) >= 3 - && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes() - ) { - $needleType = $scope->getType($expr->expr->getArgs()[$needleArgIndex]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $dimFetchType); + if ($isStrictArraySearch) { + $needleType = $scope->getType($expr->expr->getArgs()[0]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } else { + $dimFetchType = $arrayType->getIterableValueType(); } $specifiedTypes = $specifiedTypes->unionWith( @@ -915,17 +911,14 @@ public function specifyTypesInCondition( $keyType = $scope->getType($expr->expr); $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); if (!$narrowedKeyType instanceof NeverType) { - $dimFetchType = null; - if ( - $needleArgIndex !== null - && count($expr->expr->getArgs()) >= 3 - && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes() - ) { - $needleType = $scope->getType($expr->expr->getArgs()[$needleArgIndex]->value); + if ($isStrictArraySearch) { + $needleType = $scope->getType($expr->expr->getArgs()[0]->value); $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } else { + $dimFetchType = $arrayType->getIterableValueType(); } $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $narrowedKeyType, $dimFetchType), + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), ); } } @@ -2436,9 +2429,8 @@ public function create( private function createArrayDimFetchConditionalExpressionHolder( Expr\Variable $keyVar, Expr $arrayArg, - Type $arrayType, Type $narrowedKeyType, - ?Type $dimFetchType = null, + Type $dimFetchType, ): SpecifiedTypes { $dimFetch = new ArrayDimFetch($arrayArg, $keyVar); @@ -2447,7 +2439,7 @@ private function createArrayDimFetchConditionalExpressionHolder( $holder = new ConditionalExpressionHolder( [$keyExprString => ExpressionTypeHolder::createYes($keyVar, $narrowedKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $dimFetchType ?? $arrayType->getIterableValueType()), + ExpressionTypeHolder::createYes($dimFetch, $dimFetchType), ); return (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ From cd2f4afc20b553fe5c938f2e389cea1443fdd090 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 30 Apr 2026 07:53:32 +0200 Subject: [PATCH 8/9] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 902219dd764..9f7a86eb4ae 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -872,21 +872,20 @@ public function specifyTypesInCondition( && count($expr->expr->getArgs()) >= 2 ) { $funcName = $expr->expr->name->toLowerString(); - $arrayArgIndex = null; + $arrayArg = null; $sentinelType = null; $isStrictArraySearch = false; if ($funcName === 'array_search') { - $arrayArgIndex = 1; + $arrayArg = $expr->expr->getArgs()[1]->value; $sentinelType = new ConstantBooleanType(false); $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); } elseif ($funcName === 'array_find_key') { - $arrayArgIndex = 0; + $arrayArg = $expr->expr->getArgs()[0]->value; $sentinelType = new NullType(); } - if ($arrayArgIndex !== null) { - $arrayArg = $expr->expr->getArgs()[$arrayArgIndex]->value; + if ($arrayArg !== null) { $arrayType = $scope->getType($arrayArg); if ($arrayType->isArray()->yes()) { From 3c1137bdabeb713cb78a4eeccbd03e685e4eb9ab Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 30 Apr 2026 10:04:27 +0200 Subject: [PATCH 9/9] assert all branches --- .../Analyser/nsrt/array-find-key-existing.php | 8 +++++++ .../Analyser/nsrt/array-search-existing.php | 10 +++++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 22 +++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php index 5601020a392..8616fe1b319 100644 --- a/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php +++ b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php @@ -14,8 +14,16 @@ function arrayFindKeyNotNull(array $list, string $s): void $key = array_find_key($list, fn (string $v) => $v === $s); if ($key !== null) { assertType('non-empty-list', $list); + assertType('int<0, max>', $key); assertType('string', $list[$key]); + } else { + assertType('array{}', $list); + assertType('null', $key); + assertType('*ERROR*', $list[$key]); } + assertType('list', $list); + assertType('int<0, max>|null', $key); + assertType('string', $list[$key]); } /** diff --git a/tests/PHPStan/Analyser/nsrt/array-search-existing.php b/tests/PHPStan/Analyser/nsrt/array-search-existing.php index 9cc2b9284bb..2289b5c9915 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-existing.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-existing.php @@ -45,8 +45,18 @@ function arraySearchStrictNarrowsToNeedle(array $arr, int $needle): void { $key = array_search($needle, $arr, true); if ($key !== false) { + assertType('non-empty-array', $arr); + assertType('string', $key); assertType('int', $arr[$key]); + } else { + assertType('array', $arr); + assertType('false', $key); + assertType('*ERROR*', $arr[$key]); } + assertType('array', $arr); + assertType('string|false', $key); + assertType('int|string', $arr[$key]); + } /** diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index f0d10b4820a..31449040b45 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1309,7 +1309,16 @@ public function testArraySearchExisting(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-search-existing.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-search-existing.php'], [ + [ + 'Offset false does not exist on array.', + 54, + ], + [ + 'Offset string|false might not exist on array.', + 58, + ], + ]); } #[RequiresPhp('>= 8.4.0')] @@ -1317,7 +1326,16 @@ public function testArrayFindKeyExisting(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-find-key-existing.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-find-key-existing.php'], [ + [ + 'Offset null does not exist on array{}.', + 22, + ], + [ + 'Offset int<0, max>|null might not exist on list.', + 26, + ], + ]); } }