From 3d65157c1dee1bbd0409f45c7262c8b85e37c5d3 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Fri, 1 May 2026 09:03:45 +0000 Subject: [PATCH 1/3] Fix implode losing non-empty-string when ConstantArrayType has all-optional keys When a ConstantArrayType has all-optional keys (e.g. from union-typed key expressions in loops), ImplodeFunctionReturnTypeExtension's inferConstantType() enumerated every optional-key subset including the empty array. This produced '' as a possible implode result, which generalized to literal-string and lost the non-empty-string guarantee. Pass the isIterableAtLeastOnce() result into inferConstantType() and filter out the empty-array partial when the array is guaranteed non-empty. Closes https://github.com/phpstan/phpstan/issues/14558 --- .../ImplodeFunctionReturnTypeExtension.php | 14 +++- tests/PHPStan/Analyser/nsrt/bug-14558.php | 81 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14558.php diff --git a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php index 4f38ed5137d..a62fa0150ac 100644 --- a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php +++ b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php @@ -19,6 +19,8 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_filter; +use function array_values; use function count; use function implode; use function in_array; @@ -61,11 +63,12 @@ public function getTypeFromFunctionCall( private function implode(Type $arrayType, Type $separatorType): Type { + $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); if (count($arrayType->getConstantArrays()) > 0 && count($separatorType->getConstantStrings()) > 0) { $result = []; foreach ($separatorType->getConstantStrings() as $separator) { foreach ($arrayType->getConstantArrays() as $constantArray) { - $constantType = $this->inferConstantType($constantArray, $separator); + $constantType = $this->inferConstantType($constantArray, $separator, $isNonEmpty); if ($constantType !== null) { $result[] = $constantType; continue; @@ -110,7 +113,7 @@ private function implode(Type $arrayType, Type $separatorType): Type return new StringType(); } - private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType): ?Type + private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType, bool $isNonEmpty): ?Type { $sep = $separatorType->getValue(); $valueTypes = $arrayType->getValueTypes(); @@ -148,6 +151,13 @@ private function inferConstantType(ConstantArrayType $arrayType, ConstantStringT } } + if ($isNonEmpty) { + $partials = array_values(array_filter($partials, static fn (array $parts): bool => $parts !== [])); + if ($partials === []) { + return null; + } + } + $strings = []; foreach ($partials as $partial) { $strings[] = new ConstantStringType(implode($sep, $partial)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14558.php b/tests/PHPStan/Analyser/nsrt/bug-14558.php new file mode 100644 index 00000000000..6f6259d4116 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14558.php @@ -0,0 +1,81 @@ + */ +function get_sort_keys(mixed ...$args): array { return ['a']; } + +function cond(int $i): bool { return true; } + + +// Playground 1: with outer foreach loop +function test1(): void +{ + $cols_cat = [ ]; + + foreach ([ 'PrV', 'PrA', 'Acc' ] as $g) { + $num_types = num_types($g); + for ($i = 1; $i <= $num_types; $i++) { + + if (cond($i)) { + + $k = 0; + $tmp_sort_keys = [ ]; + foreach (get_sort_keys($g, $i) as $ce_tri) { + $k++; + $tmp_sort_alias = "Tri{$k}_Cat_{$g}{$i}"; + $tmp_sort_keys[$tmp_sort_alias] = $tmp_sort_alias; + } + assertType('non-falsy-string', implode(',', $tmp_sort_keys)); + $cols_cat[] = [ + 'g' => $g + , 't' => $i + , 's' => implode(',', $tmp_sort_keys) + ]; + + } + } + + } + + assertType('list, s: non-falsy-string}>', $cols_cat); +} + +// Playground 2: without outer foreach loop +function test2(): void +{ + $cols_cat = [ ]; + + $g = 'PrV'; + $num_types = num_types($g); + for ($i = 1; $i <= $num_types; $i++) { + + if (cond($i)) { + + $k = 0; + $tmp_sort_keys = [ ]; + foreach (get_sort_keys($g, $i) as $ce_tri) { + $k++; + $tmp_sort_alias = "Tri{$k}_Cat_{$g}{$i}"; + $tmp_sort_keys[$tmp_sort_alias] = $tmp_sort_alias; + } + + assertType('non-falsy-string', implode(',', $tmp_sort_keys)); + $cols_cat[] = [ + 'g' => $g + , 't' => $i + , 's' => implode(',', $tmp_sort_keys) + ]; + + } + } + + assertType('list, s: non-falsy-string}>', $cols_cat); +} From da993852c772b7a0d32e9de554e73bfeb82795ae Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 17:32:54 +0000 Subject: [PATCH 2/3] Filter empty partials inline in the foreach loop Instead of a separate array_filter step before the loop, skip empty partials directly inside the foreach that builds ConstantStringType values. This is simpler and avoids the extra array_filter/array_values calls. Co-Authored-By: Claude Opus 4.6 --- .../Php/ImplodeFunctionReturnTypeExtension.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php index a62fa0150ac..3676bb1dd5f 100644 --- a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php +++ b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php @@ -19,8 +19,6 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use function array_filter; -use function array_values; use function count; use function implode; use function in_array; @@ -151,18 +149,18 @@ private function inferConstantType(ConstantArrayType $arrayType, ConstantStringT } } - if ($isNonEmpty) { - $partials = array_values(array_filter($partials, static fn (array $parts): bool => $parts !== [])); - if ($partials === []) { - return null; - } - } - $strings = []; foreach ($partials as $partial) { + if ($partial === [] && $isNonEmpty) { + continue; + } $strings[] = new ConstantStringType(implode($sep, $partial)); } + if ($strings === []) { + return null; + } + return TypeCombinator::union(...$strings); } From 8f3e2065ca5d202be45f2d72c756b3d157875141 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 1 May 2026 17:43:46 +0000 Subject: [PATCH 3/3] Move $isNonEmpty computation inside the if block where it is used Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/ImplodeFunctionReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php index 3676bb1dd5f..a23d8443e15 100644 --- a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php +++ b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php @@ -61,8 +61,8 @@ public function getTypeFromFunctionCall( private function implode(Type $arrayType, Type $separatorType): Type { - $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); if (count($arrayType->getConstantArrays()) > 0 && count($separatorType->getConstantStrings()) > 0) { + $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); $result = []; foreach ($separatorType->getConstantStrings() as $separator) { foreach ($arrayType->getConstantArrays() as $constantArray) {