Completed
Push — master ( 71c1f5...f2b8d4 )
by David
14s
created

AbstractMissingTypeHintRule::analyzeParameter()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 28
rs 8.5806
c 0
b 0
f 0
cc 4
eloc 17
nc 4
nop 1
1
<?php
2
declare(strict_types=1);
3
4
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
5
6
7
use phpDocumentor\Reflection\DocBlockFactory;
8
use phpDocumentor\Reflection\Type;
9
use phpDocumentor\Reflection\Types\Array_;
10
use phpDocumentor\Reflection\Types\Boolean;
11
use phpDocumentor\Reflection\Types\Callable_;
12
use phpDocumentor\Reflection\Types\Float_;
13
use phpDocumentor\Reflection\Types\Integer;
14
use phpDocumentor\Reflection\Types\Mixed_;
15
use phpDocumentor\Reflection\Types\Null_;
16
use phpDocumentor\Reflection\Types\Object_;
17
use phpDocumentor\Reflection\Types\Scalar;
18
use phpDocumentor\Reflection\Types\String_;
19
use PhpParser\Node;
20
use PHPStan\Analyser\Scope;
21
use PHPStan\Broker\Broker;
22
use PHPStan\Rules\Rule;
23
use Roave\BetterReflection\BetterReflection;
24
use Roave\BetterReflection\Reflection\ReflectionClass;
25
use Roave\BetterReflection\Reflection\ReflectionFunction;
26
use Roave\BetterReflection\Reflection\ReflectionMethod;
27
use Roave\BetterReflection\Reflection\ReflectionParameter;
28
use Roave\BetterReflection\TypesFinder\PhpDocumentor\NamespaceNodeToReflectionTypeContext;
29
use Roave\BetterReflection\TypesFinder\ResolveTypes;
30
31
abstract class AbstractMissingTypeHintRule implements Rule
32
{
33
34
    /**
35
     * @var Broker
36
     */
37
    private $broker;
38
39
    public function __construct(Broker $broker)
40
    {
41
        $this->broker = $broker;
42
    }
43
44
    abstract public function getNodeType(): string;
45
46
    /**
47
     * @param ReflectionMethod|ReflectionFunction $reflection
48
     * @return string
49
     */
50
    abstract public function getContext($reflection): string;
51
52
    abstract public function isReturnIgnored(Node $node): bool;
53
54
    /**
55
     * @param \PhpParser\Node\Stmt\Function_|\PhpParser\Node\Stmt\ClassMethod $node
56
     * @param \PHPStan\Analyser\Scope $scope
57
     * @return string[]
58
     */
59
    public function processNode(Node $node, Scope $scope): array
60
    {
61
        // TODO: improve performance by caching better reflection results.
62
        $finder = (new BetterReflection())->findReflectionsOnLine();
63
64
        if ($node->getLine() < 0) {
65
            // Fixes some problems with methods in anonymous class (the line number is poorly reported).
66
            return [];
67
        }
68
69
        $reflection = $finder($scope->getFile(), $node->getLine());
70
71
        // If the method implements/extends another method, we have no choice on the signature so let's bypass this check.
72
        if ($reflection instanceof ReflectionMethod && $this->isInherited($reflection)) {
73
            return [];
74
        }
75
76
        $errors = [];
77
78
        if ($reflection === null) {
79
            throw new \RuntimeException('Could not find item at '.$scope->getFile().':'.$node->getLine());
80
        }
81
82
        foreach ($reflection->getParameters() as $parameter) {
83
            $result = $this->analyzeParameter($parameter);
84
85
            if ($result !== null) {
86
                $errors[] = $result;
87
            }
88
        }
89
90
        if (!$this->isReturnIgnored($node)) {
91
            $returnTypeError = $this->analyzeReturnType($reflection);
92
            if ($returnTypeError !== null) {
93
                $errors[] = $returnTypeError;
94
            }
95
        }
96
97
        return $errors;
98
    }
99
100
    /**
101
     * Analyzes a parameter and returns the error string if xomething goes wrong or null if everything is ok.
102
     *
103
     * @param ReflectionParameter $parameter
104
     * @return null|string
105
     */
106
    private function analyzeParameter(ReflectionParameter $parameter): ?string
107
    {
108
        $typeResolver = new \phpDocumentor\Reflection\TypeResolver();
109
110
        $phpTypeHint = $parameter->getType();
111
        try {
112
            $docBlockTypeHints = $parameter->getDocBlockTypes();
113
        } catch (\InvalidArgumentException $e) {
114
115
            return sprintf('%s, for parameter $%s, invalid docblock @param encountered. %s',
116
                $this->getContext($parameter),
0 ignored issues
show
Documentation introduced by
$parameter is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
117
                $parameter->getName(),
118
                $e->getMessage()
119
            );
120
        }
121
122
        // If there is a type-hint, we have nothing to say unless it is an array.
123
        if ($phpTypeHint !== null) {
124
            $phpdocTypeHint = $typeResolver->resolve((string) $phpTypeHint);
125
            if ($parameter->isVariadic()) {
126
                $phpdocTypeHint = new Array_($phpdocTypeHint);
127
            }
128
129
            return $this->analyzeWithTypehint($parameter, $phpdocTypeHint, $docBlockTypeHints);
0 ignored issues
show
Bug introduced by
It seems like $phpdocTypeHint defined by $typeResolver->resolve((string) $phpTypeHint) on line 124 can be null; however, TheCodingMachine\PHPStan...::analyzeWithTypehint() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
130
        } else {
131
            return $this->analyzeWithoutTypehint($parameter, $docBlockTypeHints);
132
        }
133
    }
134
135
    /**
136
     * @param ReflectionFunction|ReflectionMethod $function
137
     * @return null|string
138
     */
139
    private function analyzeReturnType($function): ?string
140
    {
141
        $reflectionPhpTypeHint = $function->getReturnType();
142
        $phpTypeHint = null;
143
        if ($reflectionPhpTypeHint !== null) {
144
            $typeResolver = new \phpDocumentor\Reflection\TypeResolver();
145
            $phpTypeHint = $typeResolver->resolve((string) $reflectionPhpTypeHint);
146
        }
147
        $docBlockTypeHints = $function->getDocBlockReturnTypes();
148
149
        // If there is a type-hint, we have nothing to say unless it is an array.
150
        if ($phpTypeHint !== null) {
151
            return $this->analyzeWithTypehint($function, $phpTypeHint, $docBlockTypeHints);
152
        } else {
153
            return $this->analyzeWithoutTypehint($function, $docBlockTypeHints);
154
        }
155
    }
156
157
    /**
158
     * @param ReflectionParameter|ReflectionMethod|ReflectionFunction $context
159
     * @param Type $phpTypeHint
160
     * @param Type[] $docBlockTypeHints
161
     * @return null|string
162
     */
163
    private function analyzeWithTypehint($context, Type $phpTypeHint, array $docBlockTypeHints): ?string
164
    {
165
        $docblockWithoutNullable = $this->typesWithoutNullable($docBlockTypeHints);
166
167
        if (!$phpTypeHint instanceof Array_) {
168
            // Let's detect mismatches between docblock and PHP typehint
169
            foreach ($docblockWithoutNullable as $docblockTypehint) {
170
                if (get_class($docblockTypehint) !== get_class($phpTypeHint)) {
171
                    if ($context instanceof ReflectionParameter) {
172
                        return sprintf('%s, parameter $%s type is type-hinted to "%s" but the @param annotation says it is a "%s". Please fix the @param annotation.', $this->getContext($context), $context->getName(), (string) $phpTypeHint, (string) $docblockTypehint);
0 ignored issues
show
Documentation introduced by
$context is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
173
                    } else {
174
                        return sprintf('%s, return type is type-hinted to "%s" but the @return annotation says it is a "%s". Please fix the @return annotation.', $this->getContext($context), (string) $phpTypeHint, (string) $docblockTypehint);
175
                    }
176
                }
177
            }
178
179
            return null;
180
        }
181
182
        if (empty($docblockWithoutNullable)) {
183
            if ($context instanceof ReflectionParameter) {
184
                return sprintf('%s, parameter $%s type is "array". Please provide a @param annotation to further specify the type of the array. For instance: @param int[] $%s', $this->getContext($context), $context->getName(), $context->getName());
0 ignored issues
show
Documentation introduced by
$context is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
185
            } else {
186
                return sprintf('%s, return type is "array". Please provide a @param annotation to further specify the type of the array. For instance: @return int[]', $this->getContext($context));
187
            }
188
        } else {
189
            foreach ($docblockWithoutNullable as $docblockTypehint) {
190
                if (!$docblockTypehint instanceof Array_) {
191
                    if ($context instanceof ReflectionParameter) {
192
                        return sprintf('%s, mismatching type-hints for parameter %s. PHP type hint is "array" and docblock type hint is %s.', $this->getContext($context), $context->getName(), (string)$docblockTypehint);
0 ignored issues
show
Documentation introduced by
$context is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
193
                    } else {
194
                        return sprintf('%s, mismatching type-hints for return type. PHP type hint is "array" and docblock declared return type is %s.', $this->getContext($context), (string)$docblockTypehint);
195
                    }
196
                }
197
198
                if ($docblockTypehint->getValueType() instanceof Mixed_) {
199
                    if (!$this->findExplicitMixedArray($context)) {
200
                        if ($context instanceof ReflectionParameter) {
201
                            return sprintf('%s, parameter $%s type is "array". Please provide a more specific @param annotation in the docblock. For instance: @param int[] $%s. Use @param mixed[] $%s if this is really an array of mixed values.', $this->getContext($context), $context->getName(), $context->getName(), $context->getName());
0 ignored issues
show
Documentation introduced by
$context is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
202
                        } else {
203
                            return sprintf('%s, return type is "array". Please provide a more specific @return annotation. For instance: @return int[]. Use @return mixed[] if this is really an array of mixed values.', $this->getContext($context));
204
                        }
205
                    }
206
                }
207
            }
208
        }
209
210
        return null;
211
    }
212
213
    /**
214
     * @param ReflectionParameter|ReflectionMethod|ReflectionFunction $context
215
     * @return bool
216
     */
217
    private function findExplicitMixedArray($context) : bool
218
    {
219
        if ($context instanceof ReflectionParameter) {
220
            $context = $context->getDeclaringFunction();
221
        }
222
223
        $docComment = $context->getDocComment();
224
225
        // Very approximate solution: let's find in the whole docblock whether there is a mixed[] value or not.
226
        // TODO: improve this to target precisely the parameter or return type.
227
        return strpos($docComment, 'mixed[]') !== false;
228
    }
229
230
    /**
231
     * @param ReflectionParameter|ReflectionMethod|ReflectionFunction $context
232
     * @param Type[] $docBlockTypeHints
233
     * @return null|string
234
     */
235
    private function analyzeWithoutTypehint($context, array $docBlockTypeHints): ?string
236
    {
237
        if (empty($docBlockTypeHints)) {
238
            if ($context instanceof ReflectionParameter) {
239
                return sprintf('%s, parameter $%s has no type-hint and no @param annotation.', $this->getContext($context), $context->getName());
0 ignored issues
show
Documentation introduced by
$context is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
240
            } else {
241
                return sprintf('%s, there is no return type and no @return annotation.', $this->getContext($context));
242
            }
243
        }
244
245
        $nativeTypehint = $this->isNativelyTypehintable($docBlockTypeHints);
246
247
        if ($nativeTypehint !== null) {
248
            if ($context instanceof ReflectionParameter) {
249
                return sprintf('%s, parameter $%s can be type-hinted to "%s".', $this->getContext($context), $context->getName(), $nativeTypehint);
0 ignored issues
show
Documentation introduced by
$context is of type object<Roave\BetterRefle...on\ReflectionParameter>, but the function expects a object<Roave\BetterRefle...ion\ReflectionFunction>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
250
            } else {
251
                return sprintf('%s, a "%s" return type can be added.', $this->getContext($context), $nativeTypehint);
252
            }
253
        }
254
255
        return null;
256
    }
257
258
    /**
259
     * @param Type[] $docBlockTypeHints
260
     * @return string|null
261
     */
262
    private function isNativelyTypehintable(array $docBlockTypeHints): ?string
263
    {
264
        if (count($docBlockTypeHints) > 2) {
265
            return null;
266
        }
267
        $isNullable = $this->isNullable($docBlockTypeHints);
268
        if (count($docBlockTypeHints) === 2 && !$isNullable) {
269
            return null;
270
        }
271
272
        $types = $this->typesWithoutNullable($docBlockTypeHints);
273
        // At this point, there is at most one element here
274
        if (empty($types)) {
275
            return null;
276
        }
277
278
        $type = $types[0];
279
280
        if ($this->isNativeType($type)) {
281
            return ($isNullable?'?':'').((string)$type);
282
        }
283
284
        if ($type instanceof Array_) {
285
            return ($isNullable?'?':'').'array';
286
        }
287
288
        // TODO: more definitions to add here
289
        // Manage interface/classes
290
        // Manage array of things => (cast to array)
291
292
        if ($type instanceof Object_) {
293
            return ($isNullable?'?':'').((string)$type);
294
        }
295
296
        return null;
297
    }
298
299
    private function isNativeType(Type $type): bool
300
    {
301
        if ($type instanceof String_
302
            || $type instanceof Integer
303
            || $type instanceof Boolean
304
            || $type instanceof Float_
305
            || $type instanceof Scalar
306
            || $type instanceof Callable_
307
            || ((string) $type) === 'iterable'
308
        ) {
309
            return true;
310
        }
311
        return false;
312
    }
313
314
    /**
315
     * @param Type[] $docBlockTypeHints
316
     * @return bool
317
     */
318
    private function isNullable(array $docBlockTypeHints): bool
319
    {
320
        foreach ($docBlockTypeHints as $docBlockTypeHint) {
321
            if ($docBlockTypeHint instanceof Null_) {
322
                return true;
323
            }
324
        }
325
        return false;
326
    }
327
328
    /**
329
     * Removes "null" from the list of types.
330
     *
331
     * @param Type[] $docBlockTypeHints
332
     * @return Type[]
333
     */
334
    private function typesWithoutNullable(array $docBlockTypeHints): array
335
    {
336
        return array_values(array_filter($docBlockTypeHints, function($item) {
337
            return !$item instanceof Null_;
338
        }));
339
    }
340
341
    private function isInherited(ReflectionMethod $method, ReflectionClass $class = null): bool
342
    {
343
        if ($class === null) {
344
            $class = $method->getDeclaringClass();
345
        }
346
        $interfaces = $class->getInterfaces();
347
        foreach ($interfaces as $interface) {
348
            if ($interface->hasMethod($method->getName())) {
349
                return true;
350
            }
351
        }
352
353
        $parentClass = $class->getParentClass();
354
        if ($parentClass !== null) {
355
            if ($parentClass->hasMethod($method->getName())) {
356
                return true;
357
            }
358
            return $this->isInherited($method, $parentClass);
359
        }
360
361
        return false;
362
    }
363
}
364