Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
107 changes: 106 additions & 1 deletion src/Analyser/ExprHandler/FuncCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PHPStan\Analyser\ExprHandler;

use Closure;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\FuncCall;
Expand All @@ -28,6 +29,7 @@
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider;
use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider;
use PHPStan\Node\ClosureReturnStatementsNode;
use PHPStan\Node\Expr\NativeTypeExpr;
use PHPStan\Node\Expr\PossiblyImpureCallExpr;
use PHPStan\Node\Expr\TypeExpr;
Expand Down Expand Up @@ -61,13 +63,15 @@
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\UnionType;
use Throwable;
use function array_fill;
use function array_filter;
use function array_map;
use function array_merge;
use function array_slice;
use function array_values;
use function count;
use function in_array;
use function is_string;
use function sprintf;
use function str_starts_with;

Expand Down Expand Up @@ -201,13 +205,89 @@
}
}

$argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context);
/** @var array{Type, Type}|null $arrayWalkValueTypes */
$arrayWalkValueTypes = null;
$arrayWalkArrayArg = null;
$arrayWalkOriginalArrayType = null;
$arrayWalkOriginalArrayNativeType = null;
$nodeCallbackForArgs = $nodeCallback;
if (
$functionReflection !== null
&& $functionReflection->getName() === 'array_walk'
&& count($normalizedExpr->getArgs()) >= 2
) {
$callbackArg = $normalizedExpr->getArgs()[1]->value;
$firstParamName = null;

if (
$callbackArg instanceof Expr\Closure
&& isset($callbackArg->params[0])
&& $callbackArg->params[0]->byRef
&& $callbackArg->params[0]->var instanceof Variable
&& is_string($callbackArg->params[0]->var->name)
) {
$firstParamName = $callbackArg->params[0]->var->name;
}

if ($firstParamName !== null) {
$arrayWalkArrayArg = $normalizedExpr->getArgs()[0]->value;
$arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg);
$arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg);

$nodeCallbackForArgs = static function (Node $node, Scope $scope) use ($nodeCallback, $firstParamName, &$arrayWalkValueTypes): void {
if ($node instanceof ClosureReturnStatementsNode) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Two commits pushed:

  1. Test commit: Added two nested closure test cases — one with array_map callback inside array_walk, one with a by-ref capturing closure — to verify correct type tracking with nested closures.

  2. Fix commit: Changed the ClosureReturnStatementsNode handler to compare $node->getClosureExpr() === $callbackArg, ensuring it only fires for the array_walk callback closure and ignores any nested closures within it. This is the same pattern used elsewhere in NodeScopeResolver (line 2776) where getAnonymousFunctionReflection() 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.

$types = [];
$nativeTypes = [];
$stmtResult = $node->getStatementResult();
foreach ($stmtResult->getExitPoints() as $exitPoint) {
$exitScope = $exitPoint->getScope();
if (!$exitScope->hasVariableType($firstParamName)->yes()) {

Check warning on line 244 in src/Analyser/ExprHandler/FuncCallHandler.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $stmtResult = $node->getStatementResult(); foreach ($stmtResult->getExitPoints() as $exitPoint) { $exitScope = $exitPoint->getScope(); - if (!$exitScope->hasVariableType($firstParamName)->yes()) { + if ($exitScope->hasVariableType($firstParamName)->no()) { continue; }

Check warning on line 244 in src/Analyser/ExprHandler/FuncCallHandler.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $stmtResult = $node->getStatementResult(); foreach ($stmtResult->getExitPoints() as $exitPoint) { $exitScope = $exitPoint->getScope(); - if (!$exitScope->hasVariableType($firstParamName)->yes()) { + if ($exitScope->hasVariableType($firstParamName)->no()) { continue; }
continue;
}

$types[] = $exitScope->getVariableType($firstParamName);
$nativeTypes[] = $exitScope->getNativeType(new Variable($firstParamName));
}
if (!$stmtResult->isAlwaysTerminating()) {
$stmtScope = $stmtResult->getScope();
if ($stmtScope->hasVariableType($firstParamName)->yes()) {

Check warning on line 253 in src/Analyser/ExprHandler/FuncCallHandler.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } if (!$stmtResult->isAlwaysTerminating()) { $stmtScope = $stmtResult->getScope(); - if ($stmtScope->hasVariableType($firstParamName)->yes()) { + if (!$stmtScope->hasVariableType($firstParamName)->no()) { $types[] = $stmtScope->getVariableType($firstParamName); $nativeTypes[] = $stmtScope->getNativeType(new Variable($firstParamName)); }

Check warning on line 253 in src/Analyser/ExprHandler/FuncCallHandler.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } if (!$stmtResult->isAlwaysTerminating()) { $stmtScope = $stmtResult->getScope(); - if ($stmtScope->hasVariableType($firstParamName)->yes()) { + if (!$stmtScope->hasVariableType($firstParamName)->no()) { $types[] = $stmtScope->getVariableType($firstParamName); $nativeTypes[] = $stmtScope->getNativeType(new Variable($firstParamName)); }
$types[] = $stmtScope->getVariableType($firstParamName);
$nativeTypes[] = $stmtScope->getNativeType(new Variable($firstParamName));
}
}
if (count($types) > 0) {
$arrayWalkValueTypes = [
TypeCombinator::union(...$types),
TypeCombinator::union(...$nativeTypes),
];
}
}
$nodeCallback($node, $scope);
};
}
}

$argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context);
$scope = $argsResult->getScope();
$hasYield = $argsResult->hasYield();
$throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating();

if ($arrayWalkValueTypes !== null && $arrayWalkArrayArg !== null) {
$newArrayType = $this->getArrayWalkResultType($arrayWalkOriginalArrayType, $arrayWalkValueTypes[0]);
$newArrayNativeType = $this->getArrayWalkResultType($arrayWalkOriginalArrayNativeType, $arrayWalkValueTypes[1]);

$scope = $nodeScopeResolver->processVirtualAssign(
$scope,
$storage,
$stmt,
$arrayWalkArrayArg,
new NativeTypeExpr($newArrayType, $newArrayNativeType),
$nodeCallback,
)->getScope();
}

if ($normalizedExpr->name instanceof Expr) {
$nameType = $scope->getType($normalizedExpr->name);
if (
Expand Down Expand Up @@ -705,6 +785,31 @@
});
}

private function getArrayWalkResultType(Type $arrayType, Type $newValueType): Type
{
return TypeTraverser::map($arrayType, static function (Type $type, callable $traverse) use ($newValueType): Type {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}

if ($type instanceof ConstantArrayType) {
return new ConstantArrayType(
$type->getKeyTypes(),
array_fill(0, count($type->getValueTypes()), $newValueType),
$type->getNextAutoIndexes(),
$type->getOptionalKeys(),
$type->isList(),
);
}

if (!$type instanceof ArrayType) {
return $type;
}

return new ArrayType($type->getKeyType(), $newValueType);
});
}

public function resolveType(MutatingScope $scope, Expr $expr): Type
{
if ($expr->name instanceof Expr) {
Expand Down
97 changes: 97 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14525.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php declare(strict_types = 1);

namespace Bug14525;

use function PHPStan\Testing\assertType;

function basicArrayWalk(): void
{
$array = ['a' => 1, 'b' => 2];
array_walk($array, function (&$value, $key): void {
$value = (string) $value;
});
assertType("array{a: '1'|'2', b: '1'|'2'}", $array);
}

function arrayWalkGeneric(): void
{
/** @var array<string, int> $array */
$array = ['a' => 1, 'b' => 2];
array_walk($array, function (&$value, $key): void {
$value = (string) $value;
});
assertType("array<string, lowercase-string&numeric-string&uppercase-string>", $array);
}

function arrayWalkNoModification(): void
{
/** @var array<string, int> $array */
$array = ['a' => 1, 'b' => 2];
array_walk($array, function (&$value, $key): void {
echo $value;
});
assertType("array<string, int>", $array);
}

function arrayWalkConditionalModification(): void
{
/** @var array<string, int> $array */
$array = ['a' => 1, 'b' => 2];
array_walk($array, function (&$value, string $key): void {
if ($key === 'a') {
$value = 'modified';
return;
}
});
assertType("array<string, 'modified'|int>", $array);
}

function arrayWalkWithoutByRef(): void
{
/** @var array<string, int> $array */
$array = ['a' => 1, 'b' => 2];
array_walk($array, function ($value, $key): void {
$value = (string) $value;
});
assertType("array<string, int>", $array);
}

function arrayWalkNonEmptyArray(): void
{
/** @var non-empty-array<string, int> $array */
$array = ['a' => 1];
array_walk($array, function (&$value): void {
$value = (string) $value;
});
assertType("non-empty-array<string, lowercase-string&numeric-string&uppercase-string>", $array);
}

function arrayWalkList(): void
{
/** @var list<int> $list */
$list = [1, 2, 3];
array_walk($list, function (&$value): void {
$value = (string) $value;
});
assertType("list<lowercase-string&numeric-string&uppercase-string>", $list);
}

function arrayWalkAlwaysTerminating(): void
{
/** @var array<string, int> $array */
$array = ['a' => 1, 'b' => 2];
array_walk($array, function (&$value): void {
$value = (string) $value;
return;
});
assertType("array<string, lowercase-string&numeric-string&uppercase-string>", $array);
}

function arrayWalkNestedArray(): void
{
$array = ['a' => ['x' => 1, 'y' => 2], 'b' => ['z' => 3]];
array_walk($array, function (&$value): void {
$value = count($value);
});
assertType("array{a: 1|2, b: 1|2}", $array);
}
4 changes: 2 additions & 2 deletions tests/PHPStan/Rules/Arrays/data/bug-7469.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ function doFoo() {
array_walk($data['languages'], static function (&$item) {
$item = strtolower(trim($item));
});
assertType("non-empty-array<'address'|'bankAccount'|'birthDate'|'email'|'firstName'|'ic'|'invoicing'|'invoicingAddress'|'languages'|'lastName'|'note'|'phone'|'radio'|'videoOnline'|'videoTvc'|'voiceExample', mixed>&hasOffsetValue('languages', non-empty-list<string>)", $data);
assertType("non-empty-array<'address'|'bankAccount'|'birthDate'|'email'|'firstName'|'ic'|'invoicing'|'invoicingAddress'|'languages'|'lastName'|'note'|'phone'|'radio'|'videoOnline'|'videoTvc'|'voiceExample', mixed>&hasOffsetValue('languages', non-empty-list<lowercase-string>)", $data);

$data['videoOnline'] = normalizePrice($data['videoOnline']);
$data['videoTvc'] = normalizePrice($data['videoTvc']);
$data['radio'] = normalizePrice($data['radio']);

$data['invoicing'] = $data['invoicing'] === 'ANO';
assertType("non-empty-array<'address'|'bankAccount'|'birthDate'|'email'|'firstName'|'ic'|'invoicing'|'invoicingAddress'|'languages'|'lastName'|'note'|'phone'|'radio'|'videoOnline'|'videoTvc'|'voiceExample', mixed>&hasOffsetValue('invoicing', bool)&hasOffsetValue('languages', non-empty-list<string>)&hasOffsetValue('radio', mixed)&hasOffsetValue('videoOnline', mixed)&hasOffsetValue('videoTvc', mixed)", $data);
assertType("non-empty-array<'address'|'bankAccount'|'birthDate'|'email'|'firstName'|'ic'|'invoicing'|'invoicingAddress'|'languages'|'lastName'|'note'|'phone'|'radio'|'videoOnline'|'videoTvc'|'voiceExample', mixed>&hasOffsetValue('invoicing', bool)&hasOffsetValue('languages', non-empty-list<lowercase-string>)&hasOffsetValue('radio', mixed)&hasOffsetValue('videoOnline', mixed)&hasOffsetValue('videoTvc', mixed)", $data);
}

function normalizePrice($value)
Expand Down
Loading