diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9889f91a046..4a22a57950c 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -847,6 +847,31 @@ public function specifyTypesInCondition( $specifiedTypes = $specifiedTypes->unionWith( $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], + ]), + ); + } } } } diff --git a/src/Parser/RichParser.php b/src/Parser/RichParser.php index 4d3f0bdb4b9..5862e3de682 100644 --- a/src/Parser/RichParser.php +++ b/src/Parser/RichParser.php @@ -361,7 +361,6 @@ private function parseIdentifiers(string $text, int $ignorePos): array throw new IgnoreParseException('Missing identifier', 1); } - /** @phpstan-ignore return.type (return type is correct, not sure why it's being changed from array shape to key-value shape) */ return $identifiers; } diff --git a/tests/PHPStan/Analyser/nsrt/array-key-last-existing.php b/tests/PHPStan/Analyser/nsrt/array-key-last-existing.php new file mode 100644 index 00000000000..7072ca7d391 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-key-last-existing.php @@ -0,0 +1,63 @@ + $name, 'comment' => null]; + } + + assertType('list', $identifiers); +} + +function appendThenUpdateFirst(string $name, string $comment): void +{ + $identifiers = []; + $c = rand(100, 200); + for ($i = 0; $i < $c; $i++) { + if (rand(0, 1) === 1) { + $key = array_key_first($identifiers); + if ($key !== null) { + $identifiers[$key]['comment'] = $comment; + } + continue; + } + + $identifiers[] = ['name' => $name, 'comment' => null]; + } + + assertType('list', $identifiers); +} + +/** + * @param list $list + */ +function maybeEmptyArray(array $list): void +{ + $key = array_key_last($list); + if ($key !== null) { + assertType('array{name: \'x\', comment: null}', $list[$key]); + $list[$key]['comment'] = 'hello'; + assertType('non-empty-list', $list); + } +}