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

voku / Simple-PHP-Code-Parser / 29085974904

10 Jul 2026 10:10AM UTC coverage: 85.103% (+0.3%) from 84.815%
29085974904

push

github

moellekenl
[+]: Add support for source-range information and enhance PHP model elements

- Introduced `endLine`, `startFilePos`, and `endFilePos` properties to model elements for precise source navigation.
- Added `PHPFileInfo` class for lightweight file summaries and improved AST access methods.
- Enhanced trait handling in class-like models to include trait uses and adaptations.
- Updated method and parameter models to include `is_returned_by_ref` and `is_promoted` properties.
- Improved PHPDoc context handling for better type resolution in models.

269 of 316 new or added lines in 16 files covered. (85.13%)

1 existing line in 1 file now uncovered.

1988 of 2336 relevant lines covered (85.1%)

175.85 hits per line

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

81.36
/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\PHPFileInfo;
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 voku\SimplePhpParser\Parsers\Visitors\PhpDocContextConnector;
23

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

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

47
    /**
48
     * Parse PHP source into the names-resolved AST used internally to build
49
     * the public model layer.
50
     *
51
     * The returned nodes retain php-parser's location attributes and have a
52
     * `parent` attribute. NameResolver also preserves aliases as
53
     * `originalName` attributes while replacing resolvable names with their
54
     * fully-qualified form. This is an escape hatch for consumers that need
55
     * syntax not represented by the compact model layer.
56
     *
57
     * @return array<int, \PhpParser\Node>
58
     *
59
     * @throws \RuntimeException when the source cannot be parsed
60
     */
61
    public static function getAstFromString(string $code): array
62
    {
63
        $errorHandler = new ParserErrorHandler();
20✔
64
        $parsedCode = self::parseAst($code, $errorHandler);
20✔
65

66
        if ($parsedCode === null || $errorHandler->getErrors() !== []) {
20✔
NEW
67
            throw new \RuntimeException(self::formatParseErrors($errorHandler));
×
68
        }
69

70
        self::resolveAst($parsedCode, $errorHandler);
20✔
71

72
        if ($errorHandler->getErrors() !== []) {
20✔
NEW
73
            throw new \RuntimeException(self::formatParseErrors($errorHandler));
×
74
        }
75

76
        return $parsedCode;
20✔
77
    }
78

79
    /**
80
     * Parse one PHP file into the names-resolved AST used internally to build
81
     * the public model layer.
82
     *
83
     * @return array<int, \PhpParser\Node>
84
     *
85
     * @throws \RuntimeException when the file cannot be read or parsed
86
     */
87
    public static function getAstFromFile(string $fileName): array
88
    {
NEW
89
        $code = \file_get_contents($fileName);
×
NEW
90
        if ($code === false) {
×
NEW
91
            $lastError = \error_get_last();
×
NEW
92
            throw new \RuntimeException('Could not read file: ' . $fileName . ($lastError !== null ? ' (' . $lastError['message'] . ')' : ''));
×
93
        }
94

NEW
95
        return self::getAstFromString($code);
×
96
    }
97

98
    /**
99
     * Return compact namespace, import, and declare metadata for one source
100
     * string without forcing callers to walk the raw AST.
101
     */
102
    public static function getFileInfoFromString(string $code): PHPFileInfo
103
    {
104
        return PHPFileInfo::fromAst(self::getAstFromString($code));
10✔
105
    }
106

107
    /**
108
     * Return compact namespace, import, and declare metadata for one PHP
109
     * file without forcing callers to walk the raw AST.
110
     *
111
     * @throws \RuntimeException when the file cannot be read or parsed
112
     */
113
    public static function getFileInfoFromFile(string $fileName): PHPFileInfo
114
    {
NEW
115
        $code = \file_get_contents($fileName);
×
NEW
116
        if ($code === false) {
×
NEW
117
            $lastError = \error_get_last();
×
NEW
118
            throw new \RuntimeException('Could not read file: ' . $fileName . ($lastError !== null ? ' (' . $lastError['message'] . ')' : ''));
×
119
        }
120

NEW
121
        return PHPFileInfo::fromAst(self::getAstFromString($code), $fileName);
×
122
    }
123

124
    /**
125
     * @param string   $className
126
     * @param string[] $autoloaderProjectPaths
127
     *
128
     * @phpstan-param class-string $className
129
     *
130
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer
131
     */
132
    public static function getFromClassName(
133
        string $className,
134
        array $autoloaderProjectPaths = []
135
    ): ParserContainer {
136
        $reflectionClass = Utils::createClassReflectionInstance($className);
10✔
137

138
        return self::getPhpFiles(
10✔
139
            (string) $reflectionClass->getFileName(),
10✔
140
            $autoloaderProjectPaths
10✔
141
        );
10✔
142
    }
143

144
    /**
145
     * @param string   $pathOrCode
146
     * @param string[] $autoloaderProjectPaths
147
     * @param string[] $pathExcludeRegex
148
     * @param string[] $fileExtensions
149
     *
150
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer
151
     */
152
    public static function getPhpFiles(
153
        string $pathOrCode,
154
        array $autoloaderProjectPaths = [],
155
        array $pathExcludeRegex = [],
156
        array $fileExtensions = []
157
    ): ParserContainer {
158
        // Push a disposable handler so restore_error_handler() below will only
159
        // pop this one entry, leaving any pre-existing handlers (e.g. PHPUnit's)
160
        // intact on the stack.
161
        \set_error_handler(null);
709✔
162
        try {
163
            foreach ($autoloaderProjectPaths as $projectPath) {
709✔
164
                if (\file_exists($projectPath) && \is_file($projectPath)) {
×
165
                    require_once $projectPath;
×
166
                } elseif (\file_exists($projectPath . '/vendor/autoload.php')) {
×
167
                    require_once $projectPath . '/vendor/autoload.php';
×
168
                } elseif (\file_exists($projectPath . '/../vendor/autoload.php')) {
×
169
                    require_once $projectPath . '/../vendor/autoload.php';
×
170
                }
171
            }
172
        } finally {
173
            \restore_error_handler();
709✔
174
        }
175

176
        $phpCodes = self::getCode(
709✔
177
            $pathOrCode,
709✔
178
            $pathExcludeRegex,
709✔
179
            $fileExtensions
709✔
180
        );
709✔
181

182
        $parserContainer = new ParserContainer();
709✔
183
        $visitor = new ASTVisitor($parserContainer);
709✔
184

185
        $processResults = [];
709✔
186
        $phpCodesChunks = \array_chunk($phpCodes, Utils::getCpuCores(), true);
709✔
187

188
        foreach ($phpCodesChunks as $phpCodesChunk) {
709✔
189
            foreach ($phpCodesChunk as $codeAndFileName) {
709✔
190
                $processResults[] = self::process(
709✔
191
                    $codeAndFileName['content'],
709✔
192
                    $codeAndFileName['fileName'],
709✔
193
                    $parserContainer,
709✔
194
                    $visitor
709✔
195
                );
709✔
196
            }
197
        }
198

199
        foreach ($processResults as $response) {
709✔
200
            if ($response instanceof ParserContainer) {
709✔
201
                $parserContainer->setTraits($response->getTraits());
709✔
202
                $parserContainer->setClasses($response->getClasses());
709✔
203
                $parserContainer->setInterfaces($response->getInterfaces());
709✔
204
                $parserContainer->setEnums($response->getEnums());
709✔
205
                $parserContainer->setConstants($response->getConstants());
709✔
206
                $parserContainer->setFunctions($response->getFunctions());
709✔
207
            } elseif ($response instanceof ParserErrorHandler) {
×
208
                $parserContainer->setParseError($response);
×
209
            }
210
        }
211

212
        $interfaces = $parserContainer->getInterfaces();
709✔
213
        foreach ($interfaces as &$interface) {
709✔
214
            $interface->parentInterfaces = $visitor->combineParentInterfaces($interface);
60✔
215
        }
216
        unset($interface);
709✔
217

218
        $pathTmp = null;
709✔
219
        if (\is_file($pathOrCode)) {
709✔
220
            $pathTmp = \realpath(\pathinfo($pathOrCode, \PATHINFO_DIRNAME));
338✔
221
        } elseif (\is_dir($pathOrCode)) {
381✔
222
            $pathTmp = \realpath($pathOrCode);
30✔
223
        }
224

225
        $classesTmp = &$parserContainer->getClassesByReference();
709✔
226
        foreach ($classesTmp as &$classTmp) {
709✔
227
            $classTmp->interfaces = Utils::flattenArray(
611✔
228
                $visitor->combineImplementedInterfaces($classTmp),
611✔
229
                false
611✔
230
            );
611✔
231

232
            self::mergeInheritdocData(
611✔
233
                $classTmp,
611✔
234
                $classesTmp,
611✔
235
                $interfaces,
611✔
236
                $parserContainer
611✔
237
            );
611✔
238
        }
239
        unset($classTmp);
709✔
240

241
        // remove properties / methods / classes from outside of the current file-path-scope
242
        if ($pathTmp) {
709✔
243
            $classesTmp2 = &$parserContainer->getClassesByReference();
358✔
244
            foreach ($classesTmp2 as $classKey => $classTmp2) {
358✔
245
                foreach ($classTmp2->constants as $constantKey => $constant) {
310✔
246
                    if ($constant->file && \strpos($constant->file, $pathTmp) === false) {
146✔
247
                        unset($classTmp2->constants[$constantKey]);
24✔
248
                    }
249
                }
250

251
                foreach ($classTmp2->properties as $propertyKey => $property) {
310✔
252
                    if ($property->file && \strpos($property->file, $pathTmp) === false) {
274✔
253
                        unset($classTmp2->properties[$propertyKey]);
30✔
254
                    }
255
                }
256

257
                foreach ($classTmp2->methods as $methodKey => $method) {
310✔
258
                    if ($method->file && \strpos($method->file, $pathTmp) === false) {
299✔
259
                        unset($classTmp2->methods[$methodKey]);
30✔
260
                    }
261
                }
262

263
                if ($classTmp2->file && \strpos($classTmp2->file, $pathTmp) === false) {
310✔
264
                    unset($classesTmp2[$classKey]);
30✔
265
                }
266
            }
267
        }
268

269
        return $parserContainer;
709✔
270
    }
271

272
    /**
273
     * @param string                                               $phpCode
274
     * @param string|null                                          $fileName
275
     * @param \voku\SimplePhpParser\Parsers\Helper\ParserContainer $parserContainer
276
     * @param \voku\SimplePhpParser\Parsers\Visitors\ASTVisitor    $visitor
277
     *
278
     * @return \voku\SimplePhpParser\Parsers\Helper\ParserContainer|\voku\SimplePhpParser\Parsers\Helper\ParserErrorHandler
279
     */
280
    public static function process(
281
        string $phpCode,
282
        ?string $fileName,
283
        ParserContainer $parserContainer,
284
        ASTVisitor $visitor
285
    ) {
286
        $errorHandler = new ParserErrorHandler();
709✔
287

288
        $parsedCode = self::parseAst($phpCode, $errorHandler);
709✔
289

290
        if ($parsedCode === null) {
709✔
291
            return $errorHandler;
×
292
        }
293

294
        self::resolveAst($parsedCode, $errorHandler);
709✔
295

296
        $visitor->fileName = $fileName;
709✔
297

298
        // Pass 2: extract model objects from the already-resolved AST.
299
        $traverser2 = new NodeTraverser();
709✔
300
        $traverser2->addVisitor($visitor);
709✔
301
        $traverser2->traverse($parsedCode);
709✔
302

303
        return $parserContainer;
709✔
304
    }
305

306
    /**
307
     * @return array<int, \PhpParser\Node>|null
308
     */
309
    private static function parseAst(string $phpCode, ParserErrorHandler $errorHandler): ?array
310
    {
311
        $parser = (new ParserFactory())->createForNewestSupportedVersion();
709✔
312

313
        return $parser->parse($phpCode, $errorHandler);
709✔
314
    }
315

316
    /**
317
     * @param array<int, \PhpParser\Node> $parsedCode
318
     */
319
    private static function resolveAst(array $parsedCode, ParserErrorHandler $errorHandler): void
320
    {
321
        $nameResolver = new NameResolver(
709✔
322
            $errorHandler,
709✔
323
            [
709✔
324
                'preserveOriginalNames' => true,
709✔
325
            ]
709✔
326
        );
709✔
327

328
        // Set parent attributes and fully resolve all names before model
329
        // extraction. ASTVisitor reads class members eagerly when it enters a
330
        // class-like node, so a single traversal would resolve their types too
331
        // late.
332
        $traverser = new NodeTraverser();
709✔
333
        $traverser->addVisitor(new ParentConnector());
709✔
334
        $traverser->addVisitor($nameResolver);
709✔
335
        $traverser->addVisitor(new PhpDocContextConnector());
709✔
336
        $traverser->traverse($parsedCode);
709✔
337
    }
338

339
    private static function formatParseErrors(ParserErrorHandler $errorHandler): string
340
    {
NEW
341
        $messages = [];
×
NEW
342
        foreach ($errorHandler->getErrors() as $error) {
×
NEW
343
            $messages[] = $error->getMessage();
×
344
        }
345

NEW
346
        return $messages === [] ? 'Could not parse PHP code.' : \implode("\n", $messages);
×
347
    }
348

349
    /**
350
     * @param string   $pathOrCode
351
     * @param string[] $pathExcludeRegex
352
     * @param string[] $fileExtensions
353
     *
354
     * @return array
355
     *
356
     * @psalm-return array<string, array{content: string, fileName: null|string}>
357
     */
358
    private static function getCode(
359
        string $pathOrCode,
360
        array $pathExcludeRegex = [],
361
        array $fileExtensions = []
362
    ): array {
363
        // init
364
        $phpCodes = [];
709✔
365
        /** @var SplFileInfo[] $phpFileIterators */
366
        $phpFileIterators = [];
709✔
367

368
        // fallback
369
        if (\count($fileExtensions) === 0) {
709✔
370
            $fileExtensions = ['.php'];
709✔
371
        }
372

373
        if (\is_file($pathOrCode)) {
709✔
374
            $phpFileIterators = [new SplFileInfo($pathOrCode)];
338✔
375
        } elseif (\is_dir($pathOrCode)) {
381✔
376
            $phpFileIterators = new RecursiveIteratorIterator(
30✔
377
                new RecursiveDirectoryIterator($pathOrCode, FilesystemIterator::SKIP_DOTS)
30✔
378
            );
30✔
379
        } else {
380
            $cacheKey = self::CACHE_KEY_HELPER . \md5($pathOrCode);
351✔
381

382
            $phpCodes[$cacheKey]['content'] = $pathOrCode;
351✔
383
            $phpCodes[$cacheKey]['fileName'] = null;
351✔
384
        }
385

386
        $cache = new Cache(null, null, false);
709✔
387

388
        $phpFileArray = [];
709✔
389
        foreach ($phpFileIterators as $fileOrCode) {
709✔
390
            $path = $fileOrCode->getRealPath();
358✔
391
            if (!$path) {
358✔
392
                continue;
×
393
            }
394

395
            $fileExtensionFound = false;
358✔
396
            foreach ($fileExtensions as $fileExtension) {
358✔
397
                if (\substr($path, -\strlen($fileExtension)) === $fileExtension) {
358✔
398
                    $fileExtensionFound = true;
358✔
399

400
                    break;
358✔
401
                }
402
            }
403
            if ($fileExtensionFound === false) {
358✔
404
                continue;
×
405
            }
406

407
            foreach ($pathExcludeRegex as $regex) {
358✔
408
                if (\preg_match($regex, $path)) {
25✔
409
                    continue 2;
25✔
410
                }
411
            }
412

413
            $cacheKey = self::CACHE_KEY_HELPER . \md5($path) . '--' . \filemtime($path);
358✔
414
            if ($cache->getCacheIsReady() === true && $cache->existsItem($cacheKey)) {
358✔
415
                $response = $cache->getItem($cacheKey);
250✔
416
                /** @noinspection PhpSillyAssignmentInspection - helper for phpstan */
417
                /** @phpstan-var array{content: string, fileName: string, cacheKey: string} $response */
418
                $response = $response;
250✔
419

420
                $phpCodes[$response['cacheKey']]['content'] = $response['content'];
250✔
421
                $phpCodes[$response['cacheKey']]['fileName'] = $response['fileName'];
250✔
422

423
                continue;
250✔
424
            }
425

426
            $phpFileArray[$cacheKey] = $path;
128✔
427
        }
428

429
        foreach ($phpFileArray as $cacheKey => $path) {
709✔
430
            $content = \file_get_contents($path);
128✔
431
            if ($content === false) {
128✔
432
                $lastError = \error_get_last();
×
433
                throw new \RuntimeException('Could not read file: ' . $path . ($lastError !== null ? ' (' . $lastError['message'] . ')' : ''));
×
434
            }
435

436
            $response = [
128✔
437
                'content'  => $content,
128✔
438
                'fileName' => $path,
128✔
439
                'cacheKey' => $cacheKey,
128✔
440
            ];
128✔
441

442
            @$cache->setItem($cacheKey, $response);
128✔
443

444
            $phpCodes[$cacheKey]['content'] = $content;
128✔
445
            $phpCodes[$cacheKey]['fileName'] = $path;
128✔
446
        }
447

448
        return $phpCodes;
709✔
449
    }
450

451
    /**
452
     * @param \voku\SimplePhpParser\Model\PHPClass   $class
453
     * @param \voku\SimplePhpParser\Model\PHPClass[] $classes
454
     * @param PHPInterface[]                         $interfaces
455
     * @param ParserContainer                        $parserContainer
456
     */
457
    private static function mergeInheritdocData(
458
        \voku\SimplePhpParser\Model\PHPClass $class,
459
        array $classes,
460
        array $interfaces,
461
        ParserContainer $parserContainer
462
    ): void {
463
        foreach ($class->properties as &$property) {
611✔
464
            if (!$class->parentClass) {
379✔
465
                break;
319✔
466
            }
467

468
            if (!$property->is_inheritdoc) {
130✔
469
                continue;
130✔
470
            }
471

472
            if (
473
                !isset($classes[$class->parentClass])
×
474
                &&
475
                \class_exists($class->parentClass, true)
×
476
            ) {
477
                $reflectionClassTmp = Utils::createClassReflectionInstance($class->parentClass);
×
478
                $classTmp = (new \voku\SimplePhpParser\Model\PHPClass($parserContainer))->readObjectFromReflection($reflectionClassTmp);
×
479
                if ($classTmp->name) {
×
480
                    $classes[$classTmp->name] = $classTmp;
×
481
                }
482
            }
483

484
            if (!isset($classes[$class->parentClass])) {
×
485
                continue;
×
486
            }
487

488
            if (!isset($classes[$class->parentClass]->properties[$property->name])) {
×
489
                continue;
×
490
            }
491

492
            $parentMethod = $classes[$class->parentClass]->properties[$property->name];
×
493
            self::mergeMissingTypeFields($property, $parentMethod);
×
494
        }
495
        unset($property);
611✔
496

497
        foreach ($class->methods as &$method) {
611✔
498
            if (!$method->is_inheritdoc) {
549✔
499
                continue;
539✔
500
            }
501

502
            foreach ($class->interfaces as $interfaceStr) {
98✔
503
                if (
504
                    !isset($interfaces[$interfaceStr])
98✔
505
                    &&
506
                    \interface_exists($interfaceStr, true)
98✔
507
                ) {
508
                    $reflectionInterfaceTmp = Utils::createClassReflectionInstance($interfaceStr);
76✔
509
                    $interfaceTmp = (new PHPInterface($parserContainer))->readObjectFromReflection($reflectionInterfaceTmp);
76✔
510
                    if ($interfaceTmp->name) {
76✔
511
                        $interfaces[$interfaceTmp->name] = $interfaceTmp;
76✔
512
                    }
513
                }
514

515
                if (!isset($interfaces[$interfaceStr])) {
98✔
516
                    continue;
×
517
                }
518

519
                if (!isset($interfaces[$interfaceStr]->methods[$method->name])) {
98✔
520
                    continue;
80✔
521
                }
522

523
                $interfaceMethod = $interfaces[$interfaceStr]->methods[$method->name];
58✔
524

525
                self::mergeMissingTypeFields($method, $interfaceMethod);
58✔
526
                $method->parameters = self::mergeMissingParameterTypeFields($method->parameters, $interfaceMethod->parameters);
58✔
527
            }
528

529
            if (!isset($classes[$class->parentClass])) {
98✔
530
                continue;
58✔
531
            }
532

533
            if (!isset($classes[$class->parentClass]->methods[$method->name])) {
70✔
534
                continue;
×
535
            }
536

537
            $parentMethod = $classes[$class->parentClass]->methods[$method->name];
70✔
538

539
            self::mergeMissingTypeFields($method, $parentMethod);
70✔
540
            $method->parameters = self::mergeMissingParameterTypeFields($method->parameters, $parentMethod->parameters);
70✔
541
        }
542
    }
543

544
    private static function mergeMissingTypeFields(object $target, object $source): void
545
    {
546
        foreach (\array_keys(\get_object_vars($target)) as $key) {
98✔
547
            if (\stripos($key, 'type') === false) {
98✔
548
                continue;
98✔
549
            }
550

551
            if ($target->{$key} === null && $source->{$key} !== null) {
98✔
552
                $target->{$key} = $source->{$key};
98✔
553
            }
554
        }
555
    }
556

557
    /**
558
     * @param array<string, \voku\SimplePhpParser\Model\PHPParameter> $targetParameters
559
     * @param array<string, \voku\SimplePhpParser\Model\PHPParameter> $sourceParameters
560
     *
561
     * @return array<string, \voku\SimplePhpParser\Model\PHPParameter>
562
     */
563
    private static function mergeMissingParameterTypeFields(array $targetParameters, array $sourceParameters): array
564
    {
565
        $sourceParameters = \array_values($sourceParameters);
98✔
566

567
        $position = 0;
98✔
568
        foreach ($targetParameters as $parameterName => $parameter) {
98✔
569
            $sourceParameter = $sourceParameters[$position] ?? null;
98✔
570
            ++$position;
98✔
571

572
            if ($sourceParameter === null) {
98✔
573
                continue;
×
574
            }
575

576
            self::mergeMissingTypeFields($parameter, $sourceParameter);
98✔
577
            $targetParameters[$parameterName] = $parameter;
98✔
578
        }
579

580
        return $targetParameters;
98✔
581
    }
582
}
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