• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

voku / Simple-PHP-Code-Parser / 29085974904

10 Jul 2026 10:10AM UTC coverage: 85.103% (+0.3%) from 84.815%
29085974904

push

github

moellekenl
[+]: Add support for source-range information and enhance PHP model elements

- Introduced `endLine`, `startFilePos`, and `endFilePos` properties to model elements for precise source navigation.
- Added `PHPFileInfo` class for lightweight file summaries and improved AST access methods.
- Enhanced trait handling in class-like models to include trait uses and adaptations.
- Updated method and parameter models to include `is_returned_by_ref` and `is_promoted` properties.
- Improved PHPDoc context handling for better type resolution in models.

269 of 316 new or added lines in 16 files covered. (85.13%)

1 existing line in 1 file now uncovered.

1988 of 2336 relevant lines covered (85.1%)

175.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.98
/src/voku/SimplePhpParser/Model/PHPProperty.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace voku\SimplePhpParser\Model;
6

7
use PhpParser\Comment\Doc;
8
use PhpParser\Node\Param;
9
use PhpParser\Node\Stmt\Property;
10
use ReflectionProperty;
11
use voku\SimplePhpParser\Parsers\Helper\DocFactoryProvider;
12
use voku\SimplePhpParser\Parsers\Helper\Utils;
13

14
class PHPProperty extends BasePHPElement
15
{
16
    /**
17
     * @var mixed|null
18
     */
19
    public $defaultValue;
20

21
    public ?string $phpDocRaw = null;
22

23
    public ?string $type = null;
24

25
    public ?string $typeFromDefaultValue = null;
26

27
    public ?string $typeFromPhpDoc = null;
28

29
    /**
30
     * Import-aware equivalent of $typeFromPhpDoc for AST-backed models.
31
     */
32
    public ?string $typeFromPhpDocResolved = null;
33

34
    public ?string $typeFromPhpDocSimple = null;
35

36
    public ?string $typeFromPhpDocExtended = null;
37

38
    public ?string $typeFromPhpDocMaybeWithComment = null;
39

40
    /**
41
     * @phpstan-var ''|'private'|'protected'|'public'
42
     */
43
    public string $access = '';
44

45
    public ?bool $is_static = null;
46

47
    public ?bool $is_readonly = null;
48

49
    public ?bool $is_inheritdoc = null;
50

51
    /**
52
     * PHP 8.4+ asymmetric visibility: the set-visibility when different from
53
     * the main (get) visibility. One of 'public', 'protected', 'private', or ''.
54
     *
55
     * @phpstan-var ''|'private'|'protected'|'public'
56
     */
57
    public string $access_set = '';
58

59
    public ?bool $is_abstract = null;
60

61
    public ?bool $is_final = null;
62

63
    /**
64
     * PHP 8.4+ property hooks defined on this property.
65
     * Keyed by hook name ('get', 'set').
66
     *
67
     * @var array<string, array{name: string, is_final: bool, params: list<string>}>
68
     */
69
    public array $hooks = [];
70

71
    /**
72
     * PHP 8.0+ attributes on this property.
73
     *
74
     * @var PHPAttribute[]
75
     */
76
    public array $attributes = [];
77

78
    /**
79
     * @param Property                                        $node
80
     * @param string|null                                     $classStr
81
     * @param \PhpParser\Node\PropertyItem|\PhpParser\Node\Stmt\PropertyProperty|null $propertyItem
82
     *
83
     * @phpstan-param class-string|null $classStr
84
     *
85
     * @return $this
86
     */
87
    public function readObjectFromPhpNode($node, $classStr = null, ?object $propertyItem = null): self
88
    {
89
        $propertyItem ??= $node->props[0];
314✔
90

91
        $this->name = $this->getConstantFQN($node, $propertyItem->name->name);
314✔
92

93
        $this->is_static = $node->isStatic();
314✔
94

95
        // Keep the guard for cross-version php-parser compatibility when readonly
96
        // helpers are restored or backported differently in downstream installs.
97
        if (\method_exists($node, 'isReadonly')) {
314✔
98
            $this->is_readonly = $node->isReadonly();
314✔
99
        }
100

101
        // PHP 8.4+ abstract / final properties
102
        if (\method_exists($node, 'isAbstract')) {
314✔
103
            $this->is_abstract = $node->isAbstract();
162✔
104
        }
105
        if (\method_exists($node, 'isFinal')) {
314✔
106
            $this->is_final = $node->isFinal();
162✔
107
        }
108

109
        // PHP 8.4+ asymmetric visibility
110
        $this->access_set = self::getAsymmetricSetVisibility($node);
314✔
111

112
        // PHP 8.4+ property hooks
113
        if (!empty($node->hooks)) {
314✔
114
            $this->hooks = $this->extractHooksFromPhpParserNodes($node->hooks);
25✔
115
        }
116

117
        // Extract PHP 8.0+ attributes (only if not already populated by reflection)
118
        if (empty($this->attributes) && !empty($node->attrGroups)) {
314✔
119
            $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups);
10✔
120
        }
121

122
        $this->prepareNode($node);
314✔
123

124
        $docComment = $node->getDocComment();
314✔
125
        if ($docComment) {
314✔
126
            $docCommentText = $docComment->getText();
178✔
127

128
            if (\stripos($docCommentText, '@inheritdoc') !== false) {
178✔
129
                $this->is_inheritdoc = true;
×
130
            }
131

132
            $this->readPhpDoc($docComment, self::getPhpDocContext($node));
178✔
133
        }
134

135
        if ($node->type !== null) {
314✔
136
            if (!$this->type) {
194✔
137
                $typeStr = Utils::typeNodeToString($node->type);
72✔
138
                if ($typeStr !== null) {
72✔
139
                    $this->type = $typeStr;
72✔
140
                }
141
            }
142

143
            if ($node->type instanceof \PhpParser\Node\NullableType) {
194✔
144
                $this->type = self::normalizeNullableTypeString($this->type);
20✔
145
            }
146
        }
147

148
        if ($propertyItem->default !== null) {
314✔
149
            $defaultValue = Utils::getPhpParserValueFromNode($propertyItem->default, $classStr);
228✔
150
            if ($defaultValue !== Utils::GET_PHP_PARSER_VALUE_FROM_NODE_HELPER) {
228✔
151
                $this->defaultValue = $defaultValue;
228✔
152

153
                $this->typeFromDefaultValue = Utils::normalizePhpType(\gettype($this->defaultValue));
228✔
154
            }
155
        }
156

157
        if ($node->isPrivate()) {
314✔
158
            $this->access = 'private';
35✔
159
        } elseif ($node->isProtected()) {
314✔
160
            $this->access = 'protected';
30✔
161
        } else {
162
            $this->access = 'public';
314✔
163
        }
164

165
        return $this;
314✔
166
    }
167

168
    /**
169
     * @param Param        $parameter
170
     * @param string|null  $classStr
171
     *
172
     * @phpstan-param class-string|null $classStr
173
     *
174
     * @return $this
175
     */
176
    public function readObjectFromPromotedParam(Param $parameter, ?string $classStr = null): self
177
    {
178
        $parameterVar = $parameter->var;
135✔
179
        if (
180
            !($parameterVar instanceof \PhpParser\Node\Expr\Variable)
135✔
181
            || !\is_string($parameterVar->name)
135✔
182
        ) {
183
            return $this;
×
184
        }
185

186
        $this->prepareNode($parameter);
135✔
187

188
        $this->name = $parameterVar->name;
135✔
189
        $this->is_static = false;
135✔
190

191
        $this->access = self::getVisibilityFromModifierFlags($parameter->flags);
135✔
192
        if ($this->access === '') {
135✔
193
            $this->access = 'public';
×
194
        }
195

196
        $this->is_readonly = self::hasReadonlyModifier($parameter->flags);
135✔
197
        $this->is_final = self::hasFinalModifier($parameter->flags);
135✔
198
        $this->access_set = self::getAsymmetricSetVisibility($parameter);
135✔
199

200
        if (!empty($parameter->hooks)) {
135✔
201
            $this->hooks = $this->extractHooksFromPhpParserNodes($parameter->hooks);
20✔
202
        }
203

204
        if ($parameter->type !== null) {
135✔
205
            $typeStr = Utils::typeNodeToString($parameter->type);
135✔
206
            if ($typeStr !== null) {
135✔
207
                $this->type = $typeStr;
135✔
208
            }
209

210
            if ($parameter->type instanceof \PhpParser\Node\NullableType) {
135✔
211
                $this->type = self::normalizeNullableTypeString($this->type);
45✔
212
            }
213
        }
214

215
        if ($parameter->default !== null) {
135✔
216
            $defaultValue = Utils::getPhpParserValueFromNode($parameter->default, $classStr, $this->parserContainer);
115✔
217
            if ($defaultValue !== Utils::GET_PHP_PARSER_VALUE_FROM_NODE_HELPER) {
115✔
218
                $this->defaultValue = $defaultValue;
115✔
219
                $this->typeFromDefaultValue = Utils::normalizePhpType(\gettype($this->defaultValue));
115✔
220
            }
221
        }
222

223
        if (!empty($parameter->attrGroups)) {
135✔
224
            $this->attributes = Utils::extractAttributesFromAstNode($parameter->attrGroups);
45✔
225
        }
226

227
        return $this;
135✔
228
    }
229
    /**
230
     * @param ReflectionProperty $property
231
     *
232
     * @return $this
233
     */
234
    public function readObjectFromReflection($property): self
235
    {
236
        $this->name = $property->getName();
265✔
237

238
        $file = $property->getDeclaringClass()->getFileName();
265✔
239
        if ($file) {
265✔
240
            $this->file = $file;
265✔
241
        }
242

243
        $this->is_static = $property->isStatic();
265✔
244

245
        // Extract PHP 8.0+ attributes
246
        $this->attributes = Utils::extractAttributesFromReflection($property);
265✔
247

248
        if ($this->is_static) {
265✔
249
            try {
250
                if (\class_exists($property->getDeclaringClass()->getName(), true)) {
30✔
251
                    $this->defaultValue = $property->getValue();
30✔
252
                }
253
            } catch (\Exception $e) {
×
254
                // nothing
255
            }
256

257
            if ($this->defaultValue !== null) {
30✔
258
                $this->typeFromDefaultValue = Utils::normalizePhpType(\gettype($this->defaultValue));
30✔
259
            }
260
        }
261

262
        if (method_exists($property, 'isReadOnly')) {
265✔
263
            $this->is_readonly = $property->isReadOnly();
265✔
264
        }
265

266
        // PHP 8.4+ abstract / final properties (via reflection)
267
        if (\method_exists($property, 'isAbstract')) {
265✔
268
            $this->is_abstract = $property->isAbstract();
113✔
269
        }
270
        if (\method_exists($property, 'isFinal')) {
265✔
271
            $this->is_final = $property->isFinal();
113✔
272
        }
273

274
        // PHP 8.4+ asymmetric visibility (via reflection)
275
        $this->access_set = self::getAsymmetricSetVisibility($property);
265✔
276

277
        // PHP 8.4+ property hooks (via reflection)
278
        if (\method_exists($property, 'getHooks')) {
265✔
279
            $this->hooks = $this->extractHooksFromReflection($property->getHooks());
113✔
280
        }
281

282
        $docComment = $property->getDocComment();
265✔
283
        if ($docComment) {
265✔
284
            if (\stripos($docComment, '@inheritdoc') !== false) {
168✔
285
                $this->is_inheritdoc = true;
×
286
            }
287

288
            $this->readPhpDoc($docComment);
168✔
289
        }
290

291
        if (\method_exists($property, 'getType')) {
265✔
292
            $type = $property->getType();
265✔
293
            if ($type !== null) {
265✔
294
                if (\method_exists($type, 'getName')) {
155✔
295
                    $this->type = Utils::normalizePhpType($type->getName(), true);
123✔
296
                } else {
297
                    $this->type = Utils::normalizePhpType($type . '', true);
56✔
298
                }
299
                try {
300
                    if ($this->type && \class_exists($this->type, true)) {
155✔
301
                        $this->type = '\\' . \ltrim($this->type, '\\');
155✔
302
                    }
303
                } catch (\Exception $e) {
×
304
                    // nothing
305
                }
306

307
                if ($type->allowsNull()) {
155✔
308
                    $this->type = self::normalizeNullableTypeString($this->type);
59✔
309
                }
310
            }
311
        }
312

313
        if ($property->isProtected()) {
265✔
314
            $access = 'protected';
70✔
315
        } elseif ($property->isPrivate()) {
255✔
316
            $access = 'private';
34✔
317
        } else {
318
            $access = 'public';
255✔
319
        }
320
        $this->access = $access;
265✔
321

322
        return $this;
265✔
323
    }
324

325
    /**
326
     * @param array<int, object> $hooks
327
     *
328
     * @return array<string, array{name: string, is_final: bool, params: list<string>}>
329
     */
330
    private function extractHooksFromPhpParserNodes(array $hooks): array
331
    {
332
        $parsedHooks = [];
30✔
333

334
        foreach ($hooks as $hook) {
30✔
335
            $hookNameNode = $hook->name ?? null;
30✔
336
            if (!$hookNameNode instanceof \PhpParser\Node\Identifier) {
30✔
337
                continue;
×
338
            }
339

340
            $hookName = $hookNameNode->toString();
30✔
341
            $hookParams = [];
30✔
342

343
            foreach ($hook->params ?? [] as $param) {
30✔
344
                if (!isset($param->var) || $param->var instanceof \PhpParser\Node\Expr\Error) {
30✔
345
                    continue;
×
346
                }
347

348
                $paramName = \is_string($param->var->name ?? null) ? $param->var->name : '';
30✔
349
                if ($paramName === '') {
30✔
350
                    continue;
×
351
                }
352

353
                $paramStr = '';
30✔
354
                if (($param->type ?? null) !== null) {
30✔
355
                    $typeStr = Utils::typeNodeToString($param->type);
30✔
356
                    if ($typeStr !== null) {
30✔
357
                        $paramStr .= $typeStr . ' ';
30✔
358
                    }
359
                }
360

361
                $paramStr .= '$' . $paramName;
30✔
362
                $hookParams[] = $paramStr;
30✔
363
            }
364

365
            $parsedHooks[$hookName] = [
30✔
366
                'name'     => $hookName,
30✔
367
                'is_final' => \method_exists($hook, 'isFinal') ? $hook->isFinal() : false,
30✔
368
                'params'   => $hookParams,
30✔
369
            ];
30✔
370
        }
371

372
        return $parsedHooks;
30✔
373
    }
374

375
    /**
376
     * @param array<int, object> $hooks
377
     *
378
     * @return array<string, array{name: string, is_final: bool, params: list<string>}>
379
     */
380
    private function extractHooksFromReflection(array $hooks): array
381
    {
382
        $parsedHooks = [];
113✔
383

384
        foreach ($hooks as $hook) {
113✔
385
            if (!\method_exists($hook, 'getName') || !\method_exists($hook, 'getParameters')) {
11✔
386
                continue;
×
387
            }
388

389
            $hookName = $hook->getName();
11✔
390
            $hookParams = [];
11✔
391

392
            foreach ($hook->getParameters() as $param) {
11✔
393
                $paramStr = '';
11✔
394
                $paramType = $param->getType();
11✔
395
                if ($paramType !== null) {
11✔
396
                    $paramStr .= $paramType . ' ';
11✔
397
                }
398
                $paramStr .= '$' . $param->getName();
11✔
399
                $hookParams[] = $paramStr;
11✔
400
            }
401

402
            $parsedHooks[$hookName] = [
11✔
403
                'name'     => $hookName,
11✔
404
                'is_final' => \method_exists($hook, 'isFinal') ? $hook->isFinal() : false,
11✔
405
                'params'   => $hookParams,
11✔
406
            ];
11✔
407
        }
408

409
        return $parsedHooks;
113✔
410
    }
411

412
    private static function normalizeNullableTypeString(?string $type): string
413
    {
414
        if ($type === null || $type === '') {
63✔
415
            return 'null|mixed';
×
416
        }
417

418
        if ($type === 'null') {
63✔
419
            return 'null|mixed';
×
420
        }
421

422
        $typeParts = \explode('|', $type);
63✔
423
        if (\in_array('null', $typeParts, true)) {
63✔
424
            return $type;
63✔
425
        }
426

427
        \array_unshift($typeParts, 'null');
51✔
428

429
        return \implode('|', $typeParts);
51✔
430
    }
431

432
    /**
433
     * @return string|null
434
     */
435
    public function getType(): ?string
436
    {
437
        if ($this->typeFromPhpDocExtended) {
10✔
438
            return $this->typeFromPhpDocExtended;
10✔
439
        }
440

441
        if ($this->type) {
10✔
442
            return $this->type;
10✔
443
        }
444

445
        if ($this->typeFromPhpDocSimple) {
×
446
            return $this->typeFromPhpDocSimple;
×
447
        }
448

449
        return null;
×
450
    }
451

452
    /**
453
     * @param Doc|string $doc
454
     */
455
    private function readPhpDoc($doc, ?\phpDocumentor\Reflection\Types\Context $context = null): void
456
    {
457
        if ($doc instanceof Doc) {
198✔
458
            $docComment = $doc->getText();
178✔
459
        } else {
460
            $docComment = $doc;
168✔
461
        }
462
        if ($docComment === '') {
198✔
463
            return;
×
464
        }
465

466
        try {
467
            $phpDoc = DocFactoryProvider::getDocFactory()->create($docComment);
198✔
468

469
            $parsedParamTags = $phpDoc->getTagsByName('var');
198✔
470

471
            if (!empty($parsedParamTags)) {
198✔
472
                foreach ($parsedParamTags as $parsedParamTag) {
198✔
473
                    $parsedParamTagParam = (string) $parsedParamTag;
198✔
474

475
                    if ($parsedParamTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Var_) {
198✔
476
                        $type = $parsedParamTag->getType();
198✔
477
                        $this->typeFromPhpDoc = Utils::normalizePhpType($type . '');
198✔
478

479
                        $typeFromPhpDocMaybeWithCommentTmp = \trim($parsedParamTagParam);
198✔
480
                        if (
481
                            $typeFromPhpDocMaybeWithCommentTmp
198✔
482
                            &&
483
                            \strpos($typeFromPhpDocMaybeWithCommentTmp, '$') !== 0
198✔
484
                        ) {
485
                            $this->typeFromPhpDocMaybeWithComment = $typeFromPhpDocMaybeWithCommentTmp;
198✔
486
                        }
487

488
                        $typeTmp = Utils::parseDocTypeObject($type);
198✔
489
                        if ($typeTmp !== '') {
198✔
490
                            $this->typeFromPhpDocSimple = $typeTmp;
198✔
491
                        }
492
                    }
493

494
                    $this->phpDocRaw = $parsedParamTagParam;
198✔
495
                    $this->typeFromPhpDocExtended = Utils::modernPhpdoc($parsedParamTagParam);
198✔
496
                }
497
            }
498

499
            $parsedParamTags = $phpDoc->getTagsByName('psalm-var')
198✔
500
                               + $phpDoc->getTagsByName('phpstan-var');
198✔
501

502
            if (!empty($parsedParamTags)) {
198✔
503
                foreach ($parsedParamTags as $parsedParamTag) {
198✔
504
                    if (!$parsedParamTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Generic) {
100✔
505
                        continue;
×
506
                    }
507

508
                    $spitedData = Utils::splitTypeAndVariable($parsedParamTag);
100✔
509
                    $parsedParamTagStr = $spitedData['parsedParamTagStr'];
100✔
510

511
                    $this->typeFromPhpDocExtended = Utils::modernPhpdoc($parsedParamTagStr);
100✔
512
                }
513
            }
514
        } catch (\Exception $e) {
×
515
            $tmpErrorMessage = $this->name . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
516
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
517
        }
518

519
        try {
520
            $resolvedPhpDoc = DocFactoryProvider::getDocFactory()->create($docComment, $context);
198✔
521
            $resolvedVarTags = $resolvedPhpDoc->getTagsByName('var');
198✔
522
            if (isset($resolvedVarTags[0]) && $resolvedVarTags[0] instanceof \phpDocumentor\Reflection\DocBlock\Tags\Var_) {
198✔
523
                $this->typeFromPhpDocResolved = (string)$resolvedVarTags[0]->getType();
198✔
524
            }
NEW
525
        } catch (\Exception $e) {
×
NEW
526
            $tmpErrorMessage = $this->name . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
NEW
527
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
528
        }
529

530
        try {
531
            $this->readPhpDocByTokens($docComment);
198✔
532
        } catch (\Exception $e) {
×
533
            $tmpErrorMessage = $this->name . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
534
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
535
        }
536
    }
537

538
    /**
539
     * @throws \PHPStan\PhpDocParser\Parser\ParserException
540
     */
541
    private function readPhpDocByTokens(string $docComment): void
542
    {
543
        $tokens = Utils::modernPhpdocTokens($docComment);
198✔
544

545
        // Track standard (@var) and extended (@phpstan-var / @psalm-var) content separately
546
        // so that the more specific phpstan/psalm annotation always wins regardless of tag order.
547
        $varContent = null;
198✔
548
        $extendedVarContent = null;
198✔
549
        $currentTarget = null; // 'standard' | 'extended'
198✔
550

551
        foreach ($tokens->getTokens() as $token) {
198✔
552
            $content = $token[0];
198✔
553

554
            if ($content === '@var') {
198✔
555
                $currentTarget = 'standard';
198✔
556
                $varContent = '';
198✔
557
                continue;
198✔
558
            }
559

560
            if ($content === '@psalm-var' || $content === '@phpstan-var') {
198✔
561
                $currentTarget = 'extended';
100✔
562
                $extendedVarContent = '';
100✔
563
                continue;
100✔
564
            }
565

566
            if ($currentTarget === 'standard') {
198✔
567
                $varContent .= $content;
198✔
568
            } elseif ($currentTarget === 'extended') {
198✔
569
                $extendedVarContent .= $content;
100✔
570
            }
571
        }
572

573
        // Prefer @phpstan-var / @psalm-var over plain @var regardless of tag order.
574
        $bestContent = null;
198✔
575
        if ($extendedVarContent !== null && \trim($extendedVarContent) !== '') {
198✔
576
            $bestContent = \trim($extendedVarContent);
100✔
577
        } elseif ($varContent !== null && \trim($varContent) !== '') {
178✔
578
            $bestContent = \trim($varContent);
178✔
579
        }
580

581
        if ($bestContent) {
198✔
582
            if (!$this->phpDocRaw) {
198✔
583
                $this->phpDocRaw = $bestContent;
×
584
            }
585
                $this->typeFromPhpDocExtended = Utils::modernPhpdoc($bestContent);
198✔
586
        }
587
    }
588
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc