diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 08e0c65f0c..1c1732089d 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -970,6 +970,11 @@ private function narrowUnionByArraySize(FuncCall $countFuncCall, UnionType $argT if ($isSize->no()) { continue; } + + $constArray = $this->turnListIntoConstantArray($countFuncCall, $innerType, $sizeType, $scope); + if ($constArray !== null) { + $innerType = $constArray; + } } if ($context->falsey()) { if (!$isSize->yes()) { @@ -986,6 +991,35 @@ private function narrowUnionByArraySize(FuncCall $countFuncCall, UnionType $argT return null; } + private function turnListIntoConstantArray(FuncCall $countFuncCall, Type $type, Type $sizeType, Scope $scope): ?Type + { + $argType = $scope->getType($countFuncCall->getArgs()[0]->value); + + if (count($countFuncCall->getArgs()) === 1) { + $isNormalCount = TrinaryLogic::createYes(); + } else { + $mode = $scope->getType($countFuncCall->getArgs()[1]->value); + $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->or($argType->getIterableValueType()->isArray()->negate()); + } + + if ( + $isNormalCount->yes() + && $type->isList()->yes() + && $sizeType instanceof ConstantIntegerType + && $sizeType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + ) { + // turn optional offsets non-optional + $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $sizeType->getValue(); $i++) { + $offsetType = new ConstantIntegerType($i); + $valueTypesBuilder->setOffsetValueType($offsetType, $type->getOffsetValueType($offsetType)); + } + return $valueTypesBuilder->getArray(); + } + + return null; + } + private function specifyTypesForConstantBinaryExpression( Expr $exprNode, ConstantScalarType $constantType, @@ -1050,21 +1084,18 @@ private function specifyTypesForConstantBinaryExpression( } if ($argType->isArray()->yes()) { - if (count($exprNode->getArgs()) === 1) { - $isNormalCount = TrinaryLogic::createYes(); - } else { - $mode = $scope->getType($exprNode->getArgs()[1]->value); - $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->or($argType->getIterableValueType()->isArray()->negate()); + if ( + $context->truthy() + && $argType->isConstantArray()->yes() + && $constantType->isSuperTypeOf($argType->getArraySize())->no() + ) { + return $this->create($exprNode->getArgs()[0]->value, new NeverType(), $context, false, $scope, $rootExpr); } $funcTypes = $this->create($exprNode, $constantType, $context, false, $scope, $rootExpr); - if ($isNormalCount->yes() && $argType->isList()->yes() && $context->truthy() && $constantType->getValue() < ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - $valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty(); - $itemType = $argType->getIterableValueType(); - for ($i = 0; $i < $constantType->getValue(); $i++) { - $valueTypesBuilder->setOffsetValueType(new ConstantIntegerType($i), $itemType); - } - $valueTypes = $this->create($exprNode->getArgs()[0]->value, $valueTypesBuilder->getArray(), $context, false, $scope, $rootExpr); + $constArray = $this->turnListIntoConstantArray($exprNode, $argType, $constantType, $scope); + if ($context->truthy() && $constArray !== null) { + $valueTypes = $this->create($exprNode->getArgs()[0]->value, $constArray, $context, false, $scope, $rootExpr); } else { $valueTypes = $this->create($exprNode->getArgs()[0]->value, new NonEmptyArrayType(), $newContext, false, $scope, $rootExpr); } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index d3848ae1db..006bf149d8 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -216,3 +216,127 @@ function countCountable(CountableFoo $x, int $mode) } assertType('ListCount\CountableFoo', $x); } + +class CountWithOptionalKeys +{ + /** + * @param array{0: mixed, 1?: string|null} $row + */ + protected function testOptionalKeys($row): void + { + if (count($row) === 0) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + + if (count($row) === 1) { + assertType('array{mixed}', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + + if (count($row) === 2) { + assertType('array{mixed, string|null}', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + + if (count($row) === 3) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + } + + /** + * @param array{mixed}|array{0: mixed, 1?: string|null} $row + */ + protected function testOptionalKeysInUnion($row): void + { + if (count($row) === 0) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + + if (count($row) === 1) { + assertType('array{mixed}', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + + if (count($row) === 2) { + assertType('array{mixed, string|null}', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + + if (count($row) === 3) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: mixed, 1?: string|null}', $row); + } + } + + /** + * @param array{string}|array{0: int, 1?: string|null} $row + */ + protected function testOptionalKeysInListsOfTaggedUnion($row): void + { + if (count($row) === 0) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: int, 1?: string|null}|array{string}', $row); + } + + if (count($row) === 1) { + assertType('array{0: int, 1?: string|null}|array{string}', $row); + } else { + assertType('array{0: int, 1?: string|null}', $row); + } + + if (count($row) === 2) { + assertType('array{int, string|null}', $row); + } else { + assertType('array{0: int, 1?: string|null}|array{string}', $row); + } + + if (count($row) === 3) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: int, 1?: string|null}|array{string}', $row); + } + } + + /** + * @param array{string}|array{0: int, 3?: string|null} $row + */ + protected function testOptionalKeysInUnionArray($row): void + { + if (count($row) === 0) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: int, 3?: string|null}|array{string}', $row); + } + + if (count($row) === 1) { + assertType('array{0: int, 3?: string|null}|array{string}', $row); + } else { + assertType('array{0: int, 3?: string|null}', $row); + } + + if (count($row) === 2) { + assertType('array{0: int, 3?: string|null}', $row); + } else { + assertType('array{0: int, 3?: string|null}|array{string}', $row); + } + + if (count($row) === 3) { + assertType('*NEVER*', $row); + } else { + assertType('array{0: int, 3?: string|null}|array{string}', $row); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index be26adf8b8..001ec26d21 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -330,7 +330,7 @@ function bug11277a(string $value): void if (preg_match('/^\[(.+,?)*\]$/', $value, $matches)) { assertType('array{0: string, 1?: non-empty-string}', $matches); if (count($matches) === 2) { - assertType('array{string, string}', $matches); // could be array{string, non-empty-string} + assertType('array{string, non-empty-string}', $matches); } } }