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

voku / Simple-PHP-Code-Parser / 24277165180

11 Apr 2026 06:58AM UTC coverage: 82.802% (-0.08%) from 82.886%
24277165180

Pull #84

github

web-flow
Merge 5ed497a2b into 5156d5d74
Pull Request #84: [WIP] Update dependencies for compatibility with PHP 8.4

8 of 9 new or added lines in 1 file covered. (88.89%)

9 existing lines in 1 file now uncovered.

1531 of 1849 relevant lines covered (82.8%)

90.82 hits per line

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

87.08
/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 RecursiveDirectoryIterator;
12
use RecursiveIteratorIterator;
13
use SplFileInfo;
14
use voku\cache\Cache;
15
use voku\SimplePhpParser\Model\PHPInterface;
16
use voku\SimplePhpParser\Parsers\Helper\ParserContainer;
17
use voku\SimplePhpParser\Parsers\Helper\ParserErrorHandler;
18
use voku\SimplePhpParser\Parsers\Helper\Utils;
19
use voku\SimplePhpParser\Parsers\Visitors\ASTVisitor;
20
use voku\SimplePhpParser\Parsers\Visitors\ParentConnector;
21

22
final class PhpCodeParser
23
{
24
    /**
25
     * @internal
26
     */
27
    private const CACHE_KEY_HELPER = 'simple-php-code-parser-v6-';
28

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

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

59
        return self::getPhpFiles(
8✔
60
            (string) $reflectionClass->getFileName(),
8✔
61
            $autoloaderProjectPaths
8✔
62
        );
8✔
63
    }
64

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

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

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

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

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

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

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

139
        $pathTmp = null;
312✔
140
        if (\is_file($pathOrCode)) {
312✔
141
            $pathTmp = \realpath(\pathinfo($pathOrCode, \PATHINFO_DIRNAME));
220✔
142
        } elseif (\is_dir($pathOrCode)) {
92✔
143
            $pathTmp = \realpath($pathOrCode);
16✔
144
        }
145

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

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

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

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

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

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

190
        return $parserContainer;
312✔
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();
312✔
208

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

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

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

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

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

236
        $visitor->fileName = $fileName;
312✔
237

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

243
        return $parserContainer;
312✔
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 = [];
312✔
262
        /** @var SplFileInfo[] $phpFileIterators */
263
        $phpFileIterators = [];
312✔
264

265
        // fallback
266
        if (\count($fileExtensions) === 0) {
312✔
267
            $fileExtensions = ['.php'];
312✔
268
        }
269

270
        if (\is_file($pathOrCode)) {
312✔
271
            $phpFileIterators = [new SplFileInfo($pathOrCode)];
220✔
272
        } elseif (\is_dir($pathOrCode)) {
92✔
273
            $phpFileIterators = new RecursiveIteratorIterator(
16✔
274
                new RecursiveDirectoryIterator($pathOrCode, FilesystemIterator::SKIP_DOTS)
16✔
275
            );
16✔
276
        } else {
277
            $cacheKey = self::CACHE_KEY_HELPER . \md5($pathOrCode);
76✔
278

279
            $phpCodes[$cacheKey]['content'] = $pathOrCode;
76✔
280
            $phpCodes[$cacheKey]['fileName'] = null;
76✔
281
        }
282

283
        $cache = new Cache(null, null, false);
312✔
284

285
        $phpFileArray = [];
312✔
286
        foreach ($phpFileIterators as $fileOrCode) {
312✔
287
            $path = $fileOrCode->getRealPath();
236✔
288
            if (!$path) {
236✔
289
                continue;
×
290
            }
291

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

297
                    break;
236✔
298
                }
299
            }
300
            if ($fileExtensionFound === false) {
236✔
301
                continue;
×
302
            }
303

304
            foreach ($pathExcludeRegex as $regex) {
236✔
305
                if (\preg_match($regex, $path)) {
8✔
306
                    continue 2;
8✔
307
                }
308
            }
309

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

317
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
158✔
318
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
158✔
319

320
                continue;
158✔
321
            }
322

323
            $phpFileArray[$cacheKey] = $path;
94✔
324
        }
325

326
        foreach ($phpFileArray as $cacheKey => $path) {
312✔
327
            $content = \file_get_contents($path);
94✔
328
            if ($content === false) {
94✔
NEW
329
                throw new \RuntimeException('Could not read file: ' . $path);
×
330
            }
331

332
            $response = [
94✔
333
                'content'  => $content,
94✔
334
                'fileName' => $path,
94✔
335
                'cacheKey' => $cacheKey,
94✔
336
            ];
94✔
337

338
            @$cache->setItem($cacheKey, $response);
94✔
339

340
            $phpCodes[$cacheKey]['content'] = $content;
94✔
341
            $phpCodes[$cacheKey]['fileName'] = $path;
94✔
342
        }
343

344
        return $phpCodes;
312✔
345
    }
346

347
    /**
348
     * @param \voku\SimplePhpParser\Model\PHPClass   $class
349
     * @param \voku\SimplePhpParser\Model\PHPClass[] $classes
350
     * @param PHPInterface[]                         $interfaces
351
     * @param ParserContainer                        $parserContainer
352
     */
353
    private static function mergeInheritdocData(
354
        \voku\SimplePhpParser\Model\PHPClass $class,
355
        array $classes,
356
        array $interfaces,
357
        ParserContainer $parserContainer
358
    ): void {
359
        foreach ($class->properties as &$property) {
242✔
360
            if (!$class->parentClass) {
186✔
361
                break;
146✔
362
            }
363

364
            if (!$property->is_inheritdoc) {
80✔
365
                continue;
80✔
366
            }
367

368
            if (
UNCOV
369
                !isset($classes[$class->parentClass])
×
370
                &&
UNCOV
371
                \class_exists($class->parentClass, true)
×
372
            ) {
UNCOV
373
                $reflectionClassTmp = Utils::createClassReflectionInstance($class->parentClass);
×
374
                $classTmp = (new \voku\SimplePhpParser\Model\PHPClass($parserContainer))->readObjectFromReflection($reflectionClassTmp);
×
375
                if ($classTmp->name) {
×
376
                    $classes[$classTmp->name] = $classTmp;
×
377
                }
378
            }
379

UNCOV
380
            if (!isset($classes[$class->parentClass])) {
×
381
                continue;
×
382
            }
383

UNCOV
384
            if (!isset($classes[$class->parentClass]->properties[$property->name])) {
×
385
                continue;
×
386
            }
387

UNCOV
388
            $parentMethod = $classes[$class->parentClass]->properties[$property->name];
×
389
            self::mergeMissingTypeFields($property, $parentMethod);
×
390
        }
391
        unset($property);
242✔
392

393
        foreach ($class->methods as &$method) {
242✔
394
            if (!$method->is_inheritdoc) {
226✔
395
                continue;
218✔
396
            }
397

398
            foreach ($class->interfaces as $interfaceStr) {
62✔
399
                if (
400
                    !isset($interfaces[$interfaceStr])
62✔
401
                    &&
402
                    \interface_exists($interfaceStr, true)
62✔
403
                ) {
404
                    $reflectionInterfaceTmp = Utils::createClassReflectionInstance($interfaceStr);
46✔
405
                    $interfaceTmp = (new PHPInterface($parserContainer))->readObjectFromReflection($reflectionInterfaceTmp);
46✔
406
                    if ($interfaceTmp->name) {
46✔
407
                        $interfaces[$interfaceTmp->name] = $interfaceTmp;
46✔
408
                    }
409
                }
410

411
                if (!isset($interfaces[$interfaceStr])) {
62✔
UNCOV
412
                    continue;
×
413
                }
414

415
                if (!isset($interfaces[$interfaceStr]->methods[$method->name])) {
62✔
416
                    continue;
48✔
417
                }
418

419
                $interfaceMethod = $interfaces[$interfaceStr]->methods[$method->name];
38✔
420

421
                self::mergeMissingTypeFields($method, $interfaceMethod);
38✔
422
                $method->parameters = self::mergeMissingParameterTypeFields($method->parameters, $interfaceMethod->parameters);
38✔
423
            }
424

425
            if (!isset($classes[$class->parentClass])) {
62✔
426
                continue;
38✔
427
            }
428

429
            if (!isset($classes[$class->parentClass]->methods[$method->name])) {
40✔
UNCOV
430
                continue;
×
431
            }
432

433
            $parentMethod = $classes[$class->parentClass]->methods[$method->name];
40✔
434

435
            self::mergeMissingTypeFields($method, $parentMethod);
40✔
436
            $method->parameters = self::mergeMissingParameterTypeFields($method->parameters, $parentMethod->parameters);
40✔
437
        }
438
    }
439

440
    private static function mergeMissingTypeFields(object $target, object $source): void
441
    {
442
        foreach (\array_keys(\get_object_vars($target)) as $key) {
62✔
443
            if (\stripos($key, 'type') === false) {
62✔
444
                continue;
62✔
445
            }
446

447
            if ($target->{$key} === null && $source->{$key} !== null) {
62✔
448
                $target->{$key} = $source->{$key};
62✔
449
            }
450
        }
451
    }
452

453
    /**
454
     * @param array<string, \voku\SimplePhpParser\Model\PHPParameter> $targetParameters
455
     * @param array<string, \voku\SimplePhpParser\Model\PHPParameter> $sourceParameters
456
     *
457
     * @return array<string, \voku\SimplePhpParser\Model\PHPParameter>
458
     */
459
    private static function mergeMissingParameterTypeFields(array $targetParameters, array $sourceParameters): array
460
    {
461
        $sourceParameters = \array_values($sourceParameters);
62✔
462

463
        $position = 0;
62✔
464
        foreach ($targetParameters as $parameterName => $parameter) {
62✔
465
            $sourceParameter = $sourceParameters[$position] ?? null;
62✔
466
            ++$position;
62✔
467

468
            if ($sourceParameter === null) {
62✔
UNCOV
469
                continue;
×
470
            }
471

472
            self::mergeMissingTypeFields($parameter, $sourceParameter);
62✔
473
            $targetParameters[$parameterName] = $parameter;
62✔
474
        }
475

476
        return $targetParameters;
62✔
477
    }
478
}
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