Skip to content

Fixed HasOffsetValueType accessory missing main-type #4162

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: 2.1.x
Choose a base branch
from
Open
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