From 0dc6560555e74a55cc96360c84c2080d46432f27 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 10:03:55 +0100 Subject: [PATCH 1/9] Implement ArrayKeyFirstLastTypeSpecifyingExtension --- ...rayKeyFirstLastTypeSpecifyingExtension.php | 58 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14081.php | 31 ++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14081.php diff --git a/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php new file mode 100644 index 0000000000..e99184c066 --- /dev/null +++ b/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php @@ -0,0 +1,58 @@ +getName()), ['array_key_first', 'array_key_last']) + && $context->true(); + } + + public function specifyTypes( + FunctionReflection $functionReflection, + FuncCall $node, + Scope $scope, + TypeSpecifierContext $context, + ): SpecifiedTypes + { + $arrayArg = $node->getArgs()[0]->value ?? null; + if ($arrayArg === null) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create( + $arrayArg, + new NonEmptyArrayType(), + $context, + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php new file mode 100644 index 0000000000..33589cae28 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -0,0 +1,31 @@ + $array */ +function first(array $array): mixed +{ + if (($key = array_key_first($array))) { + assertType('int<1, max>', $key); + assertType('non-empty-list', $array); + assertType('string', $array[$key]); + return $array[$key]; + } + return null; +} + +/** @param list $array */ +function last(array $array): mixed +{ + if (($key = array_key_last($array))) { + assertType('int<1, max>', $key); + assertType('non-empty-list', $array); + assertType('string', $array[$key]); + return $array[$key]; + } + return null; +} From aad71caf3cf3fcf08b30025c201d101f20b5eeba Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 10:04:44 +0100 Subject: [PATCH 2/9] Update ArrayKeyFirstLastTypeSpecifyingExtension.php --- src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php index e99184c066..dd191f61e5 100644 --- a/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php @@ -12,6 +12,7 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\FunctionTypeSpecifyingExtension; +use function in_array; use function strtolower; #[AutowiredService] From e8244d929f3b800c05cc457eb326b1e3dc2a618f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 10:23:40 +0100 Subject: [PATCH 3/9] More precise array_key_first/array_key_last inference --- src/Analyser/TypeSpecifier.php | 65 +++++++++++-------- ...rayKeyFirstLastTypeSpecifyingExtension.php | 59 ----------------- 2 files changed, 39 insertions(+), 85 deletions(-) delete mode 100644 src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3a1f8bd98e..ba0e6c3f1f 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -725,7 +725,46 @@ public function specifyTypesInCondition( if ($context->null()) { $specifiedTypes = $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr); + } else { + $specifiedTypes = $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->var, $context)->setRootExpr($expr); + } + + // infer $arr[$key] after $key = array_key_first/last($arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) + && count($expr->expr->getArgs()) >= 1 + ) { + $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), + ); + $isNonEmpty = true; + } else { + $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); + } + if ( + $isNonEmpty + ) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $iterableValueType = $expr->expr->name->toLowerString() === 'array_key_first' + ? $arrayType->getIterableValueType() + : $arrayType->getIterableValueType(); + + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope), + ); + } + } + } + + if ($context->null()) { // infer $arr[$key] after $key = array_rand($arr) if ( $expr->expr instanceof FuncCall @@ -755,30 +794,6 @@ public function specifyTypesInCondition( } } - // infer $arr[$key] after $key = array_key_first/last($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - if ( - $arrayType->isArray()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $iterableValueType = $expr->expr->name->toLowerString() === 'array_key_first' - ? $arrayType->getIterableValueType() - : $arrayType->getIterableValueType(); - - return $specifiedTypes->unionWith( - $this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope), - ); - } - } - // infer $list[$count] after $count = count($list) - 1 if ( $expr->expr instanceof Expr\BinaryOp\Minus @@ -806,8 +821,6 @@ public function specifyTypesInCondition( return $specifiedTypes; } - $specifiedTypes = $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->var, $context)->setRootExpr($expr); - if ($context->true()) { // infer $arr[$key] after $key = array_search($needle, $arr) if ( diff --git a/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php deleted file mode 100644 index dd191f61e5..0000000000 --- a/src/Type/Php/ArrayKeyFirstLastTypeSpecifyingExtension.php +++ /dev/null @@ -1,59 +0,0 @@ -getName()), ['array_key_first', 'array_key_last']) - && $context->true(); - } - - public function specifyTypes( - FunctionReflection $functionReflection, - FuncCall $node, - Scope $scope, - TypeSpecifierContext $context, - ): SpecifiedTypes - { - $arrayArg = $node->getArgs()[0]->value ?? null; - if ($arrayArg === null) { - return new SpecifiedTypes(); - } - - return $this->typeSpecifier->create( - $arrayArg, - new NonEmptyArrayType(), - $context, - $scope, - ); - } - - public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void - { - $this->typeSpecifier = $typeSpecifier; - } - -} From c54d9a8f9f6472e85473737219cf14893b87c049 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 10:27:56 +0100 Subject: [PATCH 4/9] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index ba0e6c3f1f..d6a39cf0fa 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -749,9 +749,7 @@ public function specifyTypesInCondition( $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); } - if ( - $isNonEmpty - ) { + if ($isNonEmpty) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); $iterableValueType = $expr->expr->name->toLowerString() === 'array_key_first' ? $arrayType->getIterableValueType() From 44e0fbd4965c1487e56b7d4f7b42f026238837fe Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 10:53:30 +0100 Subject: [PATCH 5/9] more tests --- tests/PHPStan/Analyser/nsrt/bug-14081.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index 33589cae28..67c7ad9ac9 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -29,3 +29,9 @@ function last(array $array): mixed } return null; } + +/** @param list $array */ +function firstNotNull(array $array): mixed +{ + if (($key = array_key_first($array)) !== null) { + assertType('int<0, max>', $key); // could be int<1, max> From 8f812fee26cb0b317045e337edbd422d5c54d721 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 10:53:39 +0100 Subject: [PATCH 6/9] more tests --- tests/PHPStan/Analyser/nsrt/bug-14081.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index 67c7ad9ac9..fc4b8c2d39 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -35,3 +35,21 @@ function firstNotNull(array $array): mixed { if (($key = array_key_first($array)) !== null) { assertType('int<0, max>', $key); // could be int<1, max> + assertType('list', $array); // could be non-empty-list + assertType('string', $array[$key]); + return $array[$key]; + } + return null; +} + +/** @param list $array */ +function lastNotNull(array $array): mixed +{ + if (($key = array_key_last($array)) !== null) { + assertType('int<0, max>', $key); // could be int<1, max> + assertType('list', $array); // could be non-empty-list + assertType('string', $array[$key]); + return $array[$key]; + } + return null; +} From fa26a8780f4212add92fa0bb1844fd7000096522 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 11:19:00 +0100 Subject: [PATCH 7/9] Update bug-14081.php --- tests/PHPStan/Analyser/nsrt/bug-14081.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index fc4b8c2d39..11b9a9c1f4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -30,6 +30,19 @@ function last(array $array): mixed return null; } +function maybeNonEmpty(): void +{ + if (rand(0,1)) { + $array = ['one', 'two']; + } else { + $array = []; + } + assertType("array{}|array{'one', 'two'}", $array); + $key = array_key_last($array); + assertType('0|1|null', $key); + assertType("'one'|'two'", $array[$key]); +} + /** @param list $array */ function firstNotNull(array $array): mixed { From 18eea1c4c6f713e89d1791f3361e583288024c8c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 12:04:47 +0100 Subject: [PATCH 8/9] feedback --- src/Analyser/TypeSpecifier.php | 5 +---- tests/PHPStan/Analyser/nsrt/bug-14081.php | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index d6a39cf0fa..a066bd1e0e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -751,12 +751,9 @@ public function specifyTypesInCondition( if ($isNonEmpty) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $iterableValueType = $expr->expr->name->toLowerString() === 'array_key_first' - ? $arrayType->getIterableValueType() - : $arrayType->getIterableValueType(); $specifiedTypes = $specifiedTypes->unionWith( - $this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope), + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), ); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index 11b9a9c1f4..3b03715ac2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -47,7 +47,7 @@ function maybeNonEmpty(): void function firstNotNull(array $array): mixed { if (($key = array_key_first($array)) !== null) { - assertType('int<0, max>', $key); // could be int<1, max> + assertType('int<0, max>', $key); assertType('list', $array); // could be non-empty-list assertType('string', $array[$key]); return $array[$key]; @@ -59,7 +59,7 @@ function firstNotNull(array $array): mixed function lastNotNull(array $array): mixed { if (($key = array_key_last($array)) !== null) { - assertType('int<0, max>', $key); // could be int<1, max> + assertType('int<0, max>', $key); assertType('list', $array); // could be non-empty-list assertType('string', $array[$key]); return $array[$key]; From 6be0c13659194b0790f399f36a88bb300d830387 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Feb 2026 12:26:58 +0100 Subject: [PATCH 9/9] another test --- tests/PHPStan/Analyser/nsrt/bug-14081.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index 3b03715ac2..277c811499 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -66,3 +66,20 @@ function lastNotNull(array $array): mixed } return null; } + +/** @param list $array */ +function noIf(array $array): void +{ + $key = array_key_first($array); + assertType('int<0, max>|null', $key); + assertType('list', $array); + assertType('string', $array[$key]); + + if ($array === []) { + return; + } + $key = array_key_first($array); + assertType('int<0, max>', $key); + assertType('non-empty-list', $array); + assertType('string', $array[$key]); +}