Completed
Pull Request — master (#20)
by David
03:31
created

analyzeWithoutTypehint()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 22
rs 8.6737
c 0
b 0
f 0
cc 5
eloc 13
nc 5
nop 2
1
<?php
2
declare(strict_types=1);
3
4
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
5
6
use phpDocumentor\Reflection\DocBlockFactory;
7
use phpDocumentor\Reflection\Type;
8
use phpDocumentor\Reflection\Types\Array_;
9
use phpDocumentor\Reflection\Types\Boolean;
10
use phpDocumentor\Reflection\Types\Callable_;
11
use phpDocumentor\Reflection\Types\Float_;
12
use phpDocumentor\Reflection\Types\Integer;
13
use phpDocumentor\Reflection\Types\Iterable_;
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\Reflector\Exception\IdentifierNotFound;
29
use Roave\BetterReflection\TypesFinder\PhpDocumentor\NamespaceNodeToReflectionTypeContext;
30
use Roave\BetterReflection\TypesFinder\ResolveTypes;
31
32
abstract class AbstractMissingTypeHintRule implements Rule
33
{
34
35
    /**
36
     * @var Broker
37
     */
38
    private $broker;
39
40
    public function __construct(Broker $broker)
41
    {
42
        $this->broker = $broker;
43
    }
44
45
    abstract public function getNodeType(): string;
46
47
    /**
48
     * @param ReflectionMethod|ReflectionFunction $reflection
49
     * @return string
50
     */
51
    abstract public function getContext($reflection): string;
52
53
    abstract public function isReturnIgnored(Node $node): bool;
54
55
    /**
56
     * @param \PhpParser\Node\Stmt\Function_|\PhpParser\Node\Stmt\ClassMethod $node
57
     * @param \PHPStan\Analyser\Scope $scope
58
     * @return string[]
59
     */
60
    public function processNode(Node $node, Scope $scope): array
61
    {
62
        // TODO: improve performance by caching better reflection results.
63
        $finder = (new BetterReflection())->findReflectionsOnLine();
64
65
        if ($node->getLine() < 0) {
66
            // Fixes some problems with methods in anonymous class (the line number is poorly reported).
67
            return [];
68
        }
69
70
        $reflection = $finder($scope->getFile(), $node->getLine());
71
72
        // If the method implements/extends another method, we have no choice on the signature so let's bypass this check.
73
        if ($reflection instanceof ReflectionMethod && $this->isInherited($reflection)) {
74
            return [];
75
        }
76
77
        $errors = [];
78
79
        if ($reflection === null) {
80
            throw new \RuntimeException('Could not find item at '.$scope->getFile().':'.$node->getLine());
81
        }
82
83
        foreach ($reflection->getParameters() as $parameter) {
84
            $result = $this->analyzeParameter($parameter);
85
86
            if ($result !== null) {
87
                $errors[] = $result;
88
            }
89
        }
90
91
        if (!$this->isReturnIgnored($node)) {
92
            $returnTypeError = $this->analyzeReturnType($reflection);
93
            if ($returnTypeError !== null) {
94
                $errors[] = $returnTypeError;
95
            }
96
        }
97
98
        return $errors;
99
    }
100
101
    /**
102
     * Analyzes a parameter and returns the error string if xomething goes wrong or null if everything is ok.
103
     *
104
     * @param ReflectionParameter $parameter
105
     * @return null|string
106
     */
107
    private function analyzeParameter(ReflectionParameter $parameter): ?string
108
    {
109
        $typeResolver = new \phpDocumentor\Reflection\TypeResolver();
110
111
        $phpTypeHint = $parameter->getType();
112
        try {
113
            $docBlockTypeHints = $parameter->getDocBlockTypes();
114
        } catch (\InvalidArgumentException $e) {
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 (!$this->isTypeIterable($phpTypeHint)) {
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 (!$this->isTypeIterable($docblockTypehint)) {
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 instanceof Array_ && $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
    private function isTypeIterable(Type $phpTypeHint) : bool
214
    {
215
        if ($phpTypeHint instanceof Array_ || $phpTypeHint instanceof Iterable_) {
216
            return true;
217
        }
218
        if ($phpTypeHint instanceof Object_) {
219
            // TODO: cache BetterReflection for better performance!
220
            try {
221
                $class = (new BetterReflection())->classReflector()->reflect((string) $phpTypeHint);
222
            } catch (IdentifierNotFound $e) {
223
                // Class not found? Let's not throw an error. It will be caught by other rules anyway.
224
                return false;
225
            }
226
            if ($class->implementsInterface('Traversable')) {
227
                return true;
228
            }
229
        }
230
231
        return false;
232
    }
233
234
    /**
235
     * @param ReflectionParameter|ReflectionMethod|ReflectionFunction $context
236
     * @return bool
237
     */
238
    private function findExplicitMixedArray($context) : bool
239
    {
240
        if ($context instanceof ReflectionParameter) {
241
            $context = $context->getDeclaringFunction();
242
        }
243
244
        $docComment = $context->getDocComment();
245
246
        // Very approximate solution: let's find in the whole docblock whether there is a mixed[] value or not.
247
        // TODO: improve this to target precisely the parameter or return type.
248
        return strpos($docComment, 'mixed[]') !== false;
249
    }
250
251
    /**
252
     * @param ReflectionParameter|ReflectionMethod|ReflectionFunction $context
253
     * @param Type[] $docBlockTypeHints
254
     * @return null|string
255
     */
256
    private function analyzeWithoutTypehint($context, array $docBlockTypeHints): ?string
257
    {
258
        if (empty($docBlockTypeHints)) {
259
            if ($context instanceof ReflectionParameter) {
260
                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...
261
            } else {
262
                return sprintf('%s, there is no return type and no @return annotation.', $this->getContext($context));
263
            }
264
        }
265
266
        $nativeTypehint = $this->isNativelyTypehintable($docBlockTypeHints);
267
268
        if ($nativeTypehint !== null) {
269
            if ($context instanceof ReflectionParameter) {
270
                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...
271
            } else {
272
                return sprintf('%s, a "%s" return type can be added.', $this->getContext($context), $nativeTypehint);
273
            }
274
        }
275
276
        return null;
277
    }
278
279
    /**
280
     * @param Type[] $docBlockTypeHints
281
     * @return string|null
282
     */
283
    private function isNativelyTypehintable(array $docBlockTypeHints): ?string
284
    {
285
        if (count($docBlockTypeHints) > 2) {
286
            return null;
287
        }
288
        $isNullable = $this->isNullable($docBlockTypeHints);
289
        if (count($docBlockTypeHints) === 2 && !$isNullable) {
290
            return null;
291
        }
292
293
        $types = $this->typesWithoutNullable($docBlockTypeHints);
294
        // At this point, there is at most one element here
295
        if (empty($types)) {
296
            return null;
297
        }
298
299
        $type = $types[0];
300
301
        if ($this->isNativeType($type)) {
302
            return ($isNullable?'?':'').((string)$type);
303
        }
304
305
        if ($type instanceof Array_) {
306
            return ($isNullable?'?':'').'array';
307
        }
308
309
        // TODO: more definitions to add here
310
        // Manage interface/classes
311
        // Manage array of things => (cast to array)
312
313
        if ($type instanceof Object_) {
314
            return ($isNullable?'?':'').((string)$type);
315
        }
316
317
        return null;
318
    }
319
320
    private function isNativeType(Type $type): bool
321
    {
322
        if ($type instanceof String_
323
            || $type instanceof Integer
324
            || $type instanceof Boolean
325
            || $type instanceof Float_
326
            || $type instanceof Scalar
327
            || $type instanceof Callable_
328
            || ((string) $type) === 'iterable'
329
        ) {
330
            return true;
331
        }
332
        return false;
333
    }
334
335
    /**
336
     * @param Type[] $docBlockTypeHints
337
     * @return bool
338
     */
339
    private function isNullable(array $docBlockTypeHints): bool
340
    {
341
        foreach ($docBlockTypeHints as $docBlockTypeHint) {
342
            if ($docBlockTypeHint instanceof Null_) {
343
                return true;
344
            }
345
        }
346
        return false;
347
    }
348
349
    /**
350
     * Removes "null" from the list of types.
351
     *
352
     * @param Type[] $docBlockTypeHints
353
     * @return Type[]
354
     */
355
    private function typesWithoutNullable(array $docBlockTypeHints): array
356
    {
357
        return array_values(array_filter($docBlockTypeHints, function ($item) {
358
            return !$item instanceof Null_;
359
        }));
360
    }
361
362
    private function isInherited(ReflectionMethod $method, ReflectionClass $class = null): bool
363
    {
364
        if ($class === null) {
365
            $class = $method->getDeclaringClass();
366
        }
367
        $interfaces = $class->getInterfaces();
368
        foreach ($interfaces as $interface) {
369
            if ($interface->hasMethod($method->getName())) {
370
                return true;
371
            }
372
        }
373
374
        $parentClass = $class->getParentClass();
375
        if ($parentClass !== null) {
376
            if ($parentClass->hasMethod($method->getName())) {
377
                return true;
378
            }
379
            return $this->isInherited($method, $parentClass);
380
        }
381
382
        return false;
383
    }
384
}
385