Track array_walk by-ref closure parameter modifications on array type#5527
Conversation
When array_walk() is called with a closure that modifies values by reference, PHPStan now tracks the type changes and updates the array's value type accordingly. Fixes phpstan/phpstan#14525
ondrejmirtes
left a comment
There was a problem hiding this comment.
Also first verify our hypothesis with a failing test.
| $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); | ||
|
|
||
| $nodeCallbackForArgs = static function (Node $node, Scope $scope) use ($nodeCallback, $firstParamName, &$arrayWalkValueTypes): void { | ||
| if ($node instanceof ClosureReturnStatementsNode) { |
There was a problem hiding this comment.
This would fire multiple times in case of nested closures. We only want this to fire once for the top-most closure. Ideally we'd check $scope->getAnonymousFunctionReflection() like in other places in NodeScopeResolver, but we probably do not have the anonymous function reflection to compare it against... so we probably need to get the last firing of ClosureReturnStatementsNode? Try to come up with a nice solution.
There was a problem hiding this comment.
Done. Two commits pushed:
-
Test commit: Added two nested closure test cases — one with
array_mapcallback insidearray_walk, one with a by-ref capturing closure — to verify correct type tracking with nested closures. -
Fix commit: Changed the
ClosureReturnStatementsNodehandler to compare$node->getClosureExpr() === $callbackArg, ensuring it only fires for thearray_walkcallback closure and ignores any nested closures within it. This is the same pattern used elsewhere inNodeScopeResolver(line 2776) wheregetAnonymousFunctionReflection()is compared to filter nested closure events.
Note: I couldn't construct a test case where the nested closure issue produces observably wrong results with the current code, because the outer ClosureReturnStatementsNode always fires last (depth-first traversal) and overwrites the inner's values. However, the getClosureExpr() comparison makes the code correct by construction rather than by accident of traversal order.
Verify that array_walk correctly tracks by-ref parameter types when the callback contains nested closures (e.g. array_map callback, closure capturing variables by reference). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Compare ClosureReturnStatementsNode::getClosureExpr() against the callback argument to ensure we only process the array_walk callback closure, not nested closures within it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Fixes phpstan/phpstan#14525
When
array_walk($array, function (&$value) { $value = (string) $value; })is called, PHPStan now tracks the by-reference modifications to$valueinside the closure and updates the array's value type accordingly.How it works
In
FuncCallHandler::processExpr, when anarray_walkcall is detected with a closure callback that has a by-ref first parameter:processArgsClosureReturnStatementsNodeemitted during closure processingStatementResult::getExitPoints()andStatementResult::getScope()processArgs, the array type is updated viaprocessVirtualAssignwith the new value typeKey design decisions
StatementResult::getExitPoints()instead ofClosureReturnStatementsNode::getReturnStatements()because exit point scopes areMutatingScopeobjects, while return statement scopes can beFiberScopeobjects whosegetNativeType()would suspend the fiber and cause the type capture to be deferred past the point where it's needed$nodeCallbackto avoid fiber suspension from rules processing theClosureReturnStatementsNodeTypeTraverser::mapto replace value types while preserving array structure (constant arrays, lists, non-empty arrays, etc.)array_walk, notarray_walk_recursive(which would need recursive leaf-value type replacement)Supported scenarios
$value = (string) $value)non-empty-array,listUpdated existing test
tests/PHPStan/Rules/Arrays/data/bug-7469.php- type of$data['languages']afterarray_walkwithstrtolower(trim($item))is now correctly tracked asnon-empty-list<lowercase-string>instead ofnon-empty-list<string>.