Skip to content
140 changes: 140 additions & 0 deletions src/Analyser/ConditionalThrowTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Variable;
use PHPStan\Reflection\GenericParametersAcceptorResolver;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ResolvedFunctionVariant;
use PHPStan\Type\ConditionalTypeForParameter;
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeUtils;
use function array_key_exists;
use function substr;

/**
* Resolves conditional `@throws` types like `($x is 0 ? Exception : void)` and
* `(TKey is int ? void : Exception)`.
*
* The same `ConditionalTypeForParameter` and `ConditionalType` representations
* used for conditional return types are resolved here against either the
* arguments passed at a call site (so callers see whether the call throws) or
* against the parameter variables inside the function body (so the body's throw
* points are matched against the declared `@throws` type).
*/
final class ConditionalThrowTypeResolver
{

/**
* Resolves a conditional `@throws` type against a call site. A `ResolvedFunctionVariant`
* already holds the call's bound arguments and inferred template types and knows how to
* resolve a conditional type the same way it resolves a conditional return type — both
* `ConditionalTypeForParameter` (e.g. `($x is 0 ? Exception : void)`) and `ConditionalType`
* whose subject is a template type (e.g. `(TKey is int ? void : Exception)`).
*
* `ParametersAcceptorSelector::selectFromArgs()` only resolves the variant when the return
* or parameter types are conditional/generic — it does not know about the throws type — so
* when the throws type is the only conditional one, the variant is resolved here from the
* passed arguments via `GenericParametersAcceptorResolver`.
*
* @param Arg[] $args
*/
public static function resolveForCall(
Type $throwType,
ParametersAcceptor $parametersAcceptor,
array $args,
Scope $scope,
): Type
{
if (!$throwType->hasTemplateOrLateResolvableType()) {
return $throwType;
}

// selectFromArgs() may hand back a variant that is not bound to this call's arguments
// (either an unresolved acceptor, or a method variant whose passedArgs are empty),
// so always resolve from this call's argument types against the original acceptor.
$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 $throwType;
}

return $resolvedAcceptor->resolveConditionalTypes($throwType);
}

public static function resolveForScope(Type $throwType, Scope $scope): Type
{
if (!$throwType->hasTemplateOrLateResolvableType()) {
return $throwType;
}

$passedArgs = [];
foreach (self::collectParameterNames($throwType) as $parameterName) {
$variableName = substr($parameterName, 1);
if (!$scope->hasVariableType($variableName)->yes()) {
continue;
}

$passedArgs[$parameterName] = $scope->getType(new Variable($variableName));
}

$throwType = self::mapConditionalTypesForParameter($throwType, $passedArgs);

// 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 set of
// exceptions the declaration permits — rather than left as a Maybe-certain conditional.
return TypeUtils::resolveLateResolvableTypes($throwType, true);
}

/**
* @param array<string, Type> $passedArgs
*/
private static function mapConditionalTypesForParameter(Type $throwType, array $passedArgs): Type
{
if ($passedArgs === []) {
return $throwType;
}

return TypeTraverser::map($throwType, static function (Type $type, callable $traverse) use ($passedArgs): Type {
if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $passedArgs)) {
$type = $traverse($type);
if ($type instanceof ConditionalTypeForParameter) {
return $type->toConditional($passedArgs[$type->getParameterName()]);
}

return $type;
}

return $traverse($type);
});
}

/**
* @return list<string>
*/
private static function collectParameterNames(Type $throwType): array
{
$names = [];
TypeTraverser::map($throwType, static function (Type $type, callable $traverse) use (&$names): Type {
if ($type instanceof ConditionalTypeForParameter) {
$names[] = $type->getParameterName();
}

return $traverse($type);
});

return $names;
}

}
4 changes: 4 additions & 0 deletions src/Analyser/ExprHandler/FuncCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\ConditionalThrowTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
Expand Down Expand Up @@ -604,6 +605,9 @@ private function getFunctionThrowPoint(
}

$throwType = $functionReflection->getThrowType();
if ($throwType !== null && $parametersAcceptor !== null) {
$throwType = ConditionalThrowTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedFuncCall->getArgs(), $scope);
}
if ($throwType === null) {
$returnType = $scope->getType($normalizedFuncCall);
if ($returnType instanceof NeverType && $returnType->isExplicit()) {
Expand Down
4 changes: 4 additions & 0 deletions src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\ConditionalThrowTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
Expand Down Expand Up @@ -76,6 +77,9 @@ public function getThrowPoint(
}

$throwType = $methodReflection->getThrowType();
if ($throwType !== null) {
$throwType = ConditionalThrowTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedMethodCall->getArgs(), $scope);
}
if ($throwType === null) {
$returnType = $scope->getType($normalizedMethodCall);
if ($returnType instanceof NeverType && $returnType->isExplicit()) {
Expand Down
3 changes: 2 additions & 1 deletion src/Analyser/ExprHandler/NewHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PhpParser\Node\Name;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\ConditionalThrowTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultStorage;
Expand Down Expand Up @@ -302,7 +303,7 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio
}

if ($constructorReflection->getThrowType() !== null) {
$throwType = $constructorReflection->getThrowType();
$throwType = ConditionalThrowTypeResolver::resolveForCall($constructorReflection->getThrowType(), $parametersAcceptor, $args, $scope);
if (!$throwType->isVoid()->yes()) {
return InternalThrowPoint::createExplicit($scope, $throwType, $new, true);
}
Expand Down
13 changes: 10 additions & 3 deletions src/PhpDoc/ResolvedPhpDocBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi
$result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $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();
Expand Down Expand Up @@ -1016,13 +1016,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()));
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/Reflection/ResolvedFunctionVariant.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,11 @@ public function getOriginalParametersAcceptor(): ParametersAcceptor;

public function getReturnTypeWithUnresolvableTemplateTypes(): Type;

/**
* Resolves an arbitrary declared type (e.g. a conditional `@throws` 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;

}
5 changes: 5 additions & 0 deletions src/Reflection/ResolvedFunctionVariantWithCallable.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ public function getReturnType(): Type
return $this->parametersAcceptor->getReturnType();
}

public function resolveConditionalTypes(Type $type): Type
{
return $this->parametersAcceptor->resolveConditionalTypes($type);
}

public function getPhpDocReturnType(): Type
{
return $this->parametersAcceptor->getPhpDocReturnType();
Expand Down
13 changes: 13 additions & 0 deletions src/Reflection/ResolvedFunctionVariantWithOriginal.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ public function getNativeReturnType(): Type
return $this->parametersAcceptor->getNativeReturnType();
}

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PHPStan\Rules\Exceptions;

use PhpParser\Node;
use PHPStan\Analyser\ConditionalThrowTypeResolver;
use PHPStan\Analyser\ThrowPoint;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
Expand Down Expand Up @@ -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 = ConditionalThrowTypeResolver::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;
}

Expand Down
27 changes: 22 additions & 5 deletions src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
}
Expand All @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
]);
}

}
Loading
Loading