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

voku / Simple-PHP-Code-Parser / 24286329431

11 Apr 2026 04:09PM UTC coverage: 82.898% (+0.01%) from 82.886%
24286329431

Pull #83

github

web-flow
Merge e95fe6f21 into 90e1e60d3
Pull Request #83: Fix CI pipeline: phpunit.xml validation warning, php-parser v4 test skip, and comprehensive type-analysis regression coverage

178 of 221 new or added lines in 7 files covered. (80.54%)

33 existing lines in 4 files now uncovered.

1682 of 2029 relevant lines covered (82.9%)

36.9 hits per line

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

86.67
/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-v8-';
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(
38✔
40
            $code,
38✔
41
            $autoloaderProjectPaths
38✔
42
        );
38✔
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);
3✔
58

59
        return self::getPhpFiles(
3✔
60
            (string) $reflectionClass->getFileName(),
3✔
61
            $autoloaderProjectPaths
3✔
62
        );
3✔
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);
130✔
83
        try {
84
            foreach ($autoloaderProjectPaths as $projectPath) {
130✔
UNCOV
85
                if (\file_exists($projectPath) && \is_file($projectPath)) {
×
UNCOV
86
                    require_once $projectPath;
×
UNCOV
87
                } elseif (\file_exists($projectPath . '/vendor/autoload.php')) {
×
UNCOV
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();
130✔
95
        }
96

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

221
        if ($parsedCode === null) {
130✔
UNCOV
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();
130✔
232
        $traverser1->addVisitor(new ParentConnector());
130✔
233
        $traverser1->addVisitor($nameResolver);
130✔
234
        $traverser1->traverse($parsedCode);
130✔
235

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

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

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

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

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

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

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

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

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

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

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

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

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

320
                continue;
64✔
321
            }
322

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

326
        foreach ($phpFileArray as $cacheKey => $path) {
130✔
327
            $content = \file_get_contents($path);
34✔
328
            if ($content === false) {
34✔
UNCOV
329
                $lastError = \error_get_last();
×
UNCOV
330
                throw new \RuntimeException('Could not read file: ' . $path . ($lastError !== null ? ' (' . $lastError['message'] . ')' : ''));
×
331
            }
332

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

339
            @$cache->setItem($cacheKey, $response);
34✔
340

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

345
        return $phpCodes;
130✔
346
    }
347

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

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

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

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

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

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

394
        foreach ($class->methods as &$method) {
102✔
395
            if (!$method->is_inheritdoc) {
95✔
396
                continue;
92✔
397
            }
398

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

412
                if (!isset($interfaces[$interfaceStr])) {
25✔
413
                    continue;
×
414
                }
415

416
                if (!isset($interfaces[$interfaceStr]->methods[$method->name])) {
25✔
417
                    continue;
21✔
418
                }
419

420
                $interfaceMethod = $interfaces[$interfaceStr]->methods[$method->name];
13✔
421

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

426
            if (!isset($classes[$class->parentClass])) {
25✔
427
                continue;
13✔
428
            }
429

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

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

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

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

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

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

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

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

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

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