From 01f336d653cdd893b79bbf70bb56f89bb751f620 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:15:12 +0000 Subject: [PATCH 1/7] Fix phpstan/phpstan#5952: Track __toString() throws in echo statements - Added __toString() throw point tracking to echo statement handling in NodeScopeResolver - Reuses existing CastStringHandler pattern: checks PhpVersion::throwsOnStringCast() and MethodThrowPointHelper - New regression test in tests/PHPStan/Rules/Exceptions/data/bug-5952.php - The root cause was that echo $obj did not consider implicit __toString() calls when collecting throw points --- src/Analyser/NodeScopeResolver.php | 18 ++++++ .../CatchWithUnthrownExceptionRuleTest.php | 14 +++++ .../Rules/Exceptions/data/bug-5952.php | 59 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-5952.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 001c8b5b368..0cf2cebe5c7 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -110,6 +110,7 @@ use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\LineAttributesVisitor; use PHPStan\Parser\Parser; +use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\PhpDocInheritanceResolver; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\Tag\VarTag; @@ -862,9 +863,26 @@ public function processStmtNode( $hasYield = false; $throwPoints = []; $isAlwaysTerminating = false; + $phpVersion = $this->container->getByType(PhpVersion::class); + $methodThrowPointHelper = $this->container->getByType(ExprHandler\Helper\MethodThrowPointHelper::class); foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + if ($phpVersion->throwsOnStringCast()) { + $exprType = $scope->getType($echoExpr); + $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); + if ($toStringMethod !== null) { + $throwPoint = $methodThrowPointHelper->getThrowPoint( + $toStringMethod, + $toStringMethod->getOnlyVariant(), + new Expr\MethodCall($echoExpr, new Identifier('__toString')), + $scope, + ); + if ($throwPoint !== null) { + $throwPoints[] = $throwPoint; + } + } + } $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 03e5554b19c..fd600663ef0 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -748,4 +748,18 @@ public function testBug13806(): void ]); } + public function testBug5952(): void + { + $this->analyse([__DIR__ . '/data/bug-5952.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 51, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 57, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-5952.php b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php new file mode 100644 index 00000000000..3ea488becc5 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php @@ -0,0 +1,59 @@ += 7.4 + +namespace Bug5952; + +class Foo +{ + public function __toString(): string + { + throw new \Exception(); + } +} + +$foo = new Foo(); + +try { + echo $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +class Bar +{ + /** @throws \Exception */ + public function __toString(): string + { + throw new \Exception(); + } +} + +$bar = new Bar(); + +try { + echo $bar; +} catch (\Exception $e) { + echo "Should be printed"; +} + +class Baz +{ + /** @throws void */ + public function __toString(): string + { + return 'hello'; + } +} + +$baz = new Baz(); + +try { + echo $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +try { + echo 123; +} catch (\Exception $e) { + echo "Should not be printed"; +} From 546a487d3ef92f59a91b9dc5d726fc3d8e0f3e19 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 22:37:56 +0000 Subject: [PATCH 2/7] Simulate MethodCall instead of calling MethodThrowPointHelper directly Address review feedback: process a synthetic MethodCall expression through processExprNode() instead of directly calling MethodThrowPointHelper. This leverages the existing MethodCallHandler infrastructure for __toString() throw point tracking in echo statements. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0cf2cebe5c7..c83c92b0481 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -864,7 +864,6 @@ public function processStmtNode( $throwPoints = []; $isAlwaysTerminating = false; $phpVersion = $this->container->getByType(PhpVersion::class); - $methodThrowPointHelper = $this->container->getByType(ExprHandler\Helper\MethodThrowPointHelper::class); foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -872,15 +871,15 @@ public function processStmtNode( $exprType = $scope->getType($echoExpr); $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); if ($toStringMethod !== null) { - $throwPoint = $methodThrowPointHelper->getThrowPoint( - $toStringMethod, - $toStringMethod->getOnlyVariant(), + $toStringResult = $this->processExprNode( + $stmt, new Expr\MethodCall($echoExpr, new Identifier('__toString')), $scope, + new ExpressionResultStorage(), + new NoopNodeCallback(), + ExpressionContext::createDeep(), ); - if ($throwPoint !== null) { - $throwPoints[] = $throwPoint; - } + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); } } $scope = $result->getScope(); From a39518e2886ae48d5e82f4a792400ac8dff21892 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 22:50:08 +0000 Subject: [PATCH 3/7] Remove redundant phpVersion check and add union type tests The phpVersion->throwsOnStringCast() check is unnecessary because the toString resolution already handles the PHP version gate internally. Also adds regression tests for echo on union types (int|Foo, int|Bar, int|Baz) as requested in review. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 28 ++++++++----------- .../CatchWithUnthrownExceptionRuleTest.php | 4 +++ .../Rules/Exceptions/data/bug-5952.php | 27 ++++++++++++++++++ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c83c92b0481..3e6626bf896 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -110,7 +110,6 @@ use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\LineAttributesVisitor; use PHPStan\Parser\Parser; -use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\PhpDocInheritanceResolver; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\Tag\VarTag; @@ -863,24 +862,21 @@ public function processStmtNode( $hasYield = false; $throwPoints = []; $isAlwaysTerminating = false; - $phpVersion = $this->container->getByType(PhpVersion::class); foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); - if ($phpVersion->throwsOnStringCast()) { - $exprType = $scope->getType($echoExpr); - $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); - if ($toStringMethod !== null) { - $toStringResult = $this->processExprNode( - $stmt, - new Expr\MethodCall($echoExpr, new Identifier('__toString')), - $scope, - new ExpressionResultStorage(), - new NoopNodeCallback(), - ExpressionContext::createDeep(), - ); - $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); - } + $exprType = $scope->getType($echoExpr); + $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); + if ($toStringMethod !== null) { + $toStringResult = $this->processExprNode( + $stmt, + new Expr\MethodCall($echoExpr, new Identifier('__toString')), + $scope, + new ExpressionResultStorage(), + new NoopNodeCallback(), + ExpressionContext::createDeep(), + ); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); } $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index fd600663ef0..99d7f5f0c58 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -759,6 +759,10 @@ public function testBug5952(): void 'Dead catch - Exception is never thrown in the try block.', 57, ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 84, + ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-5952.php b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php index 3ea488becc5..679518a19b5 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-5952.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php @@ -57,3 +57,30 @@ public function __toString(): string } catch (\Exception $e) { echo "Should not be printed"; } + +/** @var int|Foo $intOrFoo */ +$intOrFoo = doFoo(); + +try { + echo $intOrFoo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +/** @var int|Bar $intOrBar */ +$intOrBar = doFoo(); + +try { + echo $intOrBar; +} catch (\Exception $e) { + echo "Should be printed"; +} + +/** @var int|Baz $intOrBaz */ +$intOrBaz = doFoo(); + +try { + echo $intOrBaz; +} catch (\Exception $e) { + echo "Should not be printed"; +} From a85d75bd4975525a36e44b70e992601eda8ddafa Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 23:18:37 +0000 Subject: [PATCH 4/7] Generalize __toString() throw point tracking to all implicit call sites Extract ToStringThrowPointHelper to handle __toString() throw points and impure points in a reusable way, and apply it to all places where PHP implicitly calls __toString(): - echo (NodeScopeResolver) - print (PrintHandler) - string concatenation . (BinaryOpHandler) - concat assignment .= (AssignOpHandler) - string interpolation "$obj" / "{$obj}" (InterpolatedStringHandler) - explicit (string) cast (CastStringHandler) - refactored to use helper Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignOpHandler.php | 11 ++- src/Analyser/ExprHandler/BinaryOpHandler.php | 11 ++- .../ExprHandler/CastStringHandler.php | 38 ++--------- .../Helper/ToStringThrowPointHelper.php | 64 ++++++++++++++++++ .../ExprHandler/InterpolatedStringHandler.php | 7 ++ src/Analyser/ExprHandler/PrintHandler.php | 18 ++++- src/Analyser/NodeScopeResolver.php | 17 ++--- src/Testing/RuleTestCase.php | 1 + src/Testing/TypeInferenceTestCase.php | 1 + tests/PHPStan/Analyser/AnalyserTest.php | 1 + .../Fiber/FiberNodeScopeResolverRuleTest.php | 1 + .../Fiber/FiberNodeScopeResolverTest.php | 1 + .../CatchWithUnthrownExceptionRuleTest.php | 20 ++++++ .../Rules/Exceptions/data/bug-5952.php | 67 +++++++++++++++++++ 14 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 70e28b85895..def74e3aaf0 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -22,6 +23,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use function array_merge; use function get_class; use function sprintf; @@ -35,6 +37,7 @@ final class AssignOpHandler implements ExprHandler public function __construct( private AssignHandler $assignHandler, private InitializerExprTypeResolver $initializerExprTypeResolver, + private ToStringThrowPointHelper $toStringThrowPointHelper, ) { } @@ -85,19 +88,25 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex } $scope = $assignResult->getScope(); $throwPoints = $assignResult->getThrowPoints(); + $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } + if ($expr instanceof Expr\AssignOp\Concat) { + [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringThrowPoints); + $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + } return new ExpressionResult( $scope, hasYield: $assignResult->hasYield(), isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, - impurePoints: $assignResult->getImpurePoints(), + impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index b9d0908f78f..5a38ba952a9 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -42,6 +43,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, private PhpVersion $phpVersion, + private ToStringThrowPointHelper $toStringThrowPointHelper, ) { } @@ -62,12 +64,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftResult->getScope(), $storage, $nodeCallback, $context->enterDeep()); $throwPoints = array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()); + $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && !$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } + if ($expr instanceof BinaryOp\Concat) { + [$leftToStringThrowPoints, $leftToStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->left, $scope); + [$rightToStringThrowPoints, $rightToStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->right, $leftResult->getScope()); + $throwPoints = array_merge($throwPoints, $leftToStringThrowPoints, $rightToStringThrowPoints); + $impurePoints = array_merge($impurePoints, $leftToStringImpurePoints, $rightToStringImpurePoints); + } $scope = $rightResult->getScope(); return new ExpressionResult( @@ -75,7 +84,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, - impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), + impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 336e14dd921..5d8c07d229e 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -4,21 +4,18 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\Cast; -use PhpParser\Node\Identifier; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; -use function sprintf; +use function array_merge; /** * @implements ExprHandler @@ -29,8 +26,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, - private PhpVersion $phpVersion, - private MethodThrowPointHelper $methodThrowPointHelper, + private ToStringThrowPointHelper $toStringThrowPointHelper, ) { } @@ -46,31 +42,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $exprType = $scope->getType($expr->expr); - $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); - if ($toStringMethod !== null) { - if (!$toStringMethod->hasSideEffects()->no()) { - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'methodCall', - sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()), - $toStringMethod->isPure()->no(), - ); - } - - if ($this->phpVersion->throwsOnStringCast()) { - $throwPoint = $this->methodThrowPointHelper->getThrowPoint( - $toStringMethod, - $toStringMethod->getOnlyVariant(), - new Expr\MethodCall($expr->expr, new Identifier('__toString')), - $scope, - ); - if ($throwPoint !== null) { - $throwPoints[] = $throwPoint; - } - } - } + [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringThrowPoints); + $impurePoints = array_merge($impurePoints, $toStringImpurePoints); $scope = $exprResult->getScope(); diff --git a/src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php new file mode 100644 index 00000000000..8f8013d192f --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php @@ -0,0 +1,64 @@ +, list} + */ + public function getToStringThrowAndImpurePoints(Expr $expr, MutatingScope $scope): array + { + $throwPoints = []; + $impurePoints = []; + + $exprType = $scope->getType($expr); + $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); + if ($toStringMethod === null) { + return [[], []]; + } + + if (!$toStringMethod->hasSideEffects()->no()) { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'methodCall', + sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()), + $toStringMethod->isPure()->no(), + ); + } + + if ($this->phpVersion->throwsOnStringCast()) { + $throwPoint = $this->methodThrowPointHelper->getThrowPoint( + $toStringMethod, + $toStringMethod->getOnlyVariant(), + new Expr\MethodCall($expr, new Identifier('__toString')), + $scope, + ); + if ($throwPoint !== null) { + $throwPoints[] = $throwPoint; + } + } + + return [$throwPoints, $impurePoints]; + } + +} diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 24e4dcab0ec..f2c730f9213 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; @@ -27,6 +28,7 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ToStringThrowPointHelper $toStringThrowPointHelper, ) { } @@ -50,6 +52,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $partResult->hasYield(); $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); + + [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($part, $scope); + $throwPoints = array_merge($throwPoints, $toStringThrowPoints); + $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + $isAlwaysTerminating = $isAlwaysTerminating || $partResult->isAlwaysTerminating(); $scope = $partResult->getScope(); } diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 71f0e2d8c13..6491d4b0455 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -24,6 +25,12 @@ final class PrintHandler implements ExprHandler { + public function __construct( + private ToStringThrowPointHelper $toStringThrowPointHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Print_; @@ -37,14 +44,21 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $throwPoints = $exprResult->getThrowPoints(); + $impurePoints = $exprResult->getImpurePoints(); + + [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringThrowPoints); + $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + $scope = $exprResult->getScope(); return new ExpressionResult( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), - throwPoints: $exprResult->getThrowPoints(), - impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'print', 'print', true)]), + throwPoints: $throwPoints, + impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3e6626bf896..0ed17b64d9d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -49,6 +49,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; +use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -236,6 +237,7 @@ public function __construct( private readonly bool $implicitThrows, #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, + private readonly ToStringThrowPointHelper $toStringThrowPointHelper, ) { $earlyTerminatingMethodNames = []; @@ -865,19 +867,8 @@ public function processStmtNode( foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); - $exprType = $scope->getType($echoExpr); - $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); - if ($toStringMethod !== null) { - $toStringResult = $this->processExprNode( - $stmt, - new Expr\MethodCall($echoExpr, new Identifier('__toString')), - $scope, - new ExpressionResultStorage(), - new NoopNodeCallback(), - ExpressionContext::createDeep(), - ); - $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); - } + [$toStringThrowPoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($echoExpr, $scope); + $throwPoints = array_merge($throwPoints, $toStringThrowPoints); $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 0565407424e..b90e806074e 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -116,6 +116,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), + self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index fb793c45c91..f012d95015c 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -91,6 +91,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), + $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), ); } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 27f9daddb07..f76c8540095 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -832,6 +832,7 @@ private function createAnalyser(): Analyser [], true, $this->shouldTreatPhpDocTypesAsCertain(), + $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index e963de6bc98..523fb536d2c 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -136,6 +136,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), + self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 5bea8cce792..90721cece7b 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -69,6 +69,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), + $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), ); } diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 99d7f5f0c58..86615a292dc 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -763,6 +763,26 @@ public function testBug5952(): void 'Dead catch - Exception is never thrown in the try block.', 84, ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 97, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 110, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 125, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 138, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 151, + ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-5952.php b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php index 679518a19b5..9c8cc81a15c 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-5952.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php @@ -84,3 +84,70 @@ public function __toString(): string } catch (\Exception $e) { echo "Should not be printed"; } + +// print statement +try { + print $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + print $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// String concatenation +try { + $x = 'hello' . $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = 'hello' . $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// Concat assignment +try { + $x = 'hello'; + $x .= $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = 'hello'; + $x .= $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// String interpolation +try { + $x = "hello $foo"; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = "hello $baz"; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// String interpolation with curly braces +try { + $x = "hello {$foo}"; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = "hello {$baz}"; +} catch (\Exception $e) { + echo "Should not be printed"; +} From 879b943d6385dfef05e78441393f6ebb46f100be Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 08:24:00 +0000 Subject: [PATCH 5/7] Rename ToStringThrowPointHelper to ImplicitToStringCallHelper and return ExpressionResult Rename the helper to better describe its purpose and change the return type from array{list, list} to ExpressionResult for a cleaner API at all call sites. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignOpHandler.php | 10 ++++---- src/Analyser/ExprHandler/BinaryOpHandler.php | 12 ++++----- .../ExprHandler/CastStringHandler.php | 10 ++++---- ...per.php => ImplicitToStringCallHelper.php} | 25 +++++++++++++------ .../ExprHandler/InterpolatedStringHandler.php | 10 ++++---- src/Analyser/ExprHandler/PrintHandler.php | 10 ++++---- src/Analyser/NodeScopeResolver.php | 8 +++--- src/Testing/RuleTestCase.php | 2 +- src/Testing/TypeInferenceTestCase.php | 2 +- tests/PHPStan/Analyser/AnalyserTest.php | 2 +- .../Fiber/FiberNodeScopeResolverRuleTest.php | 2 +- .../Fiber/FiberNodeScopeResolverTest.php | 2 +- 12 files changed, 52 insertions(+), 43 deletions(-) rename src/Analyser/ExprHandler/Helper/{ToStringThrowPointHelper.php => ImplicitToStringCallHelper.php} (72%) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index def74e3aaf0..b7eb0e843e5 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -13,7 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -37,7 +37,7 @@ final class AssignOpHandler implements ExprHandler public function __construct( private AssignHandler $assignHandler, private InitializerExprTypeResolver $initializerExprTypeResolver, - private ToStringThrowPointHelper $toStringThrowPointHelper, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -96,9 +96,9 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof Expr\AssignOp\Concat) { - [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->expr, $scope); - $throwPoints = array_merge($throwPoints, $toStringThrowPoints); - $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } return new ExpressionResult( diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 5a38ba952a9..6f9fbbb71cb 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -13,7 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -43,7 +43,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, private PhpVersion $phpVersion, - private ToStringThrowPointHelper $toStringThrowPointHelper, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -72,10 +72,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof BinaryOp\Concat) { - [$leftToStringThrowPoints, $leftToStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->left, $scope); - [$rightToStringThrowPoints, $rightToStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->right, $leftResult->getScope()); - $throwPoints = array_merge($throwPoints, $leftToStringThrowPoints, $rightToStringThrowPoints); - $impurePoints = array_merge($impurePoints, $leftToStringImpurePoints, $rightToStringImpurePoints); + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope()); + $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); } $scope = $rightResult->getScope(); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 5d8c07d229e..b4e119e01f5 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -9,7 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; @@ -26,7 +26,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, - private ToStringThrowPointHelper $toStringThrowPointHelper, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -42,9 +42,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->expr, $scope); - $throwPoints = array_merge($throwPoints, $toStringThrowPoints); - $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $exprResult->getScope(); diff --git a/src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php similarity index 72% rename from src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php rename to src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 8f8013d192f..1b66bc7ba38 100644 --- a/src/Analyser/ExprHandler/Helper/ToStringThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -4,15 +4,15 @@ use PhpParser\Node\Expr; use PhpParser\Node\Identifier; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ImpurePoint; -use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use function sprintf; #[AutowiredService] -final class ToStringThrowPointHelper +final class ImplicitToStringCallHelper { public function __construct( @@ -22,10 +22,7 @@ public function __construct( { } - /** - * @return array{list, list} - */ - public function getToStringThrowAndImpurePoints(Expr $expr, MutatingScope $scope): array + public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult { $throwPoints = []; $impurePoints = []; @@ -33,7 +30,13 @@ public function getToStringThrowAndImpurePoints(Expr $expr, MutatingScope $scope $exprType = $scope->getType($expr); $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); if ($toStringMethod === null) { - return [[], []]; + return new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + ); } if (!$toStringMethod->hasSideEffects()->no()) { @@ -58,7 +61,13 @@ public function getToStringThrowAndImpurePoints(Expr $expr, MutatingScope $scope } } - return [$throwPoints, $impurePoints]; + return new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + ); } } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index f2c730f9213..bb18cff3e09 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,7 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; @@ -28,7 +28,7 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, - private ToStringThrowPointHelper $toStringThrowPointHelper, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -53,9 +53,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($part, $scope); - $throwPoints = array_merge($throwPoints, $toStringThrowPoints); - $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $partResult->isAlwaysTerminating(); $scope = $partResult->getScope(); diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 6491d4b0455..18ab04d8d29 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,7 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -26,7 +26,7 @@ final class PrintHandler implements ExprHandler { public function __construct( - private ToStringThrowPointHelper $toStringThrowPointHelper, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -47,9 +47,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); - [$toStringThrowPoints, $toStringImpurePoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($expr->expr, $scope); - $throwPoints = array_merge($throwPoints, $toStringThrowPoints); - $impurePoints = array_merge($impurePoints, $toStringImpurePoints); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $exprResult->getScope(); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0ed17b64d9d..7ea16802ffd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -49,7 +49,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; -use PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -237,7 +237,7 @@ public function __construct( private readonly bool $implicitThrows, #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, - private readonly ToStringThrowPointHelper $toStringThrowPointHelper, + private readonly ImplicitToStringCallHelper $implicitToStringCallHelper, ) { $earlyTerminatingMethodNames = []; @@ -867,8 +867,8 @@ public function processStmtNode( foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); - [$toStringThrowPoints] = $this->toStringThrowPointHelper->getToStringThrowAndImpurePoints($echoExpr, $scope); - $throwPoints = array_merge($throwPoints, $toStringThrowPoints); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index b90e806074e..18dd06bdf04 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -116,7 +116,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), - self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), + self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index f012d95015c..c033fb1f9ae 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -91,7 +91,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), - $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), + $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), ); } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index f76c8540095..98117315364 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -832,7 +832,7 @@ private function createAnalyser(): Analyser [], true, $this->shouldTreatPhpDocTypesAsCertain(), - $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), + $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 523fb536d2c..a3e65033492 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -136,7 +136,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), - self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), + self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 90721cece7b..922bd7848cd 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -69,7 +69,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), - $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ToStringThrowPointHelper::class), + $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), ); } From 281d0395450aa137aff3773aba3ba6c210db1e7f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 08:59:38 +0000 Subject: [PATCH 6/7] Use import statement for ImplicitToStringCallHelper instead of fully qualified name Co-Authored-By: Claude Opus 4.6 --- src/Testing/RuleTestCase.php | 3 ++- src/Testing/TypeInferenceTestCase.php | 3 ++- tests/PHPStan/Analyser/AnalyserTest.php | 3 ++- .../PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php | 3 ++- tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 18dd06bdf04..d8d0ba20e1c 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Analyser; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\FileAnalyser; use PHPStan\Analyser\IgnoreErrorExtensionProvider; @@ -116,7 +117,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), - self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), + self::getContainer()->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index c033fb1f9ae..7723560fd06 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -91,7 +92,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), - $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), + $container->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 98117315364..39da6a5be9e 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -6,6 +6,7 @@ use PhpParser\Lexer; use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser\Php7; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\Ignore\IgnoreLexer; use PHPStan\Collectors\Registry as CollectorRegistry; @@ -832,7 +833,7 @@ private function createAnalyser(): Analyser [], true, $this->shouldTreatPhpDocTypesAsCertain(), - $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), + $container->getByType(ImplicitToStringCallHelper::class), ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index a3e65033492..85ee19ddd05 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\Fiber; use PhpParser\Node; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; @@ -136,7 +137,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), - self::getContainer()->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), + self::getContainer()->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 922bd7848cd..952730be751 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\Fiber; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; @@ -69,7 +70,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), - $container->getByType(\PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper::class), + $container->getByType(ImplicitToStringCallHelper::class), ); } From 0e0de1cf3d59d9f71c2e45527758f54dfd5d05a7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 10:11:15 +0000 Subject: [PATCH 7/7] Collect impure points from implicit __toString() in echo handler and add purity tests The echo statement handler in NodeScopeResolver was not collecting impure points from implicit __toString() calls or from sub-expression processing. Also adds purity tests for all implicit __toString() call sites: echo, print, string concatenation, concat assignment, and string interpolation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 7 +- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 76 ++++++++++++++++++- tests/PHPStan/Rules/Pure/data/pure-method.php | 75 ++++++++++++++++++ 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7ea16802ffd..f616b644ab3 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -863,21 +863,22 @@ public function processStmtNode( } elseif ($stmt instanceof Echo_) { $hasYield = false; $throwPoints = []; + $impurePoints = []; $isAlwaysTerminating = false; foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); } $throwPoints = $overridingThrowPoints ?? $throwPoints; - $impurePoints = [ - new ImpurePoint($scope, $stmt, 'echo', 'echo', true), - ]; + $impurePoints[] = new ImpurePoint($scope, $stmt, 'echo', 'echo', true); return new InternalStatementResult($scope, $hasYield, $isAlwaysTerminating, [], $throwPoints, $impurePoints); } elseif ($stmt instanceof Return_) { if ($stmt->expr !== null) { diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 9c1a95adc9f..aea4a376c12 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -133,21 +133,89 @@ public function testRule(): void 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doFoo().', 296, ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 309, + ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 310, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doEcho().', + 311, + ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 311, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doEcho().', + 312, + ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 312, + ], + [ + 'Impure print in pure method PureMethod\TestMagicMethods::doPrint().', + 324, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doPrint().', + 325, + ], + [ + 'Impure print in pure method PureMethod\TestMagicMethods::doPrint().', + 325, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doPrint().', + 326, + ], + [ + 'Impure print in pure method PureMethod\TestMagicMethods::doPrint().', + 326, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcat().', + 339, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcat().', + 340, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcatAssign().', + 355, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcatAssign().', + 357, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doInterpolation().', + 370, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doInterpolation().', + 371, + ], [ 'Possibly impure call to a callable in pure method PureMethod\MaybeCallableFromUnion::doFoo().', - 330, + 405, ], [ 'Possibly impure call to a callable in pure method PureMethod\MaybeCallableFromUnion::doFoo().', - 330, + 405, ], [ 'Impure static property access in pure method PureMethod\StaticMethodAccessingStaticProperty::getA().', - 388, + 463, ], [ 'Impure property assignment in pure method PureMethod\StaticMethodAssigningStaticProperty::getA().', - 409, + 484, ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-method.php b/tests/PHPStan/Rules/Pure/data/pure-method.php index eca2976cda5..761afcd73b5 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-method.php +++ b/tests/PHPStan/Rules/Pure/data/pure-method.php @@ -296,6 +296,81 @@ public function doFoo( (string) $impure; } + /** + * @phpstan-pure + */ + public function doEcho( + NoMagicMethods $no, + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + echo $no; + echo $pure; + echo $maybe; + echo $impure; + } + + /** + * @phpstan-pure + */ + public function doPrint( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + print $pure; + print $maybe; + print $impure; + } + + /** + * @phpstan-pure + */ + public function doConcat( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + 'hello' . $pure; + 'hello' . $maybe; + 'hello' . $impure; + } + + /** + * @phpstan-pure + */ + public function doConcatAssign( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + $x = 'hello'; + $x .= $pure; + $x = 'hello'; + $x .= $maybe; + $x = 'hello'; + $x .= $impure; + } + + /** + * @phpstan-pure + */ + public function doInterpolation( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + "hello $pure"; + "hello $maybe"; + "hello $impure"; + } + } final class NoConstructor