Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
061f957
Add `ExprUsedAsStringNode` virtual node emitted for expressions used …
staabm May 30, 2026
2d6c39e
Update expr-used-as-string.php
staabm May 30, 2026
d4266ee
Preserve original InlineHTML statement in ExprUsedAsStringNode
phpstan-bot May 31, 2026
2bd9c59
Rename ExprUsedAsStringNode to UsedAsStringNode
phpstan-bot May 31, 2026
f3273cf
Update UsedAsStringNode doc to reflect NodeAbstract wrapping
phpstan-bot May 31, 2026
8cabc7b
Emit UsedAsStringNode for values stored into a string slot
phpstan-bot May 31, 2026
4c57919
Revert UsedAsStringNode to wrap Expr instead of NodeAbstract
phpstan-bot May 31, 2026
868fa7a
Emit UsedAsStringNode for arguments passed to a string parameter
phpstan-bot May 31, 2026
7550786
Emit UsedAsStringNode for native string parameter defaults
phpstan-bot May 31, 2026
bbdb5be
Emit UsedAsStringNode for string arguments of closures and arrow func…
phpstan-bot May 31, 2026
e9c041a
Treat string-containing union properties as a string slot for UsedAsS…
phpstan-bot May 31, 2026
a989ef6
simplify
staabm May 31, 2026
6aff995
Emit UsedAsStringNode for arguments passed to union parameters contai…
phpstan-bot May 31, 2026
4fe62a6
Test that a nowdoc used in concat-assignment fires UsedAsStringNode once
phpstan-bot May 31, 2026
694caad
Test that string literals in an array() assignment are not used as a …
phpstan-bot May 31, 2026
edb4503
simplify
staabm May 31, 2026
81ed009
Do not fire UsedAsStringNode for arguments passed to PHPDoc-only stri…
phpstan-bot May 31, 2026
0b444c5
Test that a string passed to a native mixed parameter does not fire U…
phpstan-bot May 31, 2026
6b10f69
Test that a Stringable object passed to a native string parameter fir…
phpstan-bot May 31, 2026
7f470e0
Only emit UsedAsStringNode for property assignments when the value ca…
phpstan-bot Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@
use PHPStan\Node\IssetExpr;
use PHPStan\Node\Printer\ExprPrinter;
use PHPStan\Node\PropertyAssignNode;
use PHPStan\Node\UsedAsStringNode;
use PHPStan\Node\VariableAssignNode;
use PHPStan\Node\VirtualNode;
use PHPStan\Parser\ExprUsedAsStringVisitor;
use PHPStan\Php\PhpVersion;
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
Expand Down Expand Up @@ -162,6 +164,10 @@
);
$scope = $result->getScope();

if ($expr instanceof Assign) {
$this->emitUsedAsStringNode($nodeScopeResolver, $scope, $storage, $expr, $nodeCallback);
}

if (
$expr instanceof AssignRef
&& $expr->var instanceof Variable
Expand Down Expand Up @@ -209,6 +215,111 @@
);
}

/**
* Emits a {@see UsedAsStringNode} for the assigned value when it lands in a
* string slot: a string-valued variable assignment or an assignment to a
* native property whose type allows a string.
*
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
private function emitUsedAsStringNode(
NodeScopeResolver $nodeScopeResolver,
MutatingScope $scope,
ExpressionResultStorage $storage,
Assign $expr,
callable $nodeCallback,
): void
{
if (ExprUsedAsStringVisitor::isAlreadyUsedAsStringSite($expr->expr)) {
return;
}

if (!$this->isAssignToStringSlot($scope, $expr->var, $expr->expr)) {
return;
}

$nodeScopeResolver->callNodeCallback($nodeCallback, new UsedAsStringNode($expr->expr), $scope, $storage);
}

/**
* Whether the assigned value lands in a string slot: a string-valued variable
* assignment, or an assignment to a native property whose declared type allows a
* string (a plain `string` or a union that contains `string`, e.g. `string|int`)
* when the assigned value can actually be coerced to a string in the current
* typing mode. A union without any `string` member (e.g. `int|float`) is not a
* string slot, and a value that cannot be coerced to a string (e.g. a
* non-`Stringable` object) does not land in the slot as a string.
*/
private function isAssignToStringSlot(MutatingScope $scope, Expr $var, Expr $assignedExpr): bool
{
if ($var instanceof Variable) {
return $scope->getType($assignedExpr)->isString()->yes();

Check warning on line 256 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ private function isAssignToStringSlot(MutatingScope $scope, Expr $var, Expr $assignedExpr): bool { if ($var instanceof Variable) { - return $scope->getType($assignedExpr)->isString()->yes(); + return !$scope->getType($assignedExpr)->isString()->no(); } $slotType = $this->getAssignTargetPropertyNativeType($scope, $var);
}

$slotType = $this->getAssignTargetPropertyNativeType($scope, $var);
if ($slotType === null || !$this->containsString($slotType)) {
return false;
}

// Only fire when the assigned value can actually be coerced to a string in
// the current (strict/weak) typing mode: a non-`Stringable` object assigned
// to a `string|int` property is not used as a string, while a `Stringable`
// assigned to it in weak mode is.
$coercedAssignedType = $scope->getType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes());

return $this->containsString($coercedAssignedType);
}

/**
* Whether the type is a plain `string` or a union with at least one `string`
* member (e.g. `string|int`).
*/
private function containsString(Type $type): bool
{
foreach (TypeUtils::flattenTypes($type) as $innerType) {
if ($innerType->isString()->yes()) {

Check warning on line 280 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ private function containsString(Type $type): bool { foreach (TypeUtils::flattenTypes($type) as $innerType) { - if ($innerType->isString()->yes()) { + if (!$innerType->isString()->no()) { return true; } }

Check warning on line 280 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ private function containsString(Type $type): bool { foreach (TypeUtils::flattenTypes($type) as $innerType) { - if ($innerType->isString()->yes()) { + if (!$innerType->isString()->no()) { return true; } }
return true;
}
}

return false;
}

private function getAssignTargetPropertyNativeType(MutatingScope $scope, Expr $var): ?Type
{
if ($var instanceof PropertyFetch) {
if (!$var->name instanceof Node\Identifier) {
return null;
}
$propertyName = $var->name->toString();
$holderType = $scope->getType($var->var);
if (!$holderType->hasInstanceProperty($propertyName)->yes()) {

Check warning on line 296 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $propertyName = $var->name->toString(); $holderType = $scope->getType($var->var); - if (!$holderType->hasInstanceProperty($propertyName)->yes()) { + if ($holderType->hasInstanceProperty($propertyName)->no()) { return null; } $property = $holderType->getInstanceProperty($propertyName, $scope);

Check warning on line 296 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $propertyName = $var->name->toString(); $holderType = $scope->getType($var->var); - if (!$holderType->hasInstanceProperty($propertyName)->yes()) { + if ($holderType->hasInstanceProperty($propertyName)->no()) { return null; } $property = $holderType->getInstanceProperty($propertyName, $scope);
return null;
}
$property = $holderType->getInstanceProperty($propertyName, $scope);

return $property->hasNativeType() ? $property->getNativeType() : null;
}

if ($var instanceof StaticPropertyFetch) {
if (!$var->name instanceof Node\VarLikeIdentifier) {
return null;
}
$propertyName = $var->name->toString();
$holderType = $var->class instanceof Name
? $scope->resolveTypeByName($var->class)
: $scope->getType($var->class);
$property = $scope->getStaticPropertyReflection($holderType, $propertyName);
if ($property === null || !$property->hasNativeType()) {
return null;
}

return $property->getNativeType();
}

return null;
}

/**
* @param callable(Node $node, Scope $scope): void $nodeCallback
* @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback
Expand Down
56 changes: 56 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@
use PHPStan\Node\ReturnStatement;
use PHPStan\Node\StaticMethodCallableNode;
use PHPStan\Node\UnreachableStatementNode;
use PHPStan\Node\UsedAsStringNode;
use PHPStan\Node\VariableAssignNode;
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;
Expand Down Expand Up @@ -1242,6 +1244,14 @@ public function processStmtNode(
$this->callNodeCallback($nodeCallback, $prop, $scope, $storage);
if ($prop->default !== null) {
$this->processExprNode($stmt, $prop->default, $scope, $storage, $nodeCallback, ExpressionContext::createDeep());

if (
$nativePropertyType !== null
&& $nativePropertyType->isString()->yes()
&& !ExprUsedAsStringVisitor::isAlreadyUsedAsStringSite($prop->default)
) {
$this->callNodeCallback($nodeCallback, new UsedAsStringNode($prop->default), $scope, $storage);
}
}

if (!$scope->isInClass()) {
Expand Down Expand Up @@ -2471,6 +2481,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 UsedAsStringNode(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) {
Expand Down Expand Up @@ -2754,6 +2765,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 UsedAsStringNode($expr), $usedAsStringScope, $storage);
}

/** @var ExprHandler<Expr> $exprHandler */
foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) {
if (!$exprHandler->supports($expr)) {
Expand Down Expand Up @@ -3269,6 +3285,35 @@ private function processParamNode(
}

$this->processExprNode($stmt, $param->default, $scope, $storage, $nodeCallback, ExpressionContext::createDeep());

$nativeParameterType = $param->type !== null
? ParserNodeTypeToPHPStanType::resolve($param->type, $scope->isInClass() ? $scope->getClassReflection() : null)
: null;
if (
$nativeParameterType === null
|| !$nativeParameterType->isString()->yes()
|| ExprUsedAsStringVisitor::isAlreadyUsedAsStringSite($param->default)
) {
return;
}

$this->callNodeCallback($nodeCallback, new UsedAsStringNode($param->default), $scope, $storage);
}

/**
* Whether the native type is a string slot: a plain `string` or a union that
* contains a `string` member (e.g. `string|int`). Plain `mixed` is not a string
* slot, so arguments to untyped or PHPDoc-only `@param string` parameters do not fire.
*/
private function isStringSlotType(Type $type): bool
{
foreach (TypeUtils::flattenTypes($type) as $slotMemberType) {
if ($slotMemberType->isString()->yes()) {
return true;
}
}

return false;
}

/**
Expand Down Expand Up @@ -3708,6 +3753,17 @@ public function processArgs(
$scopeToPass = $scopeToPass->enterExpressionAssign($arg->value);
}
$exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep());
// Closures and arrow functions expose their parameters as NativeParameterReflection
// (not ExtendedParameterReflection), so $parameterNativeType is null for them - their
// declared type already is the native type.
$argStringSlotType = $parameterNativeType ?? ($parameter instanceof NativeParameterReflection ? $parameter->getType() : null);
if (
$argStringSlotType !== null
&& $this->isStringSlotType($argStringSlotType)
&& !ExprUsedAsStringVisitor::isAlreadyUsedAsStringSite($arg->value)
) {
$this->callNodeCallback($nodeCallback, new UsedAsStringNode($arg->value), $scopeToPass, $storage);
}
$throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating();
Expand Down
66 changes: 66 additions & 0 deletions src/Node/UsedAsStringNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node;

use Override;
use PhpParser\Node\Expr;
use PhpParser\NodeAbstract;

/**
* Fired for every node whose value is used as a string. This covers both
* values coerced to string and values stored into a string slot:
*
* - echo and print arguments, the operand of a (string) cast,
* - string concatenation (`.` and `.=`), string interpolation/heredoc,
* - inline HTML,
* - the dynamic name of a property/method/constant access or a variable
* variable (`$foo->{$s}`, `$$s`, etc.),
* - the value assigned to a variable when that value is a string,
* - the value assigned to a native property whose type allows a string (a plain
* `string` or a union containing `string`, e.g. `string|int`) when the assigned
* value can be coerced to a string in the current typing mode (so a
* non-`Stringable` object assigned to such a slot does not fire, while a
* `Stringable` assigned to it in weak mode does), and the default of a native
* `string`-typed property,
* - an argument passed to a native parameter whose type allows a string (a plain
* `string` or a union containing `string`, e.g. `string|int`), including
* closures and arrow functions, and the default of a native `string`-typed
* parameter.
*
* Concatenations and interpolated strings are reported once for the whole
* expression instead of once per nested operand, so a rule can interpret the
* built string as a single unit. When the assigned value already produces its
* own node (a concatenation, interpolation or `(string)` cast), the enclosing
* assignment does not report it a second time.
*
* @api
*/
final class UsedAsStringNode extends NodeAbstract implements VirtualNode
{

public function __construct(private Expr $expr)
{
parent::__construct($expr->getAttributes());
}

public function getExpr(): Expr
{
return $this->expr;
}

#[Override]
public function getType(): string
{
return 'PHPStan_Node_UsedAsStringNode';
}

/**
* @return string[]
*/
#[Override]
public function getSubNodeNames(): array
{
return [];
}

}
Loading
Loading