Skip to content
Merged
35 changes: 35 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
use PHPStan\Type\IntegerType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\IterableType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\NullType;
Expand Down Expand Up @@ -1250,6 +1251,19 @@ private function processStmtNode(
$exprType = $scope->getType($stmt->expr);
$isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce();
if ($exprType->isIterable()->no() || $isIterableAtLeastOnce->maybe()) {
$foreachType = $this->getForeachIterateeType();
if (
!$foreachType->isSuperTypeOf($exprType)->yes()
&& $finalScope->getType($stmt->expr)->equals($foreachType)
) {
// restore iteratee type, in case the type was narrowed while entering the foreach
$finalScope = $finalScope->assignExpression(
$stmt->expr,
$exprType,
$scope->getNativeType($stmt->expr),
);
}

$finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr(
new BinaryOp\Identical(
$stmt->expr,
Expand Down Expand Up @@ -6307,11 +6321,32 @@ private function processVarAnnotation(MutatingScope $scope, array $variableNames
return $scope;
}

private function getForeachIterateeType(): Type
{
return new IterableType(new MixedType(), new MixedType());
}

private function enterForeach(MutatingScope $scope, MutatingScope $originalScope, Foreach_ $stmt): MutatingScope
{
if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) {
$scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt);
}

// narrow the iteratee type to those supported by foreach
$foreachType = $this->getForeachIterateeType();
$scope = $scope->specifyExpressionType(
$stmt->expr,
TypeCombinator::intersect(
$scope->getType($stmt->expr),
$foreachType,
),
TypeCombinator::intersect(
$scope->getNativeType($stmt->expr),
$foreachType,
),
TrinaryLogic::createYes(),
);

$iterateeType = $originalScope->getType($stmt->expr);
if (
($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name))
Expand Down
6 changes: 5 additions & 1 deletion src/Analyser/ResultCache/ResultCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -1131,7 +1131,7 @@ private function getComposerLocks(): array
}

/**
* @return array<string, string>
* @return array<string, array<mixed>>
*/
private function getComposerInstalled(): array
{
Expand All @@ -1149,6 +1149,10 @@ private function getComposerInstalled(): array
}

$installed = require $filePath;
if (!is_array($installed)) {
throw new ShouldNotHappenException();
}

$rootName = $installed['root']['name'];
unset($installed['root']);
unset($installed['versions'][$rootName]);
Expand Down
9 changes: 8 additions & 1 deletion src/Type/MixedType.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,14 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni

public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type
{
return new self($this->isExplicitMixed);
$types = [
new ArrayType(new MixedType(), new MixedType()),
new ObjectType(ArrayAccess::class),
];
if (!$offsetType->isInteger()->no()) {
$types[] = new StringType();
}
return TypeCombinator::union(...$types);
}

public function unsetOffset(Type $offsetType): Type
Expand Down
54 changes: 54 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13270a.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug13270a;

use function PHPStan\Testing\assertType;

final class HelloWorld
{
/**
* @param array<mixed> $data
*/
public function test(array $data): void
{
foreach($data as $k => $v) {
assertType('non-empty-array<mixed>', $data);
$data[$k]['a'] = true;
assertType("non-empty-array<(non-empty-array&hasOffsetValue('a', true))|(ArrayAccess&hasOffsetValue('a', true))>", $data);
foreach($data[$k] as $val) {
}
}
}

public function doFoo(
mixed $mixed,
mixed $mixed2,
mixed $mixed3,
mixed $mixed4,
int $i,
int $i2,
string|int $stringOrInt
): void
{
$mixed[$i]['a'] = true;
assertType('mixed', $mixed);

$mixed2[$stringOrInt]['a'] = true;
assertType('mixed', $mixed2);

$mixed3[$i][$stringOrInt] = true;
assertType('mixed', $mixed3);

$mixed4['a'][$stringOrInt] = true;
assertType('mixed', $mixed4);

$null = null;
$null[$i]['a'] = true;
assertType('non-empty-array<int, array{a: true}>', $null);

$i2['a'] = true;
assertType('*ERROR*', $i2);
}
}
27 changes: 27 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13270b.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php declare(strict_types=1);

namespace Bug13270b;

use function PHPStan\Testing\assertType;

class Test
{
/**
* @param mixed[] $data
* @return mixed[]
*/
public function parseData(array $data): array
{
if (isset($data['price'])) {
assertType('mixed~null', $data['price']);
if (!array_key_exists('priceWithVat', $data['price'])) {
$data['price']['priceWithVat'] = null;
}
assertType("(non-empty-array&hasOffsetValue('priceWithVat', mixed))|(ArrayAccess&hasOffsetValue('priceWithVat', null))", $data['price']);
if (!array_key_exists('priceWithoutVat', $data['price'])) {
$data['price']['priceWithoutVat'] = null;
}
}
return $data;
}
}
45 changes: 45 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13312.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php // lint >= 8.0

namespace Bug13312;

use function PHPStan\Testing\assertType;

function fooArr(array $arr): void {
assertType('array', $arr);
foreach ($arr as $v) {
assertType('non-empty-array', $arr);
}
assertType('array', $arr);

for ($i = 0; $i < count($arr); ++$i) {
assertType('non-empty-array', $arr);
}
assertType('array', $arr);
}

/** @param list<mixed> $arr */
function foo(array $arr): void {
assertType('list<mixed>', $arr);
foreach ($arr as $v) {
assertType('non-empty-list<mixed>', $arr);
}
assertType('list<mixed>', $arr);

for ($i = 0; $i < count($arr); ++$i) {
assertType('non-empty-list<mixed>', $arr);
}
assertType('list<mixed>', $arr);
}


function fooBar(mixed $mixed): void {
assertType('mixed', $mixed);
foreach ($mixed as $v) {
assertType('iterable', $mixed); // could be non-empty-array|Traversable
}
assertType('mixed', $mixed);

foreach ($mixed as $v) {}

assertType('mixed', $mixed);
}
19 changes: 11 additions & 8 deletions tests/PHPStan/Analyser/nsrt/composer-array-bug.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,28 @@ class Foo
public function doFoo(): void
{
if (!empty($this->config['authors'])) {
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
foreach ($this->config['authors'] as $key => $author) {
assertType("iterable", $this->config['authors']);

if (!is_array($author)) {
$this->errors[] = 'authors.'.$key.' : should be an array, '.gettype($author).' given';
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
unset($this->config['authors'][$key]);
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
continue;
}
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
foreach (['homepage', 'email', 'name', 'role'] as $authorData) {
if (isset($author[$authorData]) && !is_string($author[$authorData])) {
$this->errors[] = 'authors.'.$key.'.'.$authorData.' : invalid value, must be a string';
unset($this->config['authors'][$key][$authorData]);
}
}
if (isset($author['homepage'])) {
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
unset($this->config['authors'][$key]['homepage']);
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
}
if (isset($author['email']) && !filter_var($author['email'], FILTER_VALIDATE_EMAIL)) {
unset($this->config['authors'][$key]['email']);
Expand All @@ -44,8 +47,8 @@ public function doFoo(): void
}
}

assertType("non-empty-array&hasOffsetValue('authors', mixed)", $this->config);
assertType("mixed", $this->config['authors']);
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);

if (empty($this->config['authors'])) {
unset($this->config['authors']);
Expand All @@ -54,7 +57,7 @@ public function doFoo(): void
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
}

assertType('array', $this->config);
assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ public function doFoo()
if (!empty($this->config['authors'])) {
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
foreach ($this->config['authors'] as $key => $author) {
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
if (!is_array($author)) {
unset($this->config['authors'][$key]);
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
continue;
}
foreach (['homepage', 'email', 'name', 'role'] as $authorData) {
Expand All @@ -33,13 +33,13 @@ public function doFoo()
unset($this->config['authors'][$key]['email']);
}
if (empty($this->config['authors'][$key])) {
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
unset($this->config['authors'][$key]);
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
}
assertType("mixed", $this->config['authors']);
assertType("iterable", $this->config['authors']);
}
assertType("mixed", $this->config['authors']);
assertType("mixed~(0|0.0|''|'0'|array{}|false|null)", $this->config['authors']);
}
}

Expand Down
6 changes: 6 additions & 0 deletions tests/PHPStan/Rules/Arrays/IterableInForeachRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,10 @@ public function testMixed(bool $checkExplicitMixed, bool $checkImplicitMixed, ar
$this->analyse([__DIR__ . '/data/foreach-mixed.php'], $errors);
}

#[RequiresPhp('>= 8.0')]
public function testBug13312(): void
{
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13312.php'], []);
}

}
Loading