Skip to content

Commit 3b26455

Browse files
committed
Adding a rule to check on docblocks
1 parent 0ed6cf8 commit 3b26455

11 files changed

Lines changed: 413 additions & 2 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
/composer.lock
55
/phpunit.xml
66
/tmp
7+
/build/

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ They are more "strict" than the default PHPStan rules and some may be controvers
1414
- You should not have empty catch statements
1515
- When throwing an exception inside a catch block, [you should pass the catched exception as the "previous" exception](http://bestpractices.thecodingmachine.com/php/error_handling.html#wrapping-an-exception-do-not-lose-the-previous-exception)
1616

17+
### PHPDoc related rules
18+
19+
This is a PHP 7.1+ rule:
20+
21+
- You should use type-hinting when possible
22+
- If not possible, you should use a Docblock to specify the type
23+
- If type-hinting against an array, you should use a Docblock to further explain the content of the array
24+
1725

1826
### Work-in-progress
1927

@@ -48,4 +56,12 @@ services:
4856
class: TheCodingMachine\PHPStan\Rules\Exceptions\EmptyExceptionRule
4957
tags:
5058
- phpstan.rules.rule
59+
-
60+
class: TheCodingMachine\PHPStan\Rules\TypeHints\MissingTypeHintInFunctionRule
61+
tags:
62+
- phpstan.rules.rule
63+
-
64+
class: TheCodingMachine\PHPStan\Rules\TypeHints\MissingTypeHintInMethodRule
65+
tags:
66+
- phpstan.rules.rule
5167
```

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
}
1111
],
1212
"require": {
13-
"phpstan/phpstan": "^0.7.0"
13+
"phpstan/phpstan": "^0.7.0",
14+
"roave/better-reflection": "^1.2.0"
1415
},
1516
"require-dev": {
1617
"phpunit/phpunit": "^6.2"

phpunit.xml.dist

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
beStrictAboutTodoAnnotatedTests="true"
1010
failOnRisky="true"
1111
failOnWarning="true"
12+
convertErrorsToExceptions="true"
13+
convertNoticesToExceptions="true"
14+
convertWarningsToExceptions="true"
1215
>
1316
<testsuites>
1417
<testsuite name="Test suite">
@@ -27,6 +30,7 @@
2730
showUncoveredFiles="true"
2831
showOnlySummary="true"
2932
/>
30-
<log type="coverage-clover" target="tmp/clover.xml"/>
33+
<log type="coverage-html" target="build/coverage" charset="UTF-8" yui="true" highlight="true"/>
34+
<log type="coverage-clover" target="build/logs/clover.xml"/>
3135
</logging>
3236
</phpunit>
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
<?php
2+
3+
4+
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
5+
6+
7+
use BetterReflection\Reflection\ReflectionParameter;
8+
use BetterReflection\Util\FindReflectionOnLine;
9+
use phpDocumentor\Reflection\Type;
10+
use phpDocumentor\Reflection\Types\Array_;
11+
use phpDocumentor\Reflection\Types\Boolean;
12+
use phpDocumentor\Reflection\Types\Callable_;
13+
use phpDocumentor\Reflection\Types\Float_;
14+
use phpDocumentor\Reflection\Types\Integer;
15+
use phpDocumentor\Reflection\Types\Mixed;
16+
use phpDocumentor\Reflection\Types\Null_;
17+
use phpDocumentor\Reflection\Types\Object_;
18+
use phpDocumentor\Reflection\Types\Scalar;
19+
use phpDocumentor\Reflection\Types\String_;
20+
use PhpParser\Node;
21+
use PhpParser\Node\Stmt\Catch_;
22+
use PHPStan\Analyser\Scope;
23+
use PHPStan\Broker\Broker;
24+
use PHPStan\Rules\Rule;
25+
26+
class AbstractMissingTypeHintRule implements Rule
27+
{
28+
29+
/**
30+
* @var Broker
31+
*/
32+
private $broker;
33+
34+
public function __construct(Broker $broker)
35+
{
36+
37+
$this->broker = $broker;
38+
}
39+
40+
public function getNodeType(): string
41+
{
42+
// FIXME: does this encompass the Method_?
43+
return Node\Stmt\Function_::class;
44+
}
45+
46+
/**
47+
* @param \PhpParser\Node\Stmt\Function_ $node
48+
* @param \PHPStan\Analyser\Scope $scope
49+
* @return string[]
50+
*/
51+
public function processNode(Node $node, Scope $scope): array
52+
{
53+
// TODO: improve performance by caching better reflection results.
54+
$finder = new FindReflectionOnLine();
55+
56+
$reflection = $finder($scope->getFile(), $node->getLine());
57+
58+
$errors = [];
59+
60+
foreach ($reflection->getParameters() as $parameter) {
61+
$result = $this->analyzeParameter($parameter);
62+
63+
if ($result !== null) {
64+
$errors[] = $result;
65+
}
66+
}
67+
68+
return $errors;
69+
}
70+
71+
/**
72+
* Analyzes a parameter and returns the error string if xomething goes wrong or null if everything is ok.
73+
*
74+
* @param ReflectionParameter $parameter
75+
* @return null|string
76+
*/
77+
private function analyzeParameter(ReflectionParameter $parameter): ?string
78+
{
79+
$phpTypeHint = $parameter->getTypeHint();
80+
$docBlockTypeHints = $parameter->getDocBlockTypes();
81+
82+
// If there is a type-hint, we have nothing to say unless it is an array.
83+
if ($phpTypeHint !== null) {
84+
return $this->analyzeParameterWithTypehint($parameter, $phpTypeHint, $docBlockTypeHints);
85+
} else {
86+
return $this->analyzeParameterWithoutTypehint($parameter, $docBlockTypeHints);
87+
}
88+
}
89+
90+
/**
91+
* @param Type[] $docBlockTypeHints
92+
*/
93+
private function analyzeParameterWithTypehint(ReflectionParameter $parameter, Type $phpTypeHint, array $docBlockTypeHints): ?string
94+
{
95+
$docblockWithoutNullable = $this->typesWithoutNullable($docBlockTypeHints);
96+
97+
if (!$phpTypeHint instanceof Array_) {
98+
// Let's detect mismatches between docblock and PHP typehint
99+
foreach ($docblockWithoutNullable as $docblockTypehint) {
100+
if (get_class($docblockTypehint) !== get_class($phpTypeHint)) {
101+
return sprintf('Parameter $%s type is type-hinted to "%s" but the @param annotation says it is a "%s". Please fix the @param annotation.', $parameter->getName(), (string) $phpTypeHint, (string) $docblockTypehint);
102+
}
103+
}
104+
105+
return null;
106+
}
107+
108+
if (empty($docblockWithoutNullable)) {
109+
return sprintf('Parameter $%s type is "array". Please provide a @param annotation to further speciy the type of the array. For instance: @param int[] $%s', $parameter->getName(), $parameter->getName());
110+
} else {
111+
foreach ($docblockWithoutNullable as $docblockTypehint) {
112+
if (!$docblockTypehint instanceof Array_) {
113+
return sprintf('Mismatching type-hints for parameter %s. PHP type hint is "array" and docblock type hint is %s.', $parameter->getName(), (string) $docblockTypehint);
114+
}
115+
116+
if ($docblockTypehint->getValueType() instanceof Mixed) {
117+
return sprintf('Parameter $%s type is "array". Please provide a more specific @param annotation. For instance: @param int[] $%s', $parameter->getName(), $parameter->getName());
118+
}
119+
}
120+
}
121+
122+
return null;
123+
}
124+
125+
/**
126+
* @param Type[] $docBlockTypeHints
127+
* @return null|string
128+
*/
129+
private function analyzeParameterWithoutTypehint(ReflectionParameter $parameter, array $docBlockTypeHints): ?string
130+
{
131+
if (empty($docBlockTypeHints)) {
132+
return sprintf('Parameter $%s has no type-hint and no @param annotation.', $parameter->getName());
133+
}
134+
135+
$nativeTypehint = $this->isNativelyTypehintable($docBlockTypeHints);
136+
137+
if ($nativeTypehint !== null) {
138+
return sprintf('Parameter $%s can be type-hinted to "%s".', $parameter->getName(), $nativeTypehint);
139+
}
140+
141+
// TODO: cancel if the method is implemented from an interface or overrides another method
142+
// TODO: return types
143+
144+
return null;
145+
}
146+
147+
/**
148+
* @param Type[] $docBlockTypeHints
149+
* @return string|null
150+
*/
151+
private function isNativelyTypehintable(array $docBlockTypeHints): ?string
152+
{
153+
if (count($docBlockTypeHints) > 2) {
154+
return null;
155+
}
156+
$isNullable = $this->isNullable($docBlockTypeHints);
157+
if (count($docBlockTypeHints) === 2 && !$isNullable) {
158+
return null;
159+
}
160+
161+
$types = $this->typesWithoutNullable($docBlockTypeHints);
162+
// At this point, there is at most one element here
163+
if (empty($types)) {
164+
return null;
165+
}
166+
167+
$type = $types[0];
168+
169+
if ($this->isNativeType($type)) {
170+
return ($isNullable?'?':'').((string)$type);
171+
}
172+
173+
if ($type instanceof Array_) {
174+
return ($isNullable?'?':'').'array';
175+
}
176+
177+
// TODO: more definitions to add here
178+
// Manage interface/classes
179+
// Manage array of things => (cast to array)
180+
181+
if ($type instanceof Object_) {
182+
return ($isNullable?'?':'').((string)$type);
183+
}
184+
185+
return null;
186+
}
187+
188+
private function isNativeType(Type $type): bool
189+
{
190+
if ($type instanceof String_
191+
|| $type instanceof Integer
192+
|| $type instanceof Boolean
193+
|| $type instanceof Float_
194+
|| $type instanceof Scalar
195+
|| $type instanceof Callable_
196+
|| ((string) $type) === 'iterable'
197+
) {
198+
return true;
199+
}
200+
return false;
201+
}
202+
203+
/**
204+
* @param Type[] $docBlockTypeHints
205+
* @return bool
206+
*/
207+
private function isNullable(array $docBlockTypeHints): bool
208+
{
209+
foreach ($docBlockTypeHints as $docBlockTypeHint) {
210+
if ($docBlockTypeHint instanceof Null_) {
211+
return true;
212+
}
213+
}
214+
return false;
215+
}
216+
217+
/**
218+
* Removes "null" from the list of types.
219+
*
220+
* @param Type[] $docBlockTypeHints
221+
* @return array
222+
*/
223+
private function typesWithoutNullable(array $docBlockTypeHints): array
224+
{
225+
return array_filter($docBlockTypeHints, function($item) {
226+
return !$item instanceof Null_;
227+
});
228+
}
229+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
4+
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
5+
6+
use PhpParser\Node;
7+
8+
class MissingTypeHintInFunctionRule extends AbstractMissingTypeHintRule
9+
{
10+
public function getNodeType(): string
11+
{
12+
return Node\Stmt\Function_::class;
13+
}
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
4+
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
5+
6+
use PhpParser\Node;
7+
8+
class MissingTypeHintInMethodRule extends AbstractMissingTypeHintRule
9+
{
10+
public function getNodeType(): string
11+
{
12+
return Node\Stmt\ClassMethod::class;
13+
}
14+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
4+
5+
class MissingTypeHintRuleInFunctionTest extends \PHPStan\Rules\AbstractRuleTest
6+
{
7+
8+
protected function getRule(): \PHPStan\Rules\Rule
9+
{
10+
return new MissingTypeHintInFunctionRule(
11+
$this->createBroker()
12+
);
13+
}
14+
15+
public function testCheckCatchedException()
16+
{
17+
require_once __DIR__.'/data/typehints.php';
18+
19+
$this->analyse([__DIR__ . '/data/typehints.php'], [
20+
[
21+
'Parameter $no_type_hint has no type-hint and no @param annotation.',
22+
3,
23+
],
24+
[
25+
'Parameter $type_hintable can be type-hinted to "?string".',
26+
11,
27+
],
28+
[
29+
'Parameter $type_hintable can be type-hinted to "\DateTimeInterface".',
30+
19,
31+
],
32+
[
33+
'Parameter $type_hintable can be type-hinted to "array".',
34+
27,
35+
],
36+
[
37+
'Parameter $better_type_hint type is "array". Please provide a @param annotation to further speciy the type of the array. For instance: @param int[] $better_type_hint',
38+
40,
39+
],
40+
[
41+
'Parameter $param type is type-hinted to "string" but the @param annotation says it is a "int". Please fix the @param annotation.',
42+
48,
43+
],
44+
]);
45+
}
46+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
4+
5+
class MissingTypeHintRuleInMethodTest extends \PHPStan\Rules\AbstractRuleTest
6+
{
7+
8+
protected function getRule(): \PHPStan\Rules\Rule
9+
{
10+
return new MissingTypeHintInMethodRule(
11+
$this->createBroker()
12+
);
13+
}
14+
15+
public function testCheckCatchedException()
16+
{
17+
require_once __DIR__.'/data/typehints.php';
18+
19+
$this->analyse([__DIR__ . '/data/typehints_in_methods.php'], [
20+
[
21+
'Parameter $no_type_hint has no type-hint and no @param annotation.',
22+
5,
23+
],
24+
]);
25+
}
26+
}

0 commit comments

Comments
 (0)