From f22e5dbd310a27a7f976f01ed88e9fba1e6d5926 Mon Sep 17 00:00:00 2001
From: staabm <120441+staabm@users.noreply.github.com>
Date: Tue, 19 May 2026 14:56:04 +0000
Subject: [PATCH] Add `ExprUsedAsStringNode` virtual node emitted when
expressions are used as strings
- Add ExprUsedAsStringNode virtual node class extending NodeAbstract, wrapping the expression being used as string and the original AST node
- Emit from Echo_ statement handler in NodeScopeResolver (one per echo expression)
- Emit from PrintHandler for print expressions
- Emit from CastStringHandler for (string) casts
- Emit from InterpolatedStringHandler for interpolated strings and heredocs
- Emit from AssignOpHandler for .= concat assignment right-hand side
- Emit from NodeScopeResolver for InlineHTML using TypeExpr(ConstantStringType)
- For echo/print of concat chains, a single node is emitted wrapping the entire concat expression
---
src/Analyser/ExprHandler/AssignOpHandler.php | 3 +
.../ExprHandler/CastStringHandler.php | 3 +
.../ExprHandler/InterpolatedStringHandler.php | 3 +
src/Analyser/ExprHandler/PrintHandler.php | 3 +
src/Analyser/NodeScopeResolver.php | 3 +
src/Node/ExprUsedAsStringNode.php | 46 ++++++++++++
.../ExprUsedAsStringRuleTest.php | 71 +++++++++++++++++++
.../ExprUsedAsStringTestRule.php | 35 +++++++++
.../data/expr-used-as-string.php | 44 ++++++++++++
.../ExprUsedAsString/data/inline-html.php | 9 +++
10 files changed, 220 insertions(+)
create mode 100644 src/Node/ExprUsedAsStringNode.php
create mode 100644 tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringRuleTest.php
create mode 100644 tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringTestRule.php
create mode 100644 tests/PHPStan/Rules/ExprUsedAsString/data/expr-used-as-string.php
create mode 100644 tests/PHPStan/Rules/ExprUsedAsString/data/inline-html.php
diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php
index b7eb0e843e5..a9e7ecc0a3d 100644
--- a/src/Analyser/ExprHandler/AssignOpHandler.php
+++ b/src/Analyser/ExprHandler/AssignOpHandler.php
@@ -18,6 +18,7 @@
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\DependencyInjection\AutowiredService;
+use PHPStan\Node\ExprUsedAsStringNode;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Constant\ConstantIntegerType;
@@ -99,6 +100,8 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope);
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
+
+ $nodeScopeResolver->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($expr->expr, $expr), $scope, $storage);
}
return new ExpressionResult(
diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php
index b4e119e01f5..e7c21ba210d 100644
--- a/src/Analyser/ExprHandler/CastStringHandler.php
+++ b/src/Analyser/ExprHandler/CastStringHandler.php
@@ -13,6 +13,7 @@
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\DependencyInjection\AutowiredService;
+use PHPStan\Node\ExprUsedAsStringNode;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\Type\Type;
use function array_merge;
@@ -46,6 +47,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
+ $nodeScopeResolver->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($expr->expr, $expr), $scope, $storage);
+
$scope = $exprResult->getScope();
return new ExpressionResult(
diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php
index bb18cff3e09..0805f9700ae 100644
--- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php
+++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php
@@ -14,6 +14,7 @@
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\DependencyInjection\AutowiredService;
+use PHPStan\Node\ExprUsedAsStringNode;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Type;
@@ -61,6 +62,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$scope = $partResult->getScope();
}
+ $nodeScopeResolver->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($expr, $expr), $scope, $storage);
+
return new ExpressionResult(
$scope,
hasYield: $hasYield,
diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php
index 18ab04d8d29..7b2e47a81fd 100644
--- a/src/Analyser/ExprHandler/PrintHandler.php
+++ b/src/Analyser/ExprHandler/PrintHandler.php
@@ -14,6 +14,7 @@
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\DependencyInjection\AutowiredService;
+use PHPStan\Node\ExprUsedAsStringNode;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Type;
use function array_merge;
@@ -51,6 +52,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
+ $nodeScopeResolver->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($expr->expr, $expr), $scope, $storage);
+
$scope = $exprResult->getScope();
return new ExpressionResult(
diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php
index d013b03359a..ddb7479e5c8 100644
--- a/src/Analyser/NodeScopeResolver.php
+++ b/src/Analyser/NodeScopeResolver.php
@@ -85,6 +85,7 @@
use PHPStan\Node\Expr\PropertyInitializationExpr;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Node\Expr\UnsetOffsetExpr;
+use PHPStan\Node\ExprUsedAsStringNode;
use PHPStan\Node\FinallyExitPointsNode;
use PHPStan\Node\FunctionCallableNode;
use PHPStan\Node\FunctionReturnStatementsNode;
@@ -996,6 +997,7 @@ public function processStmtNode(
$toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope);
$throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints());
+ $this->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($echoExpr, $stmt), $scope, $storage);
$scope = $result->getScope();
$hasYield = $hasYield || $result->hasYield();
$isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating();
@@ -2402,6 +2404,7 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch
$impurePoints = [
new ImpurePoint($scope, $stmt, 'betweenPhpTags', 'output between PHP opening and closing tags', true),
];
+ $this->callNodeCallback($nodeCallback, new ExprUsedAsStringNode(new TypeExpr(new ConstantStringType($stmt->value)), $stmt), $scope, $storage);
} elseif ($stmt instanceof Node\Stmt\Block) {
$result = $this->processStmtNodesInternal($stmt, $stmt->stmts, $scope, $storage, $nodeCallback, $context);
if ($this->polluteScopeWithBlock) {
diff --git a/src/Node/ExprUsedAsStringNode.php b/src/Node/ExprUsedAsStringNode.php
new file mode 100644
index 00000000000..0b463bbc7c9
--- /dev/null
+++ b/src/Node/ExprUsedAsStringNode.php
@@ -0,0 +1,46 @@
+getAttributes());
+ }
+
+ public function getExpression(): Expr
+ {
+ return $this->expression;
+ }
+
+ public function getOriginalNode(): Expr|Node\Stmt
+ {
+ return $this->originalNode;
+ }
+
+ #[Override]
+ public function getType(): string
+ {
+ return 'PHPStan_Node_ExprUsedAsStringNode';
+ }
+
+ /**
+ * @return string[]
+ */
+ #[Override]
+ public function getSubNodeNames(): array
+ {
+ return [];
+ }
+
+}
diff --git a/tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringRuleTest.php b/tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringRuleTest.php
new file mode 100644
index 00000000000..bffaec91176
--- /dev/null
+++ b/tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringRuleTest.php
@@ -0,0 +1,71 @@
+
+ */
+class ExprUsedAsStringRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new ExprUsedAsStringTestRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/expr-used-as-string.php'], [
+ [
+ 'Expression used as string: PhpParser\Node\Scalar\String_ in PhpParser\Node\Stmt\Echo_',
+ 8,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Scalar\String_ in PhpParser\Node\Stmt\Echo_',
+ 9,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Scalar\String_ in PhpParser\Node\Stmt\Echo_',
+ 9,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Scalar\String_ in PhpParser\Node\Expr\Print_',
+ 13,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Expr\Variable in PhpParser\Node\Expr\Cast\String_',
+ 17,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Scalar\InterpolatedString in PhpParser\Node\Scalar\InterpolatedString',
+ 26,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Expr\Variable in PhpParser\Node\Expr\AssignOp\Concat',
+ 30,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Expr\BinaryOp\Concat in PhpParser\Node\Stmt\Echo_',
+ 36,
+ ],
+ [
+ 'Expression used as string: PhpParser\Node\Scalar\InterpolatedString in PhpParser\Node\Scalar\InterpolatedString',
+ 41,
+ ],
+ ]);
+ }
+
+ public function testInlineHtml(): void
+ {
+ $this->analyse([__DIR__ . '/data/inline-html.php'], [
+ [
+ 'Expression used as string: PHPStan\Node\Expr\TypeExpr in PhpParser\Node\Stmt\InlineHTML',
+ 7,
+ ],
+ ]);
+ }
+
+}
diff --git a/tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringTestRule.php b/tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringTestRule.php
new file mode 100644
index 00000000000..37073e066a3
--- /dev/null
+++ b/tests/PHPStan/Rules/ExprUsedAsString/ExprUsedAsStringTestRule.php
@@ -0,0 +1,35 @@
+
+ */
+class ExprUsedAsStringTestRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return ExprUsedAsStringNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $expr = $node->getExpression();
+ $originalNode = $node->getOriginalNode();
+
+ return [
+ RuleErrorBuilder::message('Expression used as string: ' . get_class($expr) . ' in ' . get_class($originalNode))
+ ->identifier('tests.exprUsedAsString')
+ ->build(),
+ ];
+ }
+
+}
diff --git a/tests/PHPStan/Rules/ExprUsedAsString/data/expr-used-as-string.php b/tests/PHPStan/Rules/ExprUsedAsString/data/expr-used-as-string.php
new file mode 100644
index 00000000000..797df65bdc3
--- /dev/null
+++ b/tests/PHPStan/Rules/ExprUsedAsString/data/expr-used-as-string.php
@@ -0,0 +1,44 @@
+= 8.0
+
+namespace ExprUsedAsString;
+
+class Foo {
+
+ public function doEcho(): void {
+ echo 'hello';
+ echo 'hello', ' world';
+ }
+
+ public function doPrint(): void {
+ print 'hello';
+ }
+
+ public function doCast(int $i): void {
+ (string) $i;
+ }
+
+ public function doConcat(string $a, string $b): void {
+ $a . $b;
+ 'a' . $b . 'c';
+ }
+
+ public function doInterpolatedString(string $name): void {
+ "Hello $name!";
+ }
+
+ public function doConcatAssign(string $a, string $b): void {
+ $a .= $b;
+ }
+
+}
+
+function doEchoConcat(string $s): void {
+ echo '';
+}
+
+function doHeredoc(): void {
+ $nonce = '123';
+ $html = <<
+ EOS;
+}
diff --git a/tests/PHPStan/Rules/ExprUsedAsString/data/inline-html.php b/tests/PHPStan/Rules/ExprUsedAsString/data/inline-html.php
new file mode 100644
index 00000000000..dfeb8f3f992
--- /dev/null
+++ b/tests/PHPStan/Rules/ExprUsedAsString/data/inline-html.php
@@ -0,0 +1,9 @@
+
+
+