From 061f9571d7b0a3a60e0f07f311d44589fe8f65d1 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sat, 30 May 2026 15:17:27 +0000 Subject: [PATCH 01/20] Add `ExprUsedAsStringNode` virtual node emitted for expressions used as a string - Add `PHPStan\Node\ExprUsedAsStringNode` virtual node wrapping the `Expr` whose value is used as a string. - Add `ExprUsedAsStringVisitor` (rich parser visitor) that marks expressions used in a string context via the `isExprUsedAsString` attribute: echo/print arguments, `(string)` cast operands, string concatenation (`.` and `.=`), string interpolation/heredoc, and dynamic-name expressions (`$foo->{$s}`, `$foo->{$s}()`, `Foo::${$s}`, `Foo::{$s}()`, `Foo::{$s}`, `$$s`). - Concatenation chains and interpolated strings are reported once for the whole expression instead of once per nested operand, by "claiming" nested concats/interpolation parts, so a rule can interpret the built string as a single unit. - `NodeScopeResolver::processExprNode()` emits the node for any marked expression; inline HTML emits the node wrapping a synthetic `String_`. --- src/Analyser/NodeScopeResolver.php | 8 ++ src/Node/ExprUsedAsStringNode.php | 48 +++++++++ src/Parser/ExprUsedAsStringVisitor.php | 92 ++++++++++++++++ tests/PHPStan/Node/ExprUsedAsStringRule.php | 44 ++++++++ .../PHPStan/Node/ExprUsedAsStringRuleTest.php | 101 ++++++++++++++++++ .../PHPStan/Node/data/expr-used-as-string.php | 52 +++++++++ 6 files changed, 345 insertions(+) create mode 100644 src/Node/ExprUsedAsStringNode.php create mode 100644 src/Parser/ExprUsedAsStringVisitor.php create mode 100644 tests/PHPStan/Node/ExprUsedAsStringRule.php create mode 100644 tests/PHPStan/Node/ExprUsedAsStringRuleTest.php create mode 100644 tests/PHPStan/Node/data/expr-used-as-string.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 41fdf890721..0dd3da7f508 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; @@ -111,6 +112,7 @@ use PHPStan\Node\VarTagChangedExpressionTypeNode; use PHPStan\Parser\ArrowFunctionArgVisitor; use PHPStan\Parser\ClosureArgVisitor; +use PHPStan\Parser\ExprUsedAsStringVisitor; use PHPStan\Parser\GotoLabelVisitor; use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\LineAttributesVisitor; @@ -2471,6 +2473,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 Node\Scalar\String_($stmt->value, $stmt->getAttributes())), $scope, $storage); } elseif ($stmt instanceof Node\Stmt\Block) { $result = $this->processStmtNodesInternal($stmt, $stmt->stmts, $scope, $storage, $nodeCallback, $context); if ($this->polluteScopeWithBlock) { @@ -2754,6 +2757,11 @@ public function processExprNode( $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); + if ($expr->getAttribute(ExprUsedAsStringVisitor::ATTRIBUTE_NAME) === true) { + $usedAsStringScope = $context->isDeep() ? $scope->exitFirstLevelStatements() : $scope; + $this->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($expr), $usedAsStringScope, $storage); + } + /** @var ExprHandler $exprHandler */ foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { if (!$exprHandler->supports($expr)) { diff --git a/src/Node/ExprUsedAsStringNode.php b/src/Node/ExprUsedAsStringNode.php new file mode 100644 index 00000000000..e2c59bd8c4c --- /dev/null +++ b/src/Node/ExprUsedAsStringNode.php @@ -0,0 +1,48 @@ +getAttributes()); + } + + public function getExpr(): Expr + { + return $this->expr; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_ExprUsedAsStringNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Parser/ExprUsedAsStringVisitor.php b/src/Parser/ExprUsedAsStringVisitor.php new file mode 100644 index 00000000000..8f077e2e99b --- /dev/null +++ b/src/Parser/ExprUsedAsStringVisitor.php @@ -0,0 +1,92 @@ +exprs as $expr) { + $expr->setAttribute(self::ATTRIBUTE_NAME, true); + } + } elseif ($node instanceof Print_) { + $node->expr->setAttribute(self::ATTRIBUTE_NAME, true); + } elseif ($node instanceof Cast\String_) { + $node->expr->setAttribute(self::ATTRIBUTE_NAME, true); + } elseif ($node instanceof AssignOp\Concat) { + $node->setAttribute(self::ATTRIBUTE_NAME, true); + $node->expr->setAttribute(self::CLAIMED_ATTRIBUTE_NAME, true); + } elseif ($node instanceof Concat) { + if ($node->getAttribute(self::CLAIMED_ATTRIBUTE_NAME) !== true) { + $node->setAttribute(self::ATTRIBUTE_NAME, true); + } + $node->left->setAttribute(self::CLAIMED_ATTRIBUTE_NAME, true); + $node->right->setAttribute(self::CLAIMED_ATTRIBUTE_NAME, true); + } elseif ($node instanceof InterpolatedString) { + if ($node->getAttribute(self::CLAIMED_ATTRIBUTE_NAME) !== true) { + $node->setAttribute(self::ATTRIBUTE_NAME, true); + } + foreach ($node->parts as $part) { + if (!$part instanceof Expr) { + continue; + } + $part->setAttribute(self::CLAIMED_ATTRIBUTE_NAME, true); + } + } elseif ( + $node instanceof PropertyFetch + || $node instanceof NullsafePropertyFetch + || $node instanceof MethodCall + || $node instanceof NullsafeMethodCall + || $node instanceof StaticPropertyFetch + || $node instanceof StaticCall + || $node instanceof ClassConstFetch + ) { + if ($node->name instanceof Expr) { + $node->name->setAttribute(self::ATTRIBUTE_NAME, true); + } + } elseif ($node instanceof Variable) { + if ($node->name instanceof Expr) { + $node->name->setAttribute(self::ATTRIBUTE_NAME, true); + } + } + + return null; + } + +} diff --git a/tests/PHPStan/Node/ExprUsedAsStringRule.php b/tests/PHPStan/Node/ExprUsedAsStringRule.php new file mode 100644 index 00000000000..f58fac8bbcc --- /dev/null +++ b/tests/PHPStan/Node/ExprUsedAsStringRule.php @@ -0,0 +1,44 @@ + + */ +class ExprUsedAsStringRule implements Rule +{ + + private Standard $printer; + + public function __construct() + { + $this->printer = new Standard(); + } + + public function getNodeType(): string + { + return ExprUsedAsStringNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $expr = $node->getExpr(); + + return [ + RuleErrorBuilder::message(sprintf( + 'Used as string: %s (%s)', + $this->printer->prettyPrintExpr($expr), + $scope->getType($expr)->describe(VerbosityLevel::precise()), + ))->identifier('tests.exprUsedAsString')->build(), + ]; + } + +} diff --git a/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php b/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php new file mode 100644 index 00000000000..bbd6b5454ba --- /dev/null +++ b/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php @@ -0,0 +1,101 @@ + + */ +class ExprUsedAsStringRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ExprUsedAsStringRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/expr-used-as-string.php'], [ + [ + "Used as string: 'plain' ('plain')", + 7, + ], + [ + 'Used as string: \'\' (non-falsy-string)', + 8, + ], + [ + 'Used as string: "" (non-falsy-string)', + 9, + ], + [ + "Used as string: 'printed' ('printed')", + 10, + ], + [ + "Used as string: 'a' . \$s . 'b' (non-falsy-string)", + 11, + ], + [ + 'Used as string: $s (string)', + 12, + ], + [ + 'Used as string: $s .= "appended" (non-falsy-string)', + 13, + ], + [ + 'Used as string: $s .= \' src="\' . $s . \'"\' (non-falsy-string)', + 14, + ], + [ + "Used as string: \$s . 'plain' (non-falsy-string)", + 15, + ], + [ + 'Used as string: "interp {$s} end" (non-falsy-string)', + 16, + ], + [ + "Used as string: '\n' (\"\\n\")", + 18, + ], + [ + "Used as string: \$html .= <<\nEOS (''; + echo ""; + print 'printed'; + print 'a' . $s . 'b'; + $x = (string) $s; + $s .= "appended"; + $s .= ' src="' . $s . '"'; + $t = $s . 'plain'; + $u = "interp $s end"; + ?> + + +EOS; +} + +class Holder +{ + + public string $prop = ''; + + public function method(): void + { + } + + public const CONST_NAME = 1; + +} + +function dynamicNames(Holder $h, string $name): void +{ + echo $h->{$name}; + $h->{$name}(); + $$name = 1; + $x = Holder::${$name}; + Holder::{$name}(); + $y = $h::{$name}; +} From 2d6c39e32d87278025c222d2e98dd3e131910b07 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 30 May 2026 18:02:02 +0200 Subject: [PATCH 02/20] Update expr-used-as-string.php --- tests/PHPStan/Node/data/expr-used-as-string.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Node/data/expr-used-as-string.php b/tests/PHPStan/Node/data/expr-used-as-string.php index 6684d11b81b..d4a13968513 100644 --- a/tests/PHPStan/Node/data/expr-used-as-string.php +++ b/tests/PHPStan/Node/data/expr-used-as-string.php @@ -1,4 +1,6 @@ -= 8.3 + +declare(strict_types = 1); namespace ExprUsedAsString; From d4266ee0a830c6f80d8bf524a5dd36632e795076 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 31 May 2026 07:17:05 +0000 Subject: [PATCH 03/20] Preserve original InlineHTML statement in ExprUsedAsStringNode Allow the node to wrap any NodeAbstract (not just Expr) so the original InlineHTML statement is preserved instead of being replaced by a synthetic String_. Also fix the test expectation line numbers that drifted after the data file gained a `// lint >= 8.3` header. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/NodeScopeResolver.php | 2 +- src/Node/ExprUsedAsStringNode.php | 12 +++--- tests/PHPStan/Node/ExprUsedAsStringRule.php | 21 ++++++++-- .../PHPStan/Node/ExprUsedAsStringRuleTest.php | 38 +++++++++---------- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0dd3da7f508..b134dbf34de 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2473,7 +2473,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 Node\Scalar\String_($stmt->value, $stmt->getAttributes())), $scope, $storage); + $this->callNodeCallback($nodeCallback, new ExprUsedAsStringNode($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 index e2c59bd8c4c..f04ce537220 100644 --- a/src/Node/ExprUsedAsStringNode.php +++ b/src/Node/ExprUsedAsStringNode.php @@ -3,7 +3,6 @@ namespace PHPStan\Node; use Override; -use PhpParser\Node\Expr; use PhpParser\NodeAbstract; /** @@ -15,19 +14,22 @@ * expression instead of once per nested operand, so a rule can interpret the * built string as a single unit. * + * The wrapped node is usually an expression, but for inline HTML it is the + * original {@see \PhpParser\Node\Stmt\InlineHTML} statement. + * * @api */ final class ExprUsedAsStringNode extends NodeAbstract implements VirtualNode { - public function __construct(private Expr $expr) + public function __construct(private NodeAbstract $node) { - parent::__construct($expr->getAttributes()); + parent::__construct($node->getAttributes()); } - public function getExpr(): Expr + public function getNode(): NodeAbstract { - return $this->expr; + return $this->node; } #[Override] diff --git a/tests/PHPStan/Node/ExprUsedAsStringRule.php b/tests/PHPStan/Node/ExprUsedAsStringRule.php index f58fac8bbcc..cd974fb53e2 100644 --- a/tests/PHPStan/Node/ExprUsedAsStringRule.php +++ b/tests/PHPStan/Node/ExprUsedAsStringRule.php @@ -3,10 +3,15 @@ namespace PHPStan\Node; use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\InlineHTML; use PhpParser\PrettyPrinter\Standard; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\VerbosityLevel; use function sprintf; @@ -30,13 +35,23 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - $expr = $node->getExpr(); + $inner = $node->getNode(); + + if ($inner instanceof Expr) { + $printed = $this->printer->prettyPrintExpr($inner); + $type = $scope->getType($inner)->describe(VerbosityLevel::precise()); + } elseif ($inner instanceof InlineHTML) { + $printed = $this->printer->prettyPrintExpr(new String_($inner->value, $inner->getAttributes())); + $type = (new ConstantStringType($inner->value))->describe(VerbosityLevel::precise()); + } else { + throw new ShouldNotHappenException(); + } return [ RuleErrorBuilder::message(sprintf( 'Used as string: %s (%s)', - $this->printer->prettyPrintExpr($expr), - $scope->getType($expr)->describe(VerbosityLevel::precise()), + $printed, + $type, ))->identifier('tests.exprUsedAsString')->build(), ]; } diff --git a/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php b/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php index bbd6b5454ba..120c62d6fb0 100644 --- a/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php +++ b/tests/PHPStan/Node/ExprUsedAsStringRuleTest.php @@ -21,79 +21,79 @@ public function testRule(): void $this->analyse([__DIR__ . '/data/expr-used-as-string.php'], [ [ "Used as string: 'plain' ('plain')", - 7, + 9, ], [ 'Used as string: \'\' (non-falsy-string)', - 8, + 10, ], [ 'Used as string: "" (non-falsy-string)', - 9, + 11, ], [ "Used as string: 'printed' ('printed')", - 10, + 12, ], [ "Used as string: 'a' . \$s . 'b' (non-falsy-string)", - 11, + 13, ], [ 'Used as string: $s (string)', - 12, + 14, ], [ 'Used as string: $s .= "appended" (non-falsy-string)', - 13, + 15, ], [ 'Used as string: $s .= \' src="\' . $s . \'"\' (non-falsy-string)', - 14, + 16, ], [ "Used as string: \$s . 'plain' (non-falsy-string)", - 15, + 17, ], [ 'Used as string: "interp {$s} end" (non-falsy-string)', - 16, + 18, ], [ "Used as string: '\n' (\"\\n\")", - 18, + 20, ], [ "Used as string: \$html .= <<\nEOS ('\n' (\"\\n\")", 20, ], + [ + "Used as string: '123' ('123')", + 26, + ], + [ + "Used as string: '' ('')", + 27, + ], [ "Used as string: \$html .= <<\nEOS ('