Completed
Pull Request — master (#27)
by David
11:28
created

AbstractMissingTypeHintRule::isTypeIterable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 21
rs 9.584
c 0
b 0
f 0
nc 1
cc 1
nop 1
1
<?php
2
declare(strict_types=1);
3
4
namespace TheCodingMachine\PHPStan\Rules\TypeHints;
5
6
use PHPStan\Type\ArrayType;
7
use PHPStan\Type\BooleanType;
8
use PHPStan\Type\CallableType;
9
use PHPStan\Type\FloatType;
10
use PHPStan\Type\IntegerType;
11
use PHPStan\Type\MixedType;
12
use PHPStan\Type\NullType;
13
use PHPStan\Type\ObjectType;
14
use PHPStan\Type\ObjectWithoutClassType;
15
use PHPStan\Type\StringType;
16
use PHPStan\Type\Type;
17
use PhpParser\Node;
18
use PHPStan\Analyser\Scope;
19
use PHPStan\Broker\Broker;
20
use PHPStan\Reflection\ParametersAcceptorWithPhpDocs;
21
use PHPStan\Reflection\Php\PhpParameterReflection;
22
use PHPStan\Rules\Rule;
23
use PHPStan\Type\UnionType;
24
use PHPStan\Type\VerbosityLevel;
25
26
abstract class AbstractMissingTypeHintRule implements Rule
27
{
28
29
    /**
30
     * @var Broker
31
     */
32
    private $broker;
33
34
    public function __construct(Broker $broker)
35
    {
36
        $this->broker = $broker;
37
    }
38
39
    abstract public function getNodeType(): string;
40
41
    abstract public function isReturnIgnored(Node $node): bool;
42
43
    abstract protected function getReflection(Node\FunctionLike $function, Scope $scope, Broker $broker) : ParametersAcceptorWithPhpDocs;
44
45
    abstract protected function shouldSkip(Node\FunctionLike $function, Scope $scope): bool;
46
47
    /**
48
     * @param \PhpParser\Node\Stmt\Function_|\PhpParser\Node\Stmt\ClassMethod $node
49
     * @param \PHPStan\Analyser\Scope $scope
50
     * @return string[]
51
     */
52
    public function processNode(Node $node, Scope $scope): array
53
    {
54
        /*if ($node->getLine() < 0) {
55
            // Fixes some problems with methods in anonymous class (the line number is poorly reported).
56
            return [];
57
        }*/
58
59
        if ($this->shouldSkip($node, $scope)) {
0 ignored issues
show
Compatibility introduced by
$node of type object<PhpParser\Node> is not a sub-type of object<PhpParser\Node\FunctionLike>. It seems like you assume a child interface of the interface PhpParser\Node to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
60
            return [];
61
        }
62
63
        $parametersAcceptor = $this->getReflection($node, $scope, $this->broker);
0 ignored issues
show
Compatibility introduced by
$node of type object<PhpParser\Node> is not a sub-type of object<PhpParser\Node\FunctionLike>. It seems like you assume a child interface of the interface PhpParser\Node to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
64
65
        $errors = [];
66
67
        foreach ($parametersAcceptor->getParameters() as $parameter) {
68
            $debugContext = new ParameterDebugContext($scope, $node, $parameter);
0 ignored issues
show
Compatibility introduced by
$node of type object<PhpParser\Node> is not a sub-type of object<PhpParser\Node\FunctionLike>. It seems like you assume a child interface of the interface PhpParser\Node to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
69
            $result = $this->analyzeParameter($debugContext, $parameter);
70
71
            if ($result !== null) {
72
                $errors[] = $result;
73
            }
74
        }
75
76
        if (!$this->isReturnIgnored($node)) {
77
            $debugContext = new FunctionDebugContext($scope, $node);
0 ignored issues
show
Compatibility introduced by
$node of type object<PhpParser\Node> is not a sub-type of object<PhpParser\Node\FunctionLike>. It seems like you assume a child interface of the interface PhpParser\Node to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
78
            $returnTypeError = $this->analyzeReturnType($debugContext, $parametersAcceptor);
79
            if ($returnTypeError !== null) {
80
                $errors[] = $returnTypeError;
81
            }
82
        }
83
84
        return $errors;
85
    }
86
87
    /**
88
     * Analyzes a parameter and returns the error string if something goes wrong or null if everything is ok.
89
     *
90
     * @param PhpParameterReflection $parameter
91
     * @return null|string
92
     */
93
    private function analyzeParameter(DebugContextInterface $context, PhpParameterReflection $parameter): ?string
94
    {
95
        //$typeResolver = new \phpDocumentor\Reflection\TypeResolver();
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
96
97
        $phpTypeHint = $parameter->getNativeType();
98
        //try {
99
            $docBlockTypeHints = $parameter->getPhpDocType();
100
        /*} catch (\InvalidArgumentException $e) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
64% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
101
            return sprintf('%s, for parameter $%s, invalid docblock @param encountered. %s',
102
                $this->getContext($parameter),
103
                $parameter->getName(),
104
                $e->getMessage()
105
            );
106
        }*/
107
108
        if ($phpTypeHint instanceof MixedType && $phpTypeHint->isExplicitMixed() === false) {
109
            return $this->analyzeWithoutTypehint($context, $parameter, $docBlockTypeHints);
0 ignored issues
show
Documentation introduced by
$parameter is of type object<PHPStan\Reflectio...PhpParameterReflection>, but the function expects a object<TheCodingMachine\...\DebugContextInterface>.

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...
110
        } else {
111
            // If there is a type-hint, we have nothing to say unless it is an array.
112
            if ($parameter->isVariadic()) {
113
                // Hack: wrap the native type in an array is variadic
114
                $phpTypeHint = new ArrayType(new IntegerType(), $phpTypeHint);
115
            }
116
117
            return $this->analyzeWithTypehint($context, $phpTypeHint, $docBlockTypeHints);
118
        }
119
    }
120
121
    /**
122
     * @return null|string
123
     */
124
    private function analyzeReturnType(DebugContextInterface $debugContext, ParametersAcceptorWithPhpDocs $function): ?string
125
    {
126
        $phpTypeHint = $function->getNativeReturnType();
127
        $docBlockTypeHints = $function->getPhpDocReturnType();
128
129
        // If there is a type-hint, we have nothing to say unless it is an array.
130
        if ($phpTypeHint instanceof MixedType && $phpTypeHint->isExplicitMixed() === false) {
131
            return $this->analyzeWithoutTypehint($debugContext, $function, $docBlockTypeHints);
0 ignored issues
show
Documentation introduced by
$function is of type object<PHPStan\Reflectio...ersAcceptorWithPhpDocs>, but the function expects a object<TheCodingMachine\...\DebugContextInterface>.

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...
132
        } else {
133
            return $this->analyzeWithTypehint($debugContext, $phpTypeHint, $docBlockTypeHints);
134
        }
135
    }
136
137
    /**
138
     * @param DebugContextInterface $debugContext
139
     * @param Type $phpTypeHint
140
     * @param Type $docBlockTypeHints
141
     * @return null|string
142
     */
143
    private function analyzeWithTypehint(DebugContextInterface $debugContext, Type $phpTypeHint, Type $docBlockTypeHints): ?string
144
    {
145
        $docblockWithoutNullable = $this->typesWithoutNullable($docBlockTypeHints);
146
147
        if (!$this->isTypeIterable($phpTypeHint)) {
148
            // FIXME: this should be handled with the "accepts" method of types (and actually, this is already triggered by PHPStan 0.10)
149
150
            // Let's detect mismatches between docblock and PHP typehint
151
            if ($docblockWithoutNullable instanceof UnionType) {
152
                $docblocks = $docblockWithoutNullable->getTypes();
153
            } else {
154
                $docblocks = [$docblockWithoutNullable];
155
            }
156
            foreach ($docblocks as $docblockTypehint) {
157
                if (get_class($docblockTypehint) !== get_class($phpTypeHint)) {
158
                    if ($debugContext instanceof ParameterDebugContext) {
159
                        return sprintf('%s type is type-hinted to "%s" but the @param annotation says it is a "%s". Please fix the @param annotation.', (string) $debugContext, $phpTypeHint->describe(VerbosityLevel::typeOnly()), $docblockTypehint->describe(VerbosityLevel::typeOnly()));
160
                    } elseif (!$docblockTypehint instanceof MixedType || $docblockTypehint->isExplicitMixed()) {
161
                        return sprintf('%s return type is type-hinted to "%s" but the @return annotation says it is a "%s". Please fix the @return annotation.', (string) $debugContext, $phpTypeHint->describe(VerbosityLevel::typeOnly()), $docblockTypehint->describe(VerbosityLevel::typeOnly()));
162
                    }
163
                }
164
            }
165
166
            return null;
167
        }
168
169
        if ($phpTypeHint instanceof ArrayType) {
170
            if ($docblockWithoutNullable instanceof MixedType && !$docblockWithoutNullable->isExplicitMixed()) {
171
                if ($debugContext instanceof ParameterDebugContext) {
172
                    return sprintf('%s type is "array". Please provide a @param annotation to further specify the type of the array. For instance: @param int[] $%s', (string) $debugContext, $debugContext->getName());
173
                } else {
174
                    return sprintf('%s return type is "array". Please provide a @param annotation to further specify the type of the array. For instance: @return int[]', (string) $debugContext);
175
                }
176
            } else {
177
                if ($docblockWithoutNullable instanceof UnionType) {
178
                    $docblocks = $docblockWithoutNullable->getTypes();
179
                } else {
180
                    $docblocks = [$docblockWithoutNullable];
181
                }
182
                foreach ($docblocks as $docblockTypehint) {
183
                    if (!$this->isTypeIterable($docblockTypehint)) {
184
                        if ($debugContext instanceof ParameterDebugContext) {
185
                            return sprintf('%s mismatching type-hints for parameter %s. PHP type hint is "array" and docblock type hint is %s.', (string) $debugContext, $debugContext->getName(), $docblockTypehint->describe(VerbosityLevel::typeOnly()));
186
                        } else {
187
                            return sprintf('%s mismatching type-hints for return type. PHP type hint is "array" and docblock declared return type is %s.', (string) $debugContext, $docblockTypehint->describe(VerbosityLevel::typeOnly()));
188
                        }
189
                    }
190
191
                    if ($docblockTypehint instanceof ArrayType && $docblockTypehint->getKeyType() instanceof MixedType && $docblockTypehint->getItemType() instanceof MixedType && $docblockTypehint->getKeyType()->isExplicitMixed() && $docblockTypehint->getItemType()->isExplicitMixed()) {
192
                        if ($debugContext instanceof ParameterDebugContext) {
193
                            return sprintf('%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.', (string) $debugContext, $debugContext->getName(), $debugContext->getName());
194
                        } else {
195
                            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.', (string) $debugContext);
196
                        }
197
                    }
198
                }
199
            }
200
        }
201
202
        return null;
203
    }
204
205
    private function isTypeIterable(Type $phpTypeHint) : bool
206
    {
207
        return /*$phpTypeHint->isIterable()->maybe() ||*/ $phpTypeHint->isIterable()->yes();
0 ignored issues
show
Unused Code Comprehensibility introduced by
73% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
208
        /*if ($phpTypeHint instanceof Array_ || $phpTypeHint instanceof Iterable_) {
209
            return true;
210
        }
211
        if ($phpTypeHint instanceof Object_) {
212
            // TODO: cache BetterReflection for better performance!
213
            try {
214
                $class = (new BetterReflection())->classReflector()->reflect((string) $phpTypeHint);
215
            } catch (IdentifierNotFound $e) {
216
                // Class not found? Let's not throw an error. It will be caught by other rules anyway.
217
                return false;
218
            }
219
            if ($class->implementsInterface('Traversable')) {
220
                return true;
221
            }
222
        }
223
224
        return false;*/
225
    }
226
227
    /**
228
     * @param DebugContextInterface $context
229
     * @param Type $docBlockTypeHints
230
     * @return null|string
231
     */
232
    private function analyzeWithoutTypehint(DebugContextInterface $debugContext, $context, Type $docBlockTypeHints): ?string
233
    {
234
        if ($docBlockTypeHints instanceof MixedType && $docBlockTypeHints->isExplicitMixed() === false) {
235
            if ($context instanceof PhpParameterReflection) {
236
                return sprintf('%s has no type-hint and no @param annotation.', (string) $debugContext);
237
            } else {
238
                return sprintf('%s there is no return type and no @return annotation.', (string) $debugContext);
239
            }
240
        }
241
242
        $nativeTypehint = $this->isNativelyTypehintable($docBlockTypeHints);
243
244
        if ($nativeTypehint !== null) {
245
            if ($context instanceof PhpParameterReflection) {
246
                return sprintf('%s can be type-hinted to "%s".', (string) $debugContext, $nativeTypehint);
247
            } else {
248
                return sprintf('%s a "%s" return type can be added.', (string) $debugContext, $nativeTypehint);
249
            }
250
        }
251
252
        return null;
253
    }
254
255
    /**
256
     * @param Type $docBlockTypeHints
257
     * @return string|null
258
     */
259
    private function isNativelyTypehintable(Type $docBlockTypeHints): ?string
260
    {
261
        if ($docBlockTypeHints instanceof UnionType) {
262
            $count = count($docBlockTypeHints->getTypes());
263
        } else {
264
            $count = 1;
265
        }
266
267
        if ($count > 2) {
268
            return null;
269
        }
270
        $isNullable = $this->isNullable($docBlockTypeHints);
271
        if ($count === 2 && !$isNullable) {
272
            return null;
273
        }
274
275
        $type = $this->typesWithoutNullable($docBlockTypeHints);
276
        // At this point, there is at most one element here
277
        /*if (empty($type)) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
71% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
278
            return null;
279
        }*/
280
281
        //$type = $types[0];
0 ignored issues
show
Unused Code Comprehensibility introduced by
67% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
282
283
        // "object" type-hint is not available in PHP 7.1
284
        if ($type instanceof ObjectWithoutClassType) {
285
            // In PHP 7.2, this is true but not in PHP 7.1
286
            return null;
287
        }
288
289
        if ($type instanceof ObjectType) {
290
            return ($isNullable?'?':'').'\\'.$type->describe(VerbosityLevel::typeOnly());
291
        }
292
293
        if ($type instanceof ArrayType) {
294
            return ($isNullable?'?':'').'array';
295
        }
296
297
        if ($this->isNativeType($type)) {
298
            return ($isNullable?'?':'').$type->describe(VerbosityLevel::typeOnly());
299
        }
300
301
        // TODO: more definitions to add here
302
        // Manage interface/classes
303
        // Manage array of things => (cast to array)
304
305
306
        return null;
307
    }
308
309
    private function isNativeType(Type $type): bool
310
    {
311
        if ($type instanceof StringType
312
            || $type instanceof IntegerType
313
            || $type instanceof BooleanType
314
            || $type instanceof FloatType
315
            || $type instanceof CallableType
316
            || $type->isIterable()
317
        ) {
318
            return true;
319
        }
320
        return false;
321
    }
322
323
    /**
324
     * @param Type $docBlockTypeHints
325
     * @return bool
326
     */
327
    private function isNullable(Type $docBlockTypeHints): bool
328
    {
329
        if ($docBlockTypeHints instanceof UnionType) {
330
            foreach ($docBlockTypeHints->getTypes() as $docBlockTypeHint) {
331
                if ($docBlockTypeHint instanceof NullType) {
332
                    return true;
333
                }
334
            }
335
        }
336
        return false;
337
    }
338
339
    /**
340
     * Removes "null" from the list of types.
341
     *
342
     * @param Type $docBlockTypeHints
343
     * @return Type
344
     */
345
    private function typesWithoutNullable(Type $docBlockTypeHints): Type
346
    {
347
        if ($docBlockTypeHints instanceof UnionType) {
348
            $filteredTypes = array_values(array_filter($docBlockTypeHints->getTypes(), function (Type $item) {
349
                return !$item instanceof NullType;
350
            }));
351
            if (\count($filteredTypes) === 1) {
352
                return $filteredTypes[0];
353
            }
354
            return new UnionType($filteredTypes);
355
        }
356
        return $docBlockTypeHints;
357
    }
358
}
359