Skip to content

Commit 996ad98

Browse files
ondrejmirtesclaude
andcommitted
Defer by-ref closure scope modifications after all args are processed
processClosureNode returned a scope with by-ref captured variable modifications already applied. In processArgs, this polluted the scope used for evaluating subsequent arguments - but closures are not called during argument evaluation, so by-ref changes cannot have taken effect yet. The fix separates the by-ref data (closureResultScope, byRefUses) from the returned scope in ProcessClosureResult and lets callers decide when to apply it. The standalone closure call site applies immediately, while processArgs defers it until after all arguments are processed - same pattern as the existing deferral of processImmediatelyCalledCallable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2a92c7b commit 996ad98

3 files changed

Lines changed: 40 additions & 2 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3493,7 +3493,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
34933493
}
34943494
} elseif ($expr instanceof Expr\Closure) {
34953495
$processClosureResult = $this->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null);
3496-
$scope = $processClosureResult->getScope();
3496+
$scope = $processClosureResult->applyByRefUseScope($processClosureResult->getScope());
34973497

34983498
return new ExpressionResult(
34993499
$scope,
@@ -5142,7 +5142,7 @@ private function processClosureNode(
51425142
array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints),
51435143
), $closureScope, $storage);
51445144

5145-
return new ProcessClosureResult($scope->processClosureScope($closureResultScope, null, $byRefUses), $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $isAlwaysTerminating);
5145+
return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $isAlwaysTerminating, $closureResultScope, $byRefUses);
51465146
}
51475147

51485148
/**
@@ -5551,6 +5551,8 @@ private function processArgs(
55515551
$isAlwaysTerminating = false;
55525552
/** @var list<array{InvalidateExprNode[], string[]}> $deferredInvalidateExpressions */
55535553
$deferredInvalidateExpressions = [];
5554+
/** @var ProcessClosureResult[] $deferredByRefClosureResults */
5555+
$deferredByRefClosureResults = [];
55545556
foreach ($args as $i => $arg) {
55555557
$assignByReference = false;
55565558
$parameter = null;
@@ -5662,6 +5664,7 @@ private function processArgs(
56625664
}
56635665

56645666
$scope = $closureResult->getScope();
5667+
$deferredByRefClosureResults[] = $closureResult;
56655668
$invalidateExpressions = $closureResult->getInvalidateExpressions();
56665669
if ($restoreThisScope !== null) {
56675670
$nodeFinder = new NodeFinder();
@@ -5753,6 +5756,10 @@ private function processArgs(
57535756
$scope = $this->processImmediatelyCalledCallable($scope, $invalidateExpressions, $uses);
57545757
}
57555758

5759+
foreach ($deferredByRefClosureResults as $deferredClosureResult) {
5760+
$scope = $deferredClosureResult->applyByRefUseScope($scope);
5761+
}
5762+
57565763
if ($parameters !== null) {
57575764
foreach ($args as $i => $arg) {
57585765
$assignByReference = false;

src/Analyser/ProcessClosureResult.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Analyser;
44

5+
use PhpParser\Node\ClosureUse;
56
use PHPStan\Node\InvalidateExprNode;
67

78
final class ProcessClosureResult
@@ -11,13 +12,16 @@ final class ProcessClosureResult
1112
* @param InternalThrowPoint[] $throwPoints
1213
* @param ImpurePoint[] $impurePoints
1314
* @param InvalidateExprNode[] $invalidateExpressions
15+
* @param ClosureUse[] $byRefUses
1416
*/
1517
public function __construct(
1618
private MutatingScope $scope,
1719
private array $throwPoints,
1820
private array $impurePoints,
1921
private array $invalidateExpressions,
2022
private bool $isAlwaysTerminating,
23+
private ?MutatingScope $byRefClosureResultScope = null,
24+
private array $byRefUses = [],
2125
)
2226
{
2327
}
@@ -27,6 +31,15 @@ public function getScope(): MutatingScope
2731
return $this->scope;
2832
}
2933

34+
public function applyByRefUseScope(MutatingScope $scope): MutatingScope
35+
{
36+
if ($this->byRefClosureResultScope === null) {
37+
return $scope;
38+
}
39+
40+
return $scope->processClosureScope($this->byRefClosureResultScope, null, $this->byRefUses);
41+
}
42+
3043
/**
3144
* @return InternalThrowPoint[]
3245
*/

tests/PHPStan/Rules/Methods/data/discussion-14038.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,21 @@ function(): void {
3030
);
3131
}
3232
}
33+
34+
class ByRefTest
35+
{
36+
private function foo(callable $c, string $s): void
37+
{
38+
}
39+
40+
public function testByRef(): void
41+
{
42+
$x = 'hello';
43+
$this->foo(
44+
function() use (&$x): void {
45+
$x = 42;
46+
},
47+
$x,
48+
);
49+
}
50+
}

0 commit comments

Comments
 (0)