Skip to content

Track $arr[$key] existence across array_search/array_find_key via conditional expression holders#5552

Open
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-bcl4slb
Open

Track $arr[$key] existence across array_search/array_find_key via conditional expression holders#5552
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-bcl4slb

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When $key = array_search($needle, $arr) is assigned and later checked with if ($key !== false), PHPStan reported "Offset might not exist on non-empty-array" even though the key is guaranteed to exist. Similarly, $key = array_find_key($arr, $cb) followed by if ($key !== null) had the same issue. This extends the conditional expression holder mechanism from PR #5537 (which handled array_key_first/array_key_last) to also cover array_search and array_find_key.

Changes

  • src/Analyser/TypeSpecifier.php:
    • Added conditional expression holder for $key = array_search($needle, $arr) — when $key is narrowed to not-false (via $key !== false), registers $arr[$key] as existing with the array's iterable value type
    • Added conditional expression holder for $key = array_find_key($arr, $cb) — when $key is narrowed to not-null (via $key !== null), registers $arr[$key] as existing
    • Added array_find_key to the existing array_key_first/last !== null comparison handler to narrow the array to NonEmptyArrayType
    • Removed the now-redundant standalone array_search handler from the $context->true() block since it's handled by the unified block
  • tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php: Updated existing test testArrayDimFetchAfterArraySearch to expect no error (previously asserted the buggy behavior), added testBug14537
  • tests/PHPStan/Rules/Arrays/data/bug-14537.php: Rule test covering array_search and array_find_key with reportPossiblyNonexistentGeneralArrayOffset: true
  • tests/PHPStan/Analyser/nsrt/array-search-existing.php: NSRT test for array_search offset existence tracking
  • tests/PHPStan/Analyser/nsrt/array-find-key-existing.php: NSRT test for array_find_key offset existence and non-empty array narrowing

Root cause

The TypeSpecifier handled $key = array_search(...) only in the $context->true() case (assignment-in-condition like if ($key = array_search(...))). For the common pattern of separate assignment followed by if ($key !== false), no conditional expression holder was created, so PHPStan couldn't track that $arr[$key] exists after the check. array_find_key had no offset existence tracking at all.

The fix uses the same conditional expression holder mechanism as array_key_first/array_key_last (PR #5537): when the key variable is narrowed to exclude the "not found" sentinel (false for array_search, null for array_find_key), the holder fires and registers the array dim fetch as existing.

Analogous cases probed:

  • array_key_first/array_key_last: already handled by PR Track $arr[$key] existence across array_key_first/last + !== null #5537
  • array_rand: already handled unconditionally for non-empty arrays in null context ✓
  • key(): pointer-based, unreliable for offset tracking — skipped by design
  • Reversed comparisons (false !== $key, null !== $key): tested and working ✓

Test

  • Rule test: testBug14537 covers array_search (list + string-key map) and array_find_key (list + string-key map) with reversed comparison variants, all expecting no errors with reportPossiblyNonexistentGeneralArrayOffset: true
  • NSRT tests: array-search-existing.php and array-find-key-existing.php verify correct type inference for $arr[$key] and array narrowing to non-empty
  • Updated existing test testArrayDimFetchAfterArraySearch to match corrected behavior

Fixes phpstan/phpstan#14537

…ia 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")
Comment thread src/Analyser/TypeSpecifier.php Outdated
Comment on lines +931 to +958
$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],
]),
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be de-duplicated, as we have this similar code in this file more than 2 times already

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed successfully. Here's what was done to address staabm's review:

Extracted a createArrayDimFetchConditionalExpressionHolder() helper method that encapsulates the repeated ~12-line pattern of creating a ConditionalExpressionHolder for array dim fetch tracking. This helper is now used by all three call sites:

  1. array_key_first/array_key_last block
  2. array_search block
  3. array_find_key block

Merged array_search and array_find_key into a single block using a lookup for their differences (array arg index, sentinel type, whether to narrow to non-empty), further reducing duplication.

Net result: -30 lines (87 removed, 57 added). All 7703 tests pass and PHPStan analysis is clean.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 27, 2026

can we do similar stuff for array_rand?

… 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants