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

voku / Simple-PHP-Code-Parser / 24272981784

11 Apr 2026 02:51AM UTC coverage: 79.752% (-3.5%) from 83.27%
24272981784

Pull #78

github

web-flow
Merge 7f69b0ffc into cc07ae5a0
Pull Request #78: Update dependencies: php-parser v5, phpdoc-parser v2, reflection-docblock v6, type-resolver v2, phpunit v11

26 of 33 new or added lines in 9 files covered. (78.79%)

27 existing lines in 3 files now uncovered.

1481 of 1857 relevant lines covered (79.75%)

33.51 hits per line

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

87.4
/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 RecursiveDirectoryIterator;
13
use RecursiveIteratorIterator;
14
use SplFileInfo;
15
use voku\cache\Cache;
16
use voku\SimplePhpParser\Model\PHPInterface;
17
use voku\SimplePhpParser\Parsers\Helper\ParserContainer;
18
use voku\SimplePhpParser\Parsers\Helper\ParserErrorHandler;
19
use voku\SimplePhpParser\Parsers\Helper\Utils;
20
use voku\SimplePhpParser\Parsers\Visitors\ASTVisitor;
21
use voku\SimplePhpParser\Parsers\Visitors\ParentConnector;
22
use function React\Async\await;
23
use function React\Promise\all;
24

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

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

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

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

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

97
        $phpCodes = self::getCode(
113✔
98
            $pathOrCode,
113✔
99
            $pathExcludeRegex,
113✔
100
            $fileExtensions
113✔
101
        );
113✔
102

103
        $parserContainer = new ParserContainer();
113✔
104
        $visitor = new ASTVisitor($parserContainer);
113✔
105

106
        $processResults = [];
113✔
107
        $phpCodesChunks = \array_chunk($phpCodes, Utils::getCpuCores(), true);
113✔
108

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

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

133
        $interfaces = $parserContainer->getInterfaces();
113✔
134
        foreach ($interfaces as &$interface) {
113✔
135
            $interface->parentInterfaces = $visitor->combineParentInterfaces($interface);
6✔
136
        }
137
        unset($interface);
113✔
138

139
        $pathTmp = null;
113✔
140
        if (\is_file($pathOrCode)) {
113✔
141
            $pathTmp = \realpath(\pathinfo($pathOrCode, \PATHINFO_DIRNAME));
86✔
142
        } elseif (\is_dir($pathOrCode)) {
27✔
143
            $pathTmp = \realpath($pathOrCode);
6✔
144
        }
145

146
        $classesTmp = &$parserContainer->getClassesByReference();
113✔
147
        foreach ($classesTmp as &$classTmp) {
113✔
148
            $classTmp->interfaces = Utils::flattenArray(
86✔
149
                $visitor->combineImplementedInterfaces($classTmp),
86✔
150
                false
86✔
151
            );
86✔
152

153
            self::mergeInheritdocData(
86✔
154
                $classTmp,
86✔
155
                $classesTmp,
86✔
156
                $interfaces,
86✔
157
                $parserContainer
86✔
158
            );
86✔
159
        }
160
        unset($classTmp);
113✔
161

162
        // remove properties / methods / classes from outside of the current file-path-scope
163
        if ($pathTmp) {
113✔
164
            $classesTmp2 = &$parserContainer->getClassesByReference();
92✔
165
            foreach ($classesTmp2 as $classKey => $classTmp2) {
92✔
166
                foreach ($classTmp2->constants as $constantKey => $constant) {
77✔
167
                    if ($constant->file && \strpos($constant->file, $pathTmp) === false) {
41✔
168
                        unset($classTmp2->constants[$constantKey]);
4✔
169
                    }
170
                }
171

172
                foreach ($classTmp2->properties as $propertyKey => $property) {
77✔
173
                    if ($property->file && \strpos($property->file, $pathTmp) === false) {
66✔
174
                        unset($classTmp2->properties[$propertyKey]);
6✔
175
                    }
176
                }
177

178
                foreach ($classTmp2->methods as $methodKey => $method) {
77✔
179
                    if ($method->file && \strpos($method->file, $pathTmp) === false) {
75✔
180
                        unset($classTmp2->methods[$methodKey]);
6✔
181
                    }
182
                }
183

184
                if ($classTmp2->file && \strpos($classTmp2->file, $pathTmp) === false) {
77✔
185
                    unset($classesTmp2[$classKey]);
6✔
186
                }
187
            }
188
        }
189

190
        return $parserContainer;
113✔
191
    }
192

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

209
        $errorHandler = new ParserErrorHandler();
113✔
210

211
        $nameResolver = new NameResolver(
113✔
212
            $errorHandler,
113✔
213
            [
113✔
214
                'preserveOriginalNames' => true,
113✔
215
            ]
113✔
216
        );
113✔
217

218
        /** @var \PhpParser\Node[]|null $parsedCode */
219
        $parsedCode = $parser->parse($phpCode, $errorHandler);
113✔
220

221
        if ($parsedCode === null) {
113✔
222
            return $errorHandler;
×
223
        }
224

225
        $visitor->fileName = $fileName;
113✔
226

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

238
        // Pass 2: extract model objects from the already-resolved AST.
239
        $traverser2 = new NodeTraverser();
113✔
240
        $traverser2->addVisitor($visitor);
113✔
241
        $traverser2->traverse($parsedCode);
113✔
242

243
        return $parserContainer;
113✔
244
    }
245

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

267
        // fallback
268
        if (\count($fileExtensions) === 0) {
113✔
269
            $fileExtensions = ['.php'];
113✔
270
        }
271

272
        if (\is_file($pathOrCode)) {
113✔
273
            $phpFileIterators = [new SplFileInfo($pathOrCode)];
86✔
274
        } elseif (\is_dir($pathOrCode)) {
27✔
275
            $phpFileIterators = new RecursiveIteratorIterator(
6✔
276
                new RecursiveDirectoryIterator($pathOrCode, FilesystemIterator::SKIP_DOTS)
6✔
277
            );
6✔
278
        } else {
279
            $cacheKey = self::CACHE_KEY_HELPER . \md5($pathOrCode);
21✔
280

281
            $phpCodes[$cacheKey]['content'] = $pathOrCode;
21✔
282
            $phpCodes[$cacheKey]['fileName'] = null;
21✔
283
        }
284

285
        $cache = new Cache(null, null, false);
113✔
286

287
        $phpFileArray = [];
113✔
288
        foreach ($phpFileIterators as $fileOrCode) {
113✔
289
            $path = $fileOrCode->getRealPath();
92✔
290
            if (!$path) {
92✔
291
                continue;
×
292
            }
293

294
            $fileExtensionFound = false;
92✔
295
            foreach ($fileExtensions as $fileExtension) {
92✔
296
                if (\substr($path, -\strlen($fileExtension)) === $fileExtension) {
92✔
297
                    $fileExtensionFound = true;
92✔
298

299
                    break;
92✔
300
                }
301
            }
302
            if ($fileExtensionFound === false) {
92✔
303
                continue;
×
304
            }
305

306
            foreach ($pathExcludeRegex as $regex) {
92✔
307
                if (\preg_match($regex, $path)) {
3✔
308
                    continue 2;
3✔
309
                }
310
            }
311

312
            $cacheKey = self::CACHE_KEY_HELPER . \md5($path) . '--' . \filemtime($path);
92✔
313
            if ($cache->getCacheIsReady() === true && $cache->existsItem($cacheKey)) {
92✔
314
                $response = $cache->getItem($cacheKey);
62✔
315
                /** @noinspection PhpSillyAssignmentInspection - helper for phpstan */
316
                /** @phpstan-var array{content: string, fileName: string, cacheKey: string} $response */
317
                $response = $response;
62✔
318

319
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
62✔
320
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
62✔
321

322
                continue;
62✔
323
            }
324

325
            $phpFileArray[$cacheKey] = $path;
36✔
326
        }
327

328
        $phpFileArrayChunks = \array_chunk($phpFileArray, Utils::getCpuCores(), true);
113✔
329
        foreach ($phpFileArrayChunks as $phpFileArrayChunk) {
113✔
330
            $filesystem = \React\Filesystem\Factory::create();
36✔
331

332
            foreach ($phpFileArrayChunk as $cacheKey => $path) {
36✔
333
                $phpFilePromises[] = $filesystem->detect($path)->then(
36✔
334
                    function (FileInterface $file) use ($path, $cacheKey) {
36✔
335
                        return [
36✔
336
                            'content'  => $file->getContents()->then(static function (string $contents) {
36✔
337
                                return $contents;
36✔
338
                            }),
36✔
339
                            'fileName' => $path,
36✔
340
                            'cacheKey' => $cacheKey,
36✔
341
                        ];
36✔
342
                    },
36✔
343
                    function ($e) {
36✔
344
                        throw $e;
×
345
                    }
36✔
346
                );
36✔
347
            }
348

349
            $phpFilePromiseResponses = await(all($phpFilePromises));
36✔
350
            foreach ($phpFilePromiseResponses as $response) {
36✔
351
                $response['content'] = await($response['content']);
36✔
352

353
                assert(is_string($response['content']));
354
                assert(is_string($response['cacheKey']));
355
                assert($response['fileName'] === null || is_string($response['fileName']));
356

357
                @$cache->setItem($response['cacheKey'], $response);
36✔
358

359
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
36✔
360
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
36✔
361
            }
362
        }
363

364
        return $phpCodes;
113✔
365
    }
366

367
    /**
368
     * @param \voku\SimplePhpParser\Model\PHPClass   $class
369
     * @param \voku\SimplePhpParser\Model\PHPClass[] $classes
370
     * @param PHPInterface[]                         $interfaces
371
     * @param ParserContainer                        $parserContainer
372
     */
373
    private static function mergeInheritdocData(
374
        \voku\SimplePhpParser\Model\PHPClass $class,
375
        array $classes,
376
        array $interfaces,
377
        ParserContainer $parserContainer
378
    ): void {
379
        foreach ($class->properties as &$property) {
86✔
380
            if (!$class->parentClass) {
72✔
381
                break;
57✔
382
            }
383

384
            if (!$property->is_inheritdoc) {
30✔
385
                continue;
30✔
386
            }
387

388
            if (
389
                !isset($classes[$class->parentClass])
×
390
                &&
391
                \class_exists($class->parentClass, true)
×
392
            ) {
393
                $reflectionClassTmp = Utils::createClassReflectionInstance($class->parentClass);
×
394
                $classTmp = (new \voku\SimplePhpParser\Model\PHPClass($parserContainer))->readObjectFromReflection($reflectionClassTmp);
×
395
                if ($classTmp->name) {
×
396
                    $classes[$classTmp->name] = $classTmp;
×
397
                }
398
            }
399

400
            if (!isset($classes[$class->parentClass])) {
×
401
                continue;
×
402
            }
403

404
            if (!isset($classes[$class->parentClass]->properties[$property->name])) {
×
405
                continue;
×
406
            }
407

408
            $parentMethod = $classes[$class->parentClass]->properties[$property->name];
×
409

410
            foreach ($property as $key => &$value) {
×
411
                if (
412
                    $value === null
×
413
                    &&
414
                    $parentMethod->{$key} !== null
×
415
                    &&
416
                    \stripos($key, 'type') !== false
×
417
                ) {
418
                    $value = $parentMethod->{$key};
×
419
                }
420
            }
421
        }
422
        unset($property, $value); /* @phpstan-ignore-line ? */
86✔
423

424
        foreach ($class->methods as &$method) {
86✔
425
            if (!$method->is_inheritdoc) {
81✔
426
                continue;
78✔
427
            }
428

429
            foreach ($class->interfaces as $interfaceStr) {
24✔
430
                if (
431
                    !isset($interfaces[$interfaceStr])
24✔
432
                    &&
433
                    \interface_exists($interfaceStr, true)
24✔
434
                ) {
435
                    $reflectionInterfaceTmp = Utils::createClassReflectionInstance($interfaceStr);
18✔
436
                    $interfaceTmp = (new PHPInterface($parserContainer))->readObjectFromReflection($reflectionInterfaceTmp);
18✔
437
                    if ($interfaceTmp->name) {
18✔
438
                        $interfaces[$interfaceTmp->name] = $interfaceTmp;
18✔
439
                    }
440
                }
441

442
                if (!isset($interfaces[$interfaceStr])) {
24✔
443
                    continue;
×
444
                }
445

446
                if (!isset($interfaces[$interfaceStr]->methods[$method->name])) {
24✔
447
                    continue;
18✔
448
                }
449

450
                $interfaceMethod = $interfaces[$interfaceStr]->methods[$method->name];
15✔
451

452
                foreach ($method as $key => &$value) {
15✔
453
                    if (
454
                        $value === null
15✔
455
                        &&
456
                        $interfaceMethod->{$key} !== null
15✔
457
                        &&
458
                        \stripos($key, 'type') !== false
15✔
459
                    ) {
460
                        $value = $interfaceMethod->{$key};
15✔
461
                    }
462

463
                    if ($key === 'parameters') {
15✔
464
                        $parameterCounter = 0;
15✔
465
                        foreach ($value as &$parameter) {
15✔
466
                            ++$parameterCounter;
15✔
467

468
                            \assert($parameter instanceof \voku\SimplePhpParser\Model\PHPParameter);
469

470
                            $interfaceMethodParameter = null;
15✔
471
                            $parameterCounterInterface = 0;
15✔
472
                            foreach ($interfaceMethod->parameters as $parameterInterface) {
15✔
473
                                ++$parameterCounterInterface;
15✔
474

475
                                if ($parameterCounterInterface === $parameterCounter) {
15✔
476
                                    $interfaceMethodParameter = $parameterInterface;
15✔
477
                                }
478
                            }
479

480
                            if (!$interfaceMethodParameter) {
15✔
481
                                continue;
×
482
                            }
483

484
                            foreach ($parameter as $keyInner => &$valueInner) {
15✔
485
                                if (
486
                                    $valueInner === null
15✔
487
                                    &&
488
                                    $interfaceMethodParameter->{$keyInner} !== null
15✔
489
                                    &&
490
                                    \stripos($keyInner, 'type') !== false
15✔
491
                                ) {
492
                                    $valueInner = $interfaceMethodParameter->{$keyInner};
15✔
493
                                }
494
                            }
495
                            unset($valueInner); /* @phpstan-ignore-line ? */
15✔
496
                        }
497
                        unset($parameter);
15✔
498
                    }
499
                }
500
                unset($value); /* @phpstan-ignore-line ? */
15✔
501
            }
502

503
            if (!isset($classes[$class->parentClass])) {
24✔
504
                continue;
15✔
505
            }
506

507
            if (!isset($classes[$class->parentClass]->methods[$method->name])) {
15✔
508
                continue;
×
509
            }
510

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

513
            foreach ($method as $key => &$value) {
15✔
514
                if (
515
                    $value === null
15✔
516
                    &&
517
                    $parentMethod->{$key} !== null
15✔
518
                    &&
519
                    \stripos($key, 'type') !== false
15✔
520
                ) {
521
                    $value = $parentMethod->{$key};
15✔
522
                }
523

524
                if ($key === 'parameters') {
15✔
525
                    $parameterCounter = 0;
15✔
526
                    foreach ($value as &$parameter) {
15✔
527
                        ++$parameterCounter;
15✔
528

529
                        \assert($parameter instanceof \voku\SimplePhpParser\Model\PHPParameter);
530

531
                        $parentMethodParameter = null;
15✔
532
                        $parameterCounterParent = 0;
15✔
533
                        foreach ($parentMethod->parameters as $parameterParent) {
15✔
534
                            ++$parameterCounterParent;
15✔
535

536
                            if ($parameterCounterParent === $parameterCounter) {
15✔
537
                                $parentMethodParameter = $parameterParent;
15✔
538
                            }
539
                        }
540

541
                        if (!$parentMethodParameter) {
15✔
542
                            continue;
×
543
                        }
544

545
                        foreach ($parameter as $keyInner => &$valueInner) {
15✔
546
                            if (
547
                                $valueInner === null
15✔
548
                                &&
549
                                $parentMethodParameter->{$keyInner} !== null
15✔
550
                                &&
551
                                \stripos($keyInner, 'type') !== false
15✔
552
                            ) {
553
                                $valueInner = $parentMethodParameter->{$keyInner};
15✔
554
                            }
555
                        }
556
                    }
557
                }
558
            }
559
        }
560
    }
561
}
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