diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 01ab4208a3..bbd4cd97aa 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -35,18 +35,14 @@ use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; use PHPStan\ShouldNotHappenException; -use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetType; -use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; -use PHPStan\Type\ArrayType; -use PHPStan\Type\BooleanType; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -54,9 +50,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ConstantScalarType; -use PHPStan\Type\FloatType; use PHPStan\Type\FunctionTypeSpecifyingExtension; -use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; @@ -71,7 +65,7 @@ use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; -use PHPStan\Type\ResourceType; +use PHPStan\Type\Php\CountFuncCallTypeSpecifier; use PHPStan\Type\StaticMethodTypeSpecifyingExtension; use PHPStan\Type\StaticType; use PHPStan\Type\StaticTypeFactory; @@ -93,7 +87,6 @@ use function is_string; use function strtolower; use function substr; -use const COUNT_NORMAL; #[AutowiredService(name: 'typeSpecifier', factory: '@typeSpecifierFactory::create')] final class TypeSpecifier @@ -120,6 +113,7 @@ public function __construct( private array $methodTypeSpecifyingExtensions, private array $staticMethodTypeSpecifyingExtensions, private bool $rememberPossiblyImpureFunctionValues, + private CountFuncCallTypeSpecifier $countFuncCallTypeSpecifier, ) { } @@ -265,36 +259,12 @@ public function specifyTypesInCondition( ) { $argType = $scope->getType($expr->right->getArgs()[0]->value); - $sizeType = null; - if ($leftType instanceof ConstantIntegerType) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); - } - } elseif ($leftType instanceof IntegerRangeType) { - if ($context->falsey() && $leftType->getMax() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); - } - } elseif ($context->truthy() && $leftType->getMin() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); - } - } - } else { - $sizeType = $leftType; - } + $sizeType = $this->resolveResultSizeType($leftType, $orEqual, $context); if ($sizeType !== null) { - $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); - } + // hand the size constraint to count()'s type-specifying extension via the condition type + $specifiedTypes = $this->specifyTypesInCondition($scope, $expr->right, $context->withNarrowedReturnType($sizeType))->setRootExpr($expr); + $result = $result->unionWith($specifiedTypes); } if ( @@ -374,7 +344,7 @@ public function specifyTypesInCondition( $subtractedType = $scope->getType($expr->right->right); if ( $countArgType->isList()->yes() - && $this->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() + && $this->countFuncCallTypeSpecifier->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($subtractedType)->yes() ) { $arrayArg = $expr->right->left->getArgs()[0]->value; @@ -412,20 +382,12 @@ public function specifyTypesInCondition( && count($expr->right->getArgs()) === 1 && $leftType->isInteger()->yes() ) { - if ( - $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) - || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) - ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); - if ($argType->isString()->yes()) { - $accessory = new AccessoryNonEmptyStringType(); - - if (IntegerRangeType::createAllGreaterThanOrEqualTo(2 - $offset)->isSuperTypeOf($leftType)->yes()) { - $accessory = new AccessoryNonFalsyStringType(); - } - - $result = $result->unionWith($this->create($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); - } + $sizeType = $this->resolveResultSizeType($leftType, $orEqual, $context); + if ($sizeType !== null) { + // hand the length constraint to strlen()'s type-specifying extension via the condition type + $result = $result->unionWith( + $this->specifyTypesInCondition($scope, $expr->right, $context->withNarrowedReturnType($sizeType))->setRootExpr($expr), + ); } } @@ -1346,140 +1308,34 @@ public function specifyTypesInCondition( return (new SpecifiedTypes([], []))->setRootExpr($expr); } - private function isNormalCountCall(FuncCall $countFuncCall, Type $typeToCount, Scope $scope): TrinaryLogic - { - if (count($countFuncCall->getArgs()) === 1) { - return TrinaryLogic::createYes(); - } - - $mode = $scope->getType($countFuncCall->getArgs()[1]->value); - return (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($typeToCount->getIterableValueType()->isArray()->negate()); - } - - private function specifyTypesForCountFuncCall( - FuncCall $countFuncCall, - Type $type, - Type $sizeType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - ): ?SpecifiedTypes + /** + * Resolves the integer constraint on an int-returning function call on the right-hand side of a + * `$leftType call(...)` comparison. Used to hand a condition type to that function's + * type-specifying extension (e.g. count(), strlen()). + */ + private function resolveResultSizeType(Type $leftType, bool $orEqual, TypeSpecifierContext $context): ?Type { - $isConstantArray = $type->isConstantArray(); - $isList = $type->isList(); - $oneOrMore = IntegerRangeType::fromInterval(1, null); - if ( - !$this->isNormalCountCall($countFuncCall, $type, $scope)->yes() - || (!$isConstantArray->yes() && !$isList->yes()) - || !$oneOrMore->isSuperTypeOf($sizeType)->yes() - || $sizeType->isSuperTypeOf($type->getArraySize())->yes() - ) { - return null; - } - - if ($context->falsey() && $isConstantArray->yes()) { - $remainingSize = TypeCombinator::remove($type->getArraySize(), $sizeType); - if (!$remainingSize instanceof NeverType) { - $negatedContext = $context->false() - ? TypeSpecifierContext::createTrue() - : TypeSpecifierContext::createTruthy(); - $result = $this->specifyTypesForCountFuncCall( - $countFuncCall, - $type, - $remainingSize, - $negatedContext, - $scope, - $rootExpr, - ); - if ($result !== null) { - return $result; - } - } - - // Fallback: directly filter constant arrays by their exact sizes. - // This avoids using TypeCombinator::remove() with falsey context, - // which can incorrectly remove arrays whose count doesn't match - // but whose shape is a subtype of the matched array. - $keptTypes = []; - foreach ($type->getConstantArrays() as $arrayType) { - if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { - continue; - } - - $keptTypes[] = $arrayType; - } - if ($keptTypes !== []) { - return $this->create( - $countFuncCall->getArgs()[0]->value, - TypeCombinator::union(...$keptTypes), - $context->negate(), - $scope, - )->setRootExpr($rootExpr); - } + if ($leftType instanceof ConstantIntegerType) { + return $orEqual + ? IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()) + : IntegerRangeType::createAllGreaterThan($leftType->getValue()); } - $resultTypes = []; - foreach ($type->getArrays() as $arrayType) { - $isSizeSuperTypeOfArraySize = $sizeType->isSuperTypeOf($arrayType->getArraySize()); - if ($isSizeSuperTypeOfArraySize->no()) { - continue; + if ($leftType instanceof IntegerRangeType) { + if ($context->falsey() && $leftType->getMax() !== null) { + return $orEqual + ? IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()) + : IntegerRangeType::createAllGreaterThan($leftType->getMax()); } - - if ($context->falsey() && $isSizeSuperTypeOfArraySize->maybe()) { - continue; - } - - $resultTypes[] = $isList->yes() - ? $arrayType->truncateListToSize($sizeType) - : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); - } - - if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { - $hasOptionalKeysOrUnsealed = false; - foreach ($type->getConstantArrays() as $arrayType) { - if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { - // Unsealed CATs can't be narrowed via the - // `HasOffsetValueType`-only shortcut below — the - // intersection of an unsealed shape with a single-slot - // constraint produces `NeverType`. Fall through to - // the full builder-based narrowing, which carries the - // unsealed slot via the loop above. - $hasOptionalKeysOrUnsealed = true; - break; - } - } - - if (!$hasOptionalKeysOrUnsealed) { - $argExpr = $countFuncCall->getArgs()[0]->value; - $argExprString = $this->exprPrinter->printExpr($argExpr); - - $sizeMin = null; - $sizeMax = null; - if ($sizeType instanceof ConstantIntegerType) { - $sizeMin = $sizeType->getValue(); - $sizeMax = $sizeType->getValue(); - } elseif ($sizeType instanceof IntegerRangeType) { - $sizeMin = $sizeType->getMin(); - $sizeMax = $sizeType->getMax(); - } - - $sureTypes = []; - $sureNotTypes = []; - - if ($sizeMin !== null && $sizeMin >= 1) { - $sureTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMin - 1), new MixedType())]; - } - if ($sizeMax !== null) { - $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())]; - } - - if ($sureTypes !== [] || $sureNotTypes !== []) { - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($rootExpr); - } + if ($context->truthy() && $leftType->getMin() !== null) { + return $orEqual + ? IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()) + : IntegerRangeType::createAllGreaterThan($leftType->getMin()); } + return null; } - return $this->create($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$resultTypes), $context, $scope)->setRootExpr($rootExpr); + return $leftType; } private function specifyTypesForConstantBinaryExpression( @@ -1531,110 +1387,11 @@ private function specifyTypesForConstantStringBinaryExpression( if (count($scalarValues) !== 1 || !is_string($scalarValues[0])) { return null; } - $constantStringValue = $scalarValues[0]; - - if ( - $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && strtolower($exprNode->name->toString()) === 'gettype' - && isset($exprNode->getArgs()[0]) - ) { - $type = null; - if ($constantStringValue === 'string') { - $type = new StringType(); - } - if ($constantStringValue === 'array') { - $type = new ArrayType(new MixedType(), new MixedType()); - } - if ($constantStringValue === 'boolean') { - $type = new BooleanType(); - } - if (in_array($constantStringValue, ['resource', 'resource (closed)'], true)) { - $type = new ResourceType(); - } - if ($constantStringValue === 'integer') { - $type = new IntegerType(); - } - if ($constantStringValue === 'double') { - $type = new FloatType(); - } - if ($constantStringValue === 'NULL') { - $type = new NullType(); - } - if ($constantStringValue === 'object') { - $type = new ObjectWithoutClassType(); - } - - if ($type !== null) { - $callType = $this->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - $argType = $this->create($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); - return $callType->unionWith($argType); - } - } - if ( - $context->true() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && strtolower((string) $exprNode->name) === 'get_parent_class' - && isset($exprNode->getArgs()[0]) - ) { - $argType = $scope->getType($exprNode->getArgs()[0]->value); - $objectType = new ObjectType($constantStringValue); - $classStringType = new GenericClassStringType($objectType); - - if ($argType->isString()->yes()) { - return $this->create( - $exprNode->getArgs()[0]->value, - $classStringType, - $context, - $scope, - )->setRootExpr($rootExpr); - } - - if ($argType->isObject()->yes()) { - return $this->create( - $exprNode->getArgs()[0]->value, - $objectType, - $context, - $scope, - )->setRootExpr($rootExpr); - } - - return $this->create( - $exprNode->getArgs()[0]->value, - TypeCombinator::union($objectType, $classStringType), - $context, - $scope, - )->setRootExpr($rootExpr); - } - - if ( - $context->false() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && in_array(strtolower((string) $exprNode->name), [ - 'trim', 'ltrim', 'rtrim', 'chop', - 'mb_trim', 'mb_ltrim', 'mb_rtrim', - ], true) - && isset($exprNode->getArgs()[0]) - && $constantStringValue === '' - ) { - $argValue = $exprNode->getArgs()[0]->value; - $argType = $scope->getType($argValue); - if ($argType->isString()->yes()) { - return $this->create( - $argValue, - new IntersectionType([ - new StringType(), - new AccessoryNonEmptyStringType(), - ]), - $context->negate(), - $scope, - )->setRootExpr($rootExpr); + if ($exprNode instanceof FuncCall) { + $extensionResult = $this->specifyTypesForFuncCallComparison($exprNode, $constantType, $context, $scope, $rootExpr); + if ($extensionResult !== null) { + return $extensionResult; } } @@ -2595,6 +2352,60 @@ private function getFunctionTypeSpecifyingExtensions(): array return $this->functionTypeSpecifyingExtensions; } + /** + * Dispatches a function call appearing on one side of a comparison to its type-specifying + * extensions, carrying the other side's type as the condition type. Only extensions that opt in to + * the condition type - i.e. ones that would not handle the call without it - take part, so plain + * truthy/falsey extensions are left untouched. Returns null when no extension narrows anything, so + * the caller can fall back to its remaining handling. + */ + private function specifyTypesForFuncCallComparison( + FuncCall $funcCall, + Type $conditionType, + TypeSpecifierContext $context, + Scope $scope, + Expr $rootExpr, + ): ?SpecifiedTypes + { + if ( + !$funcCall->name instanceof Name + || $funcCall->isFirstClassCallable() + || !$this->reflectionProvider->hasFunction($funcCall->name, $scope) + ) { + return null; + } + + $functionReflection = $this->reflectionProvider->getFunction($funcCall->name, $scope); + $normalizedExpr = $funcCall; + $args = $funcCall->getArgs(); + if (count($args) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + $normalizedExpr = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $funcCall) ?? $funcCall; + } + + $contextWithConditionType = $context->withNarrowedReturnType($conditionType); + foreach ($this->getFunctionTypeSpecifyingExtensions() as $extension) { + if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $contextWithConditionType)) { + continue; + } + + // Only extensions that explicitly require the condition type take part in comparisons; + // extensions that would also handle the call without it narrow truthy/falsey only. + if ($extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { + continue; + } + + $specifiedTypes = $extension->specifyTypes($functionReflection, $normalizedExpr, $scope, $contextWithConditionType); + if ($specifiedTypes->getSureTypes() === [] && $specifiedTypes->getSureNotTypes() === []) { + continue; + } + + return $specifiedTypes->setRootExpr($rootExpr); + } + + return null; + } + /** * @return MethodTypeSpecifyingExtension[] */ @@ -2897,7 +2708,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); $sizeType = $scope->getType($leftExpr); - $specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); + $specifiedTypes = $this->countFuncCallTypeSpecifier->specifyTypesForCountFuncCall($this, $unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); if ($specifiedTypes !== null) { return $specifiedTypes; } @@ -2940,7 +2751,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope ); } - $specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); + $specifiedTypes = $this->countFuncCallTypeSpecifier->specifyTypesForCountFuncCall($this, $unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); if ($specifiedTypes !== null) { if ($leftExpr !== $unwrappedLeftExpr) { $funcTypes = $this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); @@ -2999,33 +2810,6 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } } - // array_key_first($a) !== null - // array_key_last($a) !== null - // array_find_key($a, $cb) !== null - if ( - $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && isset($unwrappedLeftExpr->getArgs()[0]) - && $rightType->isNull()->yes() - ) { - $funcName = $unwrappedLeftExpr->name->toLowerString(); - $bothDirections = in_array($funcName, ['array_key_first', 'array_key_last'], true); - $notNullOnly = $funcName === 'array_find_key'; - if ($bothDirections || $notNullOnly) { - $args = $unwrappedLeftExpr->getArgs(); - $argType = $scope->getType($args[0]->value); - if ($argType->isArray()->yes()) { - if ($bothDirections) { - return $this->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); - } - if ($context->falsey()) { - return $this->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); - } - } - } - } - // preg_match($a) === $b if ( $context->true() @@ -3041,31 +2825,18 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope )->setRootExpr($expr); } - // get_class($a) === 'Foo' + // func($a) === $expr - hand off to the function's type-specifying extensions, carrying the + // compared type as the condition type (e.g. get_class($a) === Foo::class). if ( - $context->true() + !$context->null() && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower($unwrappedLeftExpr->name->toString()), ['get_class', 'get_debug_type'], true) - && isset($unwrappedLeftExpr->getArgs()[0]) ) { - $constantStringTypes = $rightType->getConstantStrings(); - if (count($constantStringTypes) === 1 && $this->reflectionProvider->hasClass($constantStringTypes[0]->getValue())) { - return $this->create( - $unwrappedLeftExpr->getArgs()[0]->value, - new ObjectType($constantStringTypes[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStringTypes[0]->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - if ($rightType->getClassStringObjectType()->isObject()->yes()) { - return $this->create( - $unwrappedLeftExpr->getArgs()[0]->value, - $rightType->getClassStringObjectType(), - $context, - $scope, - )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $extensionResult = $this->specifyTypesForFuncCallComparison($unwrappedLeftExpr, $rightType, $context, $scope, $expr); + if ($extensionResult !== null) { + if ($leftExpr !== $unwrappedLeftExpr) { + $extensionResult = $extensionResult->unionWith($this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); + } + return $extensionResult; } } diff --git a/src/Analyser/TypeSpecifierContext.php b/src/Analyser/TypeSpecifierContext.php index d7379c26ac..258f58a474 100644 --- a/src/Analyser/TypeSpecifierContext.php +++ b/src/Analyser/TypeSpecifierContext.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; /** * @api @@ -21,7 +22,7 @@ final class TypeSpecifierContext /** @var self[] */ private static array $registry; - private function __construct(private ?int $value) + private function __construct(private ?int $value, private ?Type $narrowedReturnType = null) { } @@ -90,4 +91,32 @@ public function null(): bool return $this->value === null; } + /** + * If non-null, a constraint on the analyzed function/method's return value for the branch this + * context represents (the truthy()=true branch). Type-specifying extensions may use it to narrow + * arguments more precisely than the truthy/falsey/true/false buckets allow - e.g. for + * `count($x) >= 2` the count() extension receives IntegerRangeType(2, max) here. + * + * It is null in every standard context; only comparison-rewriting code attaches one via + * withNarrowedReturnType(), and negate() drops it again. + * + * @api + */ + public function getNarrowedReturnType(): ?Type + { + return $this->narrowedReturnType; + } + + /** + * Returns a context carrying the given narrowed return type. Intentionally bypasses the flyweight + * registry. The narrowed return type is a one-directional hint valid only for this exact (bitmask, type) + * pair - negate() discards it and falls back to bitmask-only behavior. + * + * @api + */ + public function withNarrowedReturnType(Type $narrowedReturnType): self + { + return new self($this->value, $narrowedReturnType); + } + } diff --git a/src/Analyser/TypeSpecifierFactory.php b/src/Analyser/TypeSpecifierFactory.php index c42a66cb1b..c61a57b8ec 100644 --- a/src/Analyser/TypeSpecifierFactory.php +++ b/src/Analyser/TypeSpecifierFactory.php @@ -8,6 +8,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Php\CountFuncCallTypeSpecifier; use function array_merge; #[AutowiredService(name: 'typeSpecifierFactory')] @@ -36,6 +37,7 @@ public function create(): TypeSpecifier $methodTypeSpecifying, $staticMethodTypeSpecifying, $this->container->getParameter('rememberPossiblyImpureFunctionValues'), + $this->container->getByType(CountFuncCallTypeSpecifier::class), ); foreach (array_merge( diff --git a/src/Type/Php/ArrayKeyFirstLastFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyFirstLastFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..7e37006841 --- /dev/null +++ b/src/Type/Php/ArrayKeyFirstLastFunctionTypeSpecifyingExtension.php @@ -0,0 +1,67 @@ +getNarrowedReturnType() !== null + && $node->name instanceof Name + && !$node->isFirstClassCallable() + && isset($node->getArgs()[0]) + && in_array(strtolower($functionReflection->getName()), ['array_key_first', 'array_key_last', 'array_find_key'], true); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType === null || !$narrowedReturnType->isNull()->yes()) { + return new SpecifiedTypes(); + } + + $argValue = $node->getArgs()[0]->value; + if (!$scope->getType($argValue)->isArray()->yes()) { + return new SpecifiedTypes(); + } + + $functionName = strtolower($functionReflection->getName()); + $bothDirections = in_array($functionName, ['array_key_first', 'array_key_last'], true); + + if ($bothDirections || $context->falsey()) { + return $this->typeSpecifier->create($argValue, new NonEmptyArrayType(), $context->negate(), $scope); + } + + return new SpecifiedTypes(); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/CountFuncCallTypeSpecifier.php b/src/Type/Php/CountFuncCallTypeSpecifier.php new file mode 100644 index 0000000000..a6d1b489fe --- /dev/null +++ b/src/Type/Php/CountFuncCallTypeSpecifier.php @@ -0,0 +1,183 @@ +getArgs()) === 1) { + return TrinaryLogic::createYes(); + } + + $mode = $scope->getType($countFuncCall->getArgs()[1]->value); + return (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($typeToCount->getIterableValueType()->isArray()->negate()); + } + + public function specifyTypesForCountFuncCall( + TypeSpecifier $typeSpecifier, + FuncCall $countFuncCall, + Type $type, + Type $sizeType, + TypeSpecifierContext $context, + Scope $scope, + Expr $rootExpr, + ): ?SpecifiedTypes + { + $isConstantArray = $type->isConstantArray(); + $isList = $type->isList(); + $oneOrMore = IntegerRangeType::fromInterval(1, null); + if ( + !$this->isNormalCountCall($countFuncCall, $type, $scope)->yes() + || (!$isConstantArray->yes() && !$isList->yes()) + || !$oneOrMore->isSuperTypeOf($sizeType)->yes() + || $sizeType->isSuperTypeOf($type->getArraySize())->yes() + ) { + return null; + } + + if ($context->falsey() && $isConstantArray->yes()) { + $remainingSize = TypeCombinator::remove($type->getArraySize(), $sizeType); + if (!$remainingSize instanceof NeverType) { + $negatedContext = $context->false() + ? TypeSpecifierContext::createTrue() + : TypeSpecifierContext::createTruthy(); + $result = $this->specifyTypesForCountFuncCall( + $typeSpecifier, + $countFuncCall, + $type, + $remainingSize, + $negatedContext, + $scope, + $rootExpr, + ); + if ($result !== null) { + return $result; + } + } + + // Fallback: directly filter constant arrays by their exact sizes. + // This avoids using TypeCombinator::remove() with falsey context, + // which can incorrectly remove arrays whose count doesn't match + // but whose shape is a subtype of the matched array. + $keptTypes = []; + foreach ($type->getConstantArrays() as $arrayType) { + if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { + continue; + } + + $keptTypes[] = $arrayType; + } + if ($keptTypes !== []) { + return $typeSpecifier->create( + $countFuncCall->getArgs()[0]->value, + TypeCombinator::union(...$keptTypes), + $context->negate(), + $scope, + )->setRootExpr($rootExpr); + } + } + + $resultTypes = []; + foreach ($type->getArrays() as $arrayType) { + $isSizeSuperTypeOfArraySize = $sizeType->isSuperTypeOf($arrayType->getArraySize()); + if ($isSizeSuperTypeOfArraySize->no()) { + continue; + } + + if ($context->falsey() && $isSizeSuperTypeOfArraySize->maybe()) { + continue; + } + + $resultTypes[] = $isList->yes() + ? $arrayType->truncateListToSize($sizeType) + : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { + $hasOptionalKeysOrUnsealed = false; + foreach ($type->getConstantArrays() as $arrayType) { + if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { + // Unsealed CATs can't be narrowed via the + // `HasOffsetValueType`-only shortcut below — the + // intersection of an unsealed shape with a single-slot + // constraint produces `NeverType`. Fall through to + // the full builder-based narrowing, which carries the + // unsealed slot via the loop above. + $hasOptionalKeysOrUnsealed = true; + break; + } + } + + if (!$hasOptionalKeysOrUnsealed) { + $argExpr = $countFuncCall->getArgs()[0]->value; + $argExprString = $this->exprPrinter->printExpr($argExpr); + + $sizeMin = null; + $sizeMax = null; + if ($sizeType instanceof ConstantIntegerType) { + $sizeMin = $sizeType->getValue(); + $sizeMax = $sizeType->getValue(); + } elseif ($sizeType instanceof IntegerRangeType) { + $sizeMin = $sizeType->getMin(); + $sizeMax = $sizeType->getMax(); + } + + $sureTypes = []; + $sureNotTypes = []; + + if ($sizeMin !== null && $sizeMin >= 1) { + $sureTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMin - 1), new MixedType())]; + } + if ($sizeMax !== null) { + $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())]; + } + + if ($sureTypes !== [] || $sureNotTypes !== []) { + return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($rootExpr); + } + } + } + + return $typeSpecifier->create($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$resultTypes), $context, $scope)->setRootExpr($rootExpr); + } + +} diff --git a/src/Type/Php/CountFunctionTypeSpecifyingExtension.php b/src/Type/Php/CountFunctionTypeSpecifyingExtension.php index 741c7cc880..c69d1e0b4a 100644 --- a/src/Type/Php/CountFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/CountFunctionTypeSpecifyingExtension.php @@ -21,6 +21,10 @@ final class CountFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyi private TypeSpecifier $typeSpecifier; + public function __construct(private CountFuncCallTypeSpecifier $countFuncCallTypeSpecifier) + { + } + public function isFunctionSupported( FunctionReflection $functionReflection, FuncCall $node, @@ -39,7 +43,27 @@ public function specifyTypes( TypeSpecifierContext $context, ): SpecifiedTypes { - if (!$scope->getType($node->getArgs()[0]->value)->isArray()->yes()) { + $argType = $scope->getType($node->getArgs()[0]->value); + + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType !== null) { + $specifiedTypes = $this->countFuncCallTypeSpecifier->specifyTypesForCountFuncCall( + $this->typeSpecifier, + $node, + $argType, + $narrowedReturnType, + $context, + $scope, + $node, + ); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + + return new SpecifiedTypes([], []); + } + + if (!$argType->isArray()->yes()) { return new SpecifiedTypes([], []); } diff --git a/src/Type/Php/GetClassFunctionTypeSpecifyingExtension.php b/src/Type/Php/GetClassFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..91bebe6ce1 --- /dev/null +++ b/src/Type/Php/GetClassFunctionTypeSpecifyingExtension.php @@ -0,0 +1,76 @@ +getNarrowedReturnType() !== null + && $context->true() + && $node->name instanceof Name + && !$node->isFirstClassCallable() + && isset($node->getArgs()[0]) + && in_array(strtolower($functionReflection->getName()), ['get_class', 'get_debug_type'], true); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType === null) { + return new SpecifiedTypes(); + } + + $argExpr = $node->getArgs()[0]->value; + + $constantStrings = $narrowedReturnType->getConstantStrings(); + if (count($constantStrings) === 1 && $this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { + $argType = new ObjectType( + $constantStrings[0]->getValue(), + classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal(), + ); + } elseif ($narrowedReturnType->getClassStringObjectType()->isObject()->yes()) { + $argType = $narrowedReturnType->getClassStringObjectType(); + } else { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create($argExpr, $argType, $context, $scope) + ->unionWith($this->typeSpecifier->create($node, $narrowedReturnType, $context, $scope)); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/GetParentClassFunctionTypeSpecifyingExtension.php b/src/Type/Php/GetParentClassFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..75f3b45619 --- /dev/null +++ b/src/Type/Php/GetParentClassFunctionTypeSpecifyingExtension.php @@ -0,0 +1,75 @@ +. Driven by the + * narrowed return type carried by the comparison (TypeSpecifierContext::getNarrowedReturnType()). + */ +#[AutowiredService] +final class GetParentClassFunctionTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension +{ + + private TypeSpecifier $typeSpecifier; + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return $context->getNarrowedReturnType() !== null + && $context->true() + && $node->name instanceof Name + && !$node->isFirstClassCallable() + && isset($node->getArgs()[0]) + && strtolower($functionReflection->getName()) === 'get_parent_class'; + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType === null) { + return new SpecifiedTypes(); + } + + $constantStrings = $narrowedReturnType->getConstantStrings(); + if (count($constantStrings) !== 1) { + return new SpecifiedTypes(); + } + + $argValue = $node->getArgs()[0]->value; + $argType = $scope->getType($argValue); + $objectType = new ObjectType($constantStrings[0]->getValue()); + $classStringType = new GenericClassStringType($objectType); + + if ($argType->isString()->yes()) { + return $this->typeSpecifier->create($argValue, $classStringType, $context, $scope); + } + + if ($argType->isObject()->yes()) { + return $this->typeSpecifier->create($argValue, $objectType, $context, $scope); + } + + return $this->typeSpecifier->create($argValue, TypeCombinator::union($objectType, $classStringType), $context, $scope); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/GettypeFunctionTypeSpecifyingExtension.php b/src/Type/Php/GettypeFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..a999bc6da1 --- /dev/null +++ b/src/Type/Php/GettypeFunctionTypeSpecifyingExtension.php @@ -0,0 +1,105 @@ +getNarrowedReturnType() !== null + && $node->name instanceof Name + && !$node->isFirstClassCallable() + && isset($node->getArgs()[0]) + && strtolower($functionReflection->getName()) === 'gettype'; + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType === null) { + return new SpecifiedTypes(); + } + + $constantStrings = $narrowedReturnType->getConstantStrings(); + if (count($constantStrings) !== 1) { + return new SpecifiedTypes(); + } + + $argType = $this->mapGettypeValueToType($constantStrings[0]->getValue()); + if ($argType === null) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create($node, $narrowedReturnType, $context, $scope) + ->unionWith($this->typeSpecifier->create($node->getArgs()[0]->value, $argType, $context, $scope)); + } + + private function mapGettypeValueToType(string $value): ?Type + { + if ($value === 'string') { + return new StringType(); + } + if ($value === 'array') { + return new ArrayType(new MixedType(), new MixedType()); + } + if ($value === 'boolean') { + return new BooleanType(); + } + if (in_array($value, ['resource', 'resource (closed)'], true)) { + return new ResourceType(); + } + if ($value === 'integer') { + return new IntegerType(); + } + if ($value === 'double') { + return new FloatType(); + } + if ($value === 'NULL') { + return new NullType(); + } + if ($value === 'object') { + return new ObjectWithoutClassType(); + } + + return null; + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Php/StrlenFunctionTypeSpecifyingExtension.php b/src/Type/Php/StrlenFunctionTypeSpecifyingExtension.php index ff074b92c2..0d87013991 100644 --- a/src/Type/Php/StrlenFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/StrlenFunctionTypeSpecifyingExtension.php @@ -11,8 +11,11 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\FunctionTypeSpecifyingExtension; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\StaticTypeFactory; +use PHPStan\Type\Type; use function count; use function in_array; @@ -44,6 +47,11 @@ public function specifyTypes( return new SpecifiedTypes([], []); } + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType !== null) { + return $this->specifyTypesForLengthCondition($node, $narrowedReturnType, $context, $scope); + } + $argSpecifiedTypes = $this->typeSpecifier->create($node->getArgs()[0]->value, new AccessoryNonEmptyStringType(), $context, $scope); if ($context->truthy()) { @@ -57,6 +65,37 @@ public function specifyTypes( ->unionWith($argSpecifiedTypes); } + /** + * Narrows the string argument when the strlen() result is constrained to a known length range, + * e.g. `strlen($s) >= 1` makes $s non-empty-string and `>= 2` makes it non-falsy-string. + */ + private function specifyTypesForLengthCondition( + FuncCall $node, + Type $narrowedReturnType, + TypeSpecifierContext $context, + Scope $scope, + ): SpecifiedTypes + { + $oneOrMore = IntegerRangeType::createAllGreaterThanOrEqualTo(1); + + if ($context->true() && $oneOrMore->isSuperTypeOf($narrowedReturnType)->yes()) { + $accessory = new AccessoryNonEmptyStringType(); + if (IntegerRangeType::createAllGreaterThanOrEqualTo(2)->isSuperTypeOf($narrowedReturnType)->yes()) { + $accessory = new AccessoryNonFalsyStringType(); + } + + return $this->typeSpecifier->create($node->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($node); + } + + // The condition fails only when the length is below the range. We can conclude the string is + // empty (i.e. not a non-empty-string) only when the range starts exactly at 1. + if ($context->false() && $oneOrMore->equals($narrowedReturnType)) { + return $this->typeSpecifier->create($node->getArgs()[0]->value, new AccessoryNonEmptyStringType(), $context, $scope)->setRootExpr($node); + } + + return new SpecifiedTypes([], []); + } + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void { $this->typeSpecifier = $typeSpecifier; diff --git a/src/Type/Php/TrimFunctionTypeSpecifyingExtension.php b/src/Type/Php/TrimFunctionTypeSpecifyingExtension.php new file mode 100644 index 0000000000..09b67a718e --- /dev/null +++ b/src/Type/Php/TrimFunctionTypeSpecifyingExtension.php @@ -0,0 +1,79 @@ +getNarrowedReturnType() !== null + && $context->false() + && $node->name instanceof Name + && !$node->isFirstClassCallable() + && isset($node->getArgs()[0]) + && in_array(strtolower($functionReflection->getName()), [ + 'trim', 'ltrim', 'rtrim', 'chop', + 'mb_trim', 'mb_ltrim', 'mb_rtrim', + ], true); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $narrowedReturnType = $context->getNarrowedReturnType(); + if ($narrowedReturnType === null) { + return new SpecifiedTypes(); + } + + $constantStrings = $narrowedReturnType->getConstantStrings(); + if (count($constantStrings) !== 1 || $constantStrings[0]->getValue() !== '') { + return new SpecifiedTypes(); + } + + $argValue = $node->getArgs()[0]->value; + if (!$scope->getType($argValue)->isString()->yes()) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create( + $argValue, + new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + ]), + $context->negate(), + $scope, + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/tests/PHPStan/Analyser/TypeSpecifierContextTest.php b/tests/PHPStan/Analyser/TypeSpecifierContextTest.php index 10c28510a5..45b385381f 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierContextTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierContextTest.php @@ -4,6 +4,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\IntegerRangeType; use PHPUnit\Framework\Attributes\DataProvider; class TypeSpecifierContextTest extends PHPStanTestCase @@ -89,4 +90,34 @@ public function testNegateNull(): void TypeSpecifierContext::createNull()->negate(); } + public function testConditionTypeNullByDefault(): void + { + $this->assertNull(TypeSpecifierContext::createTrue()->getNarrowedReturnType()); + $this->assertNull(TypeSpecifierContext::createTruthy()->getNarrowedReturnType()); + $this->assertNull(TypeSpecifierContext::createFalsey()->getNarrowedReturnType()); + $this->assertNull(TypeSpecifierContext::createNull()->getNarrowedReturnType()); + } + + public function testWithConditionType(): void + { + $narrowedReturnType = IntegerRangeType::createAllGreaterThanOrEqualTo(2); + $context = TypeSpecifierContext::createTruthy()->withNarrowedReturnType($narrowedReturnType); + + $this->assertSame($narrowedReturnType, $context->getNarrowedReturnType()); + + // the bitmask-derived bool accessors are unaffected by the narrowed return type + $this->assertTrue($context->true()); + $this->assertTrue($context->truthy()); + $this->assertFalse($context->false()); + $this->assertFalse($context->falsey()); + $this->assertFalse($context->null()); + } + + public function testNegateDropsConditionType(): void + { + $context = TypeSpecifierContext::createTruthy()->withNarrowedReturnType(IntegerRangeType::createAllGreaterThanOrEqualTo(2)); + + $this->assertNull($context->negate()->getNarrowedReturnType()); + } + }