Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions src/Analyser/ConditionalThrowTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Variable;
use PHPStan\Reflection\ExtendedParametersAcceptor;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Type\ConditionalTypeForParameter;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeUtils;
use function array_key_exists;
use function array_last;
use function count;
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
{

/**
* @param Arg[] $args
*/
public static function resolveForCall(
Type $throwType,
ParametersAcceptor $parametersAcceptor,
array $args,
Scope $scope,
): Type
{
if (!$throwType->hasTemplateOrLateResolvableType()) {
return $throwType;
}

// ConditionalTypeForParameter (e.g. ($x is 0 ? Exception : void)) is resolved
// against the argument types passed at the call site.
$throwType = self::mapConditionalTypesForParameter($throwType, self::getPassedArgs($parametersAcceptor, $args, $scope));

// ConditionalType whose subject is a template type (e.g. (TKey is int ? void : Exception))
// is resolved against the template types inferred from the call site.
if ($parametersAcceptor instanceof ExtendedParametersAcceptor) {
$throwType = TemplateTypeHelper::resolveTemplateTypes(
$throwType,
$parametersAcceptor->getResolvedTemplateTypeMap(),
$parametersAcceptor->getCallSiteVarianceMap(),
TemplateTypeVariance::createCovariant(),
);
}

return TypeUtils::resolveLateResolvableTypes($throwType, false);
}

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;
}

/**
* @param Arg[] $args
* @return array<string, Type>
*/
private static function getPassedArgs(ParametersAcceptor $parametersAcceptor, array $args, Scope $scope): array
{
$parameters = $parametersAcceptor->getParameters();

$namedArgTypes = [];
$i = 0;
foreach ($args as $arg) {
if ($arg->unpack) {
// unpacked arguments cannot be reliably mapped to a single parameter
$i++;
continue;
}

if ($arg->name !== null) {
$namedArgTypes[$arg->name->toString()] = $scope->getType($arg->value);
continue;
}

if (isset($parameters[$i])) {
$namedArgTypes[$parameters[$i]->getName()] = $scope->getType($arg->value);
} elseif (count($parameters) > 0) {
$lastParameter = array_last($parameters);
if ($lastParameter->isVariadic()) {
$namedArgTypes[$lastParameter->getName()] = $scope->getType($arg->value);
}
}

$i++;
}

$passedArgs = [];
foreach ($parameters as $parameter) {
if (array_key_exists($parameter->getName(), $namedArgTypes)) {
$passedArgs['$' . $parameter->getName()] = $namedArgTypes[$parameter->getName()];
} elseif ($parameter->getDefaultValue() !== null) {
$passedArgs['$' . $parameter->getName()] = $parameter->getDefaultValue();
}
}

return $passedArgs;
}

}
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
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 @@
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()) {

Check warning on line 53 in src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($throwPointType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { continue; } - if ($resolvedThrowType->isSuperTypeOf($throwPointType)->yes()) { + if (!$resolvedThrowType->isSuperTypeOf($throwPointType)->no()) { continue; }

Check warning on line 53 in src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($throwPointType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { continue; } - if ($resolvedThrowType->isSuperTypeOf($throwPointType)->yes()) { + if (!$resolvedThrowType->isSuperTypeOf($throwPointType)->no()) { continue; }
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 @@
}

$phpDocThrowsType = $resolvedPhpDoc->getThrowsTag()->getType();
if ($phpDocThrowsType->isVoid()->yes()) {
return [];
}

if ($this->isThrowsValid($phpDocThrowsType)) {
return [];
}
Expand All @@ -87,10 +85,29 @@

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)) {

Check warning on line 110 in src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $throwType = new ObjectType(Throwable::class); if ($phpDocThrowsType instanceof UnionType) { foreach ($phpDocThrowsType->getTypes() as $innerType) { - if ($innerType->isVoid()->yes() || !$this->isThrowsValid($innerType)) { + if (!$innerType->isVoid()->no() || !$this->isThrowsValid($innerType)) { return false; } }
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,27 @@ 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,
],
]);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,30 @@ 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,
],
]);
}

}
Loading
Loading