diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4a22a57950c..791d79f58c7 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -848,29 +848,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()), + if (!$nonNullKeyType instanceof NeverType) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $nonNullKeyType), ); + } + } + } + } + + // 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 + && count($expr->expr->getArgs()) >= 2 + ) { + $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); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + if ($narrowToNonEmpty) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + ); + } + + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); $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), + ); + } } } } @@ -933,27 +966,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->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_ @@ -2392,6 +2404,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, @@ -3016,10 +3049,11 @@ 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 - && 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]; + } +}