Skip to content
Open
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
60 changes: 59 additions & 1 deletion src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,13 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array
}

// simplify string[] | int[] to (string|int)[]
if ($a instanceof IterableType && $b instanceof IterableType) {
// - but not TKey|TValue both of iterable, that would forget the template types
if (
$a instanceof IterableType
&& $b instanceof IterableType
&& !$a instanceof TemplateType
&& !$b instanceof TemplateType
) {
return [
new IterableType(
self::union($a->getIterableKeyType(), $b->getIterableKeyType()),
Expand Down Expand Up @@ -1132,6 +1138,48 @@ private static function findEmptyArrayNextToOversizedArray(array $arrayTypes): ?
return $hasOversizedArray ? $emptyArray : null;
}

/**
* Splits the arrays of a union into the arrays of each template type and the rest.
*
* Arrays of different template types cannot be merged: `TKey|TValue` both of
* `array` is not `TKey` - nor is it a plain array, the merge would forget which
* template types the values come from.
*
* @param list<Type> $arrayTypes
* @return list<list<Type>>
*/
private static function groupArrayTypesByTemplateType(array $arrayTypes): array
{
$groups = [];
$templateTypes = [];
$otherArrayTypes = [];
foreach ($arrayTypes as $arrayType) {
if (!$arrayType instanceof TemplateType) {
$otherArrayTypes[] = $arrayType;
continue;
}

foreach ($templateTypes as $i => $templateType) {
if (
$templateType->getName() === $arrayType->getName()
&& $templateType->getScope()->equals($arrayType->getScope())
) {
$groups[$i][] = $arrayType;
continue 2;
}
}

$templateTypes[] = $arrayType;
$groups[] = [$arrayType];
}

if ($otherArrayTypes !== []) {
$groups[] = $otherArrayTypes;
}

return $groups;
}

/**
* @param list<Type> $arrayTypes
* @return Type[]
Expand All @@ -1142,6 +1190,16 @@ private static function processArrayTypes(array $arrayTypes): array
return [];
}

$arrayTypeGroups = self::groupArrayTypesByTemplateType($arrayTypes);
if (count($arrayTypeGroups) > 1) {
$processedArrayTypes = [];
foreach ($arrayTypeGroups as $arrayTypeGroup) {
$processedArrayTypes = array_merge($processedArrayTypes, self::processArrayTypes($arrayTypeGroup));
}

return $processedArrayTypes;
}

$emptyArray = self::findEmptyArrayNextToOversizedArray($arrayTypes);
if ($emptyArray !== null) {
// like reduceArrays() does when it generalizes to an oversized array:
Expand Down
34 changes: 33 additions & 1 deletion src/Type/UnionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,11 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
}

if ($type instanceof IterableType) {
return $this->accepts($type->toArrayOrTraversable(), $strictTypes);
$result = $this->accepts($type->toArrayOrTraversable(), $strictTypes);
// TA|TB accepts TA, but not the array|Traversable it stands for
if ($result->yes() || !$type instanceof TemplateIterableType) {
return $result;
}
}

foreach (self::EQUAL_UNION_CLASSES as $baseClass => $classes) {
Expand Down Expand Up @@ -1407,14 +1411,42 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
$receivedType = TypeCombinator::union(...$remainingReceivedTypes);
}

$nakedTemplateTypes = [];
$otherTypes = [];
foreach ($this->types as $type) {
if (self::getNakedTemplateType($type) !== null) {
$nakedTemplateTypes[] = $type;
continue;
}
$otherTypes[] = $type;
$types = $types->union($type->inferTemplateTypes($receivedType));
}

if (!$types->isEmpty()) {
if ($nakedTemplateTypes === []) {
return $types;
}

// T|Box<T> receiving stdClass|Box<int>: what the other members
// infer nothing from is what the naked template types receive
$unexplainedReceivedTypes = [];
foreach ($remainingReceivedTypes as $remainingReceivedType) {
foreach ($otherTypes as $otherType) {
if (!$otherType->inferTemplateTypes($remainingReceivedType)->isEmpty()) {
continue 2;
}
}
$unexplainedReceivedTypes[] = $remainingReceivedType;
}
if ($unexplainedReceivedTypes === []) {
return $types;
}

$unexplainedReceivedType = TypeCombinator::union(...$unexplainedReceivedTypes);
foreach ($nakedTemplateTypes as $nakedTemplateType) {
$types = $types->union($nakedTemplateType->inferTemplateTypes($unexplainedReceivedType));
}

return $types;
}

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

namespace Bug15431Nsrt;

use stdClass;
use function PHPStan\Testing\assertType;

/**
* @template T of array<mixed>
*/
class Box
{
}

/**
* @template TKey of object|array<mixed>
* @template TValue of object|array<mixed>
*/
class Map
{

/**
* @template T of TKey|TValue
* @param (T is object ? T : Box<T>) $value
* @return T
*/
protected function unbox(object $value)
{
assertType('Bug15431Nsrt\Box<T of TKey of array<mixed> (class Bug15431Nsrt\Map, argument)|TValue of array<mixed> (class Bug15431Nsrt\Map, argument) (method Bug15431Nsrt\Map::unbox(), argument)>|(T of TKey of object (class Bug15431Nsrt\Map, argument)|TValue of object (class Bug15431Nsrt\Map, argument) (method Bug15431Nsrt\Map::unbox(), argument))', $value);
}

/**
* @param (TValue is object ? TValue : Box<TValue>) $v
* @param Box<TValue&array<mixed>> $b
* @param TValue&object $o
*/
public function doFoo($v, $b, $o): void
{
assertType('TValue of array<mixed> (class Bug15431Nsrt\Map, argument)|TValue of object (class Bug15431Nsrt\Map, argument)', $this->unbox($v));
assertType('TValue of array<mixed> (class Bug15431Nsrt\Map, argument)', $this->unbox($b));
assertType('TValue of object (class Bug15431Nsrt\Map, argument)', $this->unbox($o));
}

/**
* @param (TKey&array<mixed>)|(TValue&array<mixed>) $arrays
* @param (TKey&list<mixed>)|(TValue&list<mixed>) $lists
*/
public function doBar($arrays, $lists): void
{
assertType('TKey of array<mixed> (class Bug15431Nsrt\Map, argument)|TValue of array<mixed> (class Bug15431Nsrt\Map, argument)', $arrays);
assertType('TKey of list<mixed> (class Bug15431Nsrt\Map, argument)|TValue of list<mixed> (class Bug15431Nsrt\Map, argument)', $lists);
}

}

/**
* @template TA of array{a: int}
* @template TB of array{a: int}
* @template TC of array<string, int>
* @template TD of list<int>
* @param TA|TB $ab
* @param TC|TD $cd
* @param TA|array<int, string> $mixed
*/
function constantArrays($ab, $cd, $mixed): void
{
assertType('TA of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)|TB of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)', $ab);
assertType('TC of array<string, int> (function Bug15431Nsrt\constantArrays(), argument)|TD of list<int> (function Bug15431Nsrt\constantArrays(), argument)', $cd);
assertType('array<int, string>|TA of array{a: int} (function Bug15431Nsrt\constantArrays(), argument)', $mixed);
}

/**
* @template T of object|array<mixed>
* @param T|Box<T> $value
* @return T
*/
function unboxNaked($value)
{
}

/**
* @template T of object|array<mixed>
* @param (T is object ? T : Box<T>) $value
* @return T
*/
function unboxConditional(object $value)
{
}

/**
* @template T
* @param T|array<T> $value
* @return T
*/
function unwrapArray($value)
{
}

/**
* @param stdClass|Box<array{int}> $a
* @param int|array<string> $b
* @param array<string> $c
*/
function doBaz($a, $b, $c): void
{
assertType('array{int}|stdClass', unboxNaked($a));
assertType('array{int}|stdClass', unboxConditional($a));
assertType('int|string', unwrapArray($b));
assertType('string', unwrapArray($c));
}

/**
* @template TA of iterable<mixed>
* @template TB of iterable<int>
* @param TA $a
* @param TB $b
*/
function iterables($a, $b): void
{
$c = rand(0, 1) === 1 ? $a : $b;
assertType('TA of iterable (function Bug15431Nsrt\iterables(), argument)|TB of iterable<int> (function Bug15431Nsrt\iterables(), argument)', $c);
}
8 changes: 8 additions & 0 deletions tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4616,4 +4616,12 @@ public function testDeadCatch(): void
]);
}

public function testBug15431(): void
{
$this->checkThisOnly = false;
$this->checkNullables = true;
$this->checkUnionTypes = true;
$this->analyse([__DIR__ . '/data/bug-15431.php'], []);
}

}
18 changes: 18 additions & 0 deletions tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1419,4 +1419,22 @@ public function testBug13325(): void
$this->analyse([__DIR__ . '/data/bug-13325.php'], []);
}

public function testBug15431(): void
{
$this->analyse([__DIR__ . '/data/bug-15431.php'], [
[
'Method Bug15431\\WeakAnalysingMap::unboxValue() should return T of (TKey of array<mixed>|object)|(TValue of array<mixed>|object) but returns array<mixed>|(T of (TKey of object)|TValue of object).',
30,
'• Type #1 from the union: Type array<mixed> is not always the same as TKey. It breaks the contract for some argument types, typically subtypes.
• Type #2 from the union: Type array<mixed> is not always the same as TValue. It breaks the contract for some argument types, typically subtypes.
• Type array<mixed> is not always the same as T. It breaks the contract for some argument types, typically subtypes.',
],
]);
}

public function testBug15431Iterable(): void
{
$this->analyse([__DIR__ . '/data/bug-15431-iterable.php'], []);
}

}
47 changes: 47 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-15431-iterable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare(strict_types = 1);

namespace Bug15431Iterable;

/**
* @template TKey of object|iterable<mixed>
* @template TValue of object|array<mixed>
*/
class Map
{

/**
* @param TKey&iterable<mixed> $a
* @param TValue&array<mixed> $b
* @param (TValue&array<mixed>)|(TKey&iterable<mixed>) $c
* @return TKey|TValue
*/
public function doFoo($a, $b, $c)
{
if (rand(0, 1) === 1) {
return $a;
}
if (rand(0, 1) === 1) {
return $b;
}

return $c;
}

}

class Foo
{

/**
* @template TA of iterable<mixed>
* @template TB of iterable<mixed>
* @param TA $a
* @param TB $b
* @return TA|TB
*/
public function doFoo($a, $b)
{
return rand(0, 1) === 1 ? $a : $b;
}

}
44 changes: 44 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-15431.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);

namespace Bug15431;

/**
* @template T of array<mixed>
*/
class WeakAnalysingBoxedArray
{
/** @var T */
public array $value = [];
}

/**
* @template TKey of object|array<mixed>
* @template TValue of object|array<mixed>
* @template TOwner of object
*/
class WeakAnalysingMap
{
/**
* @template T of TKey|TValue
*
* @param (T is object ? T : WeakAnalysingBoxedArray<T>) $value
*
* @return T
*/
protected function unboxValue(object $value)
{
return $value instanceof WeakAnalysingBoxedArray
? $value->value
: $value;
}

/**
* @param (TValue is object ? TValue : WeakAnalysingBoxedArray<TValue>) $v
*
* @return TValue|null
*/
public function unboxValueOnly($v)
{
return $this->unboxValue($v);
}
}
Loading
Loading