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

voku / Simple-PHP-Code-Parser / 24275250560

11 Apr 2026 05:02AM UTC coverage: 82.928% (+0.07%) from 82.857%
24275250560

Pull #79

github

web-flow
Merge 287c4b76a into 3fcaa1a81
Pull Request #79: Fix PHP 8 type resolution order and safe autoloading for newer syntax

63 of 124 new or added lines in 11 files covered. (50.81%)

7 existing lines in 1 file now uncovered.

1569 of 1892 relevant lines covered (82.93%)

33.27 hits per line

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

93.09
/src/voku/SimplePhpParser/Model/PHPClass.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\Class_;
9
use ReflectionClass;
10
use voku\SimplePhpParser\Parsers\Helper\DocFactoryProvider;
11
use voku\SimplePhpParser\Parsers\Helper\Utils;
12

13
class PHPClass extends BasePHPClass
14
{
15
    /**
16
     * @phpstan-var class-string
17
     */
18
    public string $name;
19

20
    /**
21
     * @phpstan-var class-string|null
22
     */
23
    public ?string $parentClass = null;
24

25
    /**
26
     * @var string[]
27
     *
28
     * @phpstan-var class-string[]
29
     */
30
    public array $interfaces = [];
31

32
    /**
33
     * @param Class_ $node
34
     * @param null   $dummy
35
     *
36
     * @return $this
37
     */
38
    public function readObjectFromPhpNode($node, $dummy = null): self
39
    {
40
        $this->prepareNode($node);
89✔
41

42
        $this->name = static::getFQN($node);
89✔
43

44
        $this->is_final = $node->isFinal();
89✔
45

46
        $this->is_abstract = $node->isAbstract();
89✔
47

48
        $this->is_readonly = $node->isReadonly();
89✔
49

50
        $this->is_anonymous = $node->isAnonymous();
89✔
51

52
        // Extract PHP 8.0+ attributes
53
        if (!empty($node->attrGroups)) {
89✔
54
            $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups);
21✔
55
        }
56

57
        // PHP < 8.2 raises an uncatchable E_COMPILE_ERROR for certain PHP 8.2+ syntax
58
        // (standalone true/false/null types, DNF types, readonly class). Similarly,
59
        // PHP < 8.3 raises an error for PHP 8.3+ syntax (typed class constants).
60
        // Skip autoloading in those cases; AST data is still read from the node below.
61
        $canAutoload = (\PHP_VERSION_ID >= 80200 || !self::nodeUsesPHP82PlusSyntax($node))
89✔
62
            && (\PHP_VERSION_ID >= 80300 || !self::nodeUsesPHP83PlusSyntax($node));
89✔
63
        $classExists = false;
89✔
64
        if ($canAutoload) {
89✔
65
            try {
66
                if (\class_exists($this->name, true)) {
86✔
67
                    $classExists = true;
86✔
68
                }
69
            } catch (\Throwable $e) {
×
70
                // nothing
71
            }
72
        }
73
        if ($classExists) {
89✔
74
            $reflectionClass = Utils::createClassReflectionInstance($this->name);
65✔
75
            $this->readObjectFromReflection($reflectionClass);
65✔
76
        }
77

78
        $this->collectTags($node);
89✔
79

80
        if (!empty($node->extends)) {
89✔
81
            $classExtended = $node->extends->toString();
36✔
82
            /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
83
            /** @var class-string $classExtended */
84
            $classExtended = $classExtended;
36✔
85
            $this->parentClass = $classExtended;
36✔
86
        }
87

88
        $docComment = $node->getDocComment();
89✔
89
        if ($docComment) {
89✔
90
            $this->readPhpDocProperties($docComment->getText());
76✔
91
        }
92

93
        foreach ($node->getProperties() as $property) {
89✔
94
            $propertyNameTmp = $this->getConstantFQN($property, $property->props[0]->name->name);
57✔
95

96
            if (isset($this->properties[$propertyNameTmp])) {
57✔
97
                $this->properties[$propertyNameTmp] = $this->properties[$propertyNameTmp]->readObjectFromPhpNode($property, $this->name);
52✔
98
            } else {
99
                $this->properties[$propertyNameTmp] = (new PHPProperty($this->parserContainer))->readObjectFromPhpNode($property, $this->name);
7✔
100
            }
101

102
            if ($this->is_readonly) {
57✔
103
                $this->properties[$propertyNameTmp]->is_readonly = true;
5✔
104
            }
105
        }
106

107
        foreach ($node->getMethods() as $method) {
89✔
108
            $methodNameTmp = $method->name->name;
81✔
109

110
            if (isset($this->methods[$methodNameTmp])) {
81✔
111
                $this->methods[$methodNameTmp] = $this->methods[$methodNameTmp]->readObjectFromPhpNode($method, $this->name);
61✔
112
            } else {
113
                $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromPhpNode($method, $this->name);
26✔
114
            }
115

116
            if (!$this->methods[$methodNameTmp]->file) {
81✔
117
                $this->methods[$methodNameTmp]->file = $this->file;
26✔
118
            }
119
        }
120

121
        if (!empty($node->implements)) {
89✔
122
            foreach ($node->implements as $interfaceObject) {
22✔
123
                $interfaceFQN = $interfaceObject->toString();
22✔
124
                /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
125
                /** @var class-string $interfaceFQN */
126
                $interfaceFQN = $interfaceFQN;
22✔
127
                $this->interfaces[$interfaceFQN] = $interfaceFQN;
22✔
128
            }
129
        }
130

131
        return $this;
89✔
132
    }
133

134
    /**
135
     * @param ReflectionClass<object> $clazz
136
     *
137
     * @return $this
138
     */
139
    public function readObjectFromReflection($clazz): self
140
    {
141
        $this->name = $clazz->getName();
65✔
142

143
        if (!$this->line) {
65✔
144
            $lineTmp = $clazz->getStartLine();
30✔
145
            if ($lineTmp !== false) {
30✔
146
                $this->line = $lineTmp;
18✔
147
            }
148
        }
149

150
        $file = $clazz->getFileName();
65✔
151
        if ($file) {
65✔
152
            $this->file = $file;
65✔
153
        }
154

155
        $this->is_final = $clazz->isFinal();
65✔
156

157
        $this->is_abstract = $clazz->isAbstract();
65✔
158

159
        if (method_exists($clazz, 'isReadOnly')) {
65✔
160
            $this->is_readonly = $clazz->isReadOnly();
47✔
161
        }
162

163
        $this->is_anonymous = $clazz->isAnonymous();
65✔
164

165
        $this->is_cloneable = $clazz->isCloneable();
65✔
166

167
        $this->is_instantiable = $clazz->isInstantiable();
65✔
168

169
        $this->is_iterable = $clazz->isIterable();
65✔
170

171
        // Extract PHP 8.0+ attributes
172
        $this->attributes = Utils::extractAttributesFromReflection($clazz);
65✔
173

174
        $parent = $clazz->getParentClass();
65✔
175
        if ($parent) {
65✔
176
            $this->parentClass = $parent->getName();
30✔
177

178
            $classExists = false;
30✔
179
            try {
180
                if (
181
                    !$this->parserContainer->getClass($this->parentClass)
30✔
182
                    &&
183
                    \class_exists($this->parentClass, true)
30✔
184
                ) {
185
                    $classExists = true;
30✔
186
                }
187
            } catch (\Throwable $e) {
×
188
                // nothing
189
            }
190
            if ($classExists) {
30✔
191
                $reflectionClass = Utils::createClassReflectionInstance($this->parentClass);
30✔
192
                $class = (new self($this->parserContainer))->readObjectFromReflection($reflectionClass);
30✔
193
                $this->parserContainer->addClass($class);
30✔
194
            }
195
        }
196

197
        foreach ($clazz->getProperties() as $property) {
65✔
198
            $propertyPhp = (new PHPProperty($this->parserContainer))->readObjectFromReflection($property);
61✔
199
            $this->properties[$propertyPhp->name] = $propertyPhp;
61✔
200

201
            if ($this->is_readonly) {
61✔
202
                $this->properties[$propertyPhp->name]->is_readonly = true;
4✔
203
            }
204
        }
205

206
        foreach ($clazz->getInterfaceNames() as $interfaceName) {
65✔
207
            /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
208
            /** @var class-string $interfaceName */
209
            $interfaceName = $interfaceName;
29✔
210
            $this->interfaces[$interfaceName] = $interfaceName;
29✔
211
        }
212

213
        foreach ($clazz->getMethods() as $method) {
65✔
214
            $methodNameTmp = $method->getName();
64✔
215

216
            $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromReflection($method);
64✔
217

218
            if (!$this->methods[$methodNameTmp]->file) {
64✔
219
                $this->methods[$methodNameTmp]->file = $this->file;
×
220
            }
221
        }
222

223
        foreach ($clazz->getReflectionConstants() as $constant) {
65✔
224
            $constantNameTmp = $constant->getName();
40✔
225

226
            $this->constants[$constantNameTmp] = (new PHPConst($this->parserContainer))->readObjectFromReflection($constant);
40✔
227

228
            if (!$this->constants[$constantNameTmp]->file) {
40✔
229
                $this->constants[$constantNameTmp]->file = $this->file;
×
230
            }
231
        }
232

233
        return $this;
65✔
234
    }
235

236
    /**
237
     * @param string[] $access
238
     * @param bool     $skipMethodsWithLeadingUnderscore
239
     *
240
     * @return array
241
     *
242
     * @psalm-return array<string, array{
243
     *     type: null|string,
244
     *     typeFromPhpDocMaybeWithComment: null|string,
245
     *     typeFromPhpDoc: null|string,
246
     *     typeFromPhpDocSimple: null|string,
247
     *     typeFromPhpDocExtended: null|string,
248
     *     typeFromDefaultValue: null|string
249
     * }>
250
     */
251
    public function getPropertiesInfo(
252
        array $access = ['public', 'protected', 'private'],
253
        bool $skipMethodsWithLeadingUnderscore = false
254
    ): array {
255
        // init
256
        $allInfo = [];
3✔
257

258
        foreach ($this->properties as $property) {
3✔
259
            if (!\in_array($property->access, $access, true)) {
3✔
260
                continue;
×
261
            }
262

263
            if ($skipMethodsWithLeadingUnderscore && \strpos($property->name, '_') === 0) {
3✔
264
                continue;
×
265
            }
266

267
            $types = [];
3✔
268
            $types['type'] = $property->type;
3✔
269
            $types['typeFromPhpDocMaybeWithComment'] = $property->typeFromPhpDocMaybeWithComment;
3✔
270
            $types['typeFromPhpDoc'] = $property->typeFromPhpDoc;
3✔
271
            $types['typeFromPhpDocSimple'] = $property->typeFromPhpDocSimple;
3✔
272
            $types['typeFromPhpDocExtended'] = $property->typeFromPhpDocExtended;
3✔
273
            $types['typeFromDefaultValue'] = $property->typeFromDefaultValue;
3✔
274

275
            $allInfo[$property->name] = $types;
3✔
276
        }
277

278
        return $allInfo;
3✔
279
    }
280

281
    /**
282
     * @param string[] $access
283
     * @param bool     $skipDeprecatedMethods
284
     * @param bool     $skipMethodsWithLeadingUnderscore
285
     *
286
     * @return array<mixed>
287
     *
288
     * @psalm-return array<string, array{
289
     *     fullDescription: string,
290
     *     line: null|int,
291
     *     file: null|string,
292
     *     error: string,
293
     *     is_deprecated: bool,
294
     *     is_static: null|bool,
295
     *     is_meta: bool,
296
     *     is_internal: bool,
297
     *     is_removed: bool,
298
     *     paramsTypes: array<string,
299
     *         array{
300
     *              type?: null|string,
301
     *              typeFromPhpDoc?: null|string,
302
     *              typeFromPhpDocExtended?: null|string,
303
     *              typeFromPhpDocSimple?: null|string,
304
     *              typeFromPhpDocMaybeWithComment?: null|string,
305
     *              typeFromDefaultValue?: null|string
306
     *         }
307
     *     >,
308
     *     returnTypes: array{
309
     *         type: null|string,
310
     *         typeFromPhpDoc: null|string,
311
     *         typeFromPhpDocExtended: null|string,
312
     *         typeFromPhpDocSimple: null|string,
313
     *         typeFromPhpDocMaybeWithComment: null|string
314
     *     },
315
     *     paramsPhpDocRaw: array<string, null|string>,
316
     *     returnPhpDocRaw: null|string
317
     * }>
318
     */
319
    public function getMethodsInfo(
320
        array $access = ['public', 'protected', 'private'],
321
        bool $skipDeprecatedMethods = false,
322
        bool $skipMethodsWithLeadingUnderscore = false
323
    ): array {
324
        // init
325
        $allInfo = [];
9✔
326

327
        foreach ($this->methods as $method) {
9✔
328
            if (!\in_array($method->access, $access, true)) {
9✔
329
                continue;
×
330
            }
331

332
            if ($skipDeprecatedMethods && $method->hasDeprecatedTag) {
9✔
333
                continue;
×
334
            }
335

336
            if ($skipMethodsWithLeadingUnderscore && \strpos($method->name, '_') === 0) {
9✔
337
                continue;
×
338
            }
339

340
            $paramsTypes = [];
9✔
341
            foreach ($method->parameters as $tagParam) {
9✔
342
                $paramsTypes[$tagParam->name]['type'] = $tagParam->type;
9✔
343
                $paramsTypes[$tagParam->name]['typeFromPhpDocMaybeWithComment'] = $tagParam->typeFromPhpDocMaybeWithComment;
9✔
344
                $paramsTypes[$tagParam->name]['typeFromPhpDoc'] = $tagParam->typeFromPhpDoc;
9✔
345
                $paramsTypes[$tagParam->name]['typeFromPhpDocSimple'] = $tagParam->typeFromPhpDocSimple;
9✔
346
                $paramsTypes[$tagParam->name]['typeFromPhpDocExtended'] = $tagParam->typeFromPhpDocExtended;
9✔
347
                $paramsTypes[$tagParam->name]['typeFromDefaultValue'] = $tagParam->typeFromDefaultValue;
9✔
348
            }
349

350
            $returnTypes = [];
9✔
351
            $returnTypes['type'] = $method->returnType;
9✔
352
            $returnTypes['typeFromPhpDocMaybeWithComment'] = $method->returnTypeFromPhpDocMaybeWithComment;
9✔
353
            $returnTypes['typeFromPhpDoc'] = $method->returnTypeFromPhpDoc;
9✔
354
            $returnTypes['typeFromPhpDocSimple'] = $method->returnTypeFromPhpDocSimple;
9✔
355
            $returnTypes['typeFromPhpDocExtended'] = $method->returnTypeFromPhpDocExtended;
9✔
356

357
            $paramsPhpDocRaw = [];
9✔
358
            foreach ($method->parameters as $tagParam) {
9✔
359
                $paramsPhpDocRaw[$tagParam->name] = $tagParam->phpDocRaw;
9✔
360
            }
361

362
            $infoTmp = [];
9✔
363
            $infoTmp['fullDescription'] = \trim($method->summary . "\n\n" . $method->description);
9✔
364
            $infoTmp['paramsTypes'] = $paramsTypes;
9✔
365
            $infoTmp['returnTypes'] = $returnTypes;
9✔
366
            $infoTmp['paramsPhpDocRaw'] = $paramsPhpDocRaw;
9✔
367
            $infoTmp['returnPhpDocRaw'] = $method->returnPhpDocRaw;
9✔
368
            $infoTmp['line'] = $method->line ?? $this->line;
9✔
369
            $infoTmp['file'] = $method->file ?? $this->file;
9✔
370
            $infoTmp['error'] = \implode("\n", $method->parseError);
9✔
371
            foreach ($method->parameters as $parameter) {
9✔
372
                $infoTmp['error'] .= ($infoTmp['error'] ? "\n" : '') . \implode("\n", $parameter->parseError);
9✔
373
            }
374
            $infoTmp['is_deprecated'] = $method->hasDeprecatedTag;
9✔
375
            $infoTmp['is_static'] = $method->is_static;
9✔
376
            $infoTmp['is_meta'] = $method->hasMetaTag;
9✔
377
            $infoTmp['is_internal'] = $method->hasInternalTag;
9✔
378
            $infoTmp['is_removed'] = $method->hasRemovedTag;
9✔
379

380
            $allInfo[$method->name] = $infoTmp;
9✔
381
        }
382

383
        \asort($allInfo);
9✔
384

385
        return $allInfo;
9✔
386
    }
387

388
    /**
389
     * @param Doc|string $doc
390
     */
391
    private function readPhpDocProperties($doc): void
392
    {
393
        if ($doc instanceof Doc) {
76✔
394
            $docComment = $doc->getText();
×
395
        } else {
396
            $docComment = $doc;
76✔
397
        }
398
        if ($docComment === '') {
76✔
399
            return;
×
400
        }
401

402
        try {
403
            $phpDoc = DocFactoryProvider::getDocFactory()->create($docComment);
76✔
404

405
            $parsedPropertyTags = $phpDoc->getTagsByName('property')
76✔
406
                               + $phpDoc->getTagsByName('property-read')
76✔
407
                               + $phpDoc->getTagsByName('property-write');
76✔
408

409
            if (!empty($parsedPropertyTags)) {
76✔
410
                foreach ($parsedPropertyTags as $parsedPropertyTag) {
76✔
411
                    if (
412
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\PropertyRead
21✔
413
                        ||
414
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\PropertyWrite
21✔
415
                        ||
416
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Property
21✔
417
                    ) {
418
                        $propertyPhp = new PHPProperty($this->parserContainer);
21✔
419

420
                        $nameTmp = $parsedPropertyTag->getVariableName();
21✔
421
                        if (!$nameTmp) {
21✔
422
                            continue;
×
423
                        }
424

425
                        $propertyPhp->name = $nameTmp;
21✔
426

427
                        $propertyPhp->access = 'public';
21✔
428

429
                        $type = $parsedPropertyTag->getType();
21✔
430

431
                        $propertyPhp->typeFromPhpDoc = Utils::normalizePhpType($type . '');
21✔
432

433
                        $typeFromPhpDocMaybeWithCommentTmp = \trim((string) $parsedPropertyTag);
21✔
434
                        if (
435
                            $typeFromPhpDocMaybeWithCommentTmp
21✔
436
                            &&
437
                            \strpos($typeFromPhpDocMaybeWithCommentTmp, '$') !== 0
21✔
438
                        ) {
439
                            $propertyPhp->typeFromPhpDocMaybeWithComment = $typeFromPhpDocMaybeWithCommentTmp;
21✔
440
                        }
441

442
                        $typeTmp = Utils::parseDocTypeObject($type);
21✔
443
                        if ($typeTmp !== '') {
21✔
444
                            $propertyPhp->typeFromPhpDocSimple = $typeTmp;
21✔
445
                        }
446

447
                        if ($propertyPhp->typeFromPhpDoc) {
21✔
448
                            $propertyPhp->typeFromPhpDocExtended = Utils::modernPhpdoc($propertyPhp->typeFromPhpDoc);
21✔
449
                        }
450

451
                        $this->properties[$propertyPhp->name] = $propertyPhp;
21✔
452
                    }
453
                }
454
            }
455
        } catch (\Exception $e) {
×
456
            $tmpErrorMessage = ($this->name ?: '?') . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
457
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
458
        }
459
    }
460

461
    /**
462
     * Returns true if the class node uses syntax that requires PHP 8.2+ and would
463
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.2.
464
     *
465
     * @param Class_ $node
466
     *
467
     * @return bool
468
     */
469
    private static function nodeUsesPHP82PlusSyntax(Class_ $node): bool
470
    {
471
        // readonly class is PHP 8.2+
NEW
472
        if ($node->isReadonly()) {
27✔
473
            return true;
1✔
474
        }
475

476
        foreach ($node->stmts as $stmt) {
27✔
477
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassMethod) {
26✔
478
                if (self::containsPHP82PlusType($stmt->returnType)) {
25✔
479
                    return true;
4✔
480
                }
481
                foreach ($stmt->params as $param) {
25✔
482
                    if (self::containsPHP82PlusType($param->type)) {
23✔
483
                        return true;
1✔
484
                    }
485
                }
486
            } elseif ($stmt instanceof \PhpParser\Node\Stmt\Property) {
18✔
487
                if (self::containsPHP82PlusType($stmt->type)) {
17✔
488
                    return true;
2✔
489
                }
490
            }
491
        }
492

493
        return false;
24✔
494
    }
495

496
    /**
497
     * Returns true if the class node uses syntax that requires PHP 8.3+ and would
498
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.3.
499
     *
500
     * Covers: typed class constants (Stmt\ClassConst with a non-null type).
501
     *
502
     * @param Class_ $node
503
     *
504
     * @return bool
505
     */
506
    private static function nodeUsesPHP83PlusSyntax(Class_ $node): bool
507
    {
508
        foreach ($node->stmts as $stmt) {
54✔
509
            // Typed class constants are PHP 8.3+
510
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassConst && $stmt->type !== null) {
52✔
511
                return true;
4✔
512
            }
513
        }
514

515
        return false;
54✔
516
    }
517

518
    /**
519
     * Returns true if the given type node is a PHP 8.2+ type that causes an
520
     * uncatchable E_COMPILE_ERROR when loaded on PHP < 8.2.
521
     *
522
     * Covers: standalone true/false/null types and DNF types (union of intersections).
523
     *
524
     * @param \PhpParser\Node|null $typeNode
525
     *
526
     * @return bool
527
     */
528
    private static function containsPHP82PlusType($typeNode): bool
529
    {
530
        if ($typeNode === null) {
26✔
531
            return false;
21✔
532
        }
533

534
        // Standalone true, false, null as the *sole* type (not in a nullable like ?string)
535
        // are PHP 8.2+ only. PHP-Parser represents these as Identifier nodes (not Name).
536
        // Nullable null (?null) is syntactically invalid; NullableType wraps the inner type.
537
        if ($typeNode instanceof \PhpParser\Node\Identifier) {
23✔
538
            $name = \strtolower($typeNode->name);
23✔
539
            return $name === 'true' || $name === 'false' || $name === 'null';
23✔
540
        }
541

542
        // DNF types: union type containing an intersection type (PHP 8.2+)
543
        if ($typeNode instanceof \PhpParser\Node\UnionType) {
14✔
544
            foreach ($typeNode->types as $t) {
5✔
545
                if ($t instanceof \PhpParser\Node\IntersectionType || self::containsPHP82PlusType($t)) {
5✔
546
                    return true;
3✔
547
                }
548
            }
549
        }
550

551
        // Recurse into nullable type
552
        if ($typeNode instanceof \PhpParser\Node\NullableType) {
14✔
553
            return self::containsPHP82PlusType($typeNode->type);
5✔
554
        }
555

556
        return false;
14✔
557
    }
558
}
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