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

voku / Simple-PHP-Code-Parser / 24282025521

11 Apr 2026 12:00PM UTC coverage: 80.673% (-2.2%) from 82.886%
24282025521

Pull #83

github

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

128 of 202 new or added lines in 7 files covered. (63.37%)

39 existing lines in 8 files now uncovered.

1653 of 2049 relevant lines covered (80.67%)

11.04 hits per line

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

89.81
/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);
30✔
41

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

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

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

48
        // Keep the guard for cross-version php-parser compatibility when readonly
49
        // helpers are restored or backported differently in downstream installs.
50
        if (\method_exists($node, 'isReadonly')) {
30✔
51
            $this->is_readonly = $node->isReadonly();
30✔
52
        }
53

54
        $this->is_anonymous = $node->isAnonymous();
30✔
55

56
        // Extract PHP 8.0+ attributes
57
        if (!empty($node->attrGroups)) {
30✔
58
            $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups);
9✔
59
        }
60

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

84
        $this->collectTags($node);
30✔
85

86
        if (!empty($node->extends)) {
30✔
87
            $classExtended = $node->extends->toString();
13✔
88
            /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
89
            /** @var class-string $classExtended */
90
            $classExtended = $classExtended;
13✔
91
            $this->parentClass = $classExtended;
13✔
92
        }
93

94
        $docComment = $node->getDocComment();
30✔
95
        if ($docComment) {
30✔
96
            $this->readPhpDocProperties($docComment->getText());
24✔
97
        }
98

99
        foreach ($node->getProperties() as $property) {
30✔
100
            $propertyNameTmp = $this->getConstantFQN($property, $property->props[0]->name->name);
18✔
101

102
            if (isset($this->properties[$propertyNameTmp])) {
18✔
103
                $this->properties[$propertyNameTmp] = $this->properties[$propertyNameTmp]->readObjectFromPhpNode($property, $this->name);
15✔
104
            } else {
105
                $this->properties[$propertyNameTmp] = (new PHPProperty($this->parserContainer))->readObjectFromPhpNode($property, $this->name);
5✔
106
            }
107

108
            if ($this->is_readonly) {
18✔
109
                $this->properties[$propertyNameTmp]->is_readonly = true;
1✔
110
            }
111
        }
112

113
        foreach ($node->getMethods() as $method) {
30✔
114
            $methodNameTmp = $method->name->name;
28✔
115

116
            if (isset($this->methods[$methodNameTmp])) {
28✔
117
                $this->methods[$methodNameTmp] = $this->methods[$methodNameTmp]->readObjectFromPhpNode($method, $this->name);
19✔
118
            } else {
119
                $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromPhpNode($method, $this->name);
11✔
120
            }
121

122
            if (!$this->methods[$methodNameTmp]->file) {
28✔
123
                $this->methods[$methodNameTmp]->file = $this->file;
11✔
124
            }
125
        }
126

127
        $this->addPromotedPropertiesFromConstructor($node);
30✔
128

129
        if (!empty($node->implements)) {
30✔
130
            foreach ($node->implements as $interfaceObject) {
6✔
131
                $interfaceFQN = $interfaceObject->toString();
6✔
132
                /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
133
                /** @var class-string $interfaceFQN */
134
                $interfaceFQN = $interfaceFQN;
6✔
135
                $this->interfaces[$interfaceFQN] = $interfaceFQN;
6✔
136
            }
137
        }
138

139
        return $this;
30✔
140
    }
141

142
    /**
143
     * @param ReflectionClass<object> $clazz
144
     *
145
     * @return $this
146
     */
147
    public function readObjectFromReflection($clazz): self
148
    {
149
        $this->name = $clazz->getName();
20✔
150

151
        if (!$this->line) {
20✔
152
            $lineTmp = $clazz->getStartLine();
11✔
153
            if ($lineTmp !== false) {
11✔
154
                $this->line = $lineTmp;
7✔
155
            }
156
        }
157

158
        $file = $clazz->getFileName();
20✔
159
        if ($file) {
20✔
160
            $this->file = $file;
20✔
161
        }
162

163
        $this->is_final = $clazz->isFinal();
20✔
164

165
        $this->is_abstract = $clazz->isAbstract();
20✔
166

167
        if (method_exists($clazz, 'isReadOnly')) {
20✔
UNCOV
168
            $this->is_readonly = $clazz->isReadOnly();
×
169
        }
170

171
        $this->is_anonymous = $clazz->isAnonymous();
20✔
172

173
        $this->is_cloneable = $clazz->isCloneable();
20✔
174

175
        $this->is_instantiable = $clazz->isInstantiable();
20✔
176

177
        $this->is_iterable = $clazz->isIterable();
20✔
178

179
        // Extract PHP 8.0+ attributes
180
        $this->attributes = Utils::extractAttributesFromReflection($clazz);
20✔
181

182
        $parent = $clazz->getParentClass();
20✔
183
        if ($parent) {
20✔
184
            $this->parentClass = $parent->getName();
11✔
185

186
            $classExists = false;
11✔
187
            try {
188
                if (
189
                    !$this->parserContainer->getClass($this->parentClass)
11✔
190
                    &&
191
                    \class_exists($this->parentClass, true)
11✔
192
                ) {
193
                    $classExists = true;
11✔
194
                }
195
            } catch (\Throwable $e) {
×
196
                // nothing
197
            }
198
            if ($classExists) {
11✔
199
                $reflectionClass = Utils::createClassReflectionInstance($this->parentClass);
11✔
200
                $class = (new self($this->parserContainer))->readObjectFromReflection($reflectionClass);
11✔
201
                $this->parserContainer->addClass($class);
11✔
202
            }
203
        }
204

205
        foreach ($clazz->getProperties() as $property) {
20✔
206
            $propertyPhp = (new PHPProperty($this->parserContainer))->readObjectFromReflection($property);
19✔
207
            $this->properties[$propertyPhp->name] = $propertyPhp;
19✔
208

209
            if ($this->is_readonly) {
19✔
UNCOV
210
                $this->properties[$propertyPhp->name]->is_readonly = true;
×
211
            }
212
        }
213

214
        foreach ($clazz->getInterfaceNames() as $interfaceName) {
20✔
215
            /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
216
            /** @var class-string $interfaceName */
217
            $interfaceName = $interfaceName;
8✔
218
            $this->interfaces[$interfaceName] = $interfaceName;
8✔
219
        }
220

221
        foreach ($clazz->getMethods() as $method) {
20✔
222
            $methodNameTmp = $method->getName();
20✔
223

224
            $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromReflection($method);
20✔
225

226
            if (!$this->methods[$methodNameTmp]->file) {
20✔
227
                $this->methods[$methodNameTmp]->file = $this->file;
×
228
            }
229
        }
230

231
        foreach ($clazz->getReflectionConstants() as $constant) {
20✔
232
            $constantNameTmp = $constant->getName();
13✔
233

234
            $this->constants[$constantNameTmp] = (new PHPConst($this->parserContainer))->readObjectFromReflection($constant);
13✔
235

236
            if (!$this->constants[$constantNameTmp]->file) {
13✔
237
                $this->constants[$constantNameTmp]->file = $this->file;
×
238
            }
239
        }
240

241
        return $this;
20✔
242
    }
243

244
    /**
245
     * @param string[] $access
246
     * @param bool     $skipMethodsWithLeadingUnderscore
247
     *
248
     * @return array
249
     *
250
     * @psalm-return array<string, array{
251
     *     type: null|string,
252
     *     typeFromPhpDocMaybeWithComment: null|string,
253
     *     typeFromPhpDoc: null|string,
254
     *     typeFromPhpDocSimple: null|string,
255
     *     typeFromPhpDocExtended: null|string,
256
     *     typeFromDefaultValue: null|string
257
     * }>
258
     */
259
    public function getPropertiesInfo(
260
        array $access = ['public', 'protected', 'private'],
261
        bool $skipMethodsWithLeadingUnderscore = false
262
    ): array {
263
        // init
264
        $allInfo = [];
2✔
265

266
        foreach ($this->properties as $property) {
2✔
267
            if (!\in_array($property->access, $access, true)) {
2✔
268
                continue;
×
269
            }
270

271
            if ($skipMethodsWithLeadingUnderscore && \strpos($property->name, '_') === 0) {
2✔
272
                continue;
×
273
            }
274

275
            $types = [];
2✔
276
            $types['type'] = $property->type;
2✔
277
            $types['typeFromPhpDocMaybeWithComment'] = $property->typeFromPhpDocMaybeWithComment;
2✔
278
            $types['typeFromPhpDoc'] = $property->typeFromPhpDoc;
2✔
279
            $types['typeFromPhpDocSimple'] = $property->typeFromPhpDocSimple;
2✔
280
            $types['typeFromPhpDocExtended'] = $property->typeFromPhpDocExtended;
2✔
281
            $types['typeFromDefaultValue'] = $property->typeFromDefaultValue;
2✔
282

283
            $allInfo[$property->name] = $types;
2✔
284
        }
285

286
        return $allInfo;
2✔
287
    }
288

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

335
        foreach ($this->methods as $method) {
4✔
336
            if (!\in_array($method->access, $access, true)) {
4✔
337
                continue;
×
338
            }
339

340
            if ($skipDeprecatedMethods && $method->hasDeprecatedTag) {
4✔
341
                continue;
×
342
            }
343

344
            if ($skipMethodsWithLeadingUnderscore && \strpos($method->name, '_') === 0) {
4✔
345
                continue;
×
346
            }
347

348
            $paramsTypes = [];
4✔
349
            foreach ($method->parameters as $tagParam) {
4✔
350
                $paramsTypes[$tagParam->name]['type'] = $tagParam->type;
4✔
351
                $paramsTypes[$tagParam->name]['typeFromPhpDocMaybeWithComment'] = $tagParam->typeFromPhpDocMaybeWithComment;
4✔
352
                $paramsTypes[$tagParam->name]['typeFromPhpDoc'] = $tagParam->typeFromPhpDoc;
4✔
353
                $paramsTypes[$tagParam->name]['typeFromPhpDocSimple'] = $tagParam->typeFromPhpDocSimple;
4✔
354
                $paramsTypes[$tagParam->name]['typeFromPhpDocExtended'] = $tagParam->typeFromPhpDocExtended;
4✔
355
                $paramsTypes[$tagParam->name]['typeFromDefaultValue'] = $tagParam->typeFromDefaultValue;
4✔
356
            }
357

358
            $returnTypes = [];
4✔
359
            $returnTypes['type'] = $method->returnType;
4✔
360
            $returnTypes['typeFromPhpDocMaybeWithComment'] = $method->returnTypeFromPhpDocMaybeWithComment;
4✔
361
            $returnTypes['typeFromPhpDoc'] = $method->returnTypeFromPhpDoc;
4✔
362
            $returnTypes['typeFromPhpDocSimple'] = $method->returnTypeFromPhpDocSimple;
4✔
363
            $returnTypes['typeFromPhpDocExtended'] = $method->returnTypeFromPhpDocExtended;
4✔
364

365
            $paramsPhpDocRaw = [];
4✔
366
            foreach ($method->parameters as $tagParam) {
4✔
367
                $paramsPhpDocRaw[$tagParam->name] = $tagParam->phpDocRaw;
4✔
368
            }
369

370
            $infoTmp = [];
4✔
371
            $infoTmp['fullDescription'] = \trim($method->summary . "\n\n" . $method->description);
4✔
372
            $infoTmp['paramsTypes'] = $paramsTypes;
4✔
373
            $infoTmp['returnTypes'] = $returnTypes;
4✔
374
            $infoTmp['paramsPhpDocRaw'] = $paramsPhpDocRaw;
4✔
375
            $infoTmp['returnPhpDocRaw'] = $method->returnPhpDocRaw;
4✔
376
            $infoTmp['line'] = $method->line ?? $this->line;
4✔
377
            $infoTmp['file'] = $method->file ?? $this->file;
4✔
378
            $infoTmp['error'] = \implode("\n", $method->parseError);
4✔
379
            foreach ($method->parameters as $parameter) {
4✔
380
                $infoTmp['error'] .= ($infoTmp['error'] ? "\n" : '') . \implode("\n", $parameter->parseError);
4✔
381
            }
382
            $infoTmp['is_deprecated'] = $method->hasDeprecatedTag;
4✔
383
            $infoTmp['is_static'] = $method->is_static;
4✔
384
            $infoTmp['is_meta'] = $method->hasMetaTag;
4✔
385
            $infoTmp['is_internal'] = $method->hasInternalTag;
4✔
386
            $infoTmp['is_removed'] = $method->hasRemovedTag;
4✔
387

388
            $allInfo[$method->name] = $infoTmp;
4✔
389
        }
390

391
        \asort($allInfo);
4✔
392

393
        return $allInfo;
4✔
394
    }
395

396
    /**
397
     * @param Doc|string $doc
398
     */
399
    private function readPhpDocProperties($doc): void
400
    {
401
        if ($doc instanceof Doc) {
24✔
402
            $docComment = $doc->getText();
×
403
        } else {
404
            $docComment = $doc;
24✔
405
        }
406
        if ($docComment === '') {
24✔
407
            return;
×
408
        }
409

410
        try {
411
            $phpDoc = DocFactoryProvider::getDocFactory()->create($docComment);
24✔
412

413
            $parsedPropertyTags = $phpDoc->getTagsByName('property')
24✔
414
                               + $phpDoc->getTagsByName('property-read')
24✔
415
                               + $phpDoc->getTagsByName('property-write');
24✔
416

417
            if (!empty($parsedPropertyTags)) {
24✔
418
                foreach ($parsedPropertyTags as $parsedPropertyTag) {
24✔
419
                    if (
420
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\PropertyRead
7✔
421
                        ||
422
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\PropertyWrite
7✔
423
                        ||
424
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Property
7✔
425
                    ) {
426
                        $propertyPhp = new PHPProperty($this->parserContainer);
7✔
427

428
                        $nameTmp = $parsedPropertyTag->getVariableName();
7✔
429
                        if (!$nameTmp) {
7✔
430
                            continue;
×
431
                        }
432

433
                        $propertyPhp->name = $nameTmp;
7✔
434

435
                        $propertyPhp->access = 'public';
7✔
436

437
                        $type = $parsedPropertyTag->getType();
7✔
438

439
                        $propertyPhp->typeFromPhpDoc = Utils::normalizePhpType($type . '');
7✔
440

441
                        $typeFromPhpDocMaybeWithCommentTmp = \trim((string) $parsedPropertyTag);
7✔
442
                        if (
443
                            $typeFromPhpDocMaybeWithCommentTmp
7✔
444
                            &&
445
                            \strpos($typeFromPhpDocMaybeWithCommentTmp, '$') !== 0
7✔
446
                        ) {
447
                            $propertyPhp->typeFromPhpDocMaybeWithComment = $typeFromPhpDocMaybeWithCommentTmp;
7✔
448
                        }
449

450
                        $typeTmp = Utils::parseDocTypeObject($type);
7✔
451
                        if ($typeTmp !== '') {
7✔
452
                            $propertyPhp->typeFromPhpDocSimple = $typeTmp;
7✔
453
                        }
454

455
                        if ($propertyPhp->typeFromPhpDoc) {
7✔
456
                            $propertyPhp->typeFromPhpDocExtended = Utils::modernPhpdoc($propertyPhp->typeFromPhpDoc);
7✔
457
                        }
458

459
                        $this->properties[$propertyPhp->name] = $propertyPhp;
7✔
460
                    }
461
                }
462
            }
463
        } catch (\Exception $e) {
×
464
            $tmpErrorMessage = ($this->name ?: '?') . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
465
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
466
        }
467
    }
468

469
    /**
470
     * Returns true if the class node uses syntax that requires PHP 8.2+ and would
471
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.2.
472
     *
473
     * @param Class_ $node
474
     *
475
     * @return bool
476
     */
477
    private static function nodeUsesPHP82PlusSyntax(Class_ $node): bool
478
    {
479
        // readonly class is PHP 8.2+
480
        if ($node->isReadonly()) {
30✔
481
            return true;
1✔
482
        }
483

484
        foreach ($node->stmts as $stmt) {
30✔
485
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassMethod) {
29✔
486
                if (self::containsPHP82PlusType($stmt->returnType)) {
28✔
487
                    return true;
4✔
488
                }
489
                foreach ($stmt->params as $param) {
28✔
490
                    if (self::containsPHP82PlusType($param->type)) {
26✔
491
                        return true;
1✔
492
                    }
493
                }
494
            } elseif ($stmt instanceof \PhpParser\Node\Stmt\Property) {
20✔
495
                if (self::containsPHP82PlusType($stmt->type)) {
18✔
496
                    return true;
2✔
497
                }
498
            }
499
        }
500

501
        return false;
27✔
502
    }
503

504
    /**
505
     * Returns true if the class node uses syntax that requires PHP 8.3+ and would
506
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.3.
507
     *
508
     * Covers: typed class constants (Stmt\ClassConst with a non-null type).
509
     *
510
     * @param Class_ $node
511
     *
512
     * @return bool
513
     */
514
    private static function nodeUsesPHP83PlusSyntax(Class_ $node): bool
515
    {
516
        foreach ($node->stmts as $stmt) {
27✔
517
            // Typed class constants are PHP 8.3+
518
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassConst && $stmt->type !== null) {
26✔
519
                return true;
2✔
520
            }
521
        }
522

523
        return false;
27✔
524
    }
525

526
    /**
527
     * Returns true if the given type node is a PHP 8.2+ type that causes an
528
     * uncatchable E_COMPILE_ERROR when loaded on PHP < 8.2.
529
     *
530
     * Covers: standalone true/false/null types and DNF types (union of intersections).
531
     *
532
     * @param \PhpParser\Node|null $typeNode
533
     *
534
     * @return bool
535
     */
536
    private static function containsPHP82PlusType($typeNode): bool
537
    {
538
        if ($typeNode === null) {
29✔
539
            return false;
24✔
540
        }
541

542
        // Standalone true, false, null as the *sole* type (not in a nullable like ?string)
543
        // are PHP 8.2+ only. PHP-Parser represents these as Identifier nodes (not Name).
544
        // Nullable null (?null) is syntactically invalid; NullableType wraps the inner type.
545
        if ($typeNode instanceof \PhpParser\Node\Identifier) {
26✔
546
            $name = \strtolower($typeNode->name);
26✔
547
            return $name === 'true' || $name === 'false' || $name === 'null';
26✔
548
        }
549

550
        // DNF types: union type containing an intersection type (PHP 8.2+)
551
        if ($typeNode instanceof \PhpParser\Node\UnionType) {
16✔
552
            foreach ($typeNode->types as $t) {
6✔
553
                if ($t instanceof \PhpParser\Node\IntersectionType || self::containsPHP82PlusType($t)) {
6✔
554
                    return true;
3✔
555
                }
556
            }
557
        }
558

559
        // Recurse into nullable type
560
        if ($typeNode instanceof \PhpParser\Node\NullableType) {
16✔
561
            return self::containsPHP82PlusType($typeNode->type);
6✔
562
        }
563

564
        return false;
15✔
565
    }
566

567
    /**
568
     * Returns true if the class node uses syntax that requires PHP 8.4+ and would
569
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.4.
570
     *
571
     * Covers: property hooks and asymmetric visibility modifiers.
572
     *
573
     * @param Class_ $node
574
     *
575
     * @return bool
576
     */
577
    private static function nodeUsesPHP84PlusSyntax(Class_ $node): bool
578
    {
579
        foreach ($node->stmts as $stmt) {
27✔
580
            // Property hooks are PHP 8.4+
581
            if ($stmt instanceof \PhpParser\Node\Stmt\Property && !empty($stmt->hooks)) {
26✔
NEW
582
                return true;
×
583
            }
584

585
            // Asymmetric visibility on properties is PHP 8.4+
586
            if (
587
                $stmt instanceof \PhpParser\Node\Stmt\Property
26✔
588
                && self::getAsymmetricSetVisibility($stmt) !== ''
26✔
589
            ) {
NEW
590
                return true;
×
591
            }
592

593
            // Constructor with promoted properties that have hooks or asymmetric visibility
594
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassMethod) {
26✔
595
                foreach ($stmt->params as $param) {
25✔
596
                    if (!empty($param->hooks)) {
23✔
NEW
597
                        return true;
×
598
                    }
599
                    if (self::getAsymmetricSetVisibility($param) !== '') {
23✔
NEW
600
                        return true;
×
601
                    }
602
                }
603
            }
604
        }
605

606
        return false;
27✔
607
    }
608

609
    private function addPromotedPropertiesFromConstructor(Class_ $node): void
610
    {
611
        foreach ($node->getMethods() as $method) {
30✔
612
            if ($method->name->name !== '__construct') {
28✔
613
                continue;
25✔
614
            }
615

616
            foreach ($method->params as $parameter) {
11✔
617
                if (!self::isPromotedParameter($parameter)) {
11✔
618
                    continue;
3✔
619
                }
620

621
                $parameterVar = $parameter->var;
10✔
622
                if (
623
                    !($parameterVar instanceof \PhpParser\Node\Expr\Variable)
10✔
624
                    || !\is_string($parameterVar->name)
10✔
625
                ) {
NEW
626
                    continue;
×
627
                }
628

629
                $promotedProperty = (new PHPProperty($this->parserContainer))
10✔
630
                    ->readObjectFromPromotedParam($parameter, $this->name);
10✔
631

632
                $propertyName = $parameterVar->name;
10✔
633
                $existingProperty = $this->properties[$propertyName] ?? null;
10✔
634
                if ($existingProperty !== null) {
10✔
635
                    $this->mergePromotedPropertyData($existingProperty, $promotedProperty, $parameter);
8✔
636

637
                    continue;
8✔
638
                }
639

640
                $this->properties[$propertyName] = $promotedProperty;
3✔
641
            }
642

643
            break;
11✔
644
        }
645
    }
646

647
    private function mergePromotedPropertyData(
648
        PHPProperty $existingProperty,
649
        PHPProperty $promotedProperty,
650
        \PhpParser\Node\Param $parameter
651
    ): void {
652
        if ($existingProperty->access === '' && $promotedProperty->access !== '') {
8✔
NEW
653
            $existingProperty->access = $promotedProperty->access;
×
654
        }
655

656
        if ($existingProperty->type === null && $promotedProperty->type !== null) {
8✔
NEW
657
            $existingProperty->type = $promotedProperty->type;
×
658
        }
659

660
        if ($existingProperty->is_readonly === null && $promotedProperty->is_readonly !== null) {
8✔
NEW
661
            $existingProperty->is_readonly = $promotedProperty->is_readonly;
×
662
        }
663

664
        if ($existingProperty->access_set === '' && $promotedProperty->access_set !== '') {
8✔
NEW
665
            $existingProperty->access_set = $promotedProperty->access_set;
×
666
        }
667

668
        if ($existingProperty->attributes === [] && $promotedProperty->attributes !== []) {
8✔
NEW
669
            $existingProperty->attributes = $promotedProperty->attributes;
×
670
        }
671

672
        if ($parameter->default !== null && $promotedProperty->typeFromDefaultValue !== null) {
8✔
673
            $existingProperty->defaultValue = $promotedProperty->defaultValue;
7✔
674
            $existingProperty->typeFromDefaultValue = $promotedProperty->typeFromDefaultValue;
7✔
675
        }
676
    }
677
}
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