From 5d73861876e7994feca33a2751bf97cfa5f8eee9 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 d0e3d42031..3b6a59eb61 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -588,6 +588,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() === '') { @@ -596,7 +598,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 c90386c10491c3a45812fb83f22704715d1212a0 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 3b6a59eb61..21326b0003 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -588,7 +588,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) { @@ -598,7 +598,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 d69f9302924268ec8c6ed0d8c20749c82c9abbf8 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 21326b0003..e348c93f8a 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -588,7 +588,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 b8b0e28d2db5d879c2336a6a827d7eb26eaf8e25 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 e348c93f8a..384b945d17 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -117,6 +117,7 @@ use function max; use function min; use function sprintf; +use function str_starts_with; use function strtolower; use const INF; @@ -588,9 +589,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) { @@ -600,7 +601,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;