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
15 changes: 13 additions & 2 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use PHPStan\Node\Expr\GetIterableValueTypeExpr;
use PHPStan\Node\Expr\GetOffsetValueTypeExpr;
use PHPStan\Node\Expr\NativeTypeExpr;
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
use PHPStan\Node\Expr\PropertyInitializationExpr;
Expand Down Expand Up @@ -3949,13 +3950,19 @@ public function enterForeachKey(self $originalScope, Expr $iteratee, string $key
{
$iterateeType = $originalScope->getType($iteratee);
$nativeIterateeType = $originalScope->getNativeType($iteratee);

$keyType = $originalScope->getIterableKeyType($iterateeType);
$nativeKeyType = $originalScope->getIterableKeyType($nativeIterateeType);

$scope = $this->assignVariable(
$keyName,
$originalScope->getIterableKeyType($iterateeType),
$originalScope->getIterableKeyType($nativeIterateeType),
$keyType,
$nativeKeyType,
TrinaryLogic::createYes(),
);

$originalForeachKeyExpr = new OriginalForeachKeyExpr($keyName);
$scope = $scope->assignExpression($originalForeachKeyExpr, $keyType, $nativeKeyType);
if ($iterateeType->isArray()->yes()) {
$scope = $scope->assignExpression(
new Expr\ArrayDimFetch($iteratee, new Variable($keyName)),
Expand Down Expand Up @@ -4139,6 +4146,10 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp
unset($scope->expressionTypes[$parameterOriginalValueExprString]);
unset($scope->nativeExpressionTypes[$parameterOriginalValueExprString]);

$originalForeachKeyExpr = $this->getNodeKey(new OriginalForeachKeyExpr($variableName));
unset($scope->expressionTypes[$originalForeachKeyExpr]);
unset($scope->nativeExpressionTypes[$originalForeachKeyExpr]);

return $scope;
}

Expand Down
85 changes: 83 additions & 2 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
use PHPStan\Node\Expr\GetIterableValueTypeExpr;
use PHPStan\Node\Expr\GetOffsetValueTypeExpr;
use PHPStan\Node\Expr\NativeTypeExpr;
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
use PHPStan\Node\Expr\PropertyInitializationExpr;
use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr;
Expand Down Expand Up @@ -1247,14 +1248,94 @@ private function processStmtNode(
$bodyScope = $this->enterForeach($bodyScope, $originalScope, $stmt, $nodeCallback);
$finalScopeResult = $this->processStmtNodes($stmt, $stmt->stmts, $bodyScope, $nodeCallback, $context)->filterOutLoopExitPoints();
$finalScope = $finalScopeResult->getScope();
$scopesWithIterableValueType = [];

$originalKeyVarExpr = null;
$continueExitPointHasUnoriginalKeyType = false;
if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) {
$originalKeyVarExpr = new OriginalForeachKeyExpr($stmt->keyVar->name);
if ($finalScope->hasExpressionType($originalKeyVarExpr)->yes()) {
$scopesWithIterableValueType[] = $finalScope;
} else {
$continueExitPointHasUnoriginalKeyType = true;
}
}

foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
$finalScope = $continueExitPoint->getScope()->mergeWith($finalScope);
$continueScope = $continueExitPoint->getScope();
$finalScope = $continueScope->mergeWith($finalScope);
if ($originalKeyVarExpr === null || !$continueScope->hasExpressionType($originalKeyVarExpr)->yes()) {
$continueExitPointHasUnoriginalKeyType = true;
continue;
}
$scopesWithIterableValueType[] = $continueScope;
}
foreach ($finalScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
$breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class);
foreach ($breakExitPoints as $breakExitPoint) {
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
}

$exprType = $scope->getType($stmt->expr);
$hasExpr = $scope->hasExpressionType($stmt->expr);
if (
count($breakExitPoints) === 0
&& count($scopesWithIterableValueType) > 0
&& !$continueExitPointHasUnoriginalKeyType
&& $stmt->keyVar !== null
&& $exprType->isArray()->yes()
&& $exprType->isConstantArray()->no()
Copy link
Contributor

Choose a reason for hiding this comment

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

tbh I did not yet understand why this PR works, but I wonder whether we could use the same logic to also infer a better result when the iteratee is a known constant array?

like in

Copy link
Member Author

Choose a reason for hiding this comment

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

Why this works - I look at $a[$k] type at the end of the loop and at each continue point and if some other conditions are true (like there are zero break points), I rewrite the whole array value type with the $a[$k] type unioned from all the points I looked at.

Copy link
Member Author

Choose a reason for hiding this comment

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

When $foreach->expr is a constant array I feel like we need a different approach, possibly analysing the foreach separately for each item (but run $nodeCallback for the rules still only once).

Copy link
Member Author

Choose a reason for hiding this comment

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

Also I want to look at foreach ($a as &$v) to understand it better that also needs a different approach.

&& !$hasExpr->no()
) {
$arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $stmt->keyVar);
$arrayDimFetchLoopTypes = [];
foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) {
$arrayDimFetchLoopTypes[] = $scopeWithIterableValueType->getType($arrayExprDimFetch);
}

$arrayDimFetchLoopType = TypeCombinator::union(...$arrayDimFetchLoopTypes);

$arrayDimFetchLoopNativeTypes = [];
foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) {
$arrayDimFetchLoopNativeTypes[] = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch);
}

$arrayDimFetchLoopNativeType = TypeCombinator::union(...$arrayDimFetchLoopNativeTypes);

if (!$arrayDimFetchLoopType->equals($exprType->getIterableValueType())) {
$newExprType = TypeTraverser::map($exprType, static function (Type $type, callable $traverse) use ($arrayDimFetchLoopType): Type {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}

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

return new ArrayType($type->getKeyType(), $arrayDimFetchLoopType);
});
$newExprNativeType = TypeTraverser::map($scope->getNativeType($stmt->expr), static function (Type $type, callable $traverse) use ($arrayDimFetchLoopNativeType): Type {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}

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

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

if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) {
$finalScope = $finalScope->assignVariable(
$stmt->expr->name,
$newExprType,
$newExprNativeType,
$hasExpr,
);
}
}
}

$isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce();
if ($exprType->isIterable()->no() || $isIterableAtLeastOnce->maybe()) {
$finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr(
Expand Down
37 changes: 37 additions & 0 deletions src/Node/Expr/OriginalForeachKeyExpr.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node\Expr;

use Override;
use PhpParser\Node\Expr;
use PHPStan\Node\VirtualNode;

final class OriginalForeachKeyExpr extends Expr implements VirtualNode
{

public function __construct(private string $variableName)
{
parent::__construct([]);
}

public function getVariableName(): string
{
return $this->variableName;
}

#[Override]
public function getType(): string
{
return 'PHPStan_Node_OriginalForeachKeyExpr';
}

/**
* @return string[]
*/
#[Override]
public function getSubNodeNames(): array
{
return [];
}

}
6 changes: 6 additions & 0 deletions src/Node/Printer/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PHPStan\Node\Expr\GetIterableValueTypeExpr;
use PHPStan\Node\Expr\GetOffsetValueTypeExpr;
use PHPStan\Node\Expr\NativeTypeExpr;
use PHPStan\Node\Expr\OriginalForeachKeyExpr;
use PHPStan\Node\Expr\OriginalPropertyTypeExpr;
use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr;
use PHPStan\Node\Expr\PropertyInitializationExpr;
Expand Down Expand Up @@ -99,6 +100,11 @@ protected function pPHPStan_Node_ParameterVariableOriginalValueExpr(ParameterVar
return sprintf('__phpstanParameterVariableOriginalValue(%s)', $expr->getVariableName());
}

protected function pPHPStan_Node_OriginalForeachKeyExpr(OriginalForeachKeyExpr $expr): string // phpcs:ignore
{
return sprintf('__phpstanOriginalForeachKey(%s)', $expr->getVariableName());
}

protected function pPHPStan_Node_IssetExpr(IssetExpr $expr): string // phpcs:ignore
{
return sprintf('__phpstanIssetExpr(%s)', $this->p($expr->getExpr()));
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-12274.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function getItems(array $items): array
$items[$index] = 1;
}

assertType('non-empty-list<int>', $items);
assertType('non-empty-list<1>', $items);
return $items;
}

Expand Down
24 changes: 24 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13730.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);

namespace Bug13730;

use function PHPStan\dumpType;
use function PHPStan\Testing\assertType;

class HelloWorld
{
/**
* @param array<string|null> $arr
* @return array<string>
*/
public function sayHello(array $arr): array
{
foreach($arr as $k => $v) {
$arr[$k] = $v ?? '';
}

assertType('array<string>', $arr);

return $arr;
}
}
22 changes: 22 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-2273.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Bug2273;

use function PHPStan\Testing\assertType;

class Foo {
/**
* @param string[] $x
*/
public function doFoo(array $x): void
{
foreach ($x as $k => $v) {
$x[$k] = \realpath($v);
if ($x[$k] === false) {
throw new \Exception();
}
}

assertType('array<non-empty-string>', $x);
}
}
131 changes: 131 additions & 0 deletions tests/PHPStan/Analyser/nsrt/overwritten-arrays.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

namespace OverwrittenArrays;

use function PHPStan\Testing\assertType;
use function rad2deg;

class Foo
{

/**
* @param array<int, string> $a
*/
public function doFoo(array $a): void
{
foreach ($a as $k => $v) {
$a[$k] = 1;
}

assertType('array<int, 1>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo2(array $a): void
{
foreach ($a as $k => $v) {
if (rand(0, 1)) {
$a[$k] = 2;
continue;
}
$a[$k] = 1;
}

assertType('array<int, 1|2>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo3(array $a): void
{
foreach ($a as $k => $v) {
if (rand(0, 1)) {
break;
}
if (rand(0, 1)) {
$a[$k] = 2;
continue;
}
$a[$k] = 1;
}

assertType('array<int, 1|2|string>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo4(array $a): void
{
foreach ($a as $k => $v) {
$k++;
$a[$k] = 1;
}

assertType('array<int, 1|string>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo5(array $a): void
{
foreach ($a as $k => $v) {
if (rand(0, 1)) {
$k++;
$a[$k] = 2;
continue;
}
$a[$k] = 1;
}

assertType('array<int, 1|2|string>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo6(array $a): void
{
foreach ($a as $k => $v) {
if (rand(0, 1)) {
$a[$k] = 2;
continue;
}
$k++;
$a[$k] = 1;
}

assertType('array<int, 1|2|string>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo7(array $a): void
{
foreach ($a as &$v) {
$v = 1;
}

assertType('array<int, 1>', $a);
}

/**
* @param array<int, string> $a
*/
public function doFoo8(array $a): void
{
foreach ($a as &$v) {
if (rand(0, 1)) {
$v = 1;
}
}

assertType('array<int, 1|string>', $a);
}

}
Loading