Skip to content
11 changes: 10 additions & 1 deletion src/Analyser/ExprHandler/AssignOpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
Expand All @@ -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;

Expand All @@ -35,6 +37,7 @@ final class AssignOpHandler implements ExprHandler
public function __construct(
private AssignHandler $assignHandler,
private InitializerExprTypeResolver $initializerExprTypeResolver,
private ImplicitToStringCallHelper $implicitToStringCallHelper,
)
{
}
Expand Down Expand Up @@ -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) {
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
}

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),
);
Expand Down
11 changes: 10 additions & 1 deletion src/Analyser/ExprHandler/BinaryOpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
Expand Down Expand Up @@ -42,6 +43,7 @@ public function __construct(
private InitializerExprTypeResolver $initializerExprTypeResolver,
private RicherScopeGetTypeHelper $richerScopeGetTypeHelper,
private PhpVersion $phpVersion,
private ImplicitToStringCallHelper $implicitToStringCallHelper,
)
{
}
Expand All @@ -62,20 +64,27 @@ 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) {
$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();

return new ExpressionResult(
$scope,
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),
);
Expand Down
38 changes: 6 additions & 32 deletions src/Analyser/ExprHandler/CastStringHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\ImplicitToStringCallHelper;
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<Cast\String_>
Expand All @@ -29,8 +26,7 @@ final class CastStringHandler implements ExprHandler

public function __construct(
private InitializerExprTypeResolver $initializerExprTypeResolver,
private PhpVersion $phpVersion,
private MethodThrowPointHelper $methodThrowPointHelper,
private ImplicitToStringCallHelper $implicitToStringCallHelper,
)
{
}
Expand All @@ -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;
}
}
}
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());

$scope = $exprResult->getScope();

Expand Down
73 changes: 73 additions & 0 deletions src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\ExprHandler\Helper;

use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ImpurePoint;
use PHPStan\Analyser\MutatingScope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Php\PhpVersion;
use function sprintf;

#[AutowiredService]
final class ImplicitToStringCallHelper
{

public function __construct(
private PhpVersion $phpVersion,
private MethodThrowPointHelper $methodThrowPointHelper,
)
{
}

public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult
{
$throwPoints = [];
$impurePoints = [];

$exprType = $scope->getType($expr);
$toStringMethod = $scope->getMethodReflection($exprType, '__toString');
if ($toStringMethod === null) {
return new ExpressionResult(
$scope,
hasYield: false,
isAlwaysTerminating: false,
throwPoints: [],
impurePoints: [],
);
}

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 new ExpressionResult(
$scope,
hasYield: false,
isAlwaysTerminating: false,
throwPoints: $throwPoints,
impurePoints: $impurePoints,
);
}

}
7 changes: 7 additions & 0 deletions src/Analyser/ExprHandler/InterpolatedStringHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\DependencyInjection\AutowiredService;
Expand All @@ -27,6 +28,7 @@ final class InterpolatedStringHandler implements ExprHandler

public function __construct(
private InitializerExprTypeResolver $initializerExprTypeResolver,
private ImplicitToStringCallHelper $implicitToStringCallHelper,
)
{
}
Expand All @@ -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());

$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();
}
Expand Down
18 changes: 16 additions & 2 deletions src/Analyser/ExprHandler/PrintHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
use PHPStan\Analyser\ImpurePoint;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
Expand All @@ -24,6 +25,12 @@
final class PrintHandler implements ExprHandler
{

public function __construct(
private ImplicitToStringCallHelper $implicitToStringCallHelper,
)
{
}

public function supports(Expr $expr): bool
{
return $expr instanceof Print_;
Expand All @@ -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();

$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR is not only about throw points, but also impure points.

it seems the impurity portion does not have tests

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both background tasks confirmed complete. Everything was already pushed — nothing more needed.


$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)]),
);
}

Expand Down
11 changes: 8 additions & 3 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PHPStan\Analyser\ExprHandler\AssignHandler;
use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper;
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass;
use PHPStan\BetterReflection\Reflection\ReflectionEnum;
use PHPStan\BetterReflection\Reflector\Reflector;
Expand Down Expand Up @@ -236,6 +237,7 @@ public function __construct(
private readonly bool $implicitThrows,
#[AutowiredParameter]
private readonly bool $treatPhpDocTypesAsCertain,
private readonly ImplicitToStringCallHelper $implicitToStringCallHelper,
)
{
$earlyTerminatingMethodNames = [];
Expand Down Expand Up @@ -861,19 +863,22 @@ public function processStmtNode(
} elseif ($stmt instanceof Echo_) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need more changes in NodeScopeResolver to reflect the changes made in ExprHandler classes with this PR?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 11732 tests pass.

Summary: After analyzing the codebase, no additional changes are needed in NodeScopeResolver.php. Here's why:

  • The echo handler (line 872) already correctly uses ImplicitToStringCallHelper — this was added in this PR
  • All other implicit __toString() call sites are expressions (not statements), so they're handled by their respective ExprHandler classes:
    • PrintHandlerprint $obj
    • CastStringHandler(string) $obj
    • BinaryOpHandler'hello' . $obj
    • AssignOpHandler$x .= $obj
    • InterpolatedStringHandler"hello $obj"

The ImplicitToStringCallHelper is injected into NodeScopeResolver solely for the echo statement, since echo is a Stmt (not an Expr) and cannot have its own ExprHandler.

I was unable to post this reply as a PR comment due to GitHub token permissions. You may want to reply to staabm's comment manually with this explanation.

$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) {
Expand Down
2 changes: 2 additions & 0 deletions src/Testing/RuleTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,6 +117,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
[],
self::getContainer()->getParameter('exceptions')['implicitThrows'],
$this->shouldTreatPhpDocTypesAsCertain(),
self::getContainer()->getByType(ImplicitToStringCallHelper::class),
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/Testing/TypeInferenceTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,6 +92,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
static::getEarlyTerminatingFunctionCalls(),
$container->getParameter('exceptions')['implicitThrows'],
$container->getParameter('treatPhpDocTypesAsCertain'),
$container->getByType(ImplicitToStringCallHelper::class),
);
}

Expand Down
Loading
Loading