• 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

87.11
/src/voku/SimplePhpParser/Parsers/PhpCodeParser.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace voku\SimplePhpParser\Parsers;
6

7
use FilesystemIterator;
8
use PhpParser\NodeTraverser;
9
use PhpParser\NodeVisitor\NameResolver;
10
use PhpParser\ParserFactory;
11
use React\Filesystem\Node\FileInterface;
12
use React\Filesystem\Node\NodeInterface;
13
use RecursiveDirectoryIterator;
14
use RecursiveIteratorIterator;
15
use SplFileInfo;
16
use voku\cache\Cache;
17
use voku\SimplePhpParser\Model\PHPInterface;
18
use voku\SimplePhpParser\Parsers\Helper\ParserContainer;
19
use voku\SimplePhpParser\Parsers\Helper\ParserErrorHandler;
20
use voku\SimplePhpParser\Parsers\Helper\Utils;
21
use voku\SimplePhpParser\Parsers\Visitors\ASTVisitor;
22
use voku\SimplePhpParser\Parsers\Visitors\ParentConnector;
23
use function React\Async\await;
24
use function React\Promise\all;
25

26
final class PhpCodeParser
27
{
28
    /**
29
     * @internal
30
     */
31
    private const CACHE_KEY_HELPER = 'simple-php-code-parser-v6-';
32

33
    /**
34
     * @param string   $code
35
     * @param string[] $autoloaderProjectPaths
36
     *
37
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer
38
     */
39
    public static function getFromString(
40
        string $code,
41
        array $autoloaderProjectPaths = []
42
    ): ParserContainer {
43
        return self::getPhpFiles(
28✔
44
            $code,
28✔
45
            $autoloaderProjectPaths
28✔
46
        );
28✔
47
    }
48

49
    /**
50
     * @param string   $className
51
     * @param string[] $autoloaderProjectPaths
52
     *
53
     * @phpstan-param class-string $className
54
     *
55
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer
56
     */
57
    public static function getFromClassName(
58
        string $className,
59
        array $autoloaderProjectPaths = []
60
    ): ParserContainer {
61
        $reflectionClass = Utils::createClassReflectionInstance($className);
3✔
62

63
        return self::getPhpFiles(
3✔
64
            (string) $reflectionClass->getFileName(),
3✔
65
            $autoloaderProjectPaths
3✔
66
        );
3✔
67
    }
68

69
    /**
70
     * @param string   $pathOrCode
71
     * @param string[] $autoloaderProjectPaths
72
     * @param string[] $pathExcludeRegex
73
     * @param string[] $fileExtensions
74
     *
75
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer
76
     */
77
    public static function getPhpFiles(
78
        string $pathOrCode,
79
        array $autoloaderProjectPaths = [],
80
        array $pathExcludeRegex = [],
81
        array $fileExtensions = []
82
    ): ParserContainer {
83
        // Push a disposable handler so restore_error_handler() below will only
84
        // pop this one entry, leaving any pre-existing handlers (e.g. PHPUnit's)
85
        // intact on the stack.
86
        \set_error_handler(null);
115✔
87
        try {
88
            foreach ($autoloaderProjectPaths as $projectPath) {
115✔
89
                if (\file_exists($projectPath) && \is_file($projectPath)) {
×
90
                    require_once $projectPath;
×
91
                } elseif (\file_exists($projectPath . '/vendor/autoload.php')) {
×
92
                    require_once $projectPath . '/vendor/autoload.php';
×
93
                } elseif (\file_exists($projectPath . '/../vendor/autoload.php')) {
×
94
                    require_once $projectPath . '/../vendor/autoload.php';
×
95
                }
96
            }
97
        } finally {
98
            \restore_error_handler();
115✔
99
        }
100

101
        $phpCodes = self::getCode(
115✔
102
            $pathOrCode,
115✔
103
            $pathExcludeRegex,
115✔
104
            $fileExtensions
115✔
105
        );
115✔
106

107
        $parserContainer = new ParserContainer();
115✔
108
        $visitor = new ASTVisitor($parserContainer);
115✔
109

110
        $processResults = [];
115✔
111
        $phpCodesChunks = \array_chunk($phpCodes, Utils::getCpuCores(), true);
115✔
112

113
        foreach ($phpCodesChunks as $phpCodesChunk) {
115✔
114
            foreach ($phpCodesChunk as $codeAndFileName) {
115✔
115
                $processResults[] = self::process(
115✔
116
                    $codeAndFileName['content'],
115✔
117
                    $codeAndFileName['fileName'],
115✔
118
                    $parserContainer,
115✔
119
                    $visitor
115✔
120
                );
115✔
121
            }
122
        }
123

124
        foreach ($processResults as $response) {
115✔
125
            if ($response instanceof ParserContainer) {
115✔
126
                $parserContainer->setTraits($response->getTraits());
115✔
127
                $parserContainer->setClasses($response->getClasses());
115✔
128
                $parserContainer->setInterfaces($response->getInterfaces());
115✔
129
                $parserContainer->setEnums($response->getEnums());
115✔
130
                $parserContainer->setConstants($response->getConstants());
115✔
131
                $parserContainer->setFunctions($response->getFunctions());
115✔
132
            } elseif ($response instanceof ParserErrorHandler) {
×
133
                $parserContainer->setParseError($response);
×
134
            }
135
        }
136

137
        $interfaces = $parserContainer->getInterfaces();
115✔
138
        foreach ($interfaces as &$interface) {
115✔
139
            $interface->parentInterfaces = $visitor->combineParentInterfaces($interface);
6✔
140
        }
141
        unset($interface);
115✔
142

143
        $pathTmp = null;
115✔
144
        if (\is_file($pathOrCode)) {
115✔
145
            $pathTmp = \realpath(\pathinfo($pathOrCode, \PATHINFO_DIRNAME));
81✔
146
        } elseif (\is_dir($pathOrCode)) {
34✔
147
            $pathTmp = \realpath($pathOrCode);
6✔
148
        }
149

150
        $classesTmp = &$parserContainer->getClassesByReference();
115✔
151
        foreach ($classesTmp as &$classTmp) {
115✔
152
            $classTmp->interfaces = Utils::flattenArray(
89✔
153
                $visitor->combineImplementedInterfaces($classTmp),
89✔
154
                false
89✔
155
            );
89✔
156

157
            self::mergeInheritdocData(
89✔
158
                $classTmp,
89✔
159
                $classesTmp,
89✔
160
                $interfaces,
89✔
161
                $parserContainer
89✔
162
            );
89✔
163
        }
164
        unset($classTmp);
115✔
165

166
        // remove properties / methods / classes from outside of the current file-path-scope
167
        if ($pathTmp) {
115✔
168
            $classesTmp2 = &$parserContainer->getClassesByReference();
87✔
169
            foreach ($classesTmp2 as $classKey => $classTmp2) {
87✔
170
                foreach ($classTmp2->constants as $constantKey => $constant) {
73✔
171
                    if ($constant->file && \strpos($constant->file, $pathTmp) === false) {
40✔
172
                        unset($classTmp2->constants[$constantKey]);
4✔
173
                    }
174
                }
175

176
                foreach ($classTmp2->properties as $propertyKey => $property) {
73✔
177
                    if ($property->file && \strpos($property->file, $pathTmp) === false) {
63✔
178
                        unset($classTmp2->properties[$propertyKey]);
6✔
179
                    }
180
                }
181

182
                foreach ($classTmp2->methods as $methodKey => $method) {
73✔
183
                    if ($method->file && \strpos($method->file, $pathTmp) === false) {
72✔
184
                        unset($classTmp2->methods[$methodKey]);
6✔
185
                    }
186
                }
187

188
                if ($classTmp2->file && \strpos($classTmp2->file, $pathTmp) === false) {
73✔
189
                    unset($classesTmp2[$classKey]);
6✔
190
                }
191
            }
192
        }
193

194
        return $parserContainer;
115✔
195
    }
196

197
    /**
198
     * @param string                                               $phpCode
199
     * @param string|null                                          $fileName
200
     * @param \voku\SimplePhpParser\Parsers\Helper\ParserContainer $parserContainer
201
     * @param \voku\SimplePhpParser\Parsers\Visitors\ASTVisitor    $visitor
202
     *
203
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer|\voku\SimplePhpParser\Parsers\Helper\ParserErrorHandler
204
     */
205
    public static function process(
206
        string $phpCode,
207
        ?string $fileName,
208
        ParserContainer $parserContainer,
209
        ASTVisitor $visitor
210
    ) {
211
        $parser = (new ParserFactory())->createForNewestSupportedVersion();
115✔
212

213
        $errorHandler = new ParserErrorHandler();
115✔
214

215
        $nameResolver = new NameResolver(
115✔
216
            $errorHandler,
115✔
217
            [
115✔
218
                'preserveOriginalNames' => true,
115✔
219
            ]
115✔
220
        );
115✔
221

222
        /** @var \PhpParser\Node[]|null $parsedCode */
223
        $parsedCode = $parser->parse($phpCode, $errorHandler);
115✔
224

225
        if ($parsedCode === null) {
115✔
226
            return $errorHandler;
×
227
        }
228

229
        // Pass 1: set parent attributes and fully resolve all names in the AST.
230
        // NameResolver modifies Name nodes in-place (converting them to FullyQualified),
231
        // so by the time ASTVisitor runs in pass 2, every type-hint Name node already
232
        // carries its fully-qualified form. This is necessary because ASTVisitor processes
233
        // class members (properties, methods) eagerly inside enterNode(Class_), before
234
        // the single-pass traverser would have had a chance to visit those child nodes.
235
        $traverser1 = new NodeTraverser();
115✔
236
        $traverser1->addVisitor(new ParentConnector());
115✔
237
        $traverser1->addVisitor($nameResolver);
115✔
238
        $traverser1->traverse($parsedCode);
115✔
239

240
        $visitor->fileName = $fileName;
115✔
241

242
        // Pass 2: extract model objects from the already-resolved AST.
243
        $traverser2 = new NodeTraverser();
115✔
244
        $traverser2->addVisitor($visitor);
115✔
245
        $traverser2->traverse($parsedCode);
115✔
246

247
        return $parserContainer;
115✔
248
    }
249

250
    /**
251
     * @param string   $pathOrCode
252
     * @param string[] $pathExcludeRegex
253
     * @param string[] $fileExtensions
254
     *
255
     * @return array
256
     *
257
     * @psalm-return array<string, array{content: string, fileName: null|string}>
258
     */
259
    private static function getCode(
260
        string $pathOrCode,
261
        array $pathExcludeRegex = [],
262
        array $fileExtensions = []
263
    ): array {
264
        // init
265
        $phpCodes = [];
115✔
266
        /** @var SplFileInfo[] $phpFileIterators */
267
        $phpFileIterators = [];
115✔
268
        /** @var list<\React\Promise\PromiseInterface<array{content: \React\Promise\PromiseInterface<string>, fileName: string, cacheKey: string}>> $phpFilePromises */
269
        $phpFilePromises = [];
115✔
270

271
        // fallback
272
        if (\count($fileExtensions) === 0) {
115✔
273
            $fileExtensions = ['.php'];
115✔
274
        }
275

276
        if (\is_file($pathOrCode)) {
115✔
277
            $phpFileIterators = [new SplFileInfo($pathOrCode)];
81✔
278
        } elseif (\is_dir($pathOrCode)) {
34✔
279
            $phpFileIterators = new RecursiveIteratorIterator(
6✔
280
                new RecursiveDirectoryIterator($pathOrCode, FilesystemIterator::SKIP_DOTS)
6✔
281
            );
6✔
282
        } else {
283
            $cacheKey = self::CACHE_KEY_HELPER . \md5($pathOrCode);
28✔
284

285
            $phpCodes[$cacheKey]['content'] = $pathOrCode;
28✔
286
            $phpCodes[$cacheKey]['fileName'] = null;
28✔
287
        }
288

289
        $cache = new Cache(null, null, false);
115✔
290

291
        $phpFileArray = [];
115✔
292
        foreach ($phpFileIterators as $fileOrCode) {
115✔
293
            $path = $fileOrCode->getRealPath();
87✔
294
            if (!$path) {
87✔
295
                continue;
×
296
            }
297

298
            $fileExtensionFound = false;
87✔
299
            foreach ($fileExtensions as $fileExtension) {
87✔
300
                if (\substr($path, -\strlen($fileExtension)) === $fileExtension) {
87✔
301
                    $fileExtensionFound = true;
87✔
302

303
                    break;
87✔
304
                }
305
            }
306
            if ($fileExtensionFound === false) {
87✔
307
                continue;
×
308
            }
309

310
            foreach ($pathExcludeRegex as $regex) {
87✔
311
                if (\preg_match($regex, $path)) {
3✔
312
                    continue 2;
3✔
313
                }
314
            }
315

316
            $cacheKey = self::CACHE_KEY_HELPER . \md5($path) . '--' . \filemtime($path);
87✔
317
            if ($cache->getCacheIsReady() === true && $cache->existsItem($cacheKey)) {
87✔
318
                $response = $cache->getItem($cacheKey);
58✔
319
                /** @noinspection PhpSillyAssignmentInspection - helper for phpstan */
320
                /** @phpstan-var array{content: string, fileName: string, cacheKey: string} $response */
321
                $response = $response;
58✔
322

323
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
58✔
324
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
58✔
325

326
                continue;
58✔
327
            }
328

329
            $phpFileArray[$cacheKey] = $path;
35✔
330
        }
331

332
        $phpFileArrayChunks = \array_chunk($phpFileArray, Utils::getCpuCores(), true);
115✔
333
        foreach ($phpFileArrayChunks as $phpFileArrayChunk) {
115✔
334
            $filesystem = \React\Filesystem\Factory::create();
35✔
335

336
            foreach ($phpFileArrayChunk as $cacheKey => $path) {
35✔
337
                $phpFilePromises[] = $filesystem->detect($path)->then(
35✔
338
                    static function (NodeInterface $node) use ($path, $cacheKey): array {
35✔
339
                        if (!$node instanceof FileInterface) {
35✔
NEW
340
                            throw new \RuntimeException('Expected a file node for: ' . $path);
×
341
                        }
342

343
                        return [
35✔
344
                            'content'  => $node->getContents()->then(static function (string $contents): string {
35✔
345
                                return $contents;
35✔
346
                            }),
35✔
347
                            'fileName' => $path,
35✔
348
                            'cacheKey' => $cacheKey,
35✔
349
                        ];
35✔
350
                    },
35✔
351
                    function ($e) {
35✔
352
                        throw $e;
×
353
                    }
35✔
354
                );
35✔
355
            }
356

357
            /** @var list<array{content: \React\Promise\PromiseInterface<string>, fileName: string, cacheKey: string}> $phpFilePromiseResponses */
358
            $phpFilePromiseResponses = await(all($phpFilePromises));
35✔
359
            foreach ($phpFilePromiseResponses as $response) {
35✔
360
                $response['content'] = await($response['content']);
35✔
361

362
                assert(is_string($response['content']));
363
                assert(is_string($response['cacheKey']));
364
                assert($response['fileName'] === null || is_string($response['fileName']));
365

366
                @$cache->setItem($response['cacheKey'], $response);
35✔
367

368
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
35✔
369
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
35✔
370
            }
371
        }
372

373
        return $phpCodes;
115✔
374
    }
375

376
    /**
377
     * @param \voku\SimplePhpParser\Model\PHPClass   $class
378
     * @param \voku\SimplePhpParser\Model\PHPClass[] $classes
379
     * @param PHPInterface[]                         $interfaces
380
     * @param ParserContainer                        $parserContainer
381
     */
382
    private static function mergeInheritdocData(
383
        \voku\SimplePhpParser\Model\PHPClass $class,
384
        array $classes,
385
        array $interfaces,
386
        ParserContainer $parserContainer
387
    ): void {
388
        foreach ($class->properties as &$property) {
89✔
389
            if (!$class->parentClass) {
69✔
390
                break;
54✔
391
            }
392

393
            if (!$property->is_inheritdoc) {
30✔
394
                continue;
30✔
395
            }
396

397
            if (
398
                !isset($classes[$class->parentClass])
×
399
                &&
400
                \class_exists($class->parentClass, true)
×
401
            ) {
402
                $reflectionClassTmp = Utils::createClassReflectionInstance($class->parentClass);
×
403
                $classTmp = (new \voku\SimplePhpParser\Model\PHPClass($parserContainer))->readObjectFromReflection($reflectionClassTmp);
×
404
                if ($classTmp->name) {
×
405
                    $classes[$classTmp->name] = $classTmp;
×
406
                }
407
            }
408

409
            if (!isset($classes[$class->parentClass])) {
×
410
                continue;
×
411
            }
412

413
            if (!isset($classes[$class->parentClass]->properties[$property->name])) {
×
414
                continue;
×
415
            }
416

417
            $parentMethod = $classes[$class->parentClass]->properties[$property->name];
×
418

419
            foreach ($property as $key => &$value) {
×
420
                if (
UNCOV
421
                    $value === null
×
422
                    &&
UNCOV
423
                    $parentMethod->{$key} !== null
×
424
                    &&
UNCOV
425
                    \stripos($key, 'type') !== false
×
426
                ) {
UNCOV
427
                    $value = $parentMethod->{$key};
×
428
                }
429
            }
430
        }
431
        unset($property, $value); /* @phpstan-ignore-line ? */
89✔
432

433
        foreach ($class->methods as &$method) {
89✔
434
            if (!$method->is_inheritdoc) {
84✔
435
                continue;
81✔
436
            }
437

438
            foreach ($class->interfaces as $interfaceStr) {
23✔
439
                if (
440
                    !isset($interfaces[$interfaceStr])
23✔
441
                    &&
442
                    \interface_exists($interfaceStr, true)
23✔
443
                ) {
444
                    $reflectionInterfaceTmp = Utils::createClassReflectionInstance($interfaceStr);
17✔
445
                    $interfaceTmp = (new PHPInterface($parserContainer))->readObjectFromReflection($reflectionInterfaceTmp);
17✔
446
                    if ($interfaceTmp->name) {
17✔
447
                        $interfaces[$interfaceTmp->name] = $interfaceTmp;
17✔
448
                    }
449
                }
450

451
                if (!isset($interfaces[$interfaceStr])) {
23✔
UNCOV
452
                    continue;
×
453
                }
454

455
                if (!isset($interfaces[$interfaceStr]->methods[$method->name])) {
23✔
456
                    continue;
18✔
457
                }
458

459
                $interfaceMethod = $interfaces[$interfaceStr]->methods[$method->name];
14✔
460

461
                foreach ($method as $key => &$value) {
14✔
462
                    if (
463
                        $value === null
14✔
464
                        &&
465
                        $interfaceMethod->{$key} !== null
14✔
466
                        &&
467
                        \stripos($key, 'type') !== false
14✔
468
                    ) {
469
                        $value = $interfaceMethod->{$key};
14✔
470
                    }
471

472
                    if ($key === 'parameters') {
14✔
473
                        $parameterCounter = 0;
14✔
474
                        foreach ($value as &$parameter) {
14✔
475
                            ++$parameterCounter;
14✔
476

477
                            \assert($parameter instanceof \voku\SimplePhpParser\Model\PHPParameter);
478

479
                            $interfaceMethodParameter = null;
14✔
480
                            $parameterCounterInterface = 0;
14✔
481
                            foreach ($interfaceMethod->parameters as $parameterInterface) {
14✔
482
                                ++$parameterCounterInterface;
14✔
483

484
                                if ($parameterCounterInterface === $parameterCounter) {
14✔
485
                                    $interfaceMethodParameter = $parameterInterface;
14✔
486
                                }
487
                            }
488

489
                            if (!$interfaceMethodParameter) {
14✔
NEW
490
                                continue;
×
491
                            }
492

493
                            foreach ($parameter as $keyInner => &$valueInner) {
14✔
494
                                if (
495
                                    $valueInner === null
14✔
496
                                    &&
497
                                    $interfaceMethodParameter->{$keyInner} !== null
14✔
498
                                    &&
499
                                    \stripos($keyInner, 'type') !== false
14✔
500
                                ) {
501
                                    $valueInner = $interfaceMethodParameter->{$keyInner};
14✔
502
                                }
503
                            }
504
                            unset($valueInner); /* @phpstan-ignore-line ? */
14✔
505
                        }
506
                        unset($parameter);
14✔
507
                    }
508
                }
509
                unset($value); /* @phpstan-ignore-line ? */
14✔
510
            }
511

512
            if (!isset($classes[$class->parentClass])) {
23✔
513
                continue;
14✔
514
            }
515

516
            if (!isset($classes[$class->parentClass]->methods[$method->name])) {
15✔
UNCOV
517
                continue;
×
518
            }
519

520
            $parentMethod = $classes[$class->parentClass]->methods[$method->name];
15✔
521

522
            foreach ($method as $key => &$value) {
15✔
523
                if (
524
                    $value === null
15✔
525
                    &&
526
                    $parentMethod->{$key} !== null
15✔
527
                    &&
528
                    \stripos($key, 'type') !== false
15✔
529
                ) {
530
                    $value = $parentMethod->{$key};
15✔
531
                }
532

533
                if ($key === 'parameters') {
15✔
534
                    $parameterCounter = 0;
15✔
535
                    foreach ($value as &$parameter) {
15✔
536
                        ++$parameterCounter;
15✔
537

538
                        \assert($parameter instanceof \voku\SimplePhpParser\Model\PHPParameter);
539

540
                        $parentMethodParameter = null;
15✔
541
                        $parameterCounterParent = 0;
15✔
542
                        foreach ($parentMethod->parameters as $parameterParent) {
15✔
543
                            ++$parameterCounterParent;
15✔
544

545
                            if ($parameterCounterParent === $parameterCounter) {
15✔
546
                                $parentMethodParameter = $parameterParent;
15✔
547
                            }
548
                        }
549

550
                        if (!$parentMethodParameter) {
15✔
UNCOV
551
                            continue;
×
552
                        }
553

554
                        foreach ($parameter as $keyInner => &$valueInner) {
15✔
555
                            if (
556
                                $valueInner === null
15✔
557
                                &&
558
                                $parentMethodParameter->{$keyInner} !== null
15✔
559
                                &&
560
                                \stripos($keyInner, 'type') !== false
15✔
561
                            ) {
562
                                $valueInner = $parentMethodParameter->{$keyInner};
15✔
563
                            }
564
                        }
565
                    }
566
                }
567
            }
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