diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index c67cb05ff7..c3cc3abbcd 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -145,25 +145,12 @@ public static function union(Type ...$types): Type return new NeverType(); } - $benevolentTypes = []; - $benevolentUnionObject = null; // transform A | (B | C) to A | B | C for ($i = 0; $i < $typesCount; $i++) { - if ($types[$i] instanceof BenevolentUnionType) { - if ($types[$i] instanceof TemplateBenevolentUnionType && $benevolentUnionObject === null) { - $benevolentUnionObject = $types[$i]; - } - $benevolentTypesCount = 0; - $typesInner = $types[$i]->getTypes(); - foreach ($typesInner as $benevolentInnerType) { - $benevolentTypesCount++; - $benevolentTypes[$benevolentInnerType->describe(VerbosityLevel::value())] = $benevolentInnerType; - } - array_splice($types, $i, 1, $typesInner); - $typesCount += $benevolentTypesCount - 1; + if (!($types[$i] instanceof UnionType)) { continue; } - if (!($types[$i] instanceof UnionType)) { + if ($types[$i] instanceof BenevolentUnionType) { continue; } if ($types[$i] instanceof TemplateType) { @@ -347,25 +334,6 @@ public static function union(Type ...$types): Type return $types[0]; } - if ($benevolentTypes !== []) { - $tempTypes = $types; - foreach ($tempTypes as $i => $type) { - if (!isset($benevolentTypes[$type->describe(VerbosityLevel::value())])) { - break; - } - - unset($tempTypes[$i]); - } - - if ($tempTypes === []) { - if ($benevolentUnionObject instanceof TemplateBenevolentUnionType) { - return $benevolentUnionObject->withTypes($types); - } - - return new BenevolentUnionType($types); - } - } - return new UnionType($types, true); } diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 454f511c75..ef5bdf2988 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -70,6 +70,9 @@ public function __construct(private array $types, private bool $normalized = fal if (!($type instanceof UnionType)) { continue; } + if ($type instanceof BenevolentUnionType) { + continue; + } if ($type instanceof TemplateType) { continue; } diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index c8e7a21522..29952dd74d 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -4942,11 +4942,11 @@ public function dataArrayFunctions(): array 'array_search(new stdClass, $generalStringKeys, true)', ], [ - 'int|string|false', + '(int|string)|false', 'array_search($mixed, $array, true)', ], [ - 'int|string|false', + '(int|string)|false', 'array_search($mixed, $array, false)', ], [ @@ -5006,15 +5006,15 @@ public function dataArrayFunctions(): array 'array_search(\'id\', doFoo() ? $thisDoesNotExistAndIsMixedInUnion : false, true)', ], [ - 'int|string|false', + '(int|string)|false', 'array_search(1, $generalIntegers, true)', ], [ - 'int|string|false', + '(int|string)|false', 'array_search(1, $generalIntegers, false)', ], [ - 'int|string|false', + '(int|string)|false', 'array_search(1, $generalIntegers)', ], [ @@ -8820,11 +8820,11 @@ public function dataPhp73Functions(): array 'json_decode($mixed, false, 512, $integer | JSON_THROW_ON_ERROR | JSON_NUMERIC_CHECK)', ], [ - 'int|string|null', + '(int|string)|null', 'array_key_first($mixedArray)', ], [ - 'int|string|null', + '(int|string)|null', 'array_key_last($mixedArray)', ], [ @@ -8904,7 +8904,7 @@ public function dataPhp73Functions(): array '$hrtime3', ], [ - 'array{int, int}|float|int', + '(float|int)|array{int, int}', '$hrtime4', ], ]; diff --git a/tests/PHPStan/Analyser/nsrt/array-search-php7.php b/tests/PHPStan/Analyser/nsrt/array-search-php7.php index 2cd24b7c9d..3c2b923680 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-php7.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-php7.php @@ -12,9 +12,9 @@ class Foo public function mixedAndSubtractedArray($mixed, string $string): void { if (is_array($mixed)) { - assertType('int|string|false', array_search('foo', $mixed, true)); - assertType('int|string|false', array_search('foo', $mixed)); - assertType('int|string|false', array_search($string, $mixed, true)); + assertType('(int|string)|false', array_search('foo', $mixed, true)); + assertType('(int|string)|false', array_search('foo', $mixed)); + assertType('(int|string)|false', array_search($string, $mixed, true)); } else { assertType('mixed~array', $mixed); assertType('null', array_search('foo', $mixed, true)); diff --git a/tests/PHPStan/Analyser/nsrt/array-search-php8.php b/tests/PHPStan/Analyser/nsrt/array-search-php8.php index c3430cc1b5..2d7297a07f 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search-php8.php +++ b/tests/PHPStan/Analyser/nsrt/array-search-php8.php @@ -12,9 +12,9 @@ class Foo public function mixedAndSubtractedArray($mixed, string $string): void { if (is_array($mixed)) { - assertType('int|string|false', array_search('foo', $mixed, true)); - assertType('int|string|false', array_search('foo', $mixed)); - assertType('int|string|false', array_search($string, $mixed, true)); + assertType('(int|string)|false', array_search('foo', $mixed, true)); + assertType('(int|string)|false', array_search('foo', $mixed)); + assertType('(int|string)|false', array_search($string, $mixed, true)); } else { assertType('mixed~array', $mixed); assertType('*NEVER*', array_search('foo', $mixed, true)); diff --git a/tests/PHPStan/Analyser/nsrt/array-search.php b/tests/PHPStan/Analyser/nsrt/array-search.php index f26f6b7536..244e20d3c5 100644 --- a/tests/PHPStan/Analyser/nsrt/array-search.php +++ b/tests/PHPStan/Analyser/nsrt/array-search.php @@ -10,9 +10,9 @@ class Foo public function nonEmpty(array $arr, string $string): void { /** @var non-empty-array $arr */ - assertType('int|string|false', array_search('foo', $arr, true)); - assertType('int|string|false', array_search('foo', $arr)); - assertType('int|string|false', array_search($string, $arr, true)); + assertType('(int|string)|false', array_search('foo', $arr, true)); + assertType('(int|string)|false', array_search('foo', $arr)); + assertType('(int|string)|false', array_search($string, $arr, true)); } public function normalArrays(array $arr, string $string): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-7279.php b/tests/PHPStan/Analyser/nsrt/bug-7279.php new file mode 100644 index 0000000000..f5f94c1f35 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7279.php @@ -0,0 +1,87 @@ + $array + * @param (callable(T, K): bool) $fn + * + * @return ($array is non-empty-array ? T|null : null) + */ +function find(array $array, callable $fn): mixed +{ + foreach ($array as $key => $value) { + if ($fn($value, $key)) { + return $value; + } + } + + return null; +} + +/** + * @template K of array-key + * @template T + * + * @param array $array + * @param (callable(T, K): bool) $fn + * + * @return ($array is non-empty-array ? K|null : null) + */ +function findKey(array $array, callable $fn): string|int|null +{ + foreach ($array as $key => $value) { + if ($fn($value, $key)) { + return $key; + } + } + + return null; +} + +/** + * @param callable(mixed): bool $callback + * @param array $emptyList + * @param array{} $emptyMap + * @param array $unknownList + * @param array{id?: int, name?: string} $unknownMap + * @param non-empty-array> $nonEmptyList + * @param array{work: Timeline} $nonEmptyMap + */ +function doFoo(callable $callback, array $emptyList, array $emptyMap, array $unknownList, array $unknownMap, array $nonEmptyList, array $nonEmptyMap): void +{ + // Everything works great for find() + + assertType('null', find([], $callback)); + assertType('null', find($emptyList, $callback)); + assertType('null', find($emptyMap, $callback)); + + assertType('string|null', find($unknownList, $callback)); + assertType('int|string|null', find($unknownMap, $callback)); + + assertType('Bug7279\Timeline|null', find($nonEmptyList, $callback)); + assertType('Bug7279\Timeline|null', find($nonEmptyMap, $callback)); + + // But everything goes to hell for findKey() ?!? + + assertType('null', findKey([], $callback)); + assertType('null', findKey($emptyList, $callback)); + assertType('null', findKey($emptyMap, $callback)); + + assertType('int|null', findKey($unknownList, $callback)); + assertType("'id'|'name'|null", findKey($unknownMap, $callback)); + + assertType('int|null', findKey($nonEmptyList, $callback)); + assertType("'work'|null", findKey($nonEmptyMap, $callback)); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7291.php b/tests/PHPStan/Analyser/nsrt/bug-7291.php index cae3e945b3..8ebadf02bc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7291.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7291.php @@ -1,4 +1,6 @@ -= 8.0 + +declare(strict_types=1); namespace Bug7291; diff --git a/tests/PHPStan/Analyser/nsrt/bug-7423.php b/tests/PHPStan/Analyser/nsrt/bug-7423.php new file mode 100644 index 0000000000..9b631674d6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7423.php @@ -0,0 +1,150 @@ + + */ +final class IntType implements TypeGenerator +{ + public function __invoke(): int + { + return mt_rand(0, 9); + } +} + +/** + * @implements TypeGenerator + */ +final class StringType implements TypeGenerator +{ + public function __invoke(): string + { + return chr(random_int(33, 126)); + } +} + + +/** + * @implements TypeGenerator + */ +final class NullType implements TypeGenerator +{ + public function __invoke() + { + return null; + } +} + +/** + * @template TKey of array-key + * @template T + * + * @implements TypeGenerator> + */ +final class ArrayType implements TypeGenerator +{ + /** + * @var list> + */ + private array $keys = []; + + /** + * @var list> + */ + private array $values = []; + + /** + * @param TypeGenerator $key + * @param TypeGenerator $value + */ + public function __construct(TypeGenerator $key, TypeGenerator $value) + { + $this->keys[] = $key; + $this->values[] = $value; + } + + /** + * @return array + */ + public function __invoke(): array + { + $keys = $values = []; + $countKeys = count($this->keys); + + for ($i = 0; count($keys) < $countKeys; ++$i) { + $key = ($this->keys[$i])(); + + if (array_key_exists($key, $keys)) { + --$i; + + continue; + } + + $keys[$key] = $key; + } + + foreach ($this->values as $value) { + $values[] = ($value)(); + } + + return array_combine($keys, $values); + } + + /** + * @template VKey of array-key + * @template V + * + * @param TypeGenerator $key + * @param TypeGenerator $value + * + * @return ArrayType + */ + public function add(TypeGenerator $key, TypeGenerator $value): ArrayType + { + // @TODO: See if we can fix this issue in PHPStan/PSalm. + // There should not be @var annotation here. + // An issue has been opened: https://github.com/vimeo/psalm/issues/8066 + /** @var ArrayType $clone */ + $clone = clone $this; + + /** @var list> $keys */ + $keys = array_merge($this->keys, [$key]); + $clone->keys = $keys; + + /** @var list> $values */ + $values = array_merge($this->values, [$value]); + $clone->values = $values; + + return $clone; + } +} + +(function () { + $sub1 = new ArrayType(new StringType(), new NullType()); + assertType('array', $sub1()); // Passing + + $sub2 = new ArrayType(new IntType(), new NullType()); + assertType('array', $sub2()); // Passing + + $sub3 = (new ArrayType(new StringType(), new StringType()))->add(new IntType(), new IntType()); + assertType('array', $sub3()); // Failing +})(); diff --git a/tests/PHPStan/Analyser/nsrt/key-of-generic.php b/tests/PHPStan/Analyser/nsrt/key-of-generic.php index 71b05a7d3e..1d81ebb844 100644 --- a/tests/PHPStan/Analyser/nsrt/key-of-generic.php +++ b/tests/PHPStan/Analyser/nsrt/key-of-generic.php @@ -33,7 +33,7 @@ function test( ): void { assertType("'j'|'k'|null", $result->getKey()); assertType('0|1|null', $listResult->getKey()); - assertType('int|string|null', $mixedResult->getKey()); + assertType('(int|string)|null', $mixedResult->getKey()); assertType('string|null', $stringKeyResult->getKey()); assertType('int|null', $intKeyResult->getKey()); assertType('null', $emptyResult->getKey()); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 7342c880fe..dc8c57880d 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -351,31 +351,23 @@ public function testCallMethods(): void 791, ], [ - 'Parameter #1 $i of method Test\CheckDefaultArrayKeys::doBar() expects int, int|stdClass|string given.', + 'Parameter #1 $i of method Test\CheckDefaultArrayKeys::doBar() expects int, (int|string)|stdClass given.', 797, ], [ - 'Parameter #1 $str of method Test\CheckDefaultArrayKeys::doBaz() expects string, int|stdClass|string given.', + 'Parameter #1 $str of method Test\CheckDefaultArrayKeys::doBaz() expects string, (int|string)|stdClass given.', 798, ], [ - 'Parameter #1 $intOrString of method Test\CheckDefaultArrayKeys::doLorem() expects int|string, int|stdClass|string given.', + 'Parameter #1 $intOrString of method Test\CheckDefaultArrayKeys::doLorem() expects int|string, (int|string)|stdClass given.', 799, ], [ - 'Parameter #1 $stdOrInt of method Test\CheckDefaultArrayKeys::doIpsum() expects int|stdClass, int|stdClass|string given.', // should not expect this - 800, - ], - [ - 'Parameter #1 $stdOrString of method Test\CheckDefaultArrayKeys::doDolor() expects stdClass|string, int|stdClass|string given.', // should not expect this - 801, - ], - [ - 'Parameter #1 $dateOrString of method Test\CheckDefaultArrayKeys::doSit() expects DateTimeImmutable|string, int|stdClass|string given.', + 'Parameter #1 $dateOrString of method Test\CheckDefaultArrayKeys::doSit() expects DateTimeImmutable|string, (int|string)|stdClass given.', 802, ], [ - 'Parameter #1 $std of method Test\CheckDefaultArrayKeys::doAmet() expects stdClass, int|stdClass|string given.', + 'Parameter #1 $std of method Test\CheckDefaultArrayKeys::doAmet() expects stdClass, (int|string)|stdClass given.', 803, ], [ @@ -727,31 +719,23 @@ public function testCallMethodsOnThisOnly(): void 791, ], [ - 'Parameter #1 $i of method Test\CheckDefaultArrayKeys::doBar() expects int, int|stdClass|string given.', + 'Parameter #1 $i of method Test\CheckDefaultArrayKeys::doBar() expects int, (int|string)|stdClass given.', 797, ], [ - 'Parameter #1 $str of method Test\CheckDefaultArrayKeys::doBaz() expects string, int|stdClass|string given.', + 'Parameter #1 $str of method Test\CheckDefaultArrayKeys::doBaz() expects string, (int|string)|stdClass given.', 798, ], [ - 'Parameter #1 $intOrString of method Test\CheckDefaultArrayKeys::doLorem() expects int|string, int|stdClass|string given.', + 'Parameter #1 $intOrString of method Test\CheckDefaultArrayKeys::doLorem() expects int|string, (int|string)|stdClass given.', 799, ], [ - 'Parameter #1 $stdOrInt of method Test\CheckDefaultArrayKeys::doIpsum() expects int|stdClass, int|stdClass|string given.', // should not expect this - 800, - ], - [ - 'Parameter #1 $stdOrString of method Test\CheckDefaultArrayKeys::doDolor() expects stdClass|string, int|stdClass|string given.', // should not expect this - 801, - ], - [ - 'Parameter #1 $dateOrString of method Test\CheckDefaultArrayKeys::doSit() expects DateTimeImmutable|string, int|stdClass|string given.', + 'Parameter #1 $dateOrString of method Test\CheckDefaultArrayKeys::doSit() expects DateTimeImmutable|string, (int|string)|stdClass given.', 802, ], [ - 'Parameter #1 $std of method Test\CheckDefaultArrayKeys::doAmet() expects stdClass, int|stdClass|string given.', + 'Parameter #1 $std of method Test\CheckDefaultArrayKeys::doAmet() expects stdClass, (int|string)|stdClass given.', 803, ], [ @@ -2815,6 +2799,32 @@ public function testNewInstanceArgsIssue8679(): void $this->analyse([__DIR__ . '/data/reflection-class-issue-8679.php'], []); } + public function testBug7049(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Test requires PHP 8.0.'); + } + + $this->checkThisOnly = false; + $this->checkNullables = false; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/bug-7049.php'], []); + } + + public function testBug8268(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Test requires PHP 8.0.'); + } + + $this->checkThisOnly = false; + $this->checkNullables = false; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/bug-8268.php'], []); + } + public function testNonEmptyArray(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-7049.php b/tests/PHPStan/Rules/Methods/data/bug-7049.php new file mode 100644 index 0000000000..da3c187a1a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-7049.php @@ -0,0 +1,21 @@ + + */ + public function groupBy(int|string|Closure $key): array + { + return []; + } +} + +(function () { + $collection = new Collection(); + $collection->groupBy('id'); +})(); diff --git a/tests/PHPStan/Rules/Methods/data/bug-8268.php b/tests/PHPStan/Rules/Methods/data/bug-8268.php new file mode 100644 index 0000000000..08de3b5742 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-8268.php @@ -0,0 +1,56 @@ + + */ +class Collection implements ArrayAccess +{ + /** @var array */ + protected array $elements = []; + + /** @param TKey $offset */ + public function offsetExists(mixed $offset): bool { + return isset($this->elements[$offset]) || array_key_exists($offset, $this->elements); + } + + /** @param TKey $offset */ + public function offsetGet(mixed $offset): mixed { + return $this->elements[$offset] ?? null; + } + + /** + * @param TKey|null $offset + * @param T $value + */ + public function offsetSet(mixed $offset, mixed $value): void { + if ($offset === null) { + $this->elements[] = $value; + return; + } + + //$this->elements[$offset] = $value // No error there... + $this->set($offset, $value); //Error there... + } + + /** @param TKey $offset */ + public function offsetUnset(mixed $offset): void { + unset($this->elements[$offset]); + } + + /** + * @param TKey $key + * @param T $thing + */ + final public function set(int|string $key, Thing $thing): void { + $this->elements[$key] = $thing; + } +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 6084574e73..aa996c57ce 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -13,6 +13,8 @@ use Exception; use InvalidArgumentException; use Iterator; +use LengthException; +use LogicException; use ObjectShapesAcceptance\ClassWithFooIntProperty; use PHPStan\Fixture\FinalClass; use PHPStan\Reflection\Callables\SimpleImpurePoint; @@ -828,7 +830,7 @@ public function dataUnion(): iterable new BenevolentUnionType([new IntegerType(), new StringType()]), ], UnionType::class, - 'float|int|string', + '(int|string)|float', ], [ [ @@ -836,7 +838,7 @@ public function dataUnion(): iterable new BenevolentUnionType([new IntegerType(), new StringType()]), ], UnionType::class, - 'float|int|string', + '(int|string)|float', ], [ [ @@ -844,7 +846,86 @@ public function dataUnion(): iterable new BenevolentUnionType([new IntegerType(), new StringType()]), ], UnionType::class, - 'float|int|string', + '(int|string)|float', + ], + [ + [ + new BenevolentUnionType([new FloatType(), new IntegerType()]), + new NullType(), + ], + UnionType::class, + '(float|int)|null', + ], + [ + [ + new BenevolentUnionType([new IntegerType(), new StringType()]), + new ConstantStringType('1'), + ], + UnionType::class, + '(int|string)', + ], + [ + [ + new BenevolentUnionType([new IntegerType(), new StringType()]), + new StringType(), + ], + UnionType::class, + '(int|string)', + ], + [ + [ + new BenevolentUnionType([new IntegerType(), new StringType()]), + new ConstantBooleanType(true), + ], + UnionType::class, + '(int|string)|true', + ], + [ + [ + new BenevolentUnionType([new IntegerType(), new StringType()]), + new BenevolentUnionType([new ConstantIntegerType(17), new ConstantStringType('foo')]), + new BenevolentUnionType([new FloatType(), new IntegerType()]), + new BenevolentUnionType([new ConstantBooleanType(true), new ConstantFloatType(17)]), + new NullType(), + ], + UnionType::class, + '(float|int)|(17.0|true)|(int|string)|null', + ], + [ + [ + new BenevolentUnionType([new ConstantIntegerType(1), new ConstantIntegerType(2)]), + new StringType(), + ], + UnionType::class, + '(1|2)|string', + ], + [ + [ + new BenevolentUnionType([new ConstantStringType('1'), new ConstantStringType('2')]), + new StringType(), + ], + StringType::class, + 'string', + ], + [ + [ + new BenevolentUnionType([new ConstantIntegerType(1), new ConstantIntegerType(2), new ConstantIntegerType(3)]), + IntegerRangeType::fromInterval(2, 3), + ], + UnionType::class, + '(1|2|3)', + ], + [ + [ + new BenevolentUnionType([ + new ObjectType(InvalidArgumentException::class), + new ObjectType(LengthException::class), + new ObjectType(stdClass::class), + ]), + new ObjectType(LogicException::class), + ], + UnionType::class, + '(InvalidArgumentException|LengthException|stdClass)|LogicException', ], [ [ @@ -1355,7 +1436,7 @@ public function dataUnion(): iterable new FloatType(), ], UnionType::class, - 'float|int|string', + '(int|string)|float', ], [ [ @@ -1363,7 +1444,7 @@ public function dataUnion(): iterable new UnionType([new IntegerType(), new StringType(), new FloatType()]), ], UnionType::class, - 'float|int|string', + '(int|string)|float', ], [ [ @@ -1379,7 +1460,7 @@ public function dataUnion(): iterable new UnionType([new ConstantIntegerType(1), new ConstantIntegerType(2), new FloatType()]), ], UnionType::class, - 'float|int|string', + '(int|string)|float', ], [ [