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

voku / Simple-PHP-Code-Parser / 24288682424

11 Apr 2026 06:21PM UTC coverage: 82.653% (-0.2%) from 82.886%
24288682424

Pull #83

github

web-flow
Merge 00ed1eaa9 into 90e1e60d3
Pull Request #83: Fix CI pipeline: phpunit.xml validation warning, php-parser v4 test skip, and comprehensive type-analysis regression coverage

268 of 295 new or added lines in 7 files covered. (90.85%)

30 existing lines in 3 files now uncovered.

1720 of 2081 relevant lines covered (82.65%)

28.67 hits per line

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

89.87
/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\Stmt\Property;
9
use PhpParser\Node\Param;
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
    public ?string $typeFromPhpDocSimple = null;
30

31
    public ?string $typeFromPhpDocExtended = null;
32

33
    public ?string $typeFromPhpDocMaybeWithComment = null;
34

35
    /**
36
     * @phpstan-var ''|'private'|'protected'|'public'
37
     */
38
    public string $access = '';
39

40
    public ?bool $is_static = null;
41

42
    public ?bool $is_readonly = null;
43

44
    public ?bool $is_inheritdoc = null;
45

46
    /**
47
     * PHP 8.4+ asymmetric visibility: the set-visibility when different from
48
     * the main (get) visibility. One of 'public', 'protected', 'private', or ''.
49
     *
50
     * @phpstan-var ''|'private'|'protected'|'public'
51
     */
52
    public string $access_set = '';
53

54
    public ?bool $is_abstract = null;
55

56
    public ?bool $is_final = null;
57

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

66
    /**
67
     * PHP 8.0+ attributes on this property.
68
     *
69
     * @var PHPAttribute[]
70
     */
71
    public array $attributes = [];
72

73
    /**
74
     * @param Property    $node
75
     * @param string|null $classStr
76
     *
77
     * @phpstan-param class-string|null $classStr
78
     *
79
     * @return $this
80
     */
81
    public function readObjectFromPhpNode($node, $classStr = null): self
82
    {
83
        $this->name = $this->getConstantFQN($node, $node->props[0]->name->name);
50✔
84

85
        $this->is_static = $node->isStatic();
50✔
86

87
        // Keep the guard for cross-version php-parser compatibility when readonly
88
        // helpers are restored or backported differently in downstream installs.
89
        if (\method_exists($node, 'isReadonly')) {
50✔
90
            $this->is_readonly = $node->isReadonly();
50✔
91
        }
92

93
        // PHP 8.4+ abstract / final properties
94
        if (\method_exists($node, 'isAbstract')) {
50✔
95
            $this->is_abstract = $node->isAbstract();
26✔
96
        }
97
        if (\method_exists($node, 'isFinal')) {
50✔
98
            $this->is_final = $node->isFinal();
26✔
99
        }
100

101
        // PHP 8.4+ asymmetric visibility
102
        $this->access_set = self::getAsymmetricSetVisibility($node);
50✔
103

104
        // PHP 8.4+ property hooks
105
        if (!empty($node->hooks)) {
50✔
106
            $this->hooks = $this->extractHooksFromPhpParserNodes($node->hooks);
4✔
107
        }
108

109
        // Extract PHP 8.0+ attributes (only if not already populated by reflection)
110
        if (empty($this->attributes) && !empty($node->attrGroups)) {
50✔
111
            $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups);
2✔
112
        }
113

114
        $this->prepareNode($node);
50✔
115

116
        $docComment = $node->getDocComment();
50✔
117
        if ($docComment) {
50✔
118
            $docCommentText = $docComment->getText();
30✔
119

120
            if (\stripos($docCommentText, '@inheritdoc') !== false) {
30✔
121
                $this->is_inheritdoc = true;
×
122
            }
123

124
            $this->readPhpDoc($docComment);
30✔
125
        }
126

127
        if ($node->type !== null) {
50✔
128
            if (!$this->type) {
28✔
129
                $typeStr = Utils::typeNodeToString($node->type);
2✔
130
                if ($typeStr !== null) {
2✔
131
                    $this->type = $typeStr;
2✔
132
                }
133
            }
134

135
            if ($node->type instanceof \PhpParser\Node\NullableType) {
28✔
136
                $this->type = self::normalizeNullableTypeString($this->type);
4✔
137
            }
138
        }
139

140
        if ($node->props[0]->default !== null) {
50✔
141
            $defaultValue = Utils::getPhpParserValueFromNode($node->props[0]->default, $classStr);
32✔
142
            if ($defaultValue !== Utils::GET_PHP_PARSER_VALUE_FROM_NODE_HELPER) {
32✔
143
                $this->defaultValue = $defaultValue;
32✔
144

145
                $this->typeFromDefaultValue = Utils::normalizePhpType(\gettype($this->defaultValue));
32✔
146
            }
147
        }
148

149
        if ($node->isPrivate()) {
50✔
150
            $this->access = 'private';
4✔
151
        } elseif ($node->isProtected()) {
50✔
152
            $this->access = 'protected';
4✔
153
        } else {
154
            $this->access = 'public';
50✔
155
        }
156

157
        return $this;
50✔
158
    }
159

160
    /**
161
     * @param Param        $parameter
162
     * @param string|null  $classStr
163
     *
164
     * @phpstan-param class-string|null $classStr
165
     *
166
     * @return $this
167
     */
168
    public function readObjectFromPromotedParam(Param $parameter, ?string $classStr = null): self
169
    {
170
        $parameterVar = $parameter->var;
23✔
171
        if (
172
            !($parameterVar instanceof \PhpParser\Node\Expr\Variable)
23✔
173
            || !\is_string($parameterVar->name)
23✔
174
        ) {
NEW
175
            return $this;
×
176
        }
177

178
        $this->prepareNode($parameter);
23✔
179

180
        $this->name = $parameterVar->name;
23✔
181
        $this->is_static = false;
23✔
182

183
        $this->access = self::getVisibilityFromModifierFlags($parameter->flags);
23✔
184
        if ($this->access === '') {
23✔
NEW
185
            $this->access = 'public';
×
186
        }
187

188
        $this->is_readonly = self::hasReadonlyModifier($parameter->flags);
23✔
189
        $this->is_final = self::hasFinalModifier($parameter->flags);
23✔
190
        $this->access_set = self::getAsymmetricSetVisibility($parameter);
23✔
191

192
        if (!empty($parameter->hooks)) {
23✔
193
            $this->hooks = $this->extractHooksFromPhpParserNodes($parameter->hooks);
3✔
194
        }
195

196
        if ($parameter->type !== null) {
23✔
197
            $typeStr = Utils::typeNodeToString($parameter->type);
23✔
198
            if ($typeStr !== null) {
23✔
199
                $this->type = $typeStr;
23✔
200
            }
201

202
            if ($parameter->type instanceof \PhpParser\Node\NullableType) {
23✔
203
                $this->type = self::normalizeNullableTypeString($this->type);
7✔
204
            }
205
        }
206

207
        if ($parameter->default !== null) {
23✔
208
            $defaultValue = Utils::getPhpParserValueFromNode($parameter->default, $classStr, $this->parserContainer);
19✔
209
            if ($defaultValue !== Utils::GET_PHP_PARSER_VALUE_FROM_NODE_HELPER) {
19✔
210
                $this->defaultValue = $defaultValue;
19✔
211
                $this->typeFromDefaultValue = Utils::normalizePhpType(\gettype($this->defaultValue));
19✔
212
            }
213
        }
214

215
        if (!empty($parameter->attrGroups)) {
23✔
216
            $this->attributes = Utils::extractAttributesFromAstNode($parameter->attrGroups);
7✔
217
        }
218

219
        return $this;
23✔
220
    }
221
    /**
222
     * @param ReflectionProperty $property
223
     *
224
     * @return $this
225
     */
226
    public function readObjectFromReflection($property): self
227
    {
228
        $this->name = $property->getName();
52✔
229

230
        $file = $property->getDeclaringClass()->getFileName();
52✔
231
        if ($file) {
52✔
232
            $this->file = $file;
52✔
233
        }
234

235
        $this->is_static = $property->isStatic();
52✔
236

237
        // Extract PHP 8.0+ attributes
238
        $this->attributes = Utils::extractAttributesFromReflection($property);
52✔
239

240
        if ($this->is_static) {
52✔
241
            try {
242
                if (\class_exists($property->getDeclaringClass()->getName(), true)) {
4✔
243
                    $this->defaultValue = $property->getValue();
4✔
244
                }
245
            } catch (\Exception $e) {
×
246
                // nothing
247
            }
248

249
            if ($this->defaultValue !== null) {
4✔
250
                $this->typeFromDefaultValue = Utils::normalizePhpType(\gettype($this->defaultValue));
4✔
251
            }
252
        }
253

254
        if (method_exists($property, 'isReadOnly')) {
52✔
255
            $this->is_readonly = $property->isReadOnly();
52✔
256
        }
257

258
        // PHP 8.4+ abstract / final properties (via reflection)
259
        if (\method_exists($property, 'isAbstract')) {
52✔
260
            $this->is_abstract = $property->isAbstract();
27✔
261
        }
262
        if (\method_exists($property, 'isFinal')) {
52✔
263
            $this->is_final = $property->isFinal();
27✔
264
        }
265

266
        // PHP 8.4+ asymmetric visibility (via reflection)
267
        $this->access_set = self::getAsymmetricSetVisibility($property);
52✔
268

269
        // PHP 8.4+ property hooks (via reflection)
270
        if (\method_exists($property, 'getHooks')) {
52✔
271
            $this->hooks = $this->extractHooksFromReflection($property->getHooks());
27✔
272
        }
273

274
        $docComment = $property->getDocComment();
52✔
275
        if ($docComment) {
52✔
276
            if (\stripos($docComment, '@inheritdoc') !== false) {
30✔
277
                $this->is_inheritdoc = true;
×
278
            }
279

280
            $this->readPhpDoc($docComment);
30✔
281
        }
282

283
        if (\method_exists($property, 'getType')) {
52✔
284
            $type = $property->getType();
52✔
285
            if ($type !== null) {
52✔
286
                if (\method_exists($type, 'getName')) {
30✔
287
                    $this->type = Utils::normalizePhpType($type->getName(), true);
22✔
288
                } else {
289
                    $this->type = Utils::normalizePhpType($type . '', true);
12✔
290
                }
291
                try {
292
                    if ($this->type && \class_exists($this->type, true)) {
30✔
293
                        $this->type = '\\' . \ltrim($this->type, '\\');
30✔
294
                    }
295
                } catch (\Exception $e) {
×
296
                    // nothing
297
                }
298

299
                if ($type->allowsNull()) {
30✔
300
                    $this->type = self::normalizeNullableTypeString($this->type);
10✔
301
                }
302
            }
303
        }
304

305
        if ($property->isProtected()) {
52✔
306
            $access = 'protected';
12✔
307
        } elseif ($property->isPrivate()) {
50✔
308
            $access = 'private';
6✔
309
        } else {
310
            $access = 'public';
50✔
311
        }
312
        $this->access = $access;
52✔
313

314
        return $this;
52✔
315
    }
316

317
    /**
318
     * @param array<int, object> $hooks
319
     *
320
     * @return array<string, array{name: string, is_final: bool, params: list<string>}>
321
     */
322
    private function extractHooksFromPhpParserNodes(array $hooks): array
323
    {
324
        $parsedHooks = [];
5✔
325

326
        foreach ($hooks as $hook) {
5✔
327
            $hookNameNode = $hook->name ?? null;
5✔
328
            if (!$hookNameNode instanceof \PhpParser\Node\Identifier) {
5✔
NEW
329
                continue;
×
330
            }
331

332
            $hookName = $hookNameNode->toString();
5✔
333
            $hookParams = [];
5✔
334

335
            foreach ($hook->params ?? [] as $param) {
5✔
336
                if (!isset($param->var) || $param->var instanceof \PhpParser\Node\Expr\Error) {
5✔
NEW
337
                    continue;
×
338
                }
339

340
                $paramName = \is_string($param->var->name ?? null) ? $param->var->name : '';
5✔
341
                if ($paramName === '') {
5✔
NEW
342
                    continue;
×
343
                }
344

345
                $paramStr = '';
5✔
346
                if (($param->type ?? null) !== null) {
5✔
347
                    $typeStr = Utils::typeNodeToString($param->type);
5✔
348
                    if ($typeStr !== null) {
5✔
349
                        $paramStr .= $typeStr . ' ';
5✔
350
                    }
351
                }
352

353
                $paramStr .= '$' . $paramName;
5✔
354
                $hookParams[] = $paramStr;
5✔
355
            }
356

357
            $parsedHooks[$hookName] = [
5✔
358
                'name'     => $hookName,
5✔
359
                'is_final' => \method_exists($hook, 'isFinal') ? $hook->isFinal() : false,
5✔
360
                'params'   => $hookParams,
5✔
361
            ];
5✔
362
        }
363

364
        return $parsedHooks;
5✔
365
    }
366

367
    /**
368
     * @param array<int, object> $hooks
369
     *
370
     * @return array<string, array{name: string, is_final: bool, params: list<string>}>
371
     */
372
    private function extractHooksFromReflection(array $hooks): array
373
    {
374
        $parsedHooks = [];
27✔
375

376
        foreach ($hooks as $hook) {
27✔
377
            if (!\method_exists($hook, 'getName') || !\method_exists($hook, 'getParameters')) {
4✔
NEW
378
                continue;
×
379
            }
380

381
            $hookName = $hook->getName();
4✔
382
            $hookParams = [];
4✔
383

384
            foreach ($hook->getParameters() as $param) {
4✔
385
                $paramStr = '';
4✔
386
                $paramType = $param->getType();
4✔
387
                if ($paramType !== null) {
4✔
388
                    $paramStr .= $paramType . ' ';
4✔
389
                }
390
                $paramStr .= '$' . $param->getName();
4✔
391
                $hookParams[] = $paramStr;
4✔
392
            }
393

394
            $parsedHooks[$hookName] = [
4✔
395
                'name'     => $hookName,
4✔
396
                'is_final' => \method_exists($hook, 'isFinal') ? $hook->isFinal() : false,
4✔
397
                'params'   => $hookParams,
4✔
398
            ];
4✔
399
        }
400

401
        return $parsedHooks;
27✔
402
    }
403

404
    private static function normalizeNullableTypeString(?string $type): string
405
    {
406
        if ($type === null || $type === '') {
11✔
NEW
407
            return 'null|mixed';
×
408
        }
409

410
        if ($type === 'null') {
11✔
NEW
411
            return 'null|mixed';
×
412
        }
413

414
        $typeParts = \explode('|', $type);
11✔
415
        if (\in_array('null', $typeParts, true)) {
11✔
416
            return $type;
11✔
417
        }
418

419
        \array_unshift($typeParts, 'null');
8✔
420

421
        return \implode('|', $typeParts);
8✔
422
    }
423

424
    /**
425
     * @return string|null
426
     */
427
    public function getType(): ?string
428
    {
429
        if ($this->typeFromPhpDocExtended) {
2✔
430
            return $this->typeFromPhpDocExtended;
2✔
431
        }
432

433
        if ($this->type) {
2✔
434
            return $this->type;
2✔
435
        }
436

437
        if ($this->typeFromPhpDocSimple) {
×
438
            return $this->typeFromPhpDocSimple;
×
439
        }
440

441
        return null;
×
442
    }
443

444
    /**
445
     * @param Doc|string $doc
446
     */
447
    private function readPhpDoc($doc): void
448
    {
449
        if ($doc instanceof Doc) {
34✔
450
            $docComment = $doc->getText();
30✔
451
        } else {
452
            $docComment = $doc;
30✔
453
        }
454
        if ($docComment === '') {
34✔
455
            return;
×
456
        }
457

458
        try {
459
            $phpDoc = DocFactoryProvider::getDocFactory()->create($docComment);
34✔
460

461
            $parsedParamTags = $phpDoc->getTagsByName('var');
34✔
462

463
            if (!empty($parsedParamTags)) {
34✔
464
                foreach ($parsedParamTags as $parsedParamTag) {
34✔
465
                    $parsedParamTagParam = (string) $parsedParamTag;
34✔
466

467
                    if ($parsedParamTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Var_) {
34✔
468
                        $type = $parsedParamTag->getType();
34✔
469

470
                        $this->typeFromPhpDoc = Utils::normalizePhpType($type . '');
34✔
471

472
                        $typeFromPhpDocMaybeWithCommentTmp = \trim($parsedParamTagParam);
34✔
473
                        if (
474
                            $typeFromPhpDocMaybeWithCommentTmp
34✔
475
                            &&
476
                            \strpos($typeFromPhpDocMaybeWithCommentTmp, '$') !== 0
34✔
477
                        ) {
478
                            $this->typeFromPhpDocMaybeWithComment = $typeFromPhpDocMaybeWithCommentTmp;
34✔
479
                        }
480

481
                        $typeTmp = Utils::parseDocTypeObject($type);
34✔
482
                        if ($typeTmp !== '') {
34✔
483
                            $this->typeFromPhpDocSimple = $typeTmp;
34✔
484
                        }
485
                    }
486

487
                    $this->phpDocRaw = $parsedParamTagParam;
34✔
488
                    $this->typeFromPhpDocExtended = Utils::modernPhpdoc($parsedParamTagParam);
34✔
489
                }
490
            }
491

492
            $parsedParamTags = $phpDoc->getTagsByName('psalm-var')
34✔
493
                               + $phpDoc->getTagsByName('phpstan-var');
34✔
494

495
            if (!empty($parsedParamTags)) {
34✔
496
                foreach ($parsedParamTags as $parsedParamTag) {
34✔
497
                    if (!$parsedParamTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Generic) {
18✔
498
                        continue;
×
499
                    }
500

501
                    $spitedData = Utils::splitTypeAndVariable($parsedParamTag);
18✔
502
                    $parsedParamTagStr = $spitedData['parsedParamTagStr'];
18✔
503

504
                    $this->typeFromPhpDocExtended = Utils::modernPhpdoc($parsedParamTagStr);
18✔
505
                }
506
            }
507
        } catch (\Exception $e) {
×
508
            $tmpErrorMessage = $this->name . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
509
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
510
        }
511

512
        try {
513
            $this->readPhpDocByTokens($docComment);
34✔
514
        } catch (\Exception $e) {
×
515
            $tmpErrorMessage = $this->name . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
516
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
517
        }
518
    }
519

520
    /**
521
     * @throws \PHPStan\PhpDocParser\Parser\ParserException
522
     */
523
    private function readPhpDocByTokens(string $docComment): void
524
    {
525
        $tokens = Utils::modernPhpdocTokens($docComment);
34✔
526

527
        // Track standard (@var) and extended (@phpstan-var / @psalm-var) content separately
528
        // so that the more specific phpstan/psalm annotation always wins regardless of tag order.
529
        $varContent = null;
34✔
530
        $extendedVarContent = null;
34✔
531
        $currentTarget = null; // 'standard' | 'extended'
34✔
532

533
        foreach ($tokens->getTokens() as $token) {
34✔
534
            $content = $token[0];
34✔
535

536
            if ($content === '@var') {
34✔
537
                $currentTarget = 'standard';
34✔
538
                $varContent = '';
34✔
539
                continue;
34✔
540
            }
541

542
            if ($content === '@psalm-var' || $content === '@phpstan-var') {
34✔
543
                $currentTarget = 'extended';
18✔
544
                $extendedVarContent = '';
18✔
545
                continue;
18✔
546
            }
547

548
            if ($currentTarget === 'standard') {
34✔
549
                $varContent .= $content;
34✔
550
            } elseif ($currentTarget === 'extended') {
34✔
551
                $extendedVarContent .= $content;
18✔
552
            }
553
        }
554

555
        // Prefer @phpstan-var / @psalm-var over plain @var regardless of tag order.
556
        $bestContent = null;
34✔
557
        if ($extendedVarContent !== null && \trim($extendedVarContent) !== '') {
34✔
558
            $bestContent = \trim($extendedVarContent);
18✔
559
        } elseif ($varContent !== null && \trim($varContent) !== '') {
30✔
560
            $bestContent = \trim($varContent);
30✔
561
        }
562

563
        if ($bestContent) {
34✔
564
            if (!$this->phpDocRaw) {
34✔
NEW
565
                $this->phpDocRaw = $bestContent;
×
566
            }
567
            $this->typeFromPhpDocExtended = Utils::modernPhpdoc($bestContent);
34✔
568
        }
569
    }
570
}
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