Passed
Push — master ( e59da1...e2e2f1 )
by Björn
01:18 queued 14s
created

ReturnTypeDeclarationSniff::isCustomArrayType()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace BestIt\Sniffs\TypeHints;
6
7
use BestIt\CodeSniffer\CodeError;
8
use BestIt\CodeSniffer\CodeWarning;
9
use BestIt\Sniffs\AbstractSniff;
10
use BestIt\Sniffs\DocPosProviderTrait;
11
use BestIt\Sniffs\FunctionRegistrationTrait;
12
use BestIt\Sniffs\SuppressingTrait;
13
use SlevomatCodingStandard\Helpers\Annotation;
14
use SlevomatCodingStandard\Helpers\FunctionHelper;
15
use SlevomatCodingStandard\Helpers\TypeHintHelper;
16
use function array_filter;
17
use function array_intersect;
18
use function count;
19
use function explode;
20
use function in_array;
21
use function phpversion;
22
use function substr;
23
use function version_compare;
24
25
/**
26
 * Class ReturnTypeDeclarationSniff
27
 *
28
 * @author Stephan Weber <[email protected]>
29
 * @package BestIt\Sniffs\TypeHints
30
 */
31
class ReturnTypeDeclarationSniff extends AbstractSniff
32
{
33
    use DocPosProviderTrait;
34
    use FunctionRegistrationTrait;
35
    use SuppressingTrait;
36
37
    /**
38
     * You MUST provide a return type for your functions. If you can't give it, it becomes only a warning with a PHPDoc.
39
     *
40
     * @var string
41
     */
42
    public const CODE_MISSING_RETURN_TYPE = 'MissingReturnTypeHint';
43
44
    /**
45
     * The simple message of a return type is missing.
46
     *
47
     * @var string
48
     */
49
    private const MESSAGE_MISSING_RETURN_TYPE = 'Function/Method %s does not have a return type.';
50
51
    /**
52
     * The return types which match null.
53
     *
54
     * @var array
55
     */
56
    private const NULL_TYPES = ['null', 'void'];
57
58
    /**
59
     * Null as a return has no real return type, so we use this as a fallback.
60
     *
61
     * @var string
62
     */
63
    public $defaultNullReturn = '?string';
64
65
    /**
66
     * The name of the function.
67
     *
68
     * @var string|null
69
     */
70
    private $functionName = null;
71
72
    /**
73
     * Has this function a return type?
74
     *
75
     * @var null|bool
76
     */
77
    private $hasReturnType = null;
78
79
    /**
80
     * This methods should be ignored.
81
     *
82
     * @var array
83
     */
84
    public $methodsWithoutVoid = ['__construct', '__destruct', '__clone'];
85
86
    /**
87
     * Caches the types which can be used for an automatic fix.
88
     *
89
     * This array is only filled, if the return annotation situation of the phpdoc is usable for a fix.
90
     *
91
     * @var null|array
92
     */
93
    private $typesForFix = null;
94
95
    /**
96
     * Adds the return type to fix the error.
97
     *
98
     * @return void
99
     */
100
    private function addReturnType(): void
101
    {
102
        $file = $this->getFile();
103
        $returnTypeHint = $this->createReturnType();
104
105
        $file->fixer->beginChangeset();
106
107
        if ($this->isCustomArrayType($returnTypeHint)) {
108
            $returnTypeHint = ($returnTypeHint[0] === '?' ? '?' : '') . 'array';
109
        }
110
111
        $file->fixer->addContent(
112
            $this->token['parenthesis_closer'],
113
            ': ' . $returnTypeHint
114
        );
115
116
        $file->fixer->endChangeset();
117
    }
118
119
    /**
120
     * Returns true if this sniff may run.
121
     *
122
     * @return bool
123
     */
124
    protected function areRequirementsMet(): bool
125
    {
126
        return !$this->isSniffSuppressed(static::CODE_MISSING_RETURN_TYPE) && !$this->hasReturnType() &&
127
            !in_array($this->getFunctionName(), $this->methodsWithoutVoid);
128
    }
129
130
    /**
131
     * Creates the new return type for fixing or returns a null if not possible.
132
     *
133
     * @return string
134
     */
135
    private function createReturnType(): string
136
    {
137
        $returnTypeHint = '';
138
        $typeCount = count($this->typesForFix);
139
140
        foreach ($this->typesForFix as $type) {
0 ignored issues
show
Bug introduced by
The expression $this->typesForFix of type null|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
141
            // We add the default value if only null is used (which has no real native return type).
142
            if ($type === 'null' && ($typeCount === 1)) {
143
                $returnTypeHint = $this->defaultNullReturn;
144
                break; // We still need this break to prevent further execution of the default value.
145
            }
146
147
            // We add the question mark if there is a nullable type.
148
            if (in_array($type, self::NULL_TYPES, true) && ($typeCount > 1)) {
149
                $returnTypeHint = '?' . $returnTypeHint;
150
                continue; // We still need this continue to prevent further execution of the questionmark.
151
            }
152
153
            // We add a fixable "native" type. We do not fix custom classes (because it would have side effects to the
154
            // imported usage of classes.
155
            $returnTypeHint .= (TypeHintHelper::isSimpleTypeHint($type))
156
                ? TypeHintHelper::convertLongSimpleTypeHintToShort($type)
157
                : $type;
158
        }
159
160
        return $returnTypeHint;
161
    }
162
163
    /**
164
     * Fixes the return type error.
165
     *
166
     * @param CodeWarning $exception
167
     *
168
     * @return void
169
     */
170
    protected function fixDefaultProblem(CodeWarning $exception): void
171
    {
172
        // Satisfy PHPMD
173
        unset($exception);
174
175
        // This method is called, if it the error is not marked as fixable. So check our internal marker again.
176
        if ($this->typesForFix) {
177
            $this->addReturnType();
178
        }
179
    }
180
181
    /**
182
     * Returns the name of the function.
183
     *
184
     * @return string
185
     */
186
    private function getFunctionName(): string
187
    {
188
        if (!$this->functionName) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->functionName of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
189
            $this->functionName = $this->loadFunctionName();
190
        }
191
192
        return $this->functionName;
193
    }
194
195
    /**
196
     * Returns the return types of the annotation.
197
     *
198
     * @param null|Annotation $annotation
199
     *
200
     * @return array
201
     */
202
    private function getReturnsFromAnnotation(?Annotation $annotation): array
203
    {
204
        return $this->isFilledReturnAnnotation($annotation)
205
            ? explode('|', preg_split('~\\s+~', $annotation->getContent())[0])
0 ignored issues
show
Bug introduced by
It seems like $annotation is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
206
            : [];
207
    }
208
209
    /**
210
     * Returns the types of the annotation, if the types are usable?
211
     *
212
     * Usable means, that there should be one type in the return-annotation or a nullable type, which means 2 types
213
     * like null|$ANYTYPE.
214
     *
215
     * @param Annotation $annotation
216
     *
217
     * @return array|null Null if there are no usable types or the usable types.
218
     */
219
    private function getUsableReturnTypes(Annotation $annotation): ?array
220
    {
221
        $returnTypes = $this->getReturnsFromAnnotation($annotation);
222
        $returnTypeCount = count($returnTypes);
223
224
        $isNullableType = ($returnTypeCount === 2) &&
225
            version_compare(phpversion(), '7.1.0', '>') &&
226
            (count(array_intersect($returnTypes, self::NULL_TYPES)) === 1);
227
228
        return (($returnTypeCount === 1) || $isNullableType) ? $returnTypes : null;
229
    }
230
231
    /**
232
     * Check if there is a return type.
233
     *
234
     * @return bool
235
     */
236
    private function hasReturnType(): bool
237
    {
238
        if ($this->hasReturnType === null) {
239
            $this->hasReturnType = FunctionHelper::hasReturnTypeHint($this->file->getBaseFile(), $this->stackPos);
240
        }
241
242
        return $this->hasReturnType;
243
    }
244
245
    /**
246
     * Is the given array a custom array with the "[]" suffix?
247
     *
248
     * @param string $returnTypeHint
249
     *
250
     * @return bool
251
     */
252
    private function isCustomArrayType(string $returnTypeHint): bool
253
    {
254
        return substr($returnTypeHint, -2) === '[]';
255
    }
256
257
    /**
258
     * Check if function has a return annotation
259
     *
260
     * @param Annotation|null $returnAnnotation Annotation of the function
261
     *
262
     * @return bool Function has a annotation
263
     */
264
    private function isFilledReturnAnnotation(?Annotation $returnAnnotation = null): bool
265
    {
266
        return $returnAnnotation && $returnAnnotation->getContent();
0 ignored issues
show
Bug Best Practice introduced by
The expression $returnAnnotation->getContent() of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
267
    }
268
269
    /**
270
     * Check if the return annotation type is valid.
271
     *
272
     * @param string $type The type value of the return annotation
273
     *
274
     * @return bool Type is valid
275
     */
276
    private function isFixableReturnType(string $type): bool
277
    {
278
        // $type === null is not valid in our slevomat helper.
279
        return TypeHintHelper::isSimpleTypeHint($type) || $type === 'null' || $this->isCustomArrayType($type);
280
    }
281
282
    /**
283
     * Removes the return types from the given array, which are not compatible with our fix.
284
     *
285
     * @param array|null $returnTypes
286
     *
287
     * @return array The cleaned array.
288
     */
289
    private function loadFixableTypes(?array $returnTypes): array
290
    {
291
        return array_filter($returnTypes ?? [], function (string $returnType): bool {
292
            return $this->isFixableReturnType($returnType);
293
        });
294
    }
295
296
    /**
297
     * Loads the name of the function.
298
     *
299
     * @return string
300
     */
301
    private function loadFunctionName(): string
302
    {
303
        return FunctionHelper::getName($this->getFile()->getBaseFile(), $this->stackPos);
304
    }
305
306
    /**
307
     * Loads the return annotation for this method.
308
     *
309
     * @return null|Annotation
310
     */
311
    protected function loadReturnAnnotation(): ?Annotation
312
    {
313
        return FunctionHelper::findReturnAnnotation($this->getFile()->getBaseFile(), $this->stackPos);
314
    }
315
316
    /**
317
     * Check method return type based with its return annotation.
318
     *
319
     * @throws CodeWarning
320
     *
321
     * @return void
322
     */
323
    protected function processToken(): void
324
    {
325
        $this->getFile()->recordMetric($this->stackPos, 'Has return type', 'no');
326
327
        $returnAnnotation = $this->loadReturnAnnotation();
328
329
        if (!$this->isFilledReturnAnnotation($returnAnnotation) ||
330
            ($returnTypes = $this->getUsableReturnTypes($returnAnnotation))) {
0 ignored issues
show
Bug introduced by
It seems like $returnAnnotation defined by $this->loadReturnAnnotation() on line 327 can be null; however, BestIt\Sniffs\TypeHints\...:getUsableReturnTypes() 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...
331
            $this->validateReturnType($returnTypes ?? null);
332
        }
333
    }
334
335
    /**
336
     * Sets up the test.
337
     *
338
     * @return void
339
     */
340
    protected function setUp(): void
341
    {
342
        parent::setUp();
343
344
        if ($this->hasReturnType()) {
345
            $this->getFile()->recordMetric($this->stackPos, 'Has return type', 'yes');
346
        }
347
    }
348
349
    /**
350
     * Resets the data of this sniff.
351
     *
352
     * @return void
353
     */
354
    protected function tearDown(): void
355
    {
356
        $this->resetDocCommentPos();
357
358
        $this->hasReturnType = null;
359
        $this->functionName = null;
360
        $this->typesForFix = null;
361
    }
362
363
    /**
364
     * Validates the return type and registers an error if there is one.
365
     *
366
     * @param array|null $returnTypes
367
     * @throws CodeWarning
368
     *
369
     * @return void
370
     */
371
    private function validateReturnType(?array $returnTypes): void
372
    {
373
        if (!$returnTypes) {
374
            $returnTypes = [];
375
        }
376
377
        $fixableTypes = $this->loadFixableTypes($returnTypes);
378
379
        if (count($returnTypes) === count($fixableTypes)) {
380
            // Make sure this var is only filled, if it really is fixable for us.
381
            $this->typesForFix = $fixableTypes;
382
        }
383
384
        $exception =
385
            (new CodeError(static::CODE_MISSING_RETURN_TYPE, self::MESSAGE_MISSING_RETURN_TYPE, $this->stackPos))
386
                ->setPayload([$this->getFunctionName()]);
387
388
        $exception->isFixable((bool) $this->typesForFix);
389
390
        throw $exception;
391
    }
392
}
393