From 23a3a4a43f8f20574babbf5aa2aa197426c9b9fd Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Oct 2025 00:06:46 +0200 Subject: [PATCH 1/4] Improve concat result --- src/Reflection/InitializerExprTypeResolver.php | 4 +++- tests/PHPStan/Analyser/nsrt/bug-11129.php | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 8b38c5a383..6b4f6a5b26 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -506,6 +506,8 @@ public function resolveConcatType(Type $left, Type $right): Type $leftNumericStringNonEmpty = TypeCombinator::remove($leftStringType, new ConstantStringType('')); if ($leftNumericStringNonEmpty->isNumericString()->yes()) { + $allowedPattern = $left->isInteger()->yes() ? '#^[0-9.]+$#' : '#^[0-9]+$#'; + $allRightConstantsZeroOrMore = false; foreach ($rightConstantStrings as $rightConstantString) { if ($rightConstantString->getValue() === '') { @@ -514,7 +516,7 @@ public function resolveConcatType(Type $left, Type $right): Type if ( !is_numeric($rightConstantString->getValue()) - || Strings::match($rightConstantString->getValue(), '#^[0-9]+$#') === null + || Strings::match($rightConstantString->getValue(), $allowedPattern) === null ) { $allRightConstantsZeroOrMore = false; break; diff --git a/tests/PHPStan/Analyser/nsrt/bug-11129.php b/tests/PHPStan/Analyser/nsrt/bug-11129.php index a845bf1d2a..4c69d23fad 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11129.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11129.php @@ -48,7 +48,7 @@ public function foo( assertType('lowercase-string&non-falsy-string', $i.$maybeNonNumericConstStrings); assertType('lowercase-string&non-falsy-string', $maybeNonNumericConstStrings.$i); - assertType('lowercase-string&non-falsy-string&uppercase-string', $i.$maybeFloatConstStrings); // could be 'lowercase-string&non-falsy-string&numeric-string' + assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $i.$maybeFloatConstStrings); assertType('lowercase-string&non-falsy-string&uppercase-string', $maybeFloatConstStrings.$i); assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $i.$bool); @@ -68,7 +68,7 @@ public function foo( assertType('non-falsy-string&numeric-string&uppercase-string', $float.$positiveInt); assertType('non-falsy-string&uppercase-string', $float.$negativeInt); assertType('non-falsy-string&uppercase-string', $float.$i); - assertType('non-falsy-string&uppercase-string', $i.$float); // could be 'non-falsy-string&numeric-string&uppercase-string' + assertType('non-falsy-string&uppercase-string', $i.$float); assertType('non-falsy-string', $numericString.$float); assertType('non-falsy-string', $numericString.$maybeFloatConstStrings); From 22f58509144423c82da0dcada9b7451c2ce11169 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Oct 2025 10:23:38 +0200 Subject: [PATCH 2/4] Rename --- src/Reflection/InitializerExprTypeResolver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 6b4f6a5b26..f32a4690af 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -506,7 +506,7 @@ public function resolveConcatType(Type $left, Type $right): Type $leftNumericStringNonEmpty = TypeCombinator::remove($leftStringType, new ConstantStringType('')); if ($leftNumericStringNonEmpty->isNumericString()->yes()) { - $allowedPattern = $left->isInteger()->yes() ? '#^[0-9.]+$#' : '#^[0-9]+$#'; + $allowedRightPattern = $left->isInteger()->yes() ? '#^[0-9.]+$#' : '#^[0-9]+$#'; $allRightConstantsZeroOrMore = false; foreach ($rightConstantStrings as $rightConstantString) { @@ -516,7 +516,7 @@ public function resolveConcatType(Type $left, Type $right): Type if ( !is_numeric($rightConstantString->getValue()) - || Strings::match($rightConstantString->getValue(), $allowedPattern) === null + || Strings::match($rightConstantString->getValue(), $allowedRightPattern) === null ) { $allRightConstantsZeroOrMore = false; break; From 6657e3288a96c42b3fc27e896fc88b48619f2c59 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Oct 2025 10:50:07 +0200 Subject: [PATCH 3/4] Fix and add more cases --- src/Reflection/InitializerExprTypeResolver.php | 4 +++- tests/PHPStan/Analyser/nsrt/bug-11129.php | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index f32a4690af..1387ad0b52 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -506,7 +506,9 @@ public function resolveConcatType(Type $left, Type $right): Type $leftNumericStringNonEmpty = TypeCombinator::remove($leftStringType, new ConstantStringType('')); if ($leftNumericStringNonEmpty->isNumericString()->yes()) { - $allowedRightPattern = $left->isInteger()->yes() ? '#^[0-9.]+$#' : '#^[0-9]+$#'; + $allowedRightPattern = $left->isInteger()->yes() + ? '#^(\d+|\d+.\d+)([eE][+-]?\d+)?$#' // non-negative integer, float or scientific string + : '#^\d+$#'; // non-negative integer string $allRightConstantsZeroOrMore = false; foreach ($rightConstantStrings as $rightConstantString) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11129.php b/tests/PHPStan/Analyser/nsrt/bug-11129.php index 4c69d23fad..33007584be 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11129.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11129.php @@ -51,6 +51,17 @@ public function foo( assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $i.$maybeFloatConstStrings); assertType('lowercase-string&non-falsy-string&uppercase-string', $maybeFloatConstStrings.$i); + assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $i.'1'); + assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $i.'1.0'); + assertType('lowercase-string&non-falsy-string&uppercase-string', $i.'1.1.1'); + assertType('lowercase-string&non-falsy-string&uppercase-string', $i.'-1'); + assertType('lowercase-string&non-falsy-string&uppercase-string', $i.'-1.0'); + assertType('lowercase-string&non-falsy-string&numeric-string', $i.'10e-3'); + assertType('lowercase-string&non-falsy-string', $i.'-10e-3'); + assertType('non-falsy-string&numeric-string&uppercase-string', $i.'10E3'); + assertType('non-falsy-string&uppercase-string', $i.'-10E3'); + assertType('non-falsy-string', $i.'10eE3'); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $i.$bool); assertType('lowercase-string&non-empty-string&uppercase-string', $bool.$i); assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $positiveInt.$bool); @@ -75,7 +86,7 @@ public function foo( // https://3v4l.org/Ia4r0 $scientificFloatAsString = '3e4'; assertType('non-falsy-string', $numericString.$scientificFloatAsString); - assertType('lowercase-string&non-falsy-string', $i.$scientificFloatAsString); + assertType('lowercase-string&non-falsy-string&numeric-string', $i.$scientificFloatAsString); assertType('non-falsy-string', $scientificFloatAsString.$numericString); assertType('lowercase-string&non-falsy-string', $scientificFloatAsString.$i); } From 864404587ce93a8975ea47b3ab70f2d8dd07bc2d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Oct 2025 11:31:20 +0200 Subject: [PATCH 4/4] Rework --- src/Reflection/InitializerExprTypeResolver.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 1387ad0b52..008e3785fc 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -91,6 +91,7 @@ use function max; use function min; use function sprintf; +use function str_starts_with; use function strtolower; use const INF; @@ -506,9 +507,9 @@ public function resolveConcatType(Type $left, Type $right): Type $leftNumericStringNonEmpty = TypeCombinator::remove($leftStringType, new ConstantStringType('')); if ($leftNumericStringNonEmpty->isNumericString()->yes()) { - $allowedRightPattern = $left->isInteger()->yes() - ? '#^(\d+|\d+.\d+)([eE][+-]?\d+)?$#' // non-negative integer, float or scientific string - : '#^\d+$#'; // non-negative integer string + $validationCallback = $left->isInteger()->yes() + ? static fn (string $value): bool => !str_starts_with($value, '-') + : static fn (string $value): bool => Strings::match($value, '#^\d+$#') !== null; $allRightConstantsZeroOrMore = false; foreach ($rightConstantStrings as $rightConstantString) { @@ -518,7 +519,7 @@ public function resolveConcatType(Type $left, Type $right): Type if ( !is_numeric($rightConstantString->getValue()) - || Strings::match($rightConstantString->getValue(), $allowedRightPattern) === null + || !$validationCallback($rightConstantString->getValue()) ) { $allRightConstantsZeroOrMore = false; break;