Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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, $callbackArg, $firstParamName, &$arrayWalkValueTypes): void {
if ($node instanceof ClosureReturnStatementsNode && $node->getClosureExpr() === $callbackArg) {
$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.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; }

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; }
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.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)); }

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)); }
$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
125 changes: 125 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14525.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?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);
}

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

function arrayWalkWithNestedClosureByRef(): void
{
/** @var array<string, int> $array */
$array = ['a' => 1, 'b' => 2];
array_walk($array, function (&$value): void {
$capture = null;
$fn = function () use (&$capture): void {
$capture = 'hello';
};
$fn();
$value = (string) $value;
});
assertType("array<string, lowercase-string&numeric-string&uppercase-string>", $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