CommentAnalyzer::extractClassLikeDocblockInfo()   F
last analyzed

Complexity

Conditions 66
Paths > 20000

Size

Total Lines 330

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 66
nc 3325035523
nop 3
dl 0
loc 330
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
namespace Psalm\Internal\Analyzer;
3
4
use PhpParser;
5
use Psalm\Aliases;
6
use Psalm\DocComment;
7
use Psalm\Exception\DocblockParseException;
8
use Psalm\Exception\IncorrectDocblockException;
9
use Psalm\Exception\TypeParseTreeException;
10
use Psalm\FileSource;
11
use Psalm\Internal\Scanner\ClassLikeDocblockComment;
12
use Psalm\Internal\Scanner\DocblockParser;
13
use Psalm\Internal\Scanner\FunctionDocblockComment;
14
use Psalm\Internal\Scanner\VarDocblockComment;
15
use Psalm\Internal\Scanner\ParsedDocblock;
16
use Psalm\Internal\Type\ParseTree;
17
use Psalm\Internal\Type\ParseTreeCreator;
18
use Psalm\Internal\Type\TypeAlias;
19
use Psalm\Internal\Type\TypeParser;
20
use Psalm\Internal\Type\TypeTokenizer;
21
use Psalm\Type;
22
use function array_column;
23
use function array_unique;
24
use function trim;
25
use function substr_count;
26
use function strlen;
27
use function preg_replace;
28
use function str_replace;
29
use function preg_match;
30
use function count;
31
use function reset;
32
use function preg_split;
33
use const PREG_SPLIT_DELIM_CAPTURE;
34
use const PREG_SPLIT_NO_EMPTY;
35
use function array_shift;
36
use function implode;
37
use function substr;
38
use function strpos;
39
use function strtolower;
40
use function in_array;
41
use function explode;
42
use function array_merge;
43
use const PREG_OFFSET_CAPTURE;
44
use function rtrim;
45
46
/**
47
 * @internal
48
 */
49
class CommentAnalyzer
50
{
51
    const TYPE_REGEX = '(\??\\\?[\(\)A-Za-z0-9_&\<\.=,\>\[\]\-\{\}:|?\\\\]*|\$[a-zA-Z_0-9_]+)';
52
53
    /**
54
     * @param  array<string, array<string, array{Type\Union}>>|null   $template_type_map
55
     * @param  array<string, TypeAlias> $type_aliases
56
     *
57
     * @throws DocblockParseException if there was a problem parsing the docblock
58
     *
59
     * @return VarDocblockComment[]
60
     */
61
    public static function getTypeFromComment(
62
        PhpParser\Comment\Doc $comment,
63
        FileSource $source,
64
        Aliases $aliases,
65
        array $template_type_map = null,
66
        ?array $type_aliases = null
67
    ) {
68
        $parsed_docblock = DocComment::parsePreservingLength($comment);
69
70
        return self::arrayToDocblocks(
71
            $comment,
72
            $parsed_docblock,
73
            $source,
74
            $aliases,
75
            $template_type_map,
76
            $type_aliases
77
        );
78
    }
79
80
    /**
81
     * @param  array<string, array<string, array{Type\Union}>>|null   $template_type_map
82
     * @param  array<string, TypeAlias> $type_aliases
83
     *
84
     * @return VarDocblockComment[]
85
     *
86
     * @throws DocblockParseException if there was a problem parsing the docblock
87
     */
88
    public static function arrayToDocblocks(
89
        PhpParser\Comment\Doc $comment,
90
        ParsedDocblock $parsed_docblock,
91
        FileSource $source,
92
        Aliases $aliases,
93
        array $template_type_map = null,
94
        ?array $type_aliases = null
95
    ) : array {
96
        $var_id = null;
97
98
        $var_type_tokens = null;
99
        $original_type = null;
100
101
        $var_comments = [];
102
103
        $comment_text = $comment->getText();
104
105
        $var_line_number = $comment->getLine();
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
106
107
        if (isset($parsed_docblock->combined_tags['var'])) {
108
            foreach ($parsed_docblock->combined_tags['var'] as $offset => $var_line) {
109
                $var_line = trim($var_line);
110
111
                if (!$var_line) {
112
                    continue;
113
                }
114
115
                $type_start = null;
116
                $type_end = null;
117
118
                $line_parts = self::splitDocLine($var_line);
119
120
                $line_number = $comment->getLine() + substr_count($comment_text, "\n", 0, $offset);
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
121
122
                if ($line_parts && $line_parts[0]) {
123
                    $type_start = $offset + $comment->getFilePos();
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
124
                    $type_end = $type_start + strlen($line_parts[0]);
125
126
                    $line_parts[0] = self::sanitizeDocblockType($line_parts[0]);
127
128
                    if ($line_parts[0] === ''
129
                        || ($line_parts[0][0] === '$'
130
                            && !preg_match('/^\$this(\||$)/', $line_parts[0]))
131
                    ) {
132
                        throw new IncorrectDocblockException('Misplaced variable');
133
                    }
134
135
                    try {
136
                        $var_type_tokens = TypeTokenizer::getFullyQualifiedTokens(
137
                            $line_parts[0],
138
                            $aliases,
139
                            $template_type_map,
140
                            $type_aliases
141
                        );
142
                    } catch (TypeParseTreeException $e) {
143
                        throw new DocblockParseException($line_parts[0] . ' is not a valid type');
144
                    }
145
146
                    $original_type = $line_parts[0];
147
148
                    $var_line_number = $line_number;
149
150
                    if (count($line_parts) > 1 && $line_parts[1][0] === '$') {
151
                        $var_id = $line_parts[1];
152
                    }
153
                }
154
155
                if (!$var_type_tokens || !$original_type) {
156
                    continue;
157
                }
158
159
                try {
160
                    $defined_type = TypeParser::parseTokens(
161
                        $var_type_tokens,
162
                        null,
163
                        $template_type_map ?: [],
164
                        $type_aliases ?: []
165
                    );
166
                } catch (TypeParseTreeException $e) {
167
                    throw new DocblockParseException(
168
                        $line_parts[0] .
169
                        ' is not a valid type' .
170
                        ' (from ' .
171
                        $source->getFilePath() .
172
                        ':' .
173
                        $comment->getLine() .
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
174
                        ')'
175
                    );
176
                }
177
178
                $defined_type->setFromDocblock();
179
180
                $var_comment = new VarDocblockComment();
181
                $var_comment->type = $defined_type;
182
                $var_comment->original_type = $original_type;
183
                $var_comment->var_id = $var_id;
184
                $var_comment->line_number = $var_line_number;
185
                $var_comment->type_start = $type_start;
0 ignored issues
show
Documentation Bug introduced by
It seems like $type_start can also be of type double. However, the property $type_start is declared as type integer|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
186
                $var_comment->type_end = $type_end;
0 ignored issues
show
Documentation Bug introduced by
It seems like $type_end can also be of type double. However, the property $type_end is declared as type integer|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
187
188
                self::decorateVarDocblockComment($var_comment, $parsed_docblock);
189
190
                $var_comments[] = $var_comment;
191
            }
192
        }
193
194
        if (!$var_comments
195
            && (isset($parsed_docblock->tags['deprecated'])
196
                || isset($parsed_docblock->tags['internal'])
197
                || isset($parsed_docblock->tags['readonly'])
198
                || isset($parsed_docblock->tags['psalm-readonly'])
199
                || isset($parsed_docblock->tags['psalm-readonly-allow-private-mutation'])
200
                || isset($parsed_docblock->tags['psalm-taint-escape'])
201
                || isset($parsed_docblock->tags['psalm-internal']))
202
        ) {
203
            $var_comment = new VarDocblockComment();
204
205
            self::decorateVarDocblockComment($var_comment, $parsed_docblock);
206
207
            $var_comments[] = $var_comment;
208
        }
209
210
        return $var_comments;
211
    }
212
213
    private static function decorateVarDocblockComment(
214
        VarDocblockComment $var_comment,
215
        ParsedDocblock $parsed_docblock
216
    ) : void {
217
        $var_comment->deprecated = isset($parsed_docblock->tags['deprecated']);
218
        $var_comment->internal = isset($parsed_docblock->tags['internal']);
219
        $var_comment->readonly = isset($parsed_docblock->tags['readonly'])
220
            || isset($parsed_docblock->tags['psalm-readonly'])
221
            || isset($parsed_docblock->tags['psalm-readonly-allow-private-mutation']);
222
223
        $var_comment->allow_private_mutation
224
            = isset($parsed_docblock->tags['psalm-allow-private-mutation'])
225
            || isset($parsed_docblock->tags['psalm-readonly-allow-private-mutation']);
226
227
        if (isset($parsed_docblock->tags['psalm-taint-escape'])) {
228
            foreach ($parsed_docblock->tags['psalm-taint-escape'] as $param) {
229
                $param = trim($param);
230
                $var_comment->removed_taints[] = $param;
231
            }
232
        }
233
234
        if (isset($parsed_docblock->tags['psalm-internal'])) {
235
            $psalm_internal = reset($parsed_docblock->tags['psalm-internal']);
236
237
            if (!$psalm_internal) {
238
                throw new DocblockParseException('psalm-internal annotation used without specifying namespace');
239
            }
240
241
            $var_comment->psalm_internal = reset($parsed_docblock->tags['psalm-internal']);
242
243
            if (!$var_comment->internal) {
244
                throw new DocblockParseException('@psalm-internal annotation used without @internal');
245
            }
246
        }
247
    }
248
249
    private static function sanitizeDocblockType(string $docblock_type) : string
250
    {
251
        $docblock_type = preg_replace('@^[ \t]*\*@m', '', $docblock_type);
252
        $docblock_type = preg_replace('/,\n\s+\}/', '}', $docblock_type);
253
        return str_replace("\n", '', $docblock_type);
254
    }
255
256
    /**
257
     * @param  Aliases          $aliases
258
     * @param  array<string, TypeAlias> $type_aliases
259
     *
260
     * @throws DocblockParseException if there was a problem parsing the docblock
261
     *
262
     * @return array<string, TypeAlias\InlineTypeAlias>
0 ignored issues
show
Documentation introduced by
The doc-type array<string, could not be parsed: Expected ">" at position 5, but found "end of type". (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
263
     */
264
    public static function getTypeAliasesFromComment(
265
        PhpParser\Comment\Doc $comment,
266
        Aliases $aliases,
267
        ?array $type_aliases,
268
        ?string $self_fqcln
269
    ) {
270
        $parsed_docblock = DocComment::parsePreservingLength($comment);
271
272
        if (!isset($parsed_docblock->tags['psalm-type'])) {
273
            return [];
274
        }
275
276
        return self::getTypeAliasesFromCommentLines(
277
            $parsed_docblock->tags['psalm-type'],
278
            $aliases,
279
            $type_aliases,
280
            $self_fqcln
281
        );
282
    }
283
284
    /**
285
     * @param  array<string>    $type_alias_comment_lines
286
     * @param  Aliases          $aliases
287
     * @param  array<string, TypeAlias> $type_aliases
288
     *
289
     * @throws DocblockParseException if there was a problem parsing the docblock
290
     *
291
     * @return array<string, TypeAlias\InlineTypeAlias>
0 ignored issues
show
Documentation introduced by
The doc-type array<string, could not be parsed: Expected ">" at position 5, but found "end of type". (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
292
     */
293
    private static function getTypeAliasesFromCommentLines(
294
        array $type_alias_comment_lines,
295
        Aliases $aliases,
296
        ?array $type_aliases,
297
        ?string $self_fqcln
298
    ) {
299
        $type_alias_tokens = [];
300
301
        foreach ($type_alias_comment_lines as $var_line) {
302
            $var_line = trim($var_line);
303
304
            if (!$var_line) {
305
                continue;
306
            }
307
308
            $var_line = preg_replace('/[ \t]+/', ' ', preg_replace('@^[ \t]*\*@m', '', $var_line));
309
            $var_line = preg_replace('/,\n\s+\}/', '}', $var_line);
310
            $var_line = str_replace("\n", '', $var_line);
311
312
            $var_line_parts = preg_split('/( |=)/', $var_line, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
313
314
            if (!$var_line_parts) {
315
                continue;
316
            }
317
318
            $type_alias = array_shift($var_line_parts);
319
320
            if (!isset($var_line_parts[0])) {
321
                continue;
322
            }
323
324
            if ($var_line_parts[0] === ' ') {
325
                array_shift($var_line_parts);
326
            }
327
328
            if ($var_line_parts[0] === '=') {
329
                array_shift($var_line_parts);
330
            }
331
332
            if (!isset($var_line_parts[0])) {
333
                continue;
334
            }
335
336
            if ($var_line_parts[0] === ' ') {
337
                array_shift($var_line_parts);
338
            }
339
340
            $type_string = str_replace("\n", '', implode('', $var_line_parts));
341
342
            $type_string = preg_replace('/>[^>^\}]*$/', '>', $type_string);
343
            $type_string = preg_replace('/\}[^>^\}]*$/', '}', $type_string);
344
345
            try {
346
                $type_tokens = TypeTokenizer::getFullyQualifiedTokens(
347
                    $type_string,
348
                    $aliases,
349
                    null,
350
                    $type_alias_tokens + $type_aliases,
351
                    $self_fqcln
352
                );
353
            } catch (TypeParseTreeException $e) {
354
                throw new DocblockParseException($type_string . ' is not a valid type');
355
            }
356
357
            $type_alias_tokens[$type_alias] = new TypeAlias\InlineTypeAlias($type_tokens);
358
        }
359
360
        return $type_alias_tokens;
361
    }
362
363
    /**
364
     * @param  int     $line_number
0 ignored issues
show
Bug introduced by
There is no parameter named $line_number. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
365
     *
366
     * @throws DocblockParseException if there was a problem parsing the docblock
367
     *
368
     * @return FunctionDocblockComment
369
     */
370
    public static function extractFunctionDocblockInfo(PhpParser\Comment\Doc $comment)
371
    {
372
        $parsed_docblock = DocComment::parsePreservingLength($comment);
373
374
        $comment_text = $comment->getText();
375
376
        $info = new FunctionDocblockComment();
377
378
        self::checkDuplicatedTags($parsed_docblock);
379
380
        if (isset($parsed_docblock->combined_tags['return'])) {
381
            self::extractReturnType(
382
                $comment,
383
                $parsed_docblock->combined_tags['return'],
384
                $info
385
            );
386
        }
387
388
        if (isset($parsed_docblock->combined_tags['param'])) {
389
            foreach ($parsed_docblock->combined_tags['param'] as $offset => $param) {
390
                $line_parts = self::splitDocLine($param);
391
392
                if (count($line_parts) === 1 && isset($line_parts[0][0]) && $line_parts[0][0] === '$') {
393
                    continue;
394
                }
395
396
                if (count($line_parts) > 1) {
397
                    if (preg_match('/^&?(\.\.\.)?&?\$[A-Za-z0-9_]+,?$/', $line_parts[1])
398
                        && $line_parts[0][0] !== '{'
399
                    ) {
400
                        $line_parts[1] = str_replace('&', '', $line_parts[1]);
401
402
                        $line_parts[1] = preg_replace('/,$/', '', $line_parts[1]);
403
404
                        $start = $offset + $comment->getFilePos();
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
405
                        $end = $start + strlen($line_parts[0]);
406
407
                        $line_parts[0] = self::sanitizeDocblockType($line_parts[0]);
408
409
                        if ($line_parts[0] === ''
410
                            || ($line_parts[0][0] === '$'
411
                                && !preg_match('/^\$this(\||$)/', $line_parts[0]))
412
                        ) {
413
                            throw new IncorrectDocblockException('Misplaced variable');
414
                        }
415
416
                        $info->params[] = [
417
                            'name' => trim($line_parts[1]),
418
                            'type' => $line_parts[0],
419
                            'line_number' => $comment->getLine() + substr_count($comment_text, "\n", 0, $offset),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
420
                            'start' => $start,
421
                            'end' => $end,
422
                        ];
423
                    }
424
                } else {
425
                    throw new DocblockParseException('Badly-formatted @param');
426
                }
427
            }
428
        }
429
430
        if (isset($parsed_docblock->tags['param-out'])) {
431
            foreach ($parsed_docblock->tags['param-out'] as $offset => $param) {
432
                $line_parts = self::splitDocLine($param);
433
434
                if (count($line_parts) === 1 && isset($line_parts[0][0]) && $line_parts[0][0] === '$') {
435
                    continue;
436
                }
437
438
                if (count($line_parts) > 1) {
439
                    if (!preg_match('/\[[^\]]+\]/', $line_parts[0])
440
                        && preg_match('/^(\.\.\.)?&?\$[A-Za-z0-9_]+,?$/', $line_parts[1])
441
                        && $line_parts[0][0] !== '{'
442
                    ) {
443
                        if ($line_parts[1][0] === '&') {
444
                            $line_parts[1] = substr($line_parts[1], 1);
445
                        }
446
447
                        $line_parts[0] = str_replace("\n", '', preg_replace('@^[ \t]*\*@m', '', $line_parts[0]));
448
449
                        if ($line_parts[0] === ''
450
                            || ($line_parts[0][0] === '$'
451
                                && !preg_match('/^\$this(\||$)/', $line_parts[0]))
452
                        ) {
453
                            throw new IncorrectDocblockException('Misplaced variable');
454
                        }
455
456
                        $line_parts[1] = preg_replace('/,$/', '', $line_parts[1]);
457
458
                        $info->params_out[] = [
459
                            'name' => trim($line_parts[1]),
460
                            'type' => str_replace("\n", '', $line_parts[0]),
461
                            'line_number' => $comment->getLine() + substr_count($comment_text, "\n", 0, $offset),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
462
                        ];
463
                    }
464
                } else {
465
                    throw new DocblockParseException('Badly-formatted @param');
466
                }
467
            }
468
        }
469
470
        if (isset($parsed_docblock->tags['psalm-self-out'])) {
471
            foreach ($parsed_docblock->tags['psalm-self-out'] as $offset => $param) {
472
                $line_parts = self::splitDocLine($param);
473
474
                if (count($line_parts) > 0) {
475
                    $line_parts[0] = str_replace("\n", '', preg_replace('@^[ \t]*\*@m', '', $line_parts[0]));
476
477
                    $info->self_out = [
478
                        'type' => str_replace("\n", '', $line_parts[0]),
479
                        'line_number' => $comment->getLine() + substr_count($comment_text, "\n", 0, $offset),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
480
                    ];
481
                }
482
            }
483
        }
484
485
        if (isset($parsed_docblock->tags['psalm-flow'])) {
486
            foreach ($parsed_docblock->tags['psalm-flow'] as $param) {
487
                $info->flows[] = trim($param);
488
            }
489
        }
490
491
        if (isset($parsed_docblock->tags['psalm-taint-sink'])) {
492
            foreach ($parsed_docblock->tags['psalm-taint-sink'] as $param) {
493
                $param_parts = preg_split('/\s+/', trim($param));
494
495
                if (count($param_parts) === 2) {
496
                    $info->taint_sink_params[] = ['name' => $param_parts[1], 'taint' => $param_parts[0]];
497
                }
498
            }
499
        }
500
501
        // support for MediaWiki taint plugin
502
        if (isset($parsed_docblock->tags['param-taint'])) {
503
            foreach ($parsed_docblock->tags['param-taint'] as $param) {
504
                $param_parts = preg_split('/\s+/', trim($param));
505
506
                if (count($param_parts) === 2) {
507
                    $taint_type = $param_parts[1];
508
509
                    if (substr($taint_type, 0, 5) === 'exec_') {
510
                        $taint_type = substr($taint_type, 5);
511
512
                        if ($taint_type === 'tainted') {
513
                            $taint_type = 'input';
514
                        }
515
516
                        if ($taint_type === 'misc') {
517
                            $taint_type = 'text';
518
                        }
519
520
                        $info->taint_sink_params[] = ['name' => $param_parts[0], 'taint' => $taint_type];
521
                    }
522
                }
523
            }
524
        }
525
526
        if (isset($parsed_docblock->tags['psalm-taint-source'])) {
527
            foreach ($parsed_docblock->tags['psalm-taint-source'] as $param) {
528
                $param_parts = preg_split('/\s+/', trim($param));
529
530
                if ($param_parts[0]) {
531
                    $info->taint_source_types[] = $param_parts[0];
532
                }
533
            }
534
        } elseif (isset($parsed_docblock->tags['return-taint'])) {
535
            // support for MediaWiki taint plugin
536
            foreach ($parsed_docblock->tags['return-taint'] as $param) {
537
                $param_parts = preg_split('/\s+/', trim($param));
538
539
                if ($param_parts[0]) {
540
                    if ($param_parts[0] === 'tainted') {
541
                        $param_parts[0] = 'input';
542
                    }
543
544
                    if ($param_parts[0] === 'misc') {
545
                        $param_parts[0] = 'text';
546
                    }
547
548
                    if ($param_parts[0] !== 'none') {
549
                        $info->taint_source_types[] = $param_parts[0];
550
                    }
551
                }
552
            }
553
        }
554
555
        if (isset($parsed_docblock->tags['psalm-taint-unescape'])) {
556
            foreach ($parsed_docblock->tags['psalm-taint-unescape'] as $param) {
557
                $param = trim($param);
558
                $info->added_taints[] = $param;
559
            }
560
        }
561
562
        if (isset($parsed_docblock->tags['psalm-taint-escape'])) {
563
            foreach ($parsed_docblock->tags['psalm-taint-escape'] as $param) {
564
                $param = trim($param);
565
                $info->removed_taints[] = $param;
566
            }
567
        }
568
569
        if (isset($parsed_docblock->tags['psalm-assert-untainted'])) {
570
            foreach ($parsed_docblock->tags['psalm-assert-untainted'] as $param) {
571
                $param = trim($param);
572
573
                $info->assert_untainted_params[] = ['name' => $param];
574
            }
575
        }
576
577
        if (isset($parsed_docblock->tags['psalm-taint-specialize'])) {
578
            $info->specialize_call = true;
579
        }
580
581
        if (isset($parsed_docblock->tags['global'])) {
582
            foreach ($parsed_docblock->tags['global'] as $offset => $global) {
583
                $line_parts = self::splitDocLine($global);
584
585
                if (count($line_parts) === 1 && isset($line_parts[0][0]) && $line_parts[0][0] === '$') {
586
                    continue;
587
                }
588
589
                if (count($line_parts) > 1) {
590
                    if (!preg_match('/\[[^\]]+\]/', $line_parts[0])
591
                        && preg_match('/^(\.\.\.)?&?\$[A-Za-z0-9_]+,?$/', $line_parts[1])
592
                        && $line_parts[0][0] !== '{'
593
                    ) {
594
                        if ($line_parts[1][0] === '&') {
595
                            $line_parts[1] = substr($line_parts[1], 1);
596
                        }
597
598
                        if ($line_parts[0][0] === '$' && !preg_match('/^\$this(\||$)/', $line_parts[0])) {
599
                            throw new IncorrectDocblockException('Misplaced variable');
600
                        }
601
602
                        $line_parts[1] = preg_replace('/,$/', '', $line_parts[1]);
603
604
                        $info->globals[] = [
605
                            'name' => $line_parts[1],
606
                            'type' => $line_parts[0],
607
                            'line_number' => $comment->getLine() + substr_count($comment_text, "\n", 0, $offset),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
608
                        ];
609
                    }
610
                } else {
611
                    throw new DocblockParseException('Badly-formatted @param');
612
                }
613
            }
614
        }
615
616
        if (isset($parsed_docblock->tags['deprecated'])) {
617
            $info->deprecated = true;
618
        }
619
620
        if (isset($parsed_docblock->tags['internal'])) {
621
            $info->internal = true;
622
        }
623
624
        if (isset($parsed_docblock->tags['psalm-internal'])) {
625
            $psalm_internal = reset($parsed_docblock->tags['psalm-internal']);
626
            if ($psalm_internal) {
627
                $info->psalm_internal = $psalm_internal;
628
            } else {
629
                throw new DocblockParseException('@psalm-internal annotation used without specifying namespace');
630
            }
631
            $info->psalm_internal = reset($parsed_docblock->tags['psalm-internal']);
632
633
            if (! $info->internal) {
634
                throw new DocblockParseException('@psalm-internal annotation used without @internal');
635
            }
636
        }
637
638
        if (isset($parsed_docblock->tags['psalm-suppress'])) {
639
            foreach ($parsed_docblock->tags['psalm-suppress'] as $offset => $suppress_entry) {
640
                $info->suppressed_issues[$offset + $comment->getFilePos()] = preg_split('/[\s]+/', $suppress_entry)[0];
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
641
            }
642
        }
643
644
        if (isset($parsed_docblock->tags['throws'])) {
645
            foreach ($parsed_docblock->tags['throws'] as $offset => $throws_entry) {
646
                $throws_class = preg_split('/[\s]+/', $throws_entry)[0];
647
648
                if (!$throws_class) {
649
                    throw new IncorrectDocblockException('Unexpectedly empty @throws');
650
                }
651
652
                $info->throws[] = [
653
                    $throws_class,
654
                    $offset + $comment->getFilePos(),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
655
                    $comment->getLine() + substr_count($comment->getText(), "\n", 0, $offset)
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
656
                ];
657
            }
658
        }
659
660
        if (strpos(strtolower($parsed_docblock->description), '@inheritdoc') !== false
661
            || isset($parsed_docblock->tags['inheritdoc'])
662
            || isset($parsed_docblock->tags['inheritDoc'])
663
        ) {
664
            $info->inheritdoc = true;
665
        }
666
667
        if (isset($parsed_docblock->combined_tags['template'])) {
668
            foreach ($parsed_docblock->combined_tags['template'] as $template_line) {
669
                $template_type = preg_split('/[\s]+/', preg_replace('@^[ \t]*\*@m', '', $template_line));
670
671
                $template_name = array_shift($template_type);
672
673
                if (!$template_name) {
674
                    throw new IncorrectDocblockException('Empty @template tag');
675
                }
676
677
                if (count($template_type) > 1
678
                    && in_array(strtolower($template_type[0]), ['as', 'super', 'of'], true)
679
                ) {
680
                    $template_modifier = strtolower(array_shift($template_type));
681
                    $info->templates[] = [
682
                        $template_name,
683
                        $template_modifier,
684
                        implode(' ', $template_type),
685
                        false
686
                    ];
687
                } else {
688
                    $info->templates[] = [$template_name, null, null, false];
689
                }
690
            }
691
        }
692
693
        if (isset($parsed_docblock->tags['template-typeof'])) {
694
            foreach ($parsed_docblock->tags['template-typeof'] as $template_typeof) {
695
                $typeof_parts = preg_split('/[\s]+/', preg_replace('@^[ \t]*\*@m', '', $template_typeof));
696
697
                if ($typeof_parts === false || count($typeof_parts) < 2 || $typeof_parts[1][0] !== '$') {
698
                    throw new IncorrectDocblockException('Misplaced variable');
699
                }
700
701
                $info->template_typeofs[] = [
702
                    'template_type' => $typeof_parts[0],
703
                    'param_name' => substr($typeof_parts[1], 1),
704
                ];
705
            }
706
        }
707
708
        if (isset($parsed_docblock->tags['psalm-assert'])) {
709
            foreach ($parsed_docblock->tags['psalm-assert'] as $assertion) {
710
                $line_parts = self::splitDocLine($assertion);
711
712
                if (count($line_parts) < 2 || $line_parts[1][0] !== '$') {
713
                    throw new IncorrectDocblockException('Misplaced variable');
714
                }
715
716
                $line_parts[0] = self::sanitizeDocblockType($line_parts[0]);
717
718
                $info->assertions[] = [
719
                    'type' => $line_parts[0],
720
                    'param_name' => substr($line_parts[1], 1),
721
                ];
722
            }
723
        }
724
725
        if (isset($parsed_docblock->tags['psalm-assert-if-true'])) {
726
            foreach ($parsed_docblock->tags['psalm-assert-if-true'] as $assertion) {
727
                $line_parts = self::splitDocLine($assertion);
728
729
                if (count($line_parts) < 2 || $line_parts[1][0] !== '$') {
730
                    throw new IncorrectDocblockException('Misplaced variable');
731
                }
732
733
                $info->if_true_assertions[] = [
734
                    'type' => $line_parts[0],
735
                    'param_name' => substr($line_parts[1], 1),
736
                ];
737
            }
738
        }
739
740
        if (isset($parsed_docblock->tags['psalm-assert-if-false'])) {
741
            foreach ($parsed_docblock->tags['psalm-assert-if-false'] as $assertion) {
742
                $line_parts = self::splitDocLine($assertion);
743
744
                if (count($line_parts) < 2 || $line_parts[1][0] !== '$') {
745
                    throw new IncorrectDocblockException('Misplaced variable');
746
                }
747
748
                $info->if_false_assertions[] = [
749
                    'type' => $line_parts[0],
750
                    'param_name' => substr($line_parts[1], 1),
751
                ];
752
            }
753
        }
754
755
        $info->variadic = isset($parsed_docblock->tags['psalm-variadic']);
756
        $info->pure = isset($parsed_docblock->tags['psalm-pure'])
757
            || isset($parsed_docblock->tags['pure']);
758
759
        if (isset($parsed_docblock->tags['psalm-mutation-free'])) {
760
            $info->mutation_free = true;
761
        }
762
763
        if (isset($parsed_docblock->tags['psalm-external-mutation-free'])) {
764
            $info->external_mutation_free = true;
765
        }
766
767
        $info->ignore_nullable_return = isset($parsed_docblock->tags['psalm-ignore-nullable-return']);
768
        $info->ignore_falsable_return = isset($parsed_docblock->tags['psalm-ignore-falsable-return']);
769
770
        return $info;
771
    }
772
773
    /**
774
     * @param array<int, string> $return_specials
775
     * @return void
776
     */
777
    private static function extractReturnType(
778
        PhpParser\Comment\Doc $comment,
779
        array $return_specials,
780
        FunctionDocblockComment $info
781
    ) {
782
        foreach ($return_specials as $offset => $return_block) {
783
            $return_lines = explode("\n", $return_block);
784
785
            if (!trim($return_lines[0])) {
786
                return;
787
            }
788
789
            $return_block = trim($return_block);
790
791
            if (!$return_block) {
792
                return;
793
            }
794
795
            $line_parts = self::splitDocLine($return_block);
796
797
            if ($line_parts[0][0] !== '{') {
798
                if ($line_parts[0][0] === '$' && !preg_match('/^\$this(\||$)/', $line_parts[0])) {
799
                    throw new IncorrectDocblockException('Misplaced variable');
800
                }
801
802
                $start = $offset + $comment->getFilePos();
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
803
                $end = $start + strlen($line_parts[0]);
804
805
                $line_parts[0] = self::sanitizeDocblockType($line_parts[0]);
806
807
                $info->return_type = array_shift($line_parts);
808
                $info->return_type_description = $line_parts ? implode(' ', $line_parts) : null;
809
810
                $info->return_type_line_number
811
                    = $comment->getLine() + substr_count($comment->getText(), "\n", 0, $offset);
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
812
                $info->return_type_start = $start;
0 ignored issues
show
Documentation Bug introduced by
It seems like $start of type double is incompatible with the declared type integer|null of property $return_type_start.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
813
                $info->return_type_end = $end;
0 ignored issues
show
Documentation Bug introduced by
It seems like $end of type double is incompatible with the declared type integer|null of property $return_type_end.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
814
            } else {
815
                throw new DocblockParseException('Badly-formatted @return type');
816
            }
817
818
            break;
819
        }
820
    }
821
822
    /**
823
     * @throws DocblockParseException if there was a problem parsing the docblock
824
     *
825
     * @return ClassLikeDocblockComment
826
     * @psalm-suppress MixedArrayAccess
827
     */
828
    public static function extractClassLikeDocblockInfo(
829
        \PhpParser\Node $node,
830
        PhpParser\Comment\Doc $comment,
831
        Aliases $aliases
832
    ) {
833
        $parsed_docblock = DocComment::parsePreservingLength($comment);
834
        $codebase = ProjectAnalyzer::getInstance()->getCodebase();
835
836
        $info = new ClassLikeDocblockComment();
837
838
        if (isset($parsed_docblock->combined_tags['template'])) {
839
            foreach ($parsed_docblock->combined_tags['template'] as $offset => $template_line) {
840
                $template_type = preg_split('/[\s]+/', preg_replace('@^[ \t]*\*@m', '', $template_line));
841
842
                $template_name = array_shift($template_type);
843
844
                if (!$template_name) {
845
                    throw new IncorrectDocblockException('Empty @template tag');
846
                }
847
848
                if (count($template_type) > 1
849
                    && in_array(strtolower($template_type[0]), ['as', 'super', 'of'], true)
850
                ) {
851
                    $template_modifier = strtolower(array_shift($template_type));
852
                    $info->templates[] = [
853
                        $template_name,
854
                        $template_modifier,
855
                        implode(' ', $template_type),
856
                        false,
857
                        $offset
858
                    ];
859
                } else {
860
                    $info->templates[] = [$template_name, null, null, false, $offset];
861
                }
862
            }
863
        }
864
865
        if (isset($parsed_docblock->combined_tags['template-covariant'])) {
866
            foreach ($parsed_docblock->combined_tags['template-covariant'] as $offset => $template_line) {
867
                $template_type = preg_split('/[\s]+/', preg_replace('@^[ \t]*\*@m', '', $template_line));
868
869
                $template_name = array_shift($template_type);
870
871
                if (!$template_name) {
872
                    throw new IncorrectDocblockException('Empty @template-covariant tag');
873
                }
874
875
                if (count($template_type) > 1
876
                    && in_array(strtolower($template_type[0]), ['as', 'super', 'of'], true)
877
                ) {
878
                    $template_modifier = strtolower(array_shift($template_type));
879
                    $info->templates[] = [
880
                        $template_name,
881
                        $template_modifier,
882
                        implode(' ', $template_type),
883
                        true,
884
                        $offset
885
                    ];
886
                } else {
887
                    $info->templates[] = [$template_name, null, null, true, $offset];
888
                }
889
            }
890
        }
891
892
        if (isset($parsed_docblock->combined_tags['extends'])) {
893
            foreach ($parsed_docblock->combined_tags['extends'] as $template_line) {
894
                $info->template_extends[] = trim(preg_replace('@^[ \t]*\*@m', '', $template_line));
895
            }
896
        }
897
898
        if (isset($parsed_docblock->combined_tags['implements'])) {
899
            foreach ($parsed_docblock->combined_tags['implements'] as $template_line) {
900
                $info->template_implements[] = trim(preg_replace('@^[ \t]*\*@m', '', $template_line));
901
            }
902
        }
903
904
        if (isset($parsed_docblock->tags['psalm-yield'])
905
        ) {
906
            $yield = reset($parsed_docblock->tags['psalm-yield']);
907
908
            $info->yield = trim(preg_replace('@^[ \t]*\*@m', '', $yield));
909
        }
910
911
        if (isset($parsed_docblock->tags['deprecated'])) {
912
            $info->deprecated = true;
913
        }
914
915
        if (isset($parsed_docblock->tags['internal'])) {
916
            $info->internal = true;
917
        }
918
919
        if (isset($parsed_docblock->tags['final'])) {
920
            $info->final = true;
921
        }
922
923
        if (isset($parsed_docblock->tags['psalm-internal'])) {
924
            $psalm_internal = reset($parsed_docblock->tags['psalm-internal']);
925
            if ($psalm_internal) {
926
                $info->psalm_internal = $psalm_internal;
927
            } else {
928
                throw new DocblockParseException('psalm-internal annotation used without specifying namespace');
929
            }
930
931
            if (! $info->internal) {
932
                throw new DocblockParseException('@psalm-internal annotation used without @internal');
933
            }
934
        }
935
936
        if (isset($parsed_docblock->tags['mixin'])) {
937
            $mixin = trim(reset($parsed_docblock->tags['mixin']));
938
            $doc_line_parts = self::splitDocLine($mixin);
939
            $mixin = $doc_line_parts[0];
940
941
            if ($mixin) {
942
                $info->mixin = $mixin;
943
            } else {
944
                throw new DocblockParseException('@mixin annotation used without specifying class');
945
            }
946
        }
947
948
        if (isset($parsed_docblock->tags['psalm-seal-properties'])) {
949
            $info->sealed_properties = true;
950
        }
951
952
        if (isset($parsed_docblock->tags['psalm-seal-methods'])) {
953
            $info->sealed_methods = true;
954
        }
955
956
        if (isset($parsed_docblock->tags['psalm-immutable'])
957
            || isset($parsed_docblock->tags['psalm-mutation-free'])
958
        ) {
959
            $info->mutation_free = true;
960
            $info->external_mutation_free = true;
961
            $info->taint_specialize = true;
962
        }
963
964
        if (isset($parsed_docblock->tags['psalm-external-mutation-free'])) {
965
            $info->external_mutation_free = true;
966
        }
967
968
        if (isset($parsed_docblock->tags['psalm-taint-specialize'])) {
969
            $info->taint_specialize = true;
970
        }
971
972
        if (isset($parsed_docblock->tags['psalm-override-property-visibility'])) {
973
            $info->override_property_visibility = true;
974
        }
975
976
        if (isset($parsed_docblock->tags['psalm-override-method-visibility'])) {
977
            $info->override_method_visibility = true;
978
        }
979
980
        if (isset($parsed_docblock->tags['psalm-suppress'])) {
981
            foreach ($parsed_docblock->tags['psalm-suppress'] as $offset => $suppress_entry) {
982
                $info->suppressed_issues[$offset + $comment->getFilePos()] = preg_split('/[\s]+/', $suppress_entry)[0];
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
983
            }
984
        }
985
986
        if (isset($parsed_docblock->tags['psalm-import-type'])) {
987
            foreach ($parsed_docblock->tags['psalm-import-type'] as $imported_type_entry) {
988
                /** @psalm-suppress InvalidPropertyAssignmentValue */
989
                $info->imported_types[] = preg_split('/[\s]+/', $imported_type_entry);
990
            }
991
        }
992
993
        if (isset($parsed_docblock->combined_tags['method'])) {
994
            foreach ($parsed_docblock->combined_tags['method'] as $offset => $method_entry) {
995
                $method_entry = preg_replace('/[ \t]+/', ' ', trim($method_entry));
996
997
                $docblock_lines = [];
998
999
                $is_static = false;
1000
1001
                $has_return = false;
1002
1003
                if (!preg_match('/^([a-z_A-Z][a-z_0-9A-Z]+) *\(/', $method_entry, $matches)) {
1004
                    $doc_line_parts = self::splitDocLine($method_entry);
1005
1006
                    if ($doc_line_parts[0] === 'static' && !strpos($doc_line_parts[1], '(')) {
1007
                        $is_static = true;
1008
                        array_shift($doc_line_parts);
1009
                    }
1010
1011
                    if (count($doc_line_parts) > 1) {
1012
                        $docblock_lines[] = '@return ' . array_shift($doc_line_parts);
1013
                        $has_return = true;
1014
1015
                        $method_entry = implode(' ', $doc_line_parts);
1016
                    }
1017
                }
1018
1019
                $method_entry = trim(preg_replace('/\/\/.*/', '', $method_entry));
1020
1021
                $method_entry = preg_replace(
1022
                    '/array\(([0-9a-zA-Z_\'\" ]+,)*([0-9a-zA-Z_\'\" ]+)\)/',
1023
                    '[]',
1024
                    $method_entry
1025
                );
1026
1027
                $end_of_method_regex = '/(?<!array\()\) ?(\: ?(\??[\\\\a-zA-Z0-9_]+))?/';
1028
1029
                if (preg_match($end_of_method_regex, $method_entry, $matches, PREG_OFFSET_CAPTURE)) {
1030
                    $method_entry = substr($method_entry, 0, (int) $matches[0][1] + strlen((string) $matches[0][0]));
1031
                }
1032
1033
                $method_entry = str_replace([', ', '( '], [',', '('], $method_entry);
1034
                $method_entry = preg_replace('/ (?!(\$|\.\.\.|&))/', '', trim($method_entry));
1035
1036
                // replace array bracket contents
1037
                $method_entry = preg_replace('/\[([0-9a-zA-Z_\'\" ]+,)*([0-9a-zA-Z_\'\" ]+)\]/', '[]', $method_entry);
1038
1039
                if (!$method_entry) {
1040
                    throw new DocblockParseException('No @method entry specified');
1041
                }
1042
1043
                try {
1044
                    $parse_tree_creator = new ParseTreeCreator(
1045
                        TypeTokenizer::getFullyQualifiedTokens(
1046
                            $method_entry,
1047
                            $aliases,
1048
                            null
1049
                        )
1050
                    );
1051
1052
                    $method_tree = $parse_tree_creator->create();
1053
                } catch (TypeParseTreeException $e) {
1054
                    throw new DocblockParseException($method_entry . ' is not a valid method');
1055
                }
1056
1057
                if (!$method_tree instanceof ParseTree\MethodWithReturnTypeTree
1058
                    && !$method_tree instanceof ParseTree\MethodTree) {
1059
                    throw new DocblockParseException($method_entry . ' is not a valid method');
1060
                }
1061
1062
                if ($method_tree instanceof ParseTree\MethodWithReturnTypeTree) {
1063
                    if (!$has_return) {
1064
                        $docblock_lines[] = '@return ' . TypeParser::getTypeFromTree(
1065
                            $method_tree->children[1],
1066
                            $codebase
1067
                        )->toNamespacedString($aliases->namespace, $aliases->uses, null, false);
1068
                    }
1069
1070
                    $method_tree = $method_tree->children[0];
1071
                }
1072
1073
                if (!$method_tree instanceof ParseTree\MethodTree) {
1074
                    throw new DocblockParseException($method_entry . ' is not a valid method');
1075
                }
1076
1077
                $args = [];
1078
1079
                foreach ($method_tree->children as $method_tree_child) {
1080
                    if (!$method_tree_child instanceof ParseTree\MethodParamTree) {
1081
                        throw new DocblockParseException($method_entry . ' is not a valid method');
1082
                    }
1083
1084
                    $args[] = ($method_tree_child->byref ? '&' : '')
1085
                        . ($method_tree_child->variadic ? '...' : '')
1086
                        . $method_tree_child->name
1087
                        . ($method_tree_child->default != '' ? ' = ' . $method_tree_child->default : '');
1088
1089
1090
                    if ($method_tree_child->children) {
1091
                        try {
1092
                            $param_type = TypeParser::getTypeFromTree($method_tree_child->children[0], $codebase);
1093
                        } catch (\Exception $e) {
1094
                            throw new DocblockParseException(
1095
                                'Badly-formatted @method string ' . $method_entry . ' - ' . $e
1096
                            );
1097
                        }
1098
                        $docblock_lines[] = '@param \\' . $param_type . ' '
1099
                            . ($method_tree_child->variadic ? '...' : '')
1100
                            . $method_tree_child->name;
1101
                    }
1102
                }
1103
1104
                $function_string = 'function ' . $method_tree->value . '(' . implode(', ', $args) . ')';
1105
1106
                if ($is_static) {
1107
                    $function_string = 'static ' . $function_string;
1108
                }
1109
1110
                $function_docblock = $docblock_lines ? "/**\n * " . implode("\n * ", $docblock_lines) . "\n*/\n" : "";
1111
1112
                $php_string = '<?php class A { ' . $function_docblock . ' public ' . $function_string . '{} }';
1113
1114
                try {
1115
                    $statements = \Psalm\Internal\Provider\StatementsProvider::parseStatements($php_string);
1116
                } catch (\Exception $e) {
1117
                    throw new DocblockParseException('Badly-formatted @method string ' . $method_entry);
1118
                }
1119
1120
                if (!$statements
1121
                    || !$statements[0] instanceof \PhpParser\Node\Stmt\Class_
1122
                    || !isset($statements[0]->stmts[0])
1123
                    || !$statements[0]->stmts[0] instanceof \PhpParser\Node\Stmt\ClassMethod
1124
                ) {
1125
                    throw new DocblockParseException('Badly-formatted @method string ' . $method_entry);
1126
                }
1127
1128
                /** @var \PhpParser\Comment\Doc */
1129
                $node_doc_comment = $node->getDocComment();
1130
1131
                $statements[0]->stmts[0]->setAttribute('startLine', $node_doc_comment->getLine());
1132
                $statements[0]->stmts[0]->setAttribute('startFilePos', $node_doc_comment->getFilePos());
1133
                $statements[0]->stmts[0]->setAttribute('endFilePos', $node->getAttribute('startFilePos'));
1134
1135
                if ($doc_comment = $statements[0]->stmts[0]->getDocComment()) {
1136
                    $statements[0]->stmts[0]->setDocComment(
1137
                        new \PhpParser\Comment\Doc(
1138
                            $doc_comment->getText(),
1139
                            $comment->getLine() + substr_count($comment->getText(), "\n", 0, $offset),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1140
                            $node_doc_comment->getFilePos()
1141
                        )
1142
                    );
1143
                }
1144
1145
                $info->methods[] = $statements[0]->stmts[0];
1146
            }
1147
        }
1148
1149
        self::addMagicPropertyToInfo($comment, $info, $parsed_docblock->tags, 'property');
1150
        self::addMagicPropertyToInfo($comment, $info, $parsed_docblock->tags, 'psalm-property');
1151
        self::addMagicPropertyToInfo($comment, $info, $parsed_docblock->tags, 'property-read');
1152
        self::addMagicPropertyToInfo($comment, $info, $parsed_docblock->tags, 'psalm-property-read');
1153
        self::addMagicPropertyToInfo($comment, $info, $parsed_docblock->tags, 'property-write');
1154
        self::addMagicPropertyToInfo($comment, $info, $parsed_docblock->tags, 'psalm-property-write');
1155
1156
        return $info;
1157
    }
1158
1159
    /**
1160
     * @param ClassLikeDocblockComment $info
1161
     * @param array<string, array<int, string>> $specials
1162
     * @param 'property'|'psalm-property'|'property-read'|
1163
     *     'psalm-property-read'|'property-write'|'psalm-property-write' $property_tag
1164
     *
1165
     * @throws DocblockParseException
1166
     *
1167
     * @return void
1168
     */
1169
    protected static function addMagicPropertyToInfo(
1170
        PhpParser\Comment\Doc $comment,
1171
        ClassLikeDocblockComment $info,
1172
        array $specials,
1173
        string $property_tag
1174
    ) : void {
1175
        $magic_property_comments = isset($specials[$property_tag]) ? $specials[$property_tag] : [];
1176
1177
        foreach ($magic_property_comments as $offset => $property) {
1178
            $line_parts = self::splitDocLine($property);
1179
1180
            if (count($line_parts) === 1 && isset($line_parts[0][0]) && $line_parts[0][0] === '$') {
1181
                continue;
1182
            }
1183
1184
            if (count($line_parts) > 1) {
1185
                if (preg_match('/^&?\$[A-Za-z0-9_]+,?$/', $line_parts[1])
1186
                    && $line_parts[0][0] !== '{'
1187
                ) {
1188
                    $line_parts[1] = str_replace('&', '', $line_parts[1]);
1189
1190
                    $line_parts[1] = preg_replace('/,$/', '', $line_parts[1]);
1191
1192
                    $start = $offset + $comment->getFilePos();
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getFilePos() has been deprecated with message: Use getStartFilePos() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1193
                    $end = $start + strlen($line_parts[0]);
1194
1195
                    $line_parts[0] = str_replace("\n", '', preg_replace('@^[ \t]*\*@m', '', $line_parts[0]));
1196
1197
                    if ($line_parts[0] === ''
1198
                        || ($line_parts[0][0] === '$'
1199
                            && !preg_match('/^\$this(\||$)/', $line_parts[0]))
1200
                    ) {
1201
                        throw new IncorrectDocblockException('Misplaced variable');
1202
                    }
1203
1204
                    $name = trim($line_parts[1]);
1205
1206
                    if (!preg_match('/^\$([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)$/', $name)) {
1207
                        throw new DocblockParseException('Badly-formatted @property name');
1208
                    }
1209
1210
                    $info->properties[] = [
1211
                        'name' => $name,
1212
                        'type' => $line_parts[0],
1213
                        'line_number' => $comment->getLine() + substr_count($comment->getText(), "\n", 0, $offset),
0 ignored issues
show
Deprecated Code introduced by
The method PhpParser\Comment::getLine() has been deprecated with message: Use getStartLine() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1214
                        'tag' => $property_tag,
1215
                        'start' => $start,
1216
                        'end' => $end,
1217
                    ];
1218
                }
1219
            } else {
1220
                throw new DocblockParseException('Badly-formatted @property');
1221
            }
1222
        }
1223
    }
1224
1225
    /**
1226
     * @param  string $return_block
1227
     *
1228
     * @throws DocblockParseException if an invalid string is found
1229
     *
1230
     * @return array<string>
1231
     */
1232
    public static function splitDocLine($return_block)
1233
    {
1234
        $brackets = '';
1235
1236
        $type = '';
1237
1238
        $expects_callable_return = false;
1239
1240
        $return_block = str_replace("\t", ' ', $return_block);
1241
1242
        $quote_char = null;
1243
        $escaped = false;
1244
1245
        for ($i = 0, $l = strlen($return_block); $i < $l; ++$i) {
1246
            $char = $return_block[$i];
1247
            $next_char = $i < $l - 1 ? $return_block[$i + 1] : null;
1248
            $last_char = $i > 0 ? $return_block[$i - 1] : null;
1249
1250
            if ($quote_char) {
1251
                if ($char === $quote_char && $i > 1 && !$escaped) {
1252
                    $quote_char = null;
1253
1254
                    $type .= $char;
1255
1256
                    continue;
1257
                }
1258
1259
                if ($char === '\\' && !$escaped && ($next_char === $quote_char || $next_char === '\\')) {
1260
                    $escaped = true;
1261
1262
                    $type .= $char;
1263
1264
                    continue;
1265
                }
1266
1267
                $escaped = false;
1268
1269
                $type .= $char;
1270
1271
                continue;
1272
            }
1273
1274
            if ($char === '"' || $char === '\'') {
1275
                $quote_char = $char;
1276
1277
                $type .= $char;
1278
1279
                continue;
1280
            }
1281
1282
            if ($char === ':' && $last_char === ')') {
1283
                $expects_callable_return = true;
1284
1285
                $type .= $char;
1286
1287
                continue;
1288
            }
1289
1290
            if ($char === '[' || $char === '{' || $char === '(' || $char === '<') {
1291
                $brackets .= $char;
1292
            } elseif ($char === ']' || $char === '}' || $char === ')' || $char === '>') {
1293
                $last_bracket = substr($brackets, -1);
1294
                $brackets = substr($brackets, 0, -1);
1295
1296
                if (($char === ']' && $last_bracket !== '[')
1297
                    || ($char === '}' && $last_bracket !== '{')
1298
                    || ($char === ')' && $last_bracket !== '(')
1299
                    || ($char === '>' && $last_bracket !== '<')
1300
                ) {
1301
                    throw new DocblockParseException('Invalid string ' . $return_block);
1302
                }
1303
            } elseif ($char === ' ') {
1304
                if ($brackets) {
1305
                    $expects_callable_return = false;
1306
                    $type .= ' ';
1307
                    continue;
1308
                }
1309
1310
                if ($next_char === '|' || $next_char === '&') {
1311
                    $nexter_char = $i < $l - 2 ? $return_block[$i + 2] : null;
1312
1313
                    if ($nexter_char === ' ') {
1314
                        ++$i;
1315
                        $type .= $next_char . ' ';
1316
                        continue;
1317
                    }
1318
                }
1319
1320
                if ($last_char === '|' || $last_char === '&') {
1321
                    $type .= ' ';
1322
                    continue;
1323
                }
1324
1325
                if ($next_char === ':') {
1326
                    ++$i;
1327
                    $type .= ' :';
1328
                    $expects_callable_return = true;
1329
                    continue;
1330
                }
1331
1332
                if ($expects_callable_return) {
1333
                    $type .= ' ';
1334
                    $expects_callable_return = false;
1335
                    continue;
1336
                }
1337
1338
                $remaining = trim(preg_replace('@^[ \t]*\* *@m', ' ', substr($return_block, $i + 1)));
1339
1340
                if ($remaining) {
1341
                    /** @var array<string> */
1342
                    return array_merge([rtrim($type)], preg_split('/[ \s]+/', $remaining));
1343
                }
1344
1345
                return [$type];
1346
            }
1347
1348
            $expects_callable_return = false;
1349
1350
            $type .= $char;
1351
        }
1352
1353
        return [$type];
1354
    }
1355
1356
    /**
1357
     * @param ParsedDocblock $parsed_docblock
1358
     *
1359
     * @return void
1360
     *
1361
     * @throws DocblockParseException if a duplicate is found
1362
     */
1363
    private static function checkDuplicatedTags(ParsedDocblock $parsed_docblock)
1364
    {
1365
        if (count($parsed_docblock->tags['return'] ?? []) > 1
1366
            || count($parsed_docblock->tags['psalm-return'] ?? []) > 1
1367
            || count($parsed_docblock->tags['phpstan-return'] ?? []) > 1
1368
        ) {
1369
            throw new DocblockParseException('Found duplicated @return or prefixed @return tag');
1370
        }
1371
1372
        self::checkDuplicatedParams($parsed_docblock->tags['param'] ?? []);
1373
        self::checkDuplicatedParams($parsed_docblock->tags['psalm-param'] ?? []);
1374
        self::checkDuplicatedParams($parsed_docblock->tags['phpstan-param'] ?? []);
1375
    }
1376
1377
    /**
1378
     * @param array<int, string> $param
1379
     *
1380
     * @return void
1381
     *
1382
     * @throws DocblockParseException  if a duplicate is found
1383
     */
1384
    private static function checkDuplicatedParams(array $param)
1385
    {
1386
        $list_names = self::extractAllParamNames($param);
1387
1388
        if (count($list_names) !== count(array_unique($list_names))) {
1389
            throw new DocblockParseException('Found duplicated @param or prefixed @param tag');
1390
        }
1391
    }
1392
1393
    /**
1394
     * @param array<int, string> $lines
1395
     *
1396
     * @return list<string>
0 ignored issues
show
Documentation introduced by
The doc-type list<string> could not be parsed: Expected "|" or "end of type", but got "<" at position 4. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
1397
     */
1398
    private static function extractAllParamNames(array $lines)
1399
    {
1400
        $names = [];
1401
1402
        foreach ($lines as $line) {
1403
            $split_by_dollar = explode('$', $line, 2);
1404
            if (count($split_by_dollar) > 1) {
1405
                $split_by_space = explode(' ', $split_by_dollar[1], 2);
1406
                $names[] = $split_by_space[0];
1407
            }
1408
        }
1409
1410
        return $names;
1411
    }
1412
}
1413