From 3df2c12118a35f3c3e8b4c939af96c00d72295d5 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:26:21 +0000 Subject: [PATCH 1/7] Improve type inference for coalesce with `ErrorType`, `strpos` === int narrowing, and `str_repeat` return types - Handle ErrorType in InitializerExprTypeResolver coalesce: `[][0] ?? 42` now resolves to `42` instead of `*ERROR*` - Apply truthy narrowing when function/method calls are compared to truthy constants via ===, enabling `strpos($s, ':') === 5` to narrow `$s` to non-falsy-string - Return non-falsy-string from str_repeat when input is non-empty and multiplier >= 2 (result length >= 2 means it can't be '0' or '') - Preserve numeric-string in str_repeat when multiplier is exactly 1 - Update test assertions for all improved type inference cases --- src/Analyser/TypeSpecifier.php | 17 +++++++++++++++++ src/Reflection/InitializerExprTypeResolver.php | 4 ++++ .../StrRepeatFunctionReturnTypeExtension.php | 12 +++++++++++- .../nsrt/initializer-expr-type-resolver.php | 2 +- tests/PHPStan/Analyser/nsrt/literal-string.php | 8 ++++---- .../non-empty-string-str-containing-fns.php | 4 ++-- 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3e50294c94b..7a22f46b255 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1548,6 +1548,23 @@ private function specifyTypesForConstantBinaryExpression( )->setRootExpr($rootExpr)); } + if ( + $context->true() + && $constantType->toBoolean()->isTrue()->yes() + && ($exprNode instanceof FuncCall || $exprNode instanceof Expr\MethodCall || $exprNode instanceof Expr\StaticCall) + ) { + $additionalTypes = $this->specifyTypesInCondition( + $scope, + $exprNode, + TypeSpecifierContext::createTrue(), + )->setRootExpr($rootExpr); + + if ($additionalTypes->getSureTypes() !== [] || $additionalTypes->getSureNotTypes() !== []) { + $types = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + return $types->unionWith($additionalTypes); + } + } + return null; } diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 8a9fa17b3aa..fdc64192b13 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -281,6 +281,10 @@ public function getType(Expr $expr, InitializerExprContext $context): Type $leftType = $this->getType($expr->left, $context); $rightType = $this->getType($expr->right, $context); + if ($leftType instanceof ErrorType) { + return $rightType; + } + return TypeCombinator::union(TypeCombinator::removeNull($leftType), $rightType); } diff --git a/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php b/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php index 585c02bf9b0..ad1510c251b 100644 --- a/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrRepeatFunctionReturnTypeExtension.php @@ -68,7 +68,7 @@ public function getTypeFromFunctionCall( $accessoryTypes = []; if ($inputType->isNonEmptyString()->yes()) { if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($multiplierType)->yes()) { - if ($inputType->isNonFalsyString()->yes()) { + if ($inputType->isNonFalsyString()->yes() || IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($multiplierType)->yes()) { $accessoryTypes[] = new AccessoryNonFalsyStringType(); } else { $accessoryTypes[] = new AccessoryNonEmptyStringType(); @@ -76,6 +76,7 @@ public function getTypeFromFunctionCall( } } + $addedNumericString = false; if ($inputType->isLiteralString()->yes()) { $accessoryTypes[] = new AccessoryLiteralStringType(); @@ -93,10 +94,19 @@ public function getTypeFromFunctionCall( if ($onlyNumbers) { $accessoryTypes[] = new AccessoryNumericStringType(); + $addedNumericString = true; } } } + if ( + !$addedNumericString + && $inputType->isNumericString()->yes() + && (new ConstantIntegerType(1))->isSuperTypeOf($multiplierType)->yes() + ) { + $accessoryTypes[] = new AccessoryNumericStringType(); + } + if ($inputType->isLowercaseString()->yes()) { $accessoryTypes[] = new AccessoryLowercaseStringType(); } diff --git a/tests/PHPStan/Analyser/nsrt/initializer-expr-type-resolver.php b/tests/PHPStan/Analyser/nsrt/initializer-expr-type-resolver.php index 7a5daede04a..82a6d524602 100644 --- a/tests/PHPStan/Analyser/nsrt/initializer-expr-type-resolver.php +++ b/tests/PHPStan/Analyser/nsrt/initializer-expr-type-resolver.php @@ -14,7 +14,7 @@ class Foo public function doFoo(): void { - assertType('*ERROR*', self::COALESCE_SPECIAL); // could be 42 + assertType('42', self::COALESCE_SPECIAL); assertType("0|1|2|'foo'", self::COALESCE); assertType("'bar'|'foo'|true", self::TERNARY_SHORT); assertType("'bar'|'foo'", self::TERNARY_FULL); diff --git a/tests/PHPStan/Analyser/nsrt/literal-string.php b/tests/PHPStan/Analyser/nsrt/literal-string.php index c30fbdac808..28cf58c79f5 100644 --- a/tests/PHPStan/Analyser/nsrt/literal-string.php +++ b/tests/PHPStan/Analyser/nsrt/literal-string.php @@ -38,18 +38,18 @@ public function doFoo($literalString, string $string, $numericString) ); assertType('literal-string&lowercase-string&non-falsy-string', str_repeat('a', 100)); assertType('literal-string&non-falsy-string&uppercase-string', str_repeat('A', 100)); - assertType('literal-string&lowercase-string&non-empty-string&numeric-string&uppercase-string', str_repeat('0', 100)); // could be non-falsy-string + assertType('literal-string&lowercase-string&non-falsy-string&numeric-string&uppercase-string', str_repeat('0', 100)); assertType('literal-string&lowercase-string&non-falsy-string&numeric-string&uppercase-string', str_repeat('1', 100)); // Repeating a numeric type multiple times can lead to a non-numeric type: 3v4l.org/aRBdZ - assertType('non-empty-string', str_repeat($numericString, 100)); + assertType('non-falsy-string', str_repeat($numericString, 100)); assertType("''", str_repeat('1.23', 0)); assertType("''", str_repeat($string, 0)); assertType("''", str_repeat($numericString, 0)); // see https://3v4l.org/U4bM2 - assertType("non-empty-string", str_repeat($numericString, 1)); // could be numeric-string - assertType("non-empty-string", str_repeat($numericString, 2)); + assertType("non-empty-string&numeric-string", str_repeat($numericString, 1)); + assertType("non-falsy-string", str_repeat($numericString, 2)); assertType("literal-string", str_repeat($literalString, 1)); $x = rand(1,2); assertType("literal-string&lowercase-string&non-falsy-string&uppercase-string", str_repeat(' 1 ', $x)); diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 19482b7fd04..94a4baa6d92 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -100,7 +100,7 @@ public function variants(string $s) { assertType('string', $s); if (strpos($s, ':') === 5) { - assertType('string', $s); // could be non-empty-string + assertType('non-falsy-string', $s); } assertType('string', $s); if (strpos($s, ':') !== 5) { @@ -152,7 +152,7 @@ public function variants(string $s) { assertType('string', $s); if (mb_strpos($s, ':') === 5) { - assertType('string', $s); // could be non-empty-string + assertType('non-falsy-string', $s); } assertType('string', $s); if (mb_strpos($s, ':') !== 5) { From 7a7fda739ca80a17e5f3db06f01ec818ab56deca Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 22 Apr 2026 07:39:32 +0200 Subject: [PATCH 2/7] simplify --- src/Analyser/TypeSpecifier.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7a22f46b255..452684a5d13 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1553,16 +1553,13 @@ private function specifyTypesForConstantBinaryExpression( && $constantType->toBoolean()->isTrue()->yes() && ($exprNode instanceof FuncCall || $exprNode instanceof Expr\MethodCall || $exprNode instanceof Expr\StaticCall) ) { - $additionalTypes = $this->specifyTypesInCondition( + $types = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + + return $types->unionWith($this->specifyTypesInCondition( $scope, $exprNode, TypeSpecifierContext::createTrue(), - )->setRootExpr($rootExpr); - - if ($additionalTypes->getSureTypes() !== [] || $additionalTypes->getSureNotTypes() !== []) { - $types = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - return $types->unionWith($additionalTypes); - } + )->setRootExpr($rootExpr)); } return null; From fd2134602fa67c572fd605bec37e9aace1c6dbea Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 22 Apr 2026 07:48:46 +0200 Subject: [PATCH 3/7] more tests --- .../Analyser/nsrt/non-empty-string-str-containing-fns.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 94a4baa6d92..26768b8ae20 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -92,19 +92,27 @@ public function variants(string $s) { if (strpos($s, ':') !== false) { assertType('non-falsy-string', $s); + } else { + assertType('string', $s); } assertType('string', $s); if (strpos($s, ':') === false) { assertType('string', $s); + } else { + assertType('non-falsy-string', $s); } assertType('string', $s); if (strpos($s, ':') === 5) { assertType('non-falsy-string', $s); + } else { + assertType('string', $s); } assertType('string', $s); if (strpos($s, ':') !== 5) { assertType('string', $s); + } else { + assertType('non-falsy-string', $s); } assertType('string', $s); From f79cfbb8c6cac26fe9eafa4367d2b7c68a91054d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 22 Apr 2026 07:53:55 +0200 Subject: [PATCH 4/7] Update non-empty-string-str-containing-fns.php --- .../nsrt/non-empty-string-str-containing-fns.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 26768b8ae20..07c99ebcac6 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -103,12 +103,27 @@ public function variants(string $s) { } assertType('string', $s); + if (strpos($s, '0') === 0) { + assertType('string', $s); // could be non-empty-string + } else { + assertType('string', $s); + } + assertType('string', $s); + + if (strpos($s, '0') === 5) { + assertType('non-empty-string', $s); // could be non-falsy-string + } else { + assertType('string', $s); + } + assertType('string', $s); + if (strpos($s, ':') === 5) { assertType('non-falsy-string', $s); } else { assertType('string', $s); } assertType('string', $s); + if (strpos($s, ':') !== 5) { assertType('string', $s); } else { From e691b7310e01fe4a2deedd4ef80bd88f33cec43c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 22 Apr 2026 07:55:33 +0200 Subject: [PATCH 5/7] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 452684a5d13..6441c094726 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1554,7 +1554,7 @@ private function specifyTypesForConstantBinaryExpression( && ($exprNode instanceof FuncCall || $exprNode instanceof Expr\MethodCall || $exprNode instanceof Expr\StaticCall) ) { $types = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - + return $types->unionWith($this->specifyTypesInCondition( $scope, $exprNode, From cf326dd95da038150dfe772fbcb75c349dcb7be2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 22 Apr 2026 09:54:37 +0200 Subject: [PATCH 6/7] Update non-empty-string-str-containing-fns.php --- .../Analyser/nsrt/non-empty-string-str-containing-fns.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 07c99ebcac6..7d2e2345dd8 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -103,6 +103,13 @@ public function variants(string $s) { } assertType('string', $s); + if (strpos($s, '0') == 0) { // 0|false + assertType('string', $s); + } else { + assertType('string', $s); + } + assertType('string', $s); + if (strpos($s, '0') === 0) { assertType('string', $s); // could be non-empty-string } else { From 2a5bb898e1f78768dd745095f965c30fc60f9ab5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 22 Apr 2026 10:20:15 +0200 Subject: [PATCH 7/7] try to kill mutations --- .../non-empty-string-str-containing-fns.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 7d2e2345dd8..bc3887d19a7 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -110,6 +110,29 @@ public function variants(string $s) { } assertType('string', $s); + $oneOrZero = rand(0, 1); + if (strpos($s, '0') == $oneOrZero) { // 0|1|false + assertType('string', $s); + } else { + assertType('string', $s); + } + assertType('string', $s); + + $oneOrZero = rand(0, 1); + if (strpos($s, '0') === $oneOrZero) { + assertType('string', $s); // could be non-empty-string + } else { + assertType('string', $s); + } + assertType('string', $s); + + if (strpos($s, '0') == 1) { + assertType('string', $s); // could be non-empty-string + } else { + assertType('string', $s); + } + assertType('string', $s); + if (strpos($s, '0') === 0) { assertType('string', $s); // could be non-empty-string } else {