diff --git a/src/Analyser/ConditionalTypeResolver.php b/src/Analyser/ConditionalTypeResolver.php new file mode 100644 index 00000000000..7269a6bd865 --- /dev/null +++ b/src/Analyser/ConditionalTypeResolver.php @@ -0,0 +1,111 @@ +hasTemplateOrLateResolvableType()) { + return $declaredType; + } + + // A variant already bound to this call's arguments has the template types inferred + // from everything the call knows - including a closure argument's return type, which + // the argument type alone no longer tells - so it resolves the type as it is. + if ($parametersAcceptor instanceof ResolvedFunctionVariant && $parametersAcceptor->hasBoundArgs()) { + return $parametersAcceptor->resolveConditionalTypes($declaredType); + } + + // Otherwise the acceptor is not bound to this call (an unresolved acceptor, or a method + // variant resolved only against the generics of the class it is called on), so the + // variant is resolved here from this call's argument types. + $originalAcceptor = $parametersAcceptor instanceof ResolvedFunctionVariant + ? $parametersAcceptor->getOriginalParametersAcceptor() + : $parametersAcceptor; + + $argTypes = []; + foreach ($args as $i => $arg) { + $argTypes[$arg->name !== null ? $arg->name->toString() : $i] = $scope->getType($arg->value); + } + + $resolvedAcceptor = GenericParametersAcceptorResolver::resolve($argTypes, $originalAcceptor); + if (!$resolvedAcceptor instanceof ResolvedFunctionVariant) { + return $declaredType; + } + + return $resolvedAcceptor->resolveConditionalTypes($declaredType); + } + + public static function resolveForScope(Type $declaredType, Scope $scope): Type + { + if (!$declaredType->hasTemplateOrLateResolvableType()) { + return $declaredType; + } + + $declaredType = ConditionalTypeForParameter::resolveInType( + $declaredType, + static function (string $parameterName) use ($scope): ?Type { + $variableName = substr($parameterName, 1); + if (!$scope->hasVariableType($variableName)->yes()) { + return null; + } + + return $scope->getType(new Variable($variableName)); + }, + ); + + // A ConditionalType whose subject is a template type cannot be resolved to a single + // branch inside the function body (the template is not bound to a concrete type there), + // so it is conservatively collapsed to the union of its branches — the broadest type the + // declaration permits — rather than left as a Maybe-certain conditional. + return TypeUtils::resolveLateResolvableTypes($declaredType, true); + } + +} diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 9e64e632345..af8b843e08f 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -13,6 +13,7 @@ use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ConditionalTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; @@ -373,6 +374,9 @@ private function getFunctionThrowPoint( } $throwType = $functionReflection->getThrowType(); + if ($throwType !== null && $parametersAcceptor !== null) { + $throwType = ConditionalTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedFuncCall->getArgs(), $scope); + } if ($throwType === null) { $returnType = $scope->getType($normalizedFuncCall); if ($returnType instanceof NeverType && $returnType->isExplicit()) { diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 5edef897727..3f3157a56e7 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; +use PHPStan\Analyser\ConditionalTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -90,6 +91,9 @@ public function getThrowPoint( } $throwType = $methodReflection->getThrowType(); + if ($throwType !== null) { + $throwType = ConditionalTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedMethodCall->getArgs(), $scope); + } if ($throwType === null) { $returnType = $scope->getType($normalizedMethodCall); if ($returnType instanceof NeverType && $returnType->isExplicit()) { diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 05f4b5b48c7..51174d4a02c 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -10,6 +10,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\CalledMethodProcessor; +use PHPStan\Analyser\ConditionalTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; @@ -197,12 +198,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( $normalizedExpr->var, - TemplateTypeHelper::resolveTemplateTypes( - $selfOutType, - $acceptorForGenerics->getResolvedTemplateTypeMap(), - $acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), - TemplateTypeVariance::createCovariant(), - ), + ConditionalTypeResolver::resolveForCall($selfOutType, $acceptorForGenerics, $normalizedExpr->getArgs(), $scope), $scope->getNativeType($normalizedExpr->var), ); } diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 639a21b85db..2f83312cac0 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ConditionalTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; @@ -333,7 +334,7 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio } if ($constructorReflection->getThrowType() !== null) { - $throwType = $constructorReflection->getThrowType(); + $throwType = ConditionalTypeResolver::resolveForCall($constructorReflection->getThrowType(), $parametersAcceptor, $args, $scope); if (!$throwType->isVoid()->yes()) { return InternalThrowPoint::createExplicit($scope, $throwType, $new, true); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index d2da6b934b5..22e7a941086 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -40,7 +40,6 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeTraverser; -use function array_key_exists; use function array_last; use function array_map; use function array_merge; @@ -430,21 +429,17 @@ public function specifyTypesFromAsserts(TypeSpecifierContext $context, Expr\Call foreach ($asserts as $assert) { foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { - $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type { - if ($type instanceof ConditionalTypeForParameter) { - $parameterName = substr($type->getParameterName(), 1); - if (array_key_exists($parameterName, $argsMap)) { - $type = $traverse($type); - if ($type instanceof ConditionalTypeForParameter) { - $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)])); - return $type->toConditional($argType); - } - return $type; + $assertedType = ConditionalTypeForParameter::resolveInType( + $assert->getType(), + static function (string $parameterName) use ($argsMap, $scope): ?Type { + $parameterExprs = $argsMap[substr($parameterName, 1)] ?? null; + if ($parameterExprs === null) { + return null; } - } - return $traverse($type); - }); + return TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $parameterExprs)); + }, + ); $assertExpr = $assert->getParameter()->getExpr($parameterExpr); diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 12d3dcec1ff..4a7cba153f1 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -284,7 +284,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping); $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass); $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass); - $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent); + $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent, $parameterMapping); $result->mixinTags = $this->getMixinTags(); $result->requireExtendsTags = $this->getRequireExtendsTags(); $result->requireImplementsTags = $this->getRequireImplementsTags(); @@ -1038,13 +1038,20 @@ private static function mergeDeprecatedTags(?DeprecatedTag $deprecatedTag, bool return $result; } - private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent): ?ThrowsTag + private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent, InheritedPhpDocParameterMapping $parameterMapping): ?ThrowsTag { if ($throwsTag !== null) { return $throwsTag; } - return $parent->getThrowsTag(); + $parentThrowsTag = $parent->getThrowsTag(); + if ($parentThrowsTag === null) { + return null; + } + + // Conditional @throws types like ($x is 0 ? Exception : void) reference parameter + // names that may differ in the overriding method, so remap them just like @return. + return new ThrowsTag($parameterMapping->transformConditionalReturnTypeWithParameterNameMapping($parentThrowsTag->getType())); } /** diff --git a/src/Reflection/ResolvedFunctionVariant.php b/src/Reflection/ResolvedFunctionVariant.php index 92675f4f197..64ec09dd659 100644 --- a/src/Reflection/ResolvedFunctionVariant.php +++ b/src/Reflection/ResolvedFunctionVariant.php @@ -11,4 +11,17 @@ public function getOriginalParametersAcceptor(): ParametersAcceptor; public function getReturnTypeWithUnresolvableTemplateTypes(): Type; + /** + * Whether the variant is bound to the arguments of a specific call, as opposed to being + * resolved only against the generics of the class the method is called on. + */ + public function hasBoundArgs(): bool; + + /** + * Resolves an arbitrary declared type (e.g. a conditional `@throws` or `@phpstan-self-out` + * type) against this call's bound arguments and inferred template types, the same way the + * return type is resolved at the call site. + */ + public function resolveConditionalTypes(Type $type): Type; + } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 7e57be7a952..145fed25f24 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -75,6 +75,16 @@ public function getReturnType(): Type return $this->parametersAcceptor->getReturnType(); } + public function hasBoundArgs(): bool + { + return $this->parametersAcceptor->hasBoundArgs(); + } + + public function resolveConditionalTypes(Type $type): Type + { + return $this->parametersAcceptor->resolveConditionalTypes($type); + } + public function getPhpDocReturnType(): Type { return $this->parametersAcceptor->getPhpDocReturnType(); diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index d06b8a013b5..41674655695 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -17,7 +17,6 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; -use function array_key_exists; use function array_map; final class ResolvedFunctionVariantWithOriginal implements ResolvedFunctionVariant @@ -209,6 +208,24 @@ public function getNativeReturnType(): Type return $this->parametersAcceptor->getNativeReturnType(); } + public function hasBoundArgs(): bool + { + return $this->passedArgs !== []; + } + + public function resolveConditionalTypes(Type $type): Type + { + return TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->resolveConditionalTypesForParameter($type), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ), + false, + ); + } + private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance $positionVariance): Type { $references = $type->getReferencedTemplateTypes($positionVariance); @@ -392,19 +409,10 @@ private static function referencesTemplateType(Type $type, TemplateType $templat private function resolveConditionalTypesForParameter(Type $type): Type { - return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { - if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $this->passedArgs)) { - // Traverse children first, then convert — avoids infinite loop when - // the passed argument contains ConditionalTypeForParameter with a colliding parameter name. - $type = $traverse($type); - if ($type instanceof ConditionalTypeForParameter) { - return $type->toConditional($this->passedArgs[$type->getParameterName()]); - } - return $type; - } - - return $traverse($type); - }); + return ConditionalTypeForParameter::resolveInType( + $type, + fn (string $parameterName): ?Type => $this->passedArgs[$parameterName] ?? null, + ); } } diff --git a/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php b/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php index 62de7f9e2ad..86088fc15ee 100644 --- a/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php +++ b/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Exceptions; use PhpParser\Node; +use PHPStan\Analyser\ConditionalTypeResolver; use PHPStan\Analyser\ThrowPoint; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -41,11 +42,15 @@ public function check(?Type $throwType, array $throwPoints): array continue; } + // Conditional @throws types like ($x is 0 ? Exception : void) are resolved + // against the parameter variables narrowed in the scope of the throw point. + $resolvedThrowType = ConditionalTypeResolver::resolveForScope($throwType, $throwPoint->getScope()); + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { if ($throwPointType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { continue; } - if ($throwType->isSuperTypeOf($throwPointType)->yes()) { + if ($resolvedThrowType->isSuperTypeOf($throwPointType)->yes()) { continue; } diff --git a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php index 9a89cfce380..1b3f2ae26b5 100644 --- a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php +++ b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php @@ -10,6 +10,8 @@ use PHPStan\Node\InPropertyHookNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\ConditionalType; +use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -69,10 +71,6 @@ public function processNode(Node $node, Scope $scope): array } $phpDocThrowsType = $resolvedPhpDoc->getThrowsTag()->getType(); - if ($phpDocThrowsType->isVoid()->yes()) { - return []; - } - if ($this->isThrowsValid($phpDocThrowsType)) { return []; } @@ -87,10 +85,29 @@ public function processNode(Node $node, Scope $scope): array private function isThrowsValid(Type $phpDocThrowsType): bool { + // `void` standalone means "does not throw" and is a valid @throws type (it is + // likewise allowed as a branch of a conditional throws type). As a union member + // such as Throwable|void it is rejected in the UnionType handling below. + if ($phpDocThrowsType->isVoid()->yes()) { + return true; + } + + // Conditional @throws types like ($x is 0 ? Exception : void) are valid as long + // as both branches are valid throws types (a Throwable subtype or void). + if ($phpDocThrowsType instanceof ConditionalType) { + return $this->isThrowsValid($phpDocThrowsType->getIf()) + && $this->isThrowsValid($phpDocThrowsType->getElse()); + } + + if ($phpDocThrowsType instanceof ConditionalTypeForParameter) { + return $this->isThrowsValid($phpDocThrowsType->getIf()) + && $this->isThrowsValid($phpDocThrowsType->getElse()); + } + $throwType = new ObjectType(Throwable::class); if ($phpDocThrowsType instanceof UnionType) { foreach ($phpDocThrowsType->getTypes() as $innerType) { - if (!$this->isThrowsValid($innerType)) { + if ($innerType->isVoid()->yes() || !$this->isThrowsValid($innerType)) { return false; } } diff --git a/src/Type/ConditionalTypeForParameter.php b/src/Type/ConditionalTypeForParameter.php index b02c1e60a34..fbe24f04e4c 100644 --- a/src/Type/ConditionalTypeForParameter.php +++ b/src/Type/ConditionalTypeForParameter.php @@ -93,6 +93,43 @@ public function narrowTemplateType(TemplateType $templateType): self return $type; } + /** + * Replaces every ConditionalTypeForParameter inside $type with the ConditionalType on the + * subject its parameter resolves to. $getSubjectType is called with the parameter name + * including the leading `$`; returning null leaves that conditional unresolved. + * + * Shared by everything that resolves a declared conditional type against concrete + * subjects: ResolvedFunctionVariant (`@return`, `@param`, `@param-out`, + * `@param-closure-this`), TypeSpecifier (`@phpstan-assert`) and ConditionalTypeResolver + * (`@throws`, `@phpstan-self-out`). + * + * @param callable(string): ?Type $getSubjectType + */ + public static function resolveInType(Type $type, callable $getSubjectType): Type + { + if (!$type->hasTemplateOrLateResolvableType()) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($getSubjectType): Type { + if ($type instanceof self) { + $subjectType = $getSubjectType($type->getParameterName()); + if ($subjectType !== null) { + // Traverse children first, then convert — avoids infinite loop when + // the subject contains a ConditionalTypeForParameter with a colliding parameter name. + $type = $traverse($type); + if ($type instanceof self) { + return $type->toConditional($subjectType); + } + + return $type; + } + } + + return $traverse($type); + }); + } + public function toConditional(Type $subject): Type { return new ConditionalType( diff --git a/tests/PHPStan/Analyser/nsrt/conditional-self-out.php b/tests/PHPStan/Analyser/nsrt/conditional-self-out.php new file mode 100644 index 00000000000..90f738d92c1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-self-out.php @@ -0,0 +1,67 @@ +setSize(0); + assertType(EmptyCollection::class, $zero); + + $nonZero->setSize(7); + assertType(NonEmptyCollection::class, $nonZero); + + $unknown->setSize($i); + assertType('ConditionalSelfOut\EmptyCollection|ConditionalSelfOut\NonEmptyCollection', $unknown); +} + +function conditionalForTemplateType(Collection $int, Collection $string): void +{ + $int->keyBy(1); + assertType(IntKeyed::class, $int); + + $string->keyBy('foo'); + assertType(StringKeyed::class, $string); +} diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php index 5c0d007b848..08f06c6f97c 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php @@ -52,4 +52,35 @@ public function testRule(): void ]); } + public function testConditionalThrows(): void + { + require_once __DIR__ . '/data/conditional-throws-function.php'; + $this->analyse([__DIR__ . '/data/conditional-throws-function.php'], [ + [ + 'Function ConditionalThrowsFunction\callsZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 23, + ], + [ + 'Function ConditionalThrowsFunction\callsUnknown() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 35, + ], + [ + 'Function ConditionalThrowsFunction\lookupString() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 68, + ], + [ + 'Function ConditionalThrowsFunction\lookupUnknown() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 77, + ], + [ + 'Function ConditionalThrowsFunction\nestedCallsOuterZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 97, + ], + [ + 'Function ConditionalThrowsFunction\nestedCallsInnerZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 103, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php index 73bb2894d8b..6c7fdb84467 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php @@ -110,4 +110,35 @@ public function testBug13792(): void ]); } + public function testConditionalThrows(): void + { + $this->analyse([__DIR__ . '/data/conditional-throws-method.php'], [ + [ + 'Method ConditionalThrowsMethod\Caller::methodCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 81, + ], + [ + 'Method ConditionalThrowsMethod\Caller::staticCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 93, + ], + [ + 'Method ConditionalThrowsMethod\Caller::constructorZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 105, + ], + [ + 'Method ConditionalThrowsMethod\Caller::lookupString() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 123, + ], + [ + 'Method ConditionalThrowsMethod\Caller::inheritedMethodCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 129, + ], + ]); + } + + public function testGenericThrows(): void + { + $this->analyse([__DIR__ . '/data/generic-throws-9497.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php new file mode 100644 index 00000000000..f4b25975298 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php @@ -0,0 +1,110 @@ + $x + * @throws void + */ +function callsRange(int $x): void +{ + inverse($x); +} + +/** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : Exception) + */ +function lookup($key): void +{ + if (is_string($key)) { + throw new Exception('String keys are not supported.'); + } +} + +/** @throws void */ +function lookupInt(): void +{ + lookup(1); +} + +/** @throws void */ +function lookupString(): void +{ + lookup('foo'); +} + +/** + * @param int|string $key + * @throws void + */ +function lookupUnknown($key): void +{ + lookup($key); +} + +/** + * @param int $x + * @param int $y + * @throws ($x is 0 ? Exception : ($y is 0 ? Exception : void)) + */ +function nestedInverse(int $x, int $y): float +{ + if ($x === 0 || $y === 0) { + throw new Exception('Division by zero.'); + } + + return 1 / ($x * $y); +} + +/** @throws void */ +function nestedCallsOuterZero(): void +{ + nestedInverse(0, 5); +} + +/** @throws void */ +function nestedCallsInnerZero(): void +{ + nestedInverse(3, 0); +} + +/** @throws void */ +function nestedCallsNonZero(): void +{ + nestedInverse(3, 5); +} diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php new file mode 100644 index 00000000000..580cf88d5ea --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php @@ -0,0 +1,138 @@ +inverse(0); + } + + /** @throws void */ + public function methodCallNonZero(Service $service): void + { + $service->inverse(7); + } + + /** @throws void */ + public function staticCallZero(): void + { + Service::staticInverse(0); + } + + /** @throws void */ + public function staticCallNonZero(): void + { + Service::staticInverse(7); + } + + /** @throws void */ + public function constructorZero(): void + { + new Service(0); + } + + /** @throws void */ + public function constructorNonZero(): void + { + new Service(7); + } + + /** @throws void */ + public function lookupInt(Service $service): void + { + $service->lookup(1); + } + + /** @throws void */ + public function lookupString(Service $service): void + { + $service->lookup('foo'); + } + + /** @throws void */ + public function inheritedMethodCallZero(Service2 $service): void + { + $service->inverse(0); + } + + /** @throws void */ + public function inheritedMethodCallNonZero(Service2 $service): void + { + $service->inverse(7); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/generic-throws-9497.php b/tests/PHPStan/Rules/Exceptions/data/generic-throws-9497.php new file mode 100644 index 00000000000..5994cfb987b --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/generic-throws-9497.php @@ -0,0 +1,56 @@ +throwIt(new RangeException); + } + + /** + * @param T $e + * + * @template T of RuntimeException + * + * @throws T + */ + public function throwIt(RuntimeException $e): never + { + throw $e; + } + +} + +class TestClassString +{ + + /** + * @throws RangeException + */ + public function sayHello(): never + { + $this->throwIt(RangeException::class); + } + + /** + * @param class-string $e + * + * @template T of RuntimeException + * + * @throws T + */ + public function throwIt(string $e): never + { + throw new $e; + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php index 01edaab08c6..f4aba504914 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php @@ -58,6 +58,18 @@ public function testRule(): void 'PHPDoc tag @throws with type stdClass is not subtype of Throwable', 118, ], + [ + 'PHPDoc tag @throws with type ($x is int ? stdClass : void) is not subtype of Throwable', + 141, + ], + [ + 'PHPDoc tag @throws with type (TKey of int|string is int ? void : stdClass) is not subtype of Throwable', + 159, + ], + [ + 'PHPDoc tag @throws with type ($x is int ? Exception : ($y is int ? stdClass : void)) is not subtype of Throwable', + 177, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php b/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php index 7ce3088fc3f..0591477f750 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php +++ b/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php @@ -117,3 +117,63 @@ function inlineThrows() /** @throws \stdClass */ $i = 1; } + +/** + * @param int $x + * @throws ($x is 0 ? \Exception : void) + */ +function conditionalThrows($x) +{ +} + +/** + * @param int $x + * @throws ($x is 0 ? \Exception : \RuntimeException) + */ +function conditionalThrowsBothBranches($x) +{ +} + +/** + * @param int $x + * @throws ($x is 0 ? \stdClass : void) + */ +function conditionalThrowsInvalidBranch($x) +{ +} + +/** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : \Exception) + */ +function conditionalThrowsForTemplate($key) +{ +} + +/** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : \stdClass) + */ +function conditionalThrowsForTemplateInvalidBranch($key) +{ +} + +/** + * @param int $x + * @param int $y + * @throws ($x is 0 ? \Exception : ($y is 0 ? \RuntimeException : void)) + */ +function nestedConditionalThrows($x, $y) +{ +} + +/** + * @param int $x + * @param int $y + * @throws ($x is 0 ? \Exception : ($y is 0 ? \stdClass : void)) + */ +function nestedConditionalThrowsInvalidBranch($x, $y) +{ +}