From 49aa692a92b71794ece088266ce219970717df55 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 17:32:06 +0100 Subject: [PATCH 1/9] Comparison with strtolower() etc. leads lower/upper-case-string --- src/Analyser/TypeSpecifier.php | 9 +++++++ tests/PHPStan/Analyser/nsrt/bug-14047.php | 26 +++++++++++++++++++ .../non-empty-string-strcasing-specifying.php | 16 ++++++------ 3 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14047.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 79eb168577..3d311cdbdf 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -35,8 +35,10 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; @@ -2437,6 +2439,13 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { + if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { + $argType = TypeCombinator::intersect($argType, new AccessoryLowercaseStringType()); + } + if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { + $argType = TypeCombinator::intersect($argType, new AccessoryUppercaseStringType()); + } + if ($rightType->isNonFalsyString()->yes()) { return $this->create( $unwrappedLeftExpr->getArgs()[0]->value, diff --git a/tests/PHPStan/Analyser/nsrt/bug-14047.php b/tests/PHPStan/Analyser/nsrt/bug-14047.php new file mode 100644 index 0000000000..6199599397 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14047.php @@ -0,0 +1,26 @@ + Date: Tue, 3 Feb 2026 17:41:43 +0100 Subject: [PATCH 2/9] test numeric strings --- tests/PHPStan/Analyser/nsrt/bug-14047.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14047.php b/tests/PHPStan/Analyser/nsrt/bug-14047.php index 6199599397..cc087ea23a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14047.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14047.php @@ -2,6 +2,7 @@ namespace Bug14047; +use function is_numeric; use function PHPStan\Testing\assertType; function test_strings(string $a, string $b): void @@ -23,4 +24,12 @@ function test_strings(string $a, string $b): void } elseif (strtoupper($b) === $b && $b !== '') { assertType('non-empty-string&uppercase-string', $b); } + + if ($b !== '' && is_numeric($b)) { + assertType('non-empty-string&numeric-string', $b); + } + + if (strtolower($b) === $b && $b !== '' && is_numeric($b)) { + assertType('lowercase-string&non-empty-string&numeric-string', $b); + } } From bd200ca0540540c7b845ddb12a59f21b8760af8e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 20:54:07 +0100 Subject: [PATCH 3/9] Discard changes to tests/PHPStan/Analyser/nsrt/non-empty-string-strcasing-specifying.php --- .../non-empty-string-strcasing-specifying.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-strcasing-specifying.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-strcasing-specifying.php index 154c02b8d7..8bbc0772fd 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-strcasing-specifying.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-strcasing-specifying.php @@ -8,11 +8,11 @@ class Foo { public function strtolower(string $s): void { if (strtolower($s) === 'hallo') { - assertType('lowercase-string&non-falsy-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); if ('hallo' === strtolower($s)) { - assertType('lowercase-string&non-falsy-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); @@ -28,11 +28,11 @@ public function strtolower(string $s): void public function strtoupper(string $s): void { if (strtoupper($s) === 'HA') { - assertType('non-falsy-string&uppercase-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); if ('hallo' === strtoupper($s)) { - assertType('non-falsy-string&uppercase-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); @@ -48,11 +48,11 @@ public function strtoupper(string $s): void public function mb_strtoupper(string $s): void { if (mb_strtoupper($s) === 'HA') { - assertType('non-falsy-string&uppercase-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); if ('hallo' === mb_strtoupper($s)) { - assertType('non-falsy-string&uppercase-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); @@ -68,11 +68,11 @@ public function mb_strtoupper(string $s): void public function mb_strtolower(string $s): void { if (mb_strtolower($s) === 'hallo') { - assertType('lowercase-string&non-falsy-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); if ('hallo' === mb_strtolower($s)) { - assertType('lowercase-string&non-falsy-string', $s); + assertType('non-falsy-string', $s); } assertType('string', $s); From 627762e41f140868140217e0d0445e6d77b0f81b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 20:54:40 +0100 Subject: [PATCH 4/9] fix --- src/Analyser/TypeSpecifier.php | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3d311cdbdf..0f67e33f23 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2439,28 +2439,39 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { + $specifiedTypes = new SpecifiedTypes(); if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { - $argType = TypeCombinator::intersect($argType, new AccessoryLowercaseStringType()); + $specifiedTypes = $this->create( + $unwrappedRightExpr, + TypeCombinator::intersect($argType, new AccessoryLowercaseStringType()), + $context, + $scope, + ); } if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { - $argType = TypeCombinator::intersect($argType, new AccessoryUppercaseStringType()); + $specifiedTypes = $this->create( + $unwrappedRightExpr, + TypeCombinator::intersect($argType, new AccessoryUppercaseStringType()), + $context, + $scope, + ); } if ($rightType->isNonFalsyString()->yes()) { - return $this->create( + return $specifiedTypes->unionWith($this->create( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), $context, $scope, - )->setRootExpr($expr); + )->setRootExpr($expr)); } - return $this->create( + return $specifiedTypes->unionWith($this->create( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), $context, $scope, - )->setRootExpr($expr); + )->setRootExpr($expr)); } } From 524a729ada30621c26b20572c3cfabda47a1b924 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 20:58:11 +0100 Subject: [PATCH 5/9] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 0f67e33f23..a63a8d9989 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2446,7 +2446,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope TypeCombinator::intersect($argType, new AccessoryLowercaseStringType()), $context, $scope, - ); + )->setRootExpr($expr); } if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { $specifiedTypes = $this->create( @@ -2454,7 +2454,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope TypeCombinator::intersect($argType, new AccessoryUppercaseStringType()), $context, $scope, - ); + )->setRootExpr($expr); } if ($rightType->isNonFalsyString()->yes()) { From c56649010d9162ba706e4986e141c92673814772 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 21:05:36 +0100 Subject: [PATCH 6/9] more tests --- src/Analyser/TypeSpecifier.php | 4 ++-- tests/PHPStan/Analyser/nsrt/bug-14047.php | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index a63a8d9989..3b2b1e0f11 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2443,7 +2443,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { $specifiedTypes = $this->create( $unwrappedRightExpr, - TypeCombinator::intersect($argType, new AccessoryLowercaseStringType()), + TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), $context, $scope, )->setRootExpr($expr); @@ -2451,7 +2451,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { $specifiedTypes = $this->create( $unwrappedRightExpr, - TypeCombinator::intersect($argType, new AccessoryUppercaseStringType()), + TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), $context, $scope, )->setRootExpr($expr); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14047.php b/tests/PHPStan/Analyser/nsrt/bug-14047.php index cc087ea23a..624854bf12 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14047.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14047.php @@ -32,4 +32,18 @@ function test_strings(string $a, string $b): void if (strtolower($b) === $b && $b !== '' && is_numeric($b)) { assertType('lowercase-string&non-empty-string&numeric-string', $b); } + + assertType('string', $b); + if (is_numeric($b)) { + assertType('numeric-string', $b); + if (strtolower($b) === $b) { + assertType('lowercase-string&non-empty-string&numeric-string', $b); + if ($b !== '') { + assertType('lowercase-string&non-empty-string&numeric-string', $b); + } + } + if ($b !== '') { + assertType('numeric-string', $b); + } + } } From c7efe0c35341a660a7587ffa03cb25cf17c165b3 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 21:15:25 +0100 Subject: [PATCH 7/9] Update bug-14047.php --- tests/PHPStan/Analyser/nsrt/bug-14047.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14047.php b/tests/PHPStan/Analyser/nsrt/bug-14047.php index 624854bf12..d463cf55c7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14047.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14047.php @@ -7,6 +7,11 @@ function test_strings(string $a, string $b): void { + if ($a !== '' && strtolower($a) === $b) { + assertType('non-empty-string', $a); + assertType('lowercase-string&non-empty-string', $b); + } + if ($a !== '' && strtolower($a) === $a) { assertType('lowercase-string&non-empty-string', $a); } elseif ($a !== '' && strtoupper($a) === $a) { From 99553226e60a38cc43847fd097914a81cc0595f4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 21:18:59 +0100 Subject: [PATCH 8/9] Update bug-14047.php --- tests/PHPStan/Analyser/nsrt/bug-14047.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14047.php b/tests/PHPStan/Analyser/nsrt/bug-14047.php index d463cf55c7..469cc8a7e0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14047.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14047.php @@ -7,6 +7,16 @@ function test_strings(string $a, string $b): void { + if (strtolower($a) !== $b) { + assertType('string', $a); + assertType('string', $b); + } + + if ($a !== '' && strtolower($a) == $b) { + assertType('non-empty-string', $a); + assertType('lowercase-string&non-empty-string', $b); + } + if ($a !== '' && strtolower($a) === $b) { assertType('non-empty-string', $a); assertType('lowercase-string&non-empty-string', $b); From 7198948ff2f0f634a5e09b4ee9f707d2c8a8aa13 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 3 Feb 2026 21:25:32 +0100 Subject: [PATCH 9/9] Update bug-14047.php --- tests/PHPStan/Analyser/nsrt/bug-14047.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14047.php b/tests/PHPStan/Analyser/nsrt/bug-14047.php index 469cc8a7e0..361e40b61f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14047.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14047.php @@ -22,6 +22,11 @@ function test_strings(string $a, string $b): void assertType('lowercase-string&non-empty-string', $b); } + if ($a !== '' && strtolower($a) == $b && $b !== '0') { + assertType('non-empty-string', $a); + assertType('lowercase-string&non-falsy-string', $b); + } + if ($a !== '' && strtolower($a) === $a) { assertType('lowercase-string&non-empty-string', $a); } elseif ($a !== '' && strtoupper($a) === $a) {