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

voku / Simple-PHP-Code-Parser / 24274361932

11 Apr 2026 04:11AM UTC coverage: 82.857% (+0.8%) from 82.099%
24274361932

push

github

web-flow
Merge pull request #80 from voku/copilot/replace-get-cpu-cores

Replace hand-rolled getCpuCores() with fidry/cpu-core-counter

4 of 4 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

1537 of 1855 relevant lines covered (82.86%)

44.94 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
final 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(
32✔
43
            $code,
32✔
44
            $autoloaderProjectPaths
32✔
45
        );
32✔
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);
4✔
61

62
        return self::getPhpFiles(
4✔
63
            (string) $reflectionClass->getFileName(),
4✔
64
            $autoloaderProjectPaths
4✔
65
        );
4✔
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);
150✔
86
        try {
87
            foreach ($autoloaderProjectPaths as $projectPath) {
150✔
88
                if (\file_exists($projectPath) && \is_file($projectPath)) {
×
89
                    require_once $projectPath;
×
90
                } elseif (\file_exists($projectPath . '/vendor/autoload.php')) {
×
91
                    require_once $projectPath . '/vendor/autoload.php';
×
92
                } elseif (\file_exists($projectPath . '/../vendor/autoload.php')) {
×
93
                    require_once $projectPath . '/../vendor/autoload.php';
×
94
                }
95
            }
96
        } finally {
97
            \restore_error_handler();
150✔
98
        }
99

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

106
        $parserContainer = new ParserContainer();
150✔
107
        $visitor = new ASTVisitor($parserContainer);
150✔
108

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

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

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

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

142
        $pathTmp = null;
150✔
143
        if (\is_file($pathOrCode)) {
150✔
144
            $pathTmp = \realpath(\pathinfo($pathOrCode, \PATHINFO_DIRNAME));
110✔
145
        } elseif (\is_dir($pathOrCode)) {
40✔
146
            $pathTmp = \realpath($pathOrCode);
8✔
147
        }
148

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

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

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

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

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

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

193
        return $parserContainer;
150✔
194
    }
195

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

212
        $errorHandler = new ParserErrorHandler();
150✔
213

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

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

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

228
        $visitor->fileName = $fileName;
150✔
229

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

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

246
        return $parserContainer;
150✔
247
    }
248

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

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

275
        if (\is_file($pathOrCode)) {
150✔
276
            $phpFileIterators = [new SplFileInfo($pathOrCode)];
110✔
277
        } elseif (\is_dir($pathOrCode)) {
40✔
278
            $phpFileIterators = new RecursiveIteratorIterator(
8✔
279
                new RecursiveDirectoryIterator($pathOrCode, FilesystemIterator::SKIP_DOTS)
8✔
280
            );
8✔
281
        } else {
282
            $cacheKey = self::CACHE_KEY_HELPER . \md5($pathOrCode);
32✔
283

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

288
        $cache = new Cache(null, null, false);
150✔
289

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

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

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

309
            foreach ($pathExcludeRegex as $regex) {
118✔
310
                if (\preg_match($regex, $path)) {
4✔
311
                    continue 2;
4✔
312
                }
313
            }
314

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

322
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
79✔
323
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
79✔
324

325
                continue;
79✔
326
            }
327

328
            $phpFileArray[$cacheKey] = $path;
47✔
329
        }
330

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

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

352
            $phpFilePromiseResponses = await(all($phpFilePromises));
47✔
353
            foreach ($phpFilePromiseResponses as $response) {
47✔
354
                $response['content'] = await($response['content']);
47✔
355

356
                assert(is_string($response['content']));
357
                assert(is_string($response['cacheKey']));
358
                assert($response['fileName'] === null || is_string($response['fileName']));
359

360
                @$cache->setItem($response['cacheKey'], $response);
47✔
361

362
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
47✔
363
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
47✔
364
            }
365
        }
366

367
        return $phpCodes;
150✔
368
    }
369

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

387
            if (!$property->is_inheritdoc) {
40✔
388
                continue;
40✔
389
            }
390

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

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

407
            if (!isset($classes[$class->parentClass]->properties[$property->name])) {
×
408
                continue;
×
409
            }
410

411
            $parentMethod = $classes[$class->parentClass]->properties[$property->name];
×
412

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

427
        foreach ($class->methods as &$method) {
115✔
428
            if (!$method->is_inheritdoc) {
109✔
429
                continue;
105✔
430
            }
431

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

445
                if (!isset($interfaces[$interfaceStr])) {
31✔
446
                    continue;
×
447
                }
448

449
                if (!isset($interfaces[$interfaceStr]->methods[$method->name])) {
31✔
450
                    continue;
24✔
451
                }
452

453
                $interfaceMethod = $interfaces[$interfaceStr]->methods[$method->name];
19✔
454

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

466
                    if ($key === 'parameters') {
19✔
467
                        $parameterCounter = 0;
19✔
468
                        foreach ($value as &$parameter) {
19✔
469
                            ++$parameterCounter;
19✔
470

471
                            \assert($parameter instanceof \voku\SimplePhpParser\Model\PHPParameter);
472

473
                            $interfaceMethodParameter = null;
19✔
474
                            $parameterCounterInterface = 0;
19✔
475
                            foreach ($interfaceMethod->parameters as $parameterInterface) {
19✔
476
                                ++$parameterCounterInterface;
19✔
477

478
                                if ($parameterCounterInterface === $parameterCounter) {
19✔
479
                                    $interfaceMethodParameter = $parameterInterface;
19✔
480
                                }
481
                            }
482

483
                            if (!$interfaceMethodParameter) {
19✔
484
                                continue;
×
485
                            }
486

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

506
            if (!isset($classes[$class->parentClass])) {
31✔
507
                continue;
19✔
508
            }
509

510
            if (!isset($classes[$class->parentClass]->methods[$method->name])) {
20✔
511
                continue;
×
512
            }
513

514
            $parentMethod = $classes[$class->parentClass]->methods[$method->name];
20✔
515

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

527
                if ($key === 'parameters') {
20✔
528
                    $parameterCounter = 0;
20✔
529
                    foreach ($value as &$parameter) {
20✔
530
                        ++$parameterCounter;
20✔
531

532
                        \assert($parameter instanceof \voku\SimplePhpParser\Model\PHPParameter);
533

534
                        $parentMethodParameter = null;
20✔
535
                        $parameterCounterParent = 0;
20✔
536
                        foreach ($parentMethod->parameters as $parameterParent) {
20✔
537
                            ++$parameterCounterParent;
20✔
538

539
                            if ($parameterCounterParent === $parameterCounter) {
20✔
540
                                $parentMethodParameter = $parameterParent;
20✔
541
                            }
542
                        }
543

544
                        if (!$parentMethodParameter) {
20✔
545
                            continue;
×
546
                        }
547

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