Skip to content

Commit 7ebe2f2

Browse files
phpstan-botVincentLangletclaude
authored
Fix phpstan/phpstan#5952: Allow throwing exceptions from __toString() not detected since level 4 (#5405)
Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bfe7fd3 commit 7ebe2f2

16 files changed

Lines changed: 478 additions & 43 deletions

src/Analyser/ExprHandler/AssignOpHandler.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\Analyser\ExpressionResult;
1414
use PHPStan\Analyser\ExpressionResultStorage;
1515
use PHPStan\Analyser\ExprHandler;
16+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
1617
use PHPStan\Analyser\InternalThrowPoint;
1718
use PHPStan\Analyser\MutatingScope;
1819
use PHPStan\Analyser\NodeScopeResolver;
@@ -22,6 +23,7 @@
2223
use PHPStan\Type\Constant\ConstantIntegerType;
2324
use PHPStan\Type\ObjectType;
2425
use PHPStan\Type\Type;
26+
use function array_merge;
2527
use function get_class;
2628
use function sprintf;
2729

@@ -35,6 +37,7 @@ final class AssignOpHandler implements ExprHandler
3537
public function __construct(
3638
private AssignHandler $assignHandler,
3739
private InitializerExprTypeResolver $initializerExprTypeResolver,
40+
private ImplicitToStringCallHelper $implicitToStringCallHelper,
3841
)
3942
{
4043
}
@@ -85,19 +88,25 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex
8588
}
8689
$scope = $assignResult->getScope();
8790
$throwPoints = $assignResult->getThrowPoints();
91+
$impurePoints = $assignResult->getImpurePoints();
8892
if (
8993
($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) &&
9094
!$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no()
9195
) {
9296
$throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false);
9397
}
98+
if ($expr instanceof Expr\AssignOp\Concat) {
99+
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
100+
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
101+
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
102+
}
94103

95104
return new ExpressionResult(
96105
$scope,
97106
hasYield: $assignResult->hasYield(),
98107
isAlwaysTerminating: $assignResult->isAlwaysTerminating(),
99108
throwPoints: $throwPoints,
100-
impurePoints: $assignResult->getImpurePoints(),
109+
impurePoints: $impurePoints,
101110
truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr),
102111
falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr),
103112
);

src/Analyser/ExprHandler/BinaryOpHandler.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\Analyser\ExpressionResult;
1414
use PHPStan\Analyser\ExpressionResultStorage;
1515
use PHPStan\Analyser\ExprHandler;
16+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
1617
use PHPStan\Analyser\InternalThrowPoint;
1718
use PHPStan\Analyser\MutatingScope;
1819
use PHPStan\Analyser\NodeScopeResolver;
@@ -42,6 +43,7 @@ public function __construct(
4243
private InitializerExprTypeResolver $initializerExprTypeResolver,
4344
private RicherScopeGetTypeHelper $richerScopeGetTypeHelper,
4445
private PhpVersion $phpVersion,
46+
private ImplicitToStringCallHelper $implicitToStringCallHelper,
4547
)
4648
{
4749
}
@@ -62,20 +64,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
6264
$leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep());
6365
$rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftResult->getScope(), $storage, $nodeCallback, $context->enterDeep());
6466
$throwPoints = array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints());
67+
$impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints());
6568
if (
6669
($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) &&
6770
!$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no()
6871
) {
6972
$throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false);
7073
}
74+
if ($expr instanceof BinaryOp\Concat) {
75+
$leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope);
76+
$rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope());
77+
$throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints());
78+
$impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints());
79+
}
7180
$scope = $rightResult->getScope();
7281

7382
return new ExpressionResult(
7483
$scope,
7584
hasYield: $leftResult->hasYield() || $rightResult->hasYield(),
7685
isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(),
7786
throwPoints: $throwPoints,
78-
impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()),
87+
impurePoints: $impurePoints,
7988
truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr),
8089
falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr),
8190
);

src/Analyser/ExprHandler/CastStringHandler.php

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,18 @@
44

55
use PhpParser\Node\Expr;
66
use PhpParser\Node\Expr\Cast;
7-
use PhpParser\Node\Identifier;
87
use PhpParser\Node\Stmt;
98
use PHPStan\Analyser\ExpressionContext;
109
use PHPStan\Analyser\ExpressionResult;
1110
use PHPStan\Analyser\ExpressionResultStorage;
1211
use PHPStan\Analyser\ExprHandler;
13-
use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper;
14-
use PHPStan\Analyser\ImpurePoint;
12+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
1513
use PHPStan\Analyser\MutatingScope;
1614
use PHPStan\Analyser\NodeScopeResolver;
1715
use PHPStan\DependencyInjection\AutowiredService;
18-
use PHPStan\Php\PhpVersion;
1916
use PHPStan\Reflection\InitializerExprTypeResolver;
2017
use PHPStan\Type\Type;
21-
use function sprintf;
18+
use function array_merge;
2219

2320
/**
2421
* @implements ExprHandler<Cast\String_>
@@ -29,8 +26,7 @@ final class CastStringHandler implements ExprHandler
2926

3027
public function __construct(
3128
private InitializerExprTypeResolver $initializerExprTypeResolver,
32-
private PhpVersion $phpVersion,
33-
private MethodThrowPointHelper $methodThrowPointHelper,
29+
private ImplicitToStringCallHelper $implicitToStringCallHelper,
3430
)
3531
{
3632
}
@@ -46,31 +42,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
4642
$impurePoints = $exprResult->getImpurePoints();
4743
$throwPoints = $exprResult->getThrowPoints();
4844

49-
$exprType = $scope->getType($expr->expr);
50-
$toStringMethod = $scope->getMethodReflection($exprType, '__toString');
51-
if ($toStringMethod !== null) {
52-
if (!$toStringMethod->hasSideEffects()->no()) {
53-
$impurePoints[] = new ImpurePoint(
54-
$scope,
55-
$expr,
56-
'methodCall',
57-
sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()),
58-
$toStringMethod->isPure()->no(),
59-
);
60-
}
61-
62-
if ($this->phpVersion->throwsOnStringCast()) {
63-
$throwPoint = $this->methodThrowPointHelper->getThrowPoint(
64-
$toStringMethod,
65-
$toStringMethod->getOnlyVariant(),
66-
new Expr\MethodCall($expr->expr, new Identifier('__toString')),
67-
$scope,
68-
);
69-
if ($throwPoint !== null) {
70-
$throwPoints[] = $throwPoint;
71-
}
72-
}
73-
}
45+
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
46+
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
47+
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
7448

7549
$scope = $exprResult->getScope();
7650

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\ExprHandler\Helper;
4+
5+
use PhpParser\Node\Expr;
6+
use PhpParser\Node\Identifier;
7+
use PHPStan\Analyser\ExpressionResult;
8+
use PHPStan\Analyser\ImpurePoint;
9+
use PHPStan\Analyser\MutatingScope;
10+
use PHPStan\DependencyInjection\AutowiredService;
11+
use PHPStan\Php\PhpVersion;
12+
use function sprintf;
13+
14+
#[AutowiredService]
15+
final class ImplicitToStringCallHelper
16+
{
17+
18+
public function __construct(
19+
private PhpVersion $phpVersion,
20+
private MethodThrowPointHelper $methodThrowPointHelper,
21+
)
22+
{
23+
}
24+
25+
public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult
26+
{
27+
$throwPoints = [];
28+
$impurePoints = [];
29+
30+
$exprType = $scope->getType($expr);
31+
$toStringMethod = $scope->getMethodReflection($exprType, '__toString');
32+
if ($toStringMethod === null) {
33+
return new ExpressionResult(
34+
$scope,
35+
hasYield: false,
36+
isAlwaysTerminating: false,
37+
throwPoints: [],
38+
impurePoints: [],
39+
);
40+
}
41+
42+
if (!$toStringMethod->hasSideEffects()->no()) {
43+
$impurePoints[] = new ImpurePoint(
44+
$scope,
45+
$expr,
46+
'methodCall',
47+
sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()),
48+
$toStringMethod->isPure()->no(),
49+
);
50+
}
51+
52+
if ($this->phpVersion->throwsOnStringCast()) {
53+
$throwPoint = $this->methodThrowPointHelper->getThrowPoint(
54+
$toStringMethod,
55+
$toStringMethod->getOnlyVariant(),
56+
new Expr\MethodCall($expr, new Identifier('__toString')),
57+
$scope,
58+
);
59+
if ($throwPoint !== null) {
60+
$throwPoints[] = $throwPoint;
61+
}
62+
}
63+
64+
return new ExpressionResult(
65+
$scope,
66+
hasYield: false,
67+
isAlwaysTerminating: false,
68+
throwPoints: $throwPoints,
69+
impurePoints: $impurePoints,
70+
);
71+
}
72+
73+
}

src/Analyser/ExprHandler/InterpolatedStringHandler.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Analyser\ExpressionResult;
1111
use PHPStan\Analyser\ExpressionResultStorage;
1212
use PHPStan\Analyser\ExprHandler;
13+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
1314
use PHPStan\Analyser\MutatingScope;
1415
use PHPStan\Analyser\NodeScopeResolver;
1516
use PHPStan\DependencyInjection\AutowiredService;
@@ -27,6 +28,7 @@ final class InterpolatedStringHandler implements ExprHandler
2728

2829
public function __construct(
2930
private InitializerExprTypeResolver $initializerExprTypeResolver,
31+
private ImplicitToStringCallHelper $implicitToStringCallHelper,
3032
)
3133
{
3234
}
@@ -50,6 +52,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
5052
$hasYield = $hasYield || $partResult->hasYield();
5153
$throwPoints = array_merge($throwPoints, $partResult->getThrowPoints());
5254
$impurePoints = array_merge($impurePoints, $partResult->getImpurePoints());
55+
56+
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope);
57+
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
58+
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
59+
5360
$isAlwaysTerminating = $isAlwaysTerminating || $partResult->isAlwaysTerminating();
5461
$scope = $partResult->getScope();
5562
}

src/Analyser/ExprHandler/PrintHandler.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\Analyser\ExpressionResult;
1010
use PHPStan\Analyser\ExpressionResultStorage;
1111
use PHPStan\Analyser\ExprHandler;
12+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
1213
use PHPStan\Analyser\ImpurePoint;
1314
use PHPStan\Analyser\MutatingScope;
1415
use PHPStan\Analyser\NodeScopeResolver;
@@ -24,6 +25,12 @@
2425
final class PrintHandler implements ExprHandler
2526
{
2627

28+
public function __construct(
29+
private ImplicitToStringCallHelper $implicitToStringCallHelper,
30+
)
31+
{
32+
}
33+
2734
public function supports(Expr $expr): bool
2835
{
2936
return $expr instanceof Print_;
@@ -37,14 +44,21 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
3744
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
3845
{
3946
$exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep());
47+
$throwPoints = $exprResult->getThrowPoints();
48+
$impurePoints = $exprResult->getImpurePoints();
49+
50+
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
51+
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
52+
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
53+
4054
$scope = $exprResult->getScope();
4155

4256
return new ExpressionResult(
4357
$scope,
4458
hasYield: $exprResult->hasYield(),
4559
isAlwaysTerminating: $exprResult->isAlwaysTerminating(),
46-
throwPoints: $exprResult->getThrowPoints(),
47-
impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'print', 'print', true)]),
60+
throwPoints: $throwPoints,
61+
impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]),
4862
);
4963
}
5064

src/Analyser/NodeScopeResolver.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use PhpParser\NodeTraverser;
5050
use PhpParser\NodeVisitorAbstract;
5151
use PHPStan\Analyser\ExprHandler\AssignHandler;
52+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
5253
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass;
5354
use PHPStan\BetterReflection\Reflection\ReflectionEnum;
5455
use PHPStan\BetterReflection\Reflector\Reflector;
@@ -236,6 +237,7 @@ public function __construct(
236237
private readonly bool $implicitThrows,
237238
#[AutowiredParameter]
238239
private readonly bool $treatPhpDocTypesAsCertain,
240+
private readonly ImplicitToStringCallHelper $implicitToStringCallHelper,
239241
)
240242
{
241243
$earlyTerminatingMethodNames = [];
@@ -861,19 +863,22 @@ public function processStmtNode(
861863
} elseif ($stmt instanceof Echo_) {
862864
$hasYield = false;
863865
$throwPoints = [];
866+
$impurePoints = [];
864867
$isAlwaysTerminating = false;
865868
foreach ($stmt->exprs as $echoExpr) {
866869
$result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep());
867870
$throwPoints = array_merge($throwPoints, $result->getThrowPoints());
871+
$impurePoints = array_merge($impurePoints, $result->getImpurePoints());
872+
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope);
873+
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
874+
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
868875
$scope = $result->getScope();
869876
$hasYield = $hasYield || $result->hasYield();
870877
$isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating();
871878
}
872879

873880
$throwPoints = $overridingThrowPoints ?? $throwPoints;
874-
$impurePoints = [
875-
new ImpurePoint($scope, $stmt, 'echo', 'echo', true),
876-
];
881+
$impurePoints[] = new ImpurePoint($scope, $stmt, 'echo', 'echo', true);
877882
return new InternalStatementResult($scope, $hasYield, $isAlwaysTerminating, [], $throwPoints, $impurePoints);
878883
} elseif ($stmt instanceof Return_) {
879884
if ($stmt->expr !== null) {

src/Testing/RuleTestCase.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PHPStan\Analyser\Analyser;
77
use PHPStan\Analyser\AnalyserResultFinalizer;
88
use PHPStan\Analyser\Error;
9+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
910
use PHPStan\Analyser\Fiber\FiberNodeScopeResolver;
1011
use PHPStan\Analyser\FileAnalyser;
1112
use PHPStan\Analyser\IgnoreErrorExtensionProvider;
@@ -116,6 +117,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
116117
[],
117118
self::getContainer()->getParameter('exceptions')['implicitThrows'],
118119
$this->shouldTreatPhpDocTypesAsCertain(),
120+
self::getContainer()->getByType(ImplicitToStringCallHelper::class),
119121
);
120122
}
121123

src/Testing/TypeInferenceTestCase.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PhpParser\Node;
77
use PhpParser\Node\Expr\StaticCall;
88
use PhpParser\Node\Name;
9+
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
910
use PHPStan\Analyser\Fiber\FiberNodeScopeResolver;
1011
use PHPStan\Analyser\MutatingScope;
1112
use PHPStan\Analyser\NodeScopeResolver;
@@ -91,6 +92,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
9192
static::getEarlyTerminatingFunctionCalls(),
9293
$container->getParameter('exceptions')['implicitThrows'],
9394
$container->getParameter('treatPhpDocTypesAsCertain'),
95+
$container->getByType(ImplicitToStringCallHelper::class),
9496
);
9597
}
9698

0 commit comments

Comments
 (0)