diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index f466effb3d..f54a79f35e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use Countable; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; @@ -79,6 +80,7 @@ use function is_string; use function strtolower; use function substr; +use const COUNT_NORMAL; class TypeSpecifier { @@ -208,7 +210,7 @@ public function specifyTypesInCondition( if ( $expr->left instanceof FuncCall - && count($expr->left->getArgs()) === 1 + && count($expr->left->getArgs()) >= 1 && $expr->left->name instanceof Name && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen'], true) && ( @@ -237,7 +239,7 @@ public function specifyTypesInCondition( if ( !$context->null() && $expr->right instanceof FuncCall - && count($expr->right->getArgs()) === 1 + && count($expr->right->getArgs()) >= 1 && $expr->right->name instanceof Name && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) && $leftType->isInteger()->yes() @@ -247,6 +249,39 @@ public function specifyTypesInCondition( || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) ) { $argType = $scope->getType($expr->right->getArgs()[0]->value); + + if ($context->truthy() && $argType->isArray()->maybe()) { + $countables = []; + if ($argType instanceof UnionType) { + $countableInterface = new ObjectType(Countable::class); + foreach ($argType->getTypes() as $innerType) { + if ( + $innerType->isArray()->yes() + ) { + $innerType = TypeCombinator::intersect(new NonEmptyArrayType(), $innerType); + if ($innerType->isList()->yes()) { + $innerType = AccessoryArrayListType::intersectWith($innerType); + } + $countables[] = $innerType; + } + + if ( + !$countableInterface->isSuperTypeOf($innerType)->yes() + ) { + continue; + } + + $countables[] = $innerType; + } + } + + if (count($countables) > 0) { + $countableType = TypeCombinator::union(...$countables); + + return $this->create($expr->right->getArgs()[0]->value, $countableType, $context, false, $scope, $rootExpr); + } + } + if ($argType->isArray()->yes()) { $newType = new NonEmptyArrayType(); if ($context->true() && $argType->isList()->yes()) { @@ -944,7 +979,7 @@ private function specifyTypesForConstantBinaryExpression( if ( !$context->null() && $exprNode instanceof FuncCall - && count($exprNode->getArgs()) === 1 + && count($exprNode->getArgs()) >= 1 && $exprNode->name instanceof Name && in_array(strtolower((string) $exprNode->name), ['count', 'sizeof'], true) && $constantType instanceof ConstantIntegerType @@ -954,10 +989,26 @@ private function specifyTypesForConstantBinaryExpression( if ($constantType->getValue() === 0) { $newContext = $newContext->negate(); } + $argType = $scope->getType($exprNode->getArgs()[0]->value); + if ($argType->isArray()->yes()) { + if (count($exprNode->getArgs()) === 1) { + $isNormalCount = true; + } else { + $mode = $scope->getType($exprNode->getArgs()[1]->value); + if (!$mode->isInteger()->yes()) { + return new SpecifiedTypes(); + } + + $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->yes(); + if (!$isNormalCount) { + $isNormalCount = $argType->getIterableValueType()->isArray()->no(); + } + } + $funcTypes = $this->create($exprNode, $constantType, $context, false, $scope, $rootExpr); - if ($argType->isList()->yes() && $context->truthy() && $constantType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + if ($isNormalCount && $argType->isList()->yes() && $context->truthy() && $constantType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); $itemType = $argType->getIterableValueType(); for ($i = 0; $i < $constantType->getValue(); $i++) { diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index df07863448..c846dae609 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -171,6 +171,7 @@ public function dataFileAsserts(): iterable if (PHP_VERSION_ID >= 80000) { yield from $this->gatherAssertTypes(__DIR__ . '/data/minmax-php8.php'); } + yield from $this->gatherAssertTypes(__DIR__ . '/data/count-maybe.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/classPhpDocs.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/non-empty-array-key-type.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-3133.php'); diff --git a/tests/PHPStan/Analyser/data/bug-10264.php b/tests/PHPStan/Analyser/data/bug-10264.php index 5cd00d6972..20b1361a25 100644 --- a/tests/PHPStan/Analyser/data/bug-10264.php +++ b/tests/PHPStan/Analyser/data/bug-10264.php @@ -17,12 +17,22 @@ function doFoo() { assertType('list', $list); } + function doFoo2() { + /** @var list $list */ + $list = []; + + assertType('list', $list); + + assert((count($list, COUNT_NORMAL) <= 1) === true); + assertType('list', $list); + } + /** @param list $c */ public function sayHello(array $c): void { assertType('list', $c); if (count($c) > 0) { - $c = array_map(fn () => new stdClass(), $c); + $c = array_map(fn() => new stdClass(), $c); assertType('non-empty-list', $c); } else { assertType('array{}', $c); @@ -30,4 +40,41 @@ public function sayHello(array $c): void assertType('list', $c); } + + function doBar() { + /** @var list $list */ + $list = []; + + assertType('list', $list); + + assert((count($list, COUNT_RECURSIVE) <= 1) === true); + assertType('list', $list); + } + + function doIf():void { + /** @var list $list */ + $list = []; + + assertType('list', $list); + + if( count($list, COUNT_RECURSIVE) >= 1) { + assertType('non-empty-list', $list); + } else { + assertType('array{}', $list); + } + } + + function countModeInt(int $i):void { + /** @var list $list */ + $list = []; + + assertType('list', $list); + + if( count($list, $i) >= 1) { + assertType('non-empty-list', $list); + } else { + assertType('array{}', $list); + } + } + } diff --git a/tests/PHPStan/Analyser/data/count-maybe.php b/tests/PHPStan/Analyser/data/count-maybe.php new file mode 100644 index 0000000000..255c936d22 --- /dev/null +++ b/tests/PHPStan/Analyser/data/count-maybe.php @@ -0,0 +1,192 @@ + 0) { + assertType('float', $notCountable); + } else { + assertType('float', $notCountable); + } + assertType('float', $notCountable); +} + +/** + * @param array|int $maybeMode + */ +function doBar2(float $notCountable, $maybeMode): void +{ + if (count($notCountable, $maybeMode) > 0) { + assertType('float', $notCountable); + } else { + assertType('float', $notCountable); + } + assertType('float', $notCountable); +} + +function doBar3(float $notCountable, float $invalidMode): void +{ + if (count($notCountable, $invalidMode) > 0) { + assertType('float', $notCountable); + } else { + assertType('float', $notCountable); + } + assertType('float', $notCountable); +} + +/** + * @param float|int[] $maybeCountable + */ +function doFoo1($maybeCountable, int $mode): void +{ + if (count($maybeCountable, $mode) > 0) { + assertType('non-empty-array', $maybeCountable); + } else { + assertType('array|float', $maybeCountable); + } + assertType('array|float', $maybeCountable); +} + +/** + * @param float|int[] $maybeCountable + * @param array|int $maybeMode + */ +function doFoo2($maybeCountable, $maybeMode): void +{ + if (count($maybeCountable, $maybeMode) > 0) { + assertType('non-empty-array', $maybeCountable); + } else { + assertType('array|float', $maybeCountable); + } + assertType('array|float', $maybeCountable); +} + +/** + * @param float|int[] $maybeCountable + */ +function doFoo3($maybeCountable, float $invalidMode): void +{ + if (count($maybeCountable, $invalidMode) > 0) { + assertType('non-empty-array', $maybeCountable); + } else { + assertType('array|float', $maybeCountable); + } + assertType('array|float', $maybeCountable); +} + +/** + * @param float|list $maybeCountable + */ +function doFoo4($maybeCountable, int $mode): void +{ + if (count($maybeCountable, $mode) > 0) { + assertType('non-empty-list', $maybeCountable); + } else { + assertType('list|float', $maybeCountable); + } + assertType('list|float', $maybeCountable); +} + +/** + * @param float|list $maybeCountable + * @param array|int $maybeMode + */ +function doFoo5($maybeCountable, $maybeMode): void +{ + if (count($maybeCountable, $maybeMode) > 0) { + assertType('non-empty-list', $maybeCountable); + } else { + assertType('list|float', $maybeCountable); + } + assertType('list|float', $maybeCountable); +} + +/** + * @param float|list $maybeCountable + */ +function doFoo6($maybeCountable, float $invalidMode): void +{ + if (count($maybeCountable, $invalidMode) > 0) { + assertType('non-empty-list', $maybeCountable); + } else { + assertType('list|float', $maybeCountable); + } + assertType('list|float', $maybeCountable); +} + +/** + * @param float|list|Countable $maybeCountable + */ +function doFoo7($maybeCountable, int $mode): void +{ + if (count($maybeCountable, $mode) > 0) { + assertType('non-empty-list|Countable', $maybeCountable); + } else { + assertType('list|Countable|float', $maybeCountable); + } + assertType('list|Countable|float', $maybeCountable); +} + +/** + * @param float|list|Countable $maybeCountable + * @param array|int $maybeMode + */ +function doFoo8($maybeCountable, $maybeMode): void +{ + if (count($maybeCountable, $maybeMode) > 0) { + assertType('non-empty-list|Countable', $maybeCountable); + } else { + assertType('list|Countable|float', $maybeCountable); + } + assertType('list|Countable|float', $maybeCountable); +} + +/** + * @param float|list|Countable $maybeCountable + */ +function doFoo9($maybeCountable, float $invalidMode): void +{ + if (count($maybeCountable, $invalidMode) > 0) { + assertType('non-empty-list|Countable', $maybeCountable); + } else { + assertType('list|Countable|float', $maybeCountable); + } + assertType('list|Countable|float', $maybeCountable); +} + +function doFooBar1(array $countable, int $mode): void +{ + if (count($countable, $mode) > 0) { + assertType('non-empty-array', $countable); + } else { + assertType('array{}', $countable); + } + assertType('array', $countable); +} + +/** + * @param array|int $maybeMode + */ +function doFooBar2(array $countable, $maybeMode): void +{ + if (count($countable, $maybeMode) > 0) { + assertType('non-empty-array', $countable); + } else { + assertType('array{}', $countable); + } + assertType('array', $countable); +} + +function doFooBar3(array $countable, float $invalidMode): void +{ + if (count($countable, $invalidMode) > 0) { + assertType('non-empty-array', $countable); + } else { + assertType('array{}', $countable); + } + assertType('array', $countable); +} diff --git a/tests/PHPStan/Analyser/data/list-count.php b/tests/PHPStan/Analyser/data/list-count.php index 75eedbd4e4..d3848ae1db 100644 --- a/tests/PHPStan/Analyser/data/list-count.php +++ b/tests/PHPStan/Analyser/data/list-count.php @@ -22,3 +22,197 @@ function foo(array $items) { } assertType('list', $items); } + +/** + * @param list $items + */ +function modeCount(array $items, int $mode) { + assertType('list', $items); + if (count($items, $mode) === 3) { + assertType('array{int, int, int}', $items); + array_shift($items); + assertType('array{int, int}', $items); + } elseif (count($items, $mode) === 0) { + assertType('array{}', $items); + } elseif (count($items, $mode) === 5) { + assertType('array{int, int, int, int, int}', $items); + } else { + assertType('non-empty-list', $items); + } + assertType('list', $items); +} + +/** + * @param list $items + */ +function modeCountOnMaybeArray(array $items, int $mode) { + assertType('list|int>', $items); + if (count($items, $mode) === 3) { + assertType('non-empty-list|int>', $items); + array_shift($items); + assertType('list|int>', $items); + } elseif (count($items, $mode) === 0) { + assertType('array{}', $items); + } elseif (count($items, $mode) === 5) { + assertType('non-empty-list|int>', $items); + } else { + assertType('non-empty-list|int>', $items); + } + assertType('list|int>', $items); +} + + +/** + * @param list $items + */ +function normalCount(array $items) { + assertType('list', $items); + if (count($items, COUNT_NORMAL) === 3) { + assertType('array{int, int, int}', $items); + array_shift($items); + assertType('array{int, int}', $items); + } elseif (count($items, COUNT_NORMAL) === 0) { + assertType('array{}', $items); + } elseif (count($items, COUNT_NORMAL) === 5) { + assertType('array{int, int, int, int, int}', $items); + } else { + assertType('non-empty-list', $items); + } + assertType('list', $items); +} + +/** + * @param list $items + */ +function recursiveCountOnMaybeArray(array $items):void { + assertType('list|int>', $items); + if (count($items, COUNT_RECURSIVE) === 3) { + assertType('non-empty-list|int>', $items); + array_shift($items); + assertType('list|int>', $items); + } elseif (count($items, COUNT_RECURSIVE) === 0) { + assertType('array{}', $items); + } elseif (count($items, COUNT_RECURSIVE) === 5) { + assertType('non-empty-list|int>', $items); + } else { + assertType('non-empty-list|int>', $items); + } + assertType('list|int>', $items); +} + +/** + * @param list $items + */ +function normalCountOnMaybeArray(array $items):void { + assertType('list|int>', $items); + if (count($items, COUNT_NORMAL) === 3) { + assertType('array{array|int, array|int, array|int}', $items); + array_shift($items); + assertType('array{array|int, array|int}', $items); + } elseif (count($items, COUNT_NORMAL) === 0) { + assertType('array{}', $items); + } elseif (count($items, COUNT_NORMAL) === 5) { + assertType('array{array|int, array|int, array|int, array|int, array|int}', $items); + } else { + assertType('non-empty-list|int>', $items); + } + assertType('list|int>', $items); +} + +class A {} + +/** + * @param list $items + */ +function cannotCountRecursive($items, int $mode) +{ + if (count($items) === 3) { + assertType('array{ListCount\A, ListCount\A, ListCount\A}', $items); + } + if (count($items, COUNT_NORMAL) === 3) { + assertType('array{ListCount\A, ListCount\A, ListCount\A}', $items); + } + if (count($items, COUNT_RECURSIVE) === 3) { + assertType('array{ListCount\A, ListCount\A, ListCount\A}', $items); + } + if (count($items, $mode) === 3) { + assertType('array{ListCount\A, ListCount\A, ListCount\A}', $items); + } +} + +/** + * @param list> $items + */ +function cannotCountRecursiveNestedArray($items, int $mode) +{ + if (count($items) === 3) { + assertType('array{array, array, array}', $items); + } + if (count($items, COUNT_NORMAL) === 3) { + assertType('array{array, array, array}', $items); + } + if (count($items, COUNT_RECURSIVE) === 3) { + assertType('non-empty-list>', $items); + } + if (count($items, $mode) === 3) { + assertType('non-empty-list>', $items); + } +} + +class CountableFoo implements \Countable +{ + public function count(): int + { + return 3; + } +} + +/** + * @param list $items + */ +function cannotCountRecursiveCountable($items, int $mode) +{ + if (count($items) === 3) { + assertType('array{ListCount\CountableFoo, ListCount\CountableFoo, ListCount\CountableFoo}', $items); + } + if (count($items, COUNT_NORMAL) === 3) { + assertType('array{ListCount\CountableFoo, ListCount\CountableFoo, ListCount\CountableFoo}', $items); + } + if (count($items, COUNT_RECURSIVE) === 3) { + assertType('array{ListCount\CountableFoo, ListCount\CountableFoo, ListCount\CountableFoo}', $items); + } + if (count($items, $mode) === 3) { + assertType('array{ListCount\CountableFoo, ListCount\CountableFoo, ListCount\CountableFoo}', $items); + } +} + +function countCountable(CountableFoo $x, int $mode) +{ + if (count($x) === 3) { + assertType('ListCount\CountableFoo', $x); + } else { + assertType('ListCount\CountableFoo', $x); + } + assertType('ListCount\CountableFoo', $x); + + if (count($x, COUNT_NORMAL) === 3) { + assertType('ListCount\CountableFoo', $x); + } else { + assertType('ListCount\CountableFoo', $x); + } + assertType('ListCount\CountableFoo', $x); + + if (count($x, COUNT_RECURSIVE) === 3) { + assertType('ListCount\CountableFoo', $x); + } else { + assertType('ListCount\CountableFoo', $x); + } + assertType('ListCount\CountableFoo', $x); + + if (count($x, $mode) === 3) { + assertType('ListCount\CountableFoo', $x); + } else { + assertType('ListCount\CountableFoo', $x); + } + assertType('ListCount\CountableFoo', $x); +} diff --git a/tests/PHPStan/Analyser/data/minmax-arrays.php b/tests/PHPStan/Analyser/data/minmax-arrays.php index 1a68d50b56..f6ccac6e26 100644 --- a/tests/PHPStan/Analyser/data/minmax-arrays.php +++ b/tests/PHPStan/Analyser/data/minmax-arrays.php @@ -125,3 +125,52 @@ public function unionType(): void assertType('9', max([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); } } + +/** + * @param int[] $ints + */ +function countMode(array $ints, int $mode): void +{ + if (count($ints, $mode) > 0) { + assertType('int', min($ints)); + assertType('int', max($ints)); + } else { + assertType('false', min($ints)); + assertType('false', max($ints)); + } +} + +/** + * @param int[] $ints + */ +function countNormal(array $ints): void +{ + if (count($ints, COUNT_NORMAL) > 0) { + assertType('int', min($ints)); + assertType('int', max($ints)); + } else { + assertType('false', min($ints)); + assertType('false', max($ints)); + } +} + +/** + * @param int[] $ints + */ +function countRecursive(array $ints): void +{ + if (count($ints, COUNT_RECURSIVE) <= 0) { + assertType('false', min($ints)); + assertType('false', max($ints)); + } else { + assertType('int', min($ints)); + assertType('int', max($ints)); + } + if (count($ints, COUNT_RECURSIVE) < 1) { + assertType('false', min($ints)); + assertType('false', max($ints)); + } else { + assertType('int', min($ints)); + assertType('int', max($ints)); + } +} diff --git a/tests/PHPStan/Analyser/data/minmax-php8.php b/tests/PHPStan/Analyser/data/minmax-php8.php index 3d738d4c38..fa88c0fe87 100644 --- a/tests/PHPStan/Analyser/data/minmax-php8.php +++ b/tests/PHPStan/Analyser/data/minmax-php8.php @@ -126,3 +126,52 @@ public function unionType(): void assertType('9', max([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); } } + +/** + * @param int[] $ints + */ +function countMode(array $ints, int $mode): void +{ + if (count($ints, $mode) > 0) { + assertType('int', min($ints)); + assertType('int', max($ints)); + } else { + assertType('*ERROR*', min($ints)); + assertType('*ERROR*', max($ints)); + } +} + +/** + * @param int[] $ints + */ +function countNormal(array $ints): void +{ + if (count($ints, COUNT_NORMAL) > 0) { + assertType('int', min($ints)); + assertType('int', max($ints)); + } else { + assertType('*ERROR*', min($ints)); + assertType('*ERROR*', max($ints)); + } +} + +/** + * @param int[] $ints + */ +function countRecursive(array $ints): void +{ + if (count($ints, COUNT_RECURSIVE) < 1) { + assertType('*ERROR*', min($ints)); + assertType('*ERROR*', max($ints)); + } else { + assertType('int', min($ints)); + assertType('int', max($ints)); + } + if (count($ints, COUNT_RECURSIVE) < 2) { + assertType('int', min($ints)); + assertType('int', max($ints)); + } else { + assertType('int', min($ints)); + assertType('int', max($ints)); + } +}