diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 334b96f68e..5be76e218e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -742,7 +742,7 @@ public function specifyTypesInCondition( if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } - return $result->setNewConditionalExpressionHolders(array_merge( + return $result->setNewConditionalExpressionHolders($this->mergeConditionalHolderArrays( $this->processBooleanNotSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders, $rightScope), $this->processBooleanNotSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders, $scope), $this->processBooleanSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders, $rightScope), @@ -795,7 +795,7 @@ public function specifyTypesInCondition( if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } - return $result->setNewConditionalExpressionHolders(array_merge( + return $result->setNewConditionalExpressionHolders($this->mergeConditionalHolderArrays( $this->processBooleanNotSureConditionalTypes($scope, $leftTypes, $rightTypes, $rightScope), $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes, $scope), $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes, $rightScope), @@ -1110,6 +1110,15 @@ public function specifyTypesInCondition( return $result; } + + if ($constantArrays === [] && $varType->isArray()->yes() && $varType->getIterableValueType()->isNull()->no()) { + return $this->create( + $issetExpr->var, + new HasOffsetType($dimType), + $context, + $scope, + )->setRootExpr($expr); + } } } } @@ -2117,6 +2126,35 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes $holders[$exprString][$holder->getKey()] = $holder; } + foreach ($rightTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if (!$this->isTrackableExpression($expr)) { + continue; + } + + if (!isset($holders[$exprString])) { + $holders[$exprString] = []; + } + + $conditions = $conditionExpressionTypes; + foreach (array_keys($conditions) as $conditionExprString) { + if ($conditionExprString !== $exprString) { + continue; + } + unset($conditions[$conditionExprString]); + } + + if (count($conditions) === 0) { + continue; + } + + $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; + $holder = new ConditionalExpressionHolder( + $conditions, + ExpressionTypeHolder::createYes($expr, TypeCombinator::remove($targetScope->getType($expr), $type)), + ); + $holders[$exprString][$holder->getKey()] = $holder; + } + return $holders; } @@ -2134,6 +2172,25 @@ private function isTrackableExpression(Expr $expr): bool || $expr instanceof Expr\StaticPropertyFetch; } + /** + * @param array ...$arrays + * @return array + */ + private function mergeConditionalHolderArrays(array ...$arrays): array + { + $result = []; + foreach ($arrays as $array) { + foreach ($array as $exprString => $holders) { + if (!isset($result[$exprString])) { + $result[$exprString] = $holders; + } else { + $result[$exprString] = array_merge($result[$exprString], $holders); + } + } + } + return $result; + } + /** * Flatten a deep BooleanOr chain into leaf expressions and process them * without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n) @@ -2309,6 +2366,35 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy $holders[$exprString][$holder->getKey()] = $holder; } + foreach ($rightTypes->getSureTypes() as $exprString => [$expr, $type]) { + if (!$this->isTrackableExpression($expr)) { + continue; + } + + if (!isset($holders[$exprString])) { + $holders[$exprString] = []; + } + + $conditions = $conditionExpressionTypes; + foreach (array_keys($conditions) as $conditionExprString) { + if ($conditionExprString !== $exprString) { + continue; + } + unset($conditions[$conditionExprString]); + } + + if (count($conditions) === 0) { + continue; + } + + $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; + $holder = new ConditionalExpressionHolder( + $conditions, + ExpressionTypeHolder::createYes($expr, TypeCombinator::intersect($targetScope->getType($expr), $type)), + ); + $holders[$exprString][$holder->getKey()] = $holder; + } + return $holders; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11918.php b/tests/PHPStan/Analyser/nsrt/bug-11918.php new file mode 100644 index 0000000000..0d1d08a46b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11918.php @@ -0,0 +1,69 @@ +|string|false> $options + */ +function narrowMaybeSetArrayKey(array $options): void +{ + if (array_key_exists('a', $options) && !is_string($options['a'])) { + exit(1); + } + + // At this point: either 'a' doesn't exist in $options, or it's a string + assertType("array|string|false>", $options); + assertType('string', $options['a'] ?? 'fallback'); +} + +/** + * @param array|string|false> $options + */ +function narrowMaybeSetArrayKeyIsInt(array $options): void +{ + if (array_key_exists('b', $options) && !is_int($options['b'])) { + exit(1); + } + + assertType("array|string|false>", $options); +} + +/** + * @param array $data + */ +function narrowWithIsset(array $data): void +{ + if (isset($data['key']) && !is_string($data['key'])) { + exit(1); + } + + // After: 'key' either doesn't exist or is string (possibly non-falsy substring) + assertType('string', $data['key'] ?? 'default'); +} + +/** + * @param array $data + */ +function narrowWithNegatedOr(array $data): void +{ + if (!array_key_exists('x', $data) || is_string($data['x'])) { + // Inside here: either 'x' doesn't exist or it's string + assertType('string', $data['x'] ?? 'default'); + } +} + +/** + * @param array $data + */ +function narrowWithInstanceof(array $data): void +{ + if (array_key_exists('obj', $data) && !$data['obj'] instanceof \stdClass) { + exit(1); + } + + assertType('stdClass', $data['obj'] ?? new \stdClass()); +}