From e9e58a7aa53a5ea427ea057e36980d1c6d968271 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 24 May 2026 11:17:36 +0200 Subject: [PATCH 1/4] Retain class_exists expression types in closure --- src/Analyser/MutatingScope.php | 2 +- .../nsrt/closure-retain-expression-types.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index be806b0613d..9ce45811c8b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2157,7 +2157,7 @@ private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): return $expr instanceof FuncCall && !$expr->isFirstClassCallable() && $expr->name instanceof FullyQualified - && $expr->name->toLowerString() === 'function_exists' + && in_array($expr->name->toLowerString(), ['function_exists', 'class_exists'], true) && isset($expr->getArgs()[0]) && count($this->getType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 && $type->isTrue()->yes(); diff --git a/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php b/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php index 0db3aa730e3..17d3970e8f3 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php +++ b/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php @@ -21,3 +21,18 @@ function () { }; } }; + +function () { + assertType('bool', class_exists('foo123')); + if (class_exists('foo123')) { + assertType('true', class_exists('foo123')); + function () { + assertType('true', class_exists('foo123')); + }; + } else { + assertType('false', class_exists('foo123')); + function () { + assertType('bool', class_exists('foo123')); + }; + } +}; From 03bf12583e98c1ff1df5de1f3b2b88155578fae8 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 24 May 2026 12:32:30 +0200 Subject: [PATCH 2/4] cover more variants --- src/Analyser/MutatingScope.php | 12 +++- ...sExistsFunctionTypeSpecifyingExtension.php | 2 +- .../nsrt/closure-retain-expression-types.php | 61 ++++++++++++++++--- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9ce45811c8b..f7de71487c6 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2157,7 +2157,17 @@ private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): return $expr instanceof FuncCall && !$expr->isFirstClassCallable() && $expr->name instanceof FullyQualified - && in_array($expr->name->toLowerString(), ['function_exists', 'class_exists'], true) + && in_array( + $expr->name->toLowerString(), + [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + 'function_exists', + ], + true, + ) && isset($expr->getArgs()[0]) && count($this->getType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 && $type->isTrue()->yes(); diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php index 06c1aad5937..2a953b124a2 100644 --- a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -49,7 +49,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $args = $node->getArgs(); $argType = $scope->getType($args[0]->value); if ($argType instanceof ConstantStringType) { - $funcCall = new FuncCall(new FullyQualified('class_exists'), [ + $funcCall = new FuncCall(new FullyQualified($functionReflection->getName()), [ new Arg(new String_(ltrim($argType->getValue(), '\\'))), ]); return $this->typeSpecifier->create( diff --git a/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php b/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php index 17d3970e8f3..5d144fe7b4e 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php +++ b/tests/PHPStan/Analyser/nsrt/closure-retain-expression-types.php @@ -3,7 +3,9 @@ namespace ClosureRetainExpressionTypes; use function class_exists; -use function defined; +use function interface_exists; +use function enum_exists; +use function trait_exists; use function function_exists; use function PHPStan\Testing\assertType; @@ -23,16 +25,61 @@ function () { }; function () { - assertType('bool', class_exists('foo123')); - if (class_exists('foo123')) { - assertType('true', class_exists('foo123')); + assertType('bool', class_exists('foo345')); + if (class_exists('foo345')) { + assertType('true', class_exists('foo345')); function () { - assertType('true', class_exists('foo123')); + assertType('true', class_exists('foo345')); }; } else { - assertType('false', class_exists('foo123')); + assertType('false', class_exists('foo345')); function () { - assertType('bool', class_exists('foo123')); + assertType('bool', class_exists('foo345')); + }; + } +}; + +function () { + assertType('bool', enum_exists('foo567')); + if (enum_exists('foo567')) { + assertType('true', enum_exists('foo567')); + function () { + assertType('true', enum_exists('foo567')); + }; + } else { + assertType('false', enum_exists('foo567')); + function () { + assertType('bool', enum_exists('foo567')); + }; + } +}; + +function () { + assertType('bool', interface_exists('foo890')); + if (interface_exists('foo890')) { + assertType('true', interface_exists('foo890')); + function () { + assertType('true', interface_exists('foo890')); + }; + } else { + assertType('false', interface_exists('foo890')); + function () { + assertType('bool', interface_exists('foo890')); + }; + } +}; + +function () { + assertType('bool', trait_exists('fooabc')); + if (trait_exists('fooabc')) { + assertType('true', trait_exists('fooabc')); + function () { + assertType('true', trait_exists('fooabc')); + }; + } else { + assertType('false', trait_exists('fooabc')); + function () { + assertType('bool', trait_exists('fooabc')); }; } }; From 20143f8438a166e24dd33952bd8b5f7c0211ae5a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 24 May 2026 12:46:44 +0200 Subject: [PATCH 3/4] Update ClassExistsFunctionTypeSpecifyingExtension.php --- ...sExistsFunctionTypeSpecifyingExtension.php | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php index 2a953b124a2..186ff45ea23 100644 --- a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -49,14 +49,30 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $args = $node->getArgs(); $argType = $scope->getType($args[0]->value); if ($argType instanceof ConstantStringType) { - $funcCall = new FuncCall(new FullyQualified($functionReflection->getName()), [ - new Arg(new String_(ltrim($argType->getValue(), '\\'))), - ]); return $this->typeSpecifier->create( - new AlwaysRememberedExpr($funcCall, new BooleanType(), new BooleanType()), + new AlwaysRememberedExpr( + new FuncCall(new FullyQualified($functionReflection->getName()), [ + new Arg(new String_(ltrim($argType->getValue(), '\\'))), + ]), + new BooleanType(), + new BooleanType(), + ), new ConstantBooleanType(true), $context, $scope, + )->unionWith( + $this->typeSpecifier->create( + new AlwaysRememberedExpr( + new FuncCall(new FullyQualified('class_exists'), [ + new Arg(new String_(ltrim($argType->getValue(), '\\'))), + ]), + new BooleanType(), + new BooleanType(), + ), + new ConstantBooleanType(true), + $context, + $scope, + ), ); } From 6492c3b1f926a7bd53b21d1cbe3f4b02ed8a366d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 24 May 2026 12:48:55 +0200 Subject: [PATCH 4/4] Update ClassExistsFunctionTypeSpecifyingExtension.php --- src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php index 186ff45ea23..68272836808 100644 --- a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -14,6 +14,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Reflection\FunctionReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -49,6 +50,9 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $args = $node->getArgs(); $argType = $scope->getType($args[0]->value); if ($argType instanceof ConstantStringType) { + if ($functionReflection->getName() === '') { + throw new ShouldNotHappenException(); + } return $this->typeSpecifier->create( new AlwaysRememberedExpr( new FuncCall(new FullyQualified($functionReflection->getName()), [