From 9da740d4e06576ed66b829d300c348aba14fc33c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 7 Nov 2025 21:37:40 +0100 Subject: [PATCH] Support writing to array in foreach with value-by-ref --- src/Analyser/MutatingScope.php | 65 ++++++++++++++++--- src/Analyser/NodeScopeResolver.php | 1 + ...IntertwinedVariableByReferenceWithExpr.php | 47 ++++++++++++++ src/Node/Expr/OriginalForeachKeyExpr.php | 5 +- .../ParameterVariableOriginalValueExpr.php | 5 +- src/Node/Printer/Printer.php | 6 ++ .../Analyser/nsrt/overwritten-arrays.php | 55 ++++++++++++++++ .../TooWideMethodReturnTypehintRuleTest.php | 7 ++ .../Rules/TooWideTypehints/data/bug-13676.php | 36 ++++++++++ 9 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php create mode 100644 tests/PHPStan/Rules/TooWideTypehints/data/bug-13676.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 5aa8f26bc5..6b4aa7ffb0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -33,6 +33,7 @@ use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; use PHPStan\Node\Expr\GetOffsetValueTypeExpr; +use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalPropertyTypeExpr; @@ -3929,18 +3930,39 @@ public function enterMatch(Expr\Match_ $expr): self return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName): self + public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName, bool $valueByRef): self { $iterateeType = $originalScope->getType($iteratee); $nativeIterateeType = $originalScope->getNativeType($iteratee); + $valueType = $originalScope->getIterableValueType($iterateeType); + $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); $scope = $this->assignVariable( $valueName, - $originalScope->getIterableValueType($iterateeType), - $originalScope->getIterableValueType($nativeIterateeType), + $valueType, + $nativeValueType, TrinaryLogic::createYes(), ); + if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetOffsetValueTypeExpr( + $iteratee, + new GetIterableKeyTypeExpr($iteratee), + new Variable($valueName), + )), + $valueType, + $nativeValueType, + ); + } if ($keyName !== null) { $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); + + if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($valueName, new Expr\ArrayDimFetch($iteratee, new Variable($keyName)), new Variable($valueName)), + $valueType, + $nativeValueType, + ); + } } return $scope; @@ -4142,13 +4164,38 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp $scope->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($node, $nativeType, $certainty); } - $parameterOriginalValueExprString = $this->getNodeKey(new ParameterVariableOriginalValueExpr($variableName)); - unset($scope->expressionTypes[$parameterOriginalValueExprString]); - unset($scope->nativeExpressionTypes[$parameterOriginalValueExprString]); + foreach ($scope->expressionTypes as $expressionType) { + if (!$expressionType->getExpr() instanceof IntertwinedVariableByReferenceWithExpr) { + continue; + } + if (!$expressionType->getCertainty()->yes()) { + continue; + } + if ($expressionType->getExpr()->getVariableName() !== $variableName) { + continue; + } + + $has = $scope->hasExpressionType($expressionType->getExpr()->getExpr()); + if ( + $expressionType->getExpr()->getExpr() instanceof Variable + && is_string($expressionType->getExpr()->getExpr()->name) + && !$has->no() + ) { + $scope = $scope->assignVariable( + $expressionType->getExpr()->getExpr()->name, + $scope->getType($expressionType->getExpr()->getAssignedExpr()), + $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), + $has, + ); + } else { + $scope = $scope->assignExpression( + $expressionType->getExpr()->getExpr(), + $scope->getType($expressionType->getExpr()->getAssignedExpr()), + $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), + ); + } - $originalForeachKeyExpr = $this->getNodeKey(new OriginalForeachKeyExpr($variableName)); - unset($scope->expressionTypes[$originalForeachKeyExpr]); - unset($scope->nativeExpressionTypes[$originalForeachKeyExpr]); + } return $scope; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index db49cd61ca..11bd88f402 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6699,6 +6699,7 @@ private function enterForeach(MutatingScope $scope, MutatingScope $originalScope $stmt->expr, $stmt->valueVar->name, $keyVarName, + $stmt->byRef, ); $vars = [$stmt->valueVar->name]; if ($keyVarName !== null) { diff --git a/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php new file mode 100644 index 0000000000..2b4358a4a6 --- /dev/null +++ b/src/Node/Expr/IntertwinedVariableByReferenceWithExpr.php @@ -0,0 +1,47 @@ +variableName; + } + + public function getExpr(): Expr + { + return $this->expr; + } + + public function getAssignedExpr(): Expr + { + return $this->assignedExpr; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_IntertwinedVariableByReferenceWithExpr'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return ['expr']; + } + +} diff --git a/src/Node/Expr/OriginalForeachKeyExpr.php b/src/Node/Expr/OriginalForeachKeyExpr.php index 44d135d2d8..3db12ab2ea 100644 --- a/src/Node/Expr/OriginalForeachKeyExpr.php +++ b/src/Node/Expr/OriginalForeachKeyExpr.php @@ -9,9 +9,12 @@ final class OriginalForeachKeyExpr extends Expr implements VirtualNode { + public Expr\Variable $var; + public function __construct(private string $variableName) { parent::__construct([]); + $this->var = new Expr\Variable($this->variableName); } public function getVariableName(): string @@ -31,7 +34,7 @@ public function getType(): string #[Override] public function getSubNodeNames(): array { - return []; + return ['var']; } } diff --git a/src/Node/Expr/ParameterVariableOriginalValueExpr.php b/src/Node/Expr/ParameterVariableOriginalValueExpr.php index fa1315e9b8..78c94cf2d9 100644 --- a/src/Node/Expr/ParameterVariableOriginalValueExpr.php +++ b/src/Node/Expr/ParameterVariableOriginalValueExpr.php @@ -9,9 +9,12 @@ final class ParameterVariableOriginalValueExpr extends Expr implements VirtualNode { + public Expr\Variable $var; + public function __construct(private string $variableName) { parent::__construct([]); + $this->var = new Expr\Variable($this->variableName); } public function getVariableName(): string @@ -31,7 +34,7 @@ public function getType(): string #[Override] public function getSubNodeNames(): array { - return []; + return ['var']; } } diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 2a9da55e28..9ba3109d28 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -10,6 +10,7 @@ use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; use PHPStan\Node\Expr\GetOffsetValueTypeExpr; +use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalPropertyTypeExpr; @@ -105,6 +106,11 @@ protected function pPHPStan_Node_OriginalForeachKeyExpr(OriginalForeachKeyExpr $ return sprintf('__phpstanOriginalForeachKey(%s)', $expr->getVariableName()); } + protected function pPHPStan_Node_IntertwinedVariableByReferenceWithExpr(IntertwinedVariableByReferenceWithExpr $expr): string // phpcs:ignore + { + return sprintf('__phpstanIntertwinedVariableByReference(%s, %s, %s)', $expr->getVariableName(), $this->p($expr->getExpr()), $this->p($expr->getAssignedExpr())); + } + protected function pPHPStan_Node_IssetExpr(IssetExpr $expr): string // phpcs:ignore { return sprintf('__phpstanIssetExpr(%s)', $this->p($expr->getExpr())); diff --git a/tests/PHPStan/Analyser/nsrt/overwritten-arrays.php b/tests/PHPStan/Analyser/nsrt/overwritten-arrays.php index fd532e08c6..878ae9d082 100644 --- a/tests/PHPStan/Analyser/nsrt/overwritten-arrays.php +++ b/tests/PHPStan/Analyser/nsrt/overwritten-arrays.php @@ -118,4 +118,59 @@ public function doFoo6(array $a): void assertType('array', $a); } + /** + * @param array $a + */ + public function doFoo7(array $a): void + { + foreach ($a as &$v) { + $v = 1; + } + + assertType('array', $a); // could be array + } + + /** + * @param array $a + */ + public function doFoo8(array $a): void + { + foreach ($a as &$v) { + if (rand(0, 1)) { + $v = 1; + } + } + + assertType('array', $a); + } + + /** + * @param array $a + */ + public function doFoo9(array $a): void + { + foreach ($a as $k => &$v) { + $v = 1; + assertType('non-empty-array', $a); + assertType('1', $a[$k]); + } + + assertType('array', $a); + } + + /** + * @param array $a + */ + public function doFoo10(array $a): void + { + foreach ($a as $k => &$v) { + $k++; + $v = 1; + assertType('non-empty-array', $a); + assertType('1|string', $a[$k]); + } + + assertType('array', $a); + } + } diff --git a/tests/PHPStan/Rules/TooWideTypehints/TooWideMethodReturnTypehintRuleTest.php b/tests/PHPStan/Rules/TooWideTypehints/TooWideMethodReturnTypehintRuleTest.php index bace8a5b41..0cb376aebc 100644 --- a/tests/PHPStan/Rules/TooWideTypehints/TooWideMethodReturnTypehintRuleTest.php +++ b/tests/PHPStan/Rules/TooWideTypehints/TooWideMethodReturnTypehintRuleTest.php @@ -302,4 +302,11 @@ public function testNestedTooWideType(): void ]); } + public function testBug13676(): void + { + $this->reportTooWideBool = true; + $this->reportNestedTooWideType = true; + $this->analyse([__DIR__ . '/data/bug-13676.php'], []); + } + } diff --git a/tests/PHPStan/Rules/TooWideTypehints/data/bug-13676.php b/tests/PHPStan/Rules/TooWideTypehints/data/bug-13676.php new file mode 100644 index 0000000000..d184abff85 --- /dev/null +++ b/tests/PHPStan/Rules/TooWideTypehints/data/bug-13676.php @@ -0,0 +1,36 @@ +> $rows + * + * @return array> + */ + private function prepareExpectedRows(array $rows): array + { + if (rand(0,1)) { + return $rows; + } + + if (rand(0,1)) { + foreach ($rows as &$row) { + foreach ($row as &$value) { + $value = (string) $value; + } + } + } + + if (rand(0,1)) { + return $rows; + } + + foreach ($rows as &$row) { + $row = array_change_key_case($row, CASE_UPPER); + } + + return $rows; + } +}