Skip to content

Commit 3f7e878

Browse files
committed
New config parameter exceptions.check.throwTypeCovariance
1 parent d072ced commit 3f7e878

File tree

6 files changed

+325
-1
lines changed

6 files changed

+325
-1
lines changed

build/phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ parameters:
7070
check:
7171
missingCheckedExceptionInThrows: true
7272
tooWideThrowType: true
73+
throwTypeCovariance: true
7374
ignoreErrors:
7475
- '#^Dynamic call to static method PHPUnit\\Framework\\\S+\(\)\.$#'
7576
- '#should be contravariant with parameter \$node \(PhpParser\\Node\) of method PHPStan\\Rules\\Rule<PhpParser\\Node>::processNode\(\)$#'

conf/config.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ parameters:
2222
check:
2323
missingCheckedExceptionInThrows: false
2424
tooWideThrowType: true
25+
throwTypeCovariance: false
2526
featureToggles:
2627
bleedingEdge: false
2728
checkNonStringableDynamicAccess: false
@@ -245,6 +246,8 @@ conditionalTags:
245246
phpstan.rules.rule: %exceptions.check.missingCheckedExceptionInThrows%
246247
PHPStan\Rules\Properties\UninitializedPropertyRule:
247248
phpstan.rules.rule: %checkUninitializedProperties%
249+
PHPStan\Rules\Exceptions\MethodThrowTypeCovarianceRule:
250+
phpstan.rules.rule: %exceptions.check.throwTypeCovariance%
248251

249252
services:
250253
-
@@ -259,6 +262,11 @@ services:
259262
-
260263
class: PHPStan\Rules\Properties\UninitializedPropertyRule
261264

265+
-
266+
class: PHPStan\Rules\Exceptions\MethodThrowTypeCovarianceRule
267+
arguments:
268+
implicitThrows: %exceptions.implicitThrows%
269+
262270
# autowired services are now registered with the help of attributes
263271
# like #[PHPStan\DependencyInjection\AutowiredService] or #[PHPStan\DependencyInjection\GenerateFactory]
264272

conf/parametersSchema.neon

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ parametersSchema:
2323
checkedExceptionClasses: listOf(string()),
2424
check: structure([
2525
missingCheckedExceptionInThrows: bool(),
26-
tooWideThrowType: bool()
26+
tooWideThrowType: bool(),
27+
throwTypeCovariance: bool()
2728
])
2829
])
2930
featureToggles: structure([
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Exceptions;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Node\InClassMethodNode;
8+
use PHPStan\Rules\Methods\ParentMethodHelper;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\Type\ObjectType;
12+
use PHPStan\Type\VerbosityLevel;
13+
use PHPStan\Type\VoidType;
14+
use Throwable;
15+
use function sprintf;
16+
17+
/**
18+
* @implements Rule<InClassMethodNode>
19+
*/
20+
final class MethodThrowTypeCovarianceRule implements Rule
21+
{
22+
23+
public function __construct(
24+
private ParentMethodHelper $parentMethodHelper,
25+
private bool $implicitThrows,
26+
)
27+
{
28+
}
29+
30+
public function getNodeType(): string
31+
{
32+
return InClassMethodNode::class;
33+
}
34+
35+
public function processNode(Node $node, Scope $scope): array
36+
{
37+
$method = $node->getMethodReflection();
38+
$methodName = $method->getName();
39+
if ($methodName === '__construct') {
40+
return [];
41+
}
42+
43+
if ($method->isPrivate()) {
44+
return [];
45+
}
46+
47+
$throwType = $method->getThrowType();
48+
if ($throwType === null) {
49+
if ($this->implicitThrows) {
50+
$throwType = new ObjectType(Throwable::class);
51+
} else {
52+
$throwType = new VoidType();
53+
}
54+
}
55+
56+
if ($throwType->isVoid()->yes()) {
57+
return [];
58+
}
59+
60+
$errors = [];
61+
foreach ($this->parentMethodHelper->collectParentMethods($methodName, $method->getDeclaringClass()) as [$parentMethod, $parentMethodDeclaringClass]) {
62+
$parentThrowType = $parentMethod->getThrowType();
63+
$explicitParentThrowsVoid = true;
64+
if ($parentThrowType === null) {
65+
if ($this->implicitThrows) {
66+
$parentThrowType = new ObjectType(Throwable::class);
67+
} else {
68+
$parentThrowType = new VoidType();
69+
$explicitParentThrowsVoid = false;
70+
}
71+
} else {
72+
$explicitParentThrowsVoid = $parentThrowType->isVoid()->yes();
73+
}
74+
75+
if ($parentThrowType->isVoid()->yes()) {
76+
if ($explicitParentThrowsVoid) {
77+
$errors[] = RuleErrorBuilder::message(sprintf(
78+
'Method %s::%s() should not throw %s because parent method %s::%s() has PHPDoc tag @throws void.',
79+
$method->getDeclaringClass()->getDisplayName(),
80+
$method->getName(),
81+
$throwType->describe(VerbosityLevel::typeOnly()),
82+
$parentMethodDeclaringClass->getDisplayName(),
83+
$parentMethod->getName(),
84+
))->identifier('throws.shouldBeVoid')->build();
85+
} else {
86+
$errors[] = RuleErrorBuilder::message(sprintf(
87+
'Method %s::%s() should not throw %s because parent method %s::%s() does not have PHPDoc tag @throws.',
88+
$method->getDeclaringClass()->getDisplayName(),
89+
$method->getName(),
90+
$throwType->describe(VerbosityLevel::typeOnly()),
91+
$parentMethodDeclaringClass->getDisplayName(),
92+
$parentMethod->getName(),
93+
))->identifier('throws.notCovariantWithImplicitVoid')->build();
94+
}
95+
continue;
96+
}
97+
98+
if ($parentThrowType->isSuperTypeOf($throwType)->yes()) {
99+
continue;
100+
}
101+
102+
$errors[] = RuleErrorBuilder::message(sprintf(
103+
'PHPDoc tag @throws type %s of method %s::%s() should be covariant with PHPDoc @throws type %s of method %s::%s().',
104+
$throwType->describe(VerbosityLevel::typeOnly()),
105+
$method->getDeclaringClass()->getDisplayName(),
106+
$method->getName(),
107+
$parentThrowType->describe(VerbosityLevel::typeOnly()),
108+
$parentMethodDeclaringClass->getDisplayName(),
109+
$parentMethod->getName(),
110+
))->identifier('throws.notCovariant')->build();
111+
}
112+
113+
return $errors;
114+
}
115+
116+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Exceptions;
4+
5+
use PHPStan\Reflection\Php\PhpClassReflectionExtension;
6+
use PHPStan\Rules\Methods\ParentMethodHelper;
7+
use PHPStan\Rules\Rule as TRule;
8+
use PHPStan\Testing\RuleTestCase;
9+
use PHPUnit\Framework\Attributes\DataProvider;
10+
11+
/**
12+
* @extends RuleTestCase<MethodThrowTypeCovarianceRule>
13+
*/
14+
class MethodThrowTypeCovarianceRuleTest extends RuleTestCase
15+
{
16+
17+
private bool $implicitThrows;
18+
19+
protected function getRule(): TRule
20+
{
21+
return new MethodThrowTypeCovarianceRule(new ParentMethodHelper(
22+
self::getContainer()->getByType(PhpClassReflectionExtension::class),
23+
), $this->implicitThrows);
24+
}
25+
26+
public static function dataRule(): iterable
27+
{
28+
yield [
29+
false,
30+
[
31+
[
32+
'Method MethodThrowTypeCovariance\Baz::noThrowType() should not throw Exception because parent method MethodThrowTypeCovariance\Foo::noThrowType() does not have PHPDoc tag @throws.',
33+
47,
34+
],
35+
[
36+
'PHPDoc tag @throws type RuntimeException of method MethodThrowTypeCovariance\Baz::logicException() should be covariant with PHPDoc @throws type LogicException of method MethodThrowTypeCovariance\Foo::logicException().',
37+
56,
38+
],
39+
[
40+
'Method MethodThrowTypeCovariance\VoidInChild3::throwVoid() should not throw RuntimeException because parent method MethodThrowTypeCovariance\VoidInParent::throwVoid() has PHPDoc tag @throws void.',
41+
122,
42+
],
43+
],
44+
];
45+
46+
yield [
47+
true,
48+
[
49+
[
50+
'PHPDoc tag @throws type RuntimeException of method MethodThrowTypeCovariance\Baz::logicException() should be covariant with PHPDoc @throws type LogicException of method MethodThrowTypeCovariance\Foo::logicException().',
51+
56,
52+
],
53+
[
54+
'Method MethodThrowTypeCovariance\VoidInChild3::throwVoid() should not throw RuntimeException because parent method MethodThrowTypeCovariance\VoidInParent::throwVoid() has PHPDoc tag @throws void.',
55+
122,
56+
],
57+
],
58+
];
59+
}
60+
61+
/**
62+
* @param list<array{0: string, 1: int, 2?: string|null}> $expectedErrors
63+
*/
64+
#[DataProvider('dataRule')]
65+
public function testRule(bool $implicitThrows, array $expectedErrors): void
66+
{
67+
$this->implicitThrows = $implicitThrows;
68+
$this->analyse([__DIR__ . '/data/method-throw-type-covariance.php'], $expectedErrors);
69+
}
70+
71+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace MethodThrowTypeCovariance;
4+
5+
class Foo
6+
{
7+
8+
public function noThrowType(): void
9+
{
10+
11+
}
12+
13+
/**
14+
* @throws \LogicException
15+
*/
16+
public function logicException(): void
17+
{
18+
19+
}
20+
21+
}
22+
23+
class Bar extends Foo
24+
{
25+
26+
public function noThrowType(): void
27+
{
28+
// ok
29+
}
30+
31+
/**
32+
* @throws \BadFunctionCallException
33+
*/
34+
public function logicException(): void
35+
{
36+
// ok
37+
}
38+
39+
}
40+
41+
class Baz extends Foo
42+
{
43+
44+
/**
45+
* @throws \Exception
46+
*/
47+
public function noThrowType(): void
48+
{
49+
// ok with implicitThrows: true
50+
// error with implicitThrows: false
51+
}
52+
53+
/**
54+
* @throws \RuntimeException
55+
*/
56+
public function logicException(): void
57+
{
58+
// error - RuntimeException does not extend LogicException
59+
}
60+
61+
}
62+
63+
class VoidThrows extends Foo
64+
{
65+
66+
/**
67+
* @throws void
68+
*/
69+
public function noThrowType(): void
70+
{
71+
// ok
72+
}
73+
74+
/**
75+
* @throws void
76+
*/
77+
public function logicException(): void
78+
{
79+
// ok
80+
}
81+
82+
}
83+
84+
class VoidInParent
85+
{
86+
/**
87+
* @throws void
88+
*/
89+
public function throwVoid(): void
90+
{
91+
92+
}
93+
}
94+
95+
class VoidInChild extends VoidInParent
96+
{
97+
98+
public function throwVoid(): void
99+
{
100+
// ok - @throws void inherited
101+
}
102+
}
103+
104+
class VoidInChild2 extends VoidInParent
105+
{
106+
107+
/**
108+
* @throws void
109+
*/
110+
public function throwVoid(): void
111+
{
112+
// ok
113+
}
114+
}
115+
116+
class VoidInChild3 extends VoidInParent
117+
{
118+
119+
/**
120+
* @throws \RuntimeException
121+
*/
122+
public function throwVoid(): void
123+
{
124+
// error
125+
}
126+
127+
}

0 commit comments

Comments
 (0)