Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.09% covered (warning)
75.09%
205 / 273
35.29% covered (danger)
35.29%
6 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileSymbolScanner
75.09% covered (warning)
75.09%
205 / 273
35.29% covered (danger)
35.29%
6 / 17
266.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
5.39
 findInFiles
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
9.14
 find
84.42% covered (warning)
84.42%
65 / 77
0.00% covered (danger)
0.00%
0 / 1
19.23
 splitByNamespace
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
14.51
 addDiscoveredClassChange
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
4.91
 addDiscoveredNamespaceChange
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getBuiltIns
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadBuiltIns
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 isBuiltInSymbol
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getParser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrettyPrinter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parsePhpCode
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getNamespaceDeclarations
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
17.69
 hasMalformedNamespaceDeclaration
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
27.80
 nextSignificantTokenIndex
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 isNamespaceNameToken
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2/**
3 * The purpose of this class is only to find changes that should be made.
4 * i.e. classes and namespaces to change.
5 * Those recorded are updated in a later step.
6 */
7
8namespace BrianHenryIE\Strauss\Pipeline;
9
10use BrianHenryIE\SimplePhpParser\Model\PHPClass;
11use BrianHenryIE\SimplePhpParser\Model\PHPConst;
12use BrianHenryIE\SimplePhpParser\Model\PHPFunction;
13use BrianHenryIE\SimplePhpParser\Parsers\Helper\ParserContainer;
14use BrianHenryIE\SimplePhpParser\Parsers\Helper\ParserErrorHandler;
15use BrianHenryIE\SimplePhpParser\Parsers\Helper\Utils;
16use BrianHenryIE\SimplePhpParser\Parsers\PhpCodeParser;
17use BrianHenryIE\SimplePhpParser\Parsers\Visitors\ASTVisitor;
18use BrianHenryIE\Strauss\Composer\ComposerPackage;
19use BrianHenryIE\Strauss\Config\FileSymbolScannerConfigInterface;
20use BrianHenryIE\Strauss\Files\DiscoveredFiles;
21use BrianHenryIE\Strauss\Files\FileBase;
22use BrianHenryIE\Strauss\Files\FileWithDependency;
23use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
24use BrianHenryIE\Strauss\Helpers\ParserErrorException;
25use BrianHenryIE\Strauss\Types\ClassSymbol;
26use BrianHenryIE\Strauss\Types\ConstantSymbol;
27use BrianHenryIE\Strauss\Types\DiscoveredSymbol;
28use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
29use BrianHenryIE\Strauss\Types\EnumSymbol;
30use BrianHenryIE\Strauss\Types\FunctionSymbol;
31use BrianHenryIE\Strauss\Types\InterfaceSymbol;
32use BrianHenryIE\Strauss\Types\NamespaceSymbol;
33use BrianHenryIE\Strauss\Types\TraitSymbol;
34use League\Flysystem\FilesystemException;
35use PhpParser\Node;
36use PhpParser\Parser;
37use PhpParser\ParserFactory;
38use PhpParser\PrettyPrinter\Standard;
39use Psr\Log\LoggerAwareTrait;
40use Psr\Log\LoggerInterface;
41use Psr\Log\NullLogger;
42
43class FileSymbolScanner
44{
45    use LoggerAwareTrait;
46
47    /**
48     * @var array<class-string<DiscoveredSymbol>,string>
49     */
50    private const SYMBOL_LOG_TYPES = [
51        ClassSymbol::class => 'class',
52        ConstantSymbol::class => 'constant',
53        EnumSymbol::class => 'enum',
54        FunctionSymbol::class => 'function',
55        InterfaceSymbol::class => 'interface',
56        NamespaceSymbol::class => 'namespace',
57        TraitSymbol::class => 'trait',
58    ];
59
60    protected DiscoveredSymbols $discoveredSymbols;
61
62    protected FileSystem $filesystem;
63
64    protected FileSymbolScannerConfigInterface $config;
65
66    /** @var string[] */
67    protected array $builtIns = [];
68
69    protected ?Parser $parser = null;
70    protected ?Standard $prettyPrinter = null;
71
72    /**
73     * @var array<string,bool>
74     */
75    protected array $loggedSymbols = [];
76
77    /**
78     * @var array<string,bool>
79     */
80    protected array $builtInsLookup = [];
81
82    /**
83     * FileScanner constructor.
84     */
85    public function __construct(
86        FileSymbolScannerConfigInterface $config,
87        DiscoveredSymbols $discoveredSymbols,
88        FileSystem $filesystem,
89        ?LoggerInterface $logger = null
90    ) {
91        $this->config = $config;
92        $this->discoveredSymbols = $discoveredSymbols;
93        $this->filesystem = $filesystem;
94        $this->logger = $logger ?? new NullLogger();
95    }
96
97    protected function add(DiscoveredSymbol $symbol, ?FileBase $file = null): void
98    {
99        if (in_array($symbol->getOriginalFqdnName(), $this->getBuiltIns())) {
100            $this->logger->debug('Skipping built-in symbol {symbolName}, possible a polyfill.', [
101                'symbolName' => $symbol->getOriginalLocalName(),
102            ]);
103            return;
104        }
105
106        $this->discoveredSymbols->add($symbol);
107
108        if ($file instanceof FileWithDependency) {
109            $file->getDependency()->addDiscoveredSymbol($symbol);
110        }
111
112        $level = in_array($symbol->getOriginalFqdnName(), $this->loggedSymbols) ? 'debug' : 'info';
113        $newText = in_array($symbol->getOriginalFqdnName(), $this->loggedSymbols) ? '' : 'new ';
114
115        $this->loggedSymbols[] = $symbol->getOriginalFqdnName();
116//        $this->loggedSymbols[$symbol->getOriginalFqdnName()] = true;
117
118        $this->logger->log(
119            $level,
120            sprintf(
121                "Found %s%s:::%s",
122                $newText,
123                // From `BrianHenryIE\Strauss\Types\TraitSymbol` -> `trait`
124                strtolower(str_replace('Symbol', '', array_reverse(explode('\\', get_class($symbol)))[0])),
125                $symbol->getOriginalFqdnName()
126            )
127        );
128    }
129
130    /**
131     * @throws FilesystemException
132     */
133    public function findInFiles(DiscoveredFiles $files): DiscoveredSymbols
134    {
135        $packagesToPrefixLookup = array_fill_keys(array_keys($this->config->getPackagesToPrefix()), true);
136        $projectDirectory = $this->config->getProjectAbsolutePath();
137
138        foreach ($files->getFiles() as $file) {
139            if ($file instanceof FileWithDependency
140                && !in_array($file->getDependency()->getPackageName(), array_keys($this->config->getPackagesToPrefix()))) {
141                    /**
142                     * We will not prefix symbols found in this file because it is not in a default or listed package.
143                     *
144                     * TODO: Move this logic to {@see MarkSymbolsForRenaming}.
145                     */
146                    $file->setDoPrefix(false);
147//                    continue;
148            }
149
150            if ($file instanceof FileWithDependency
151                && !isset($packagesToPrefixLookup[$file->getDependency()->getPackageName()])
152            ) {
153                $doPrefix = false;
154                $file->setDoPrefix($doPrefix);
155            }
156
157            $relativeFilePath =
158                $file instanceof FileWithDependency
159                    ? $file->getVendorRelativePath()
160                    : $this->filesystem->getRelativePath($projectDirectory, $file->getSourcePath());
161
162            if (!$file->isPhpFile()) {
163                $file->setDoPrefix(false);
164                $this->logger->debug("Skipping non-PHP file:::". $relativeFilePath);
165                continue;
166            }
167
168            $this->logger->info("Scanning file:::" . $relativeFilePath);
169            $this->find(
170                /**
171                 * "one unreadable file cancels scanning of all remaining files with no per-file error handling."
172                 * I think this is desirable, since we just ran the file list a moment ago, if something is unreadable, that's a show stopper.
173                 */
174                $this->filesystem->read($file->getSourcePath()),
175                $file,
176                $file instanceof FileWithDependency ? $file->getDependency() : null
177            );
178        }
179
180        return $this->discoveredSymbols;
181    }
182
183    protected function find(string $contents, FileBase $file, ?ComposerPackage $package = null): void
184    {
185        $namespaces = $this->splitByNamespace($contents);
186        PhpCodeParser::$classExistsAutoload = false;
187
188        foreach ($namespaces as $namespaceName => $contents) {
189            $namespaceSymbol = $this->addDiscoveredNamespaceChange($namespaceName, $file, $package);
190
191            /**
192             * Swallow the `E_USER_DEPRECATED` notices triggered while the parser reads docblocks.
193             *
194             * `phpdocumentor/reflection-docblock` 5.x emits deprecations from its own
195             * `DocBlock\Tags\Method` factory when parsing `@method` tags (e.g. in the PayPal SDK
196             * scanned by {@see \BrianHenryIE\Strauss\Tests\Issues\MozartIssue13Test}):
197             *
198             *   phpdocumentor/reflection-docblock/src/DocBlock/Tags/Method.php:102
199             *     "Create using static factory is deprecated, this method should not be called directly[...]"
200             *   phpdocumentor/reflection-docblock/src/DocBlock/Tags/Method.php:342
201             *     "Create method parameters via legacy format is deprecated[...]"
202             *
203             * It is a transitive dependency pinned to 5.x by `brianhenryie/simple-php-code-parser`
204             * (~5.3) and `json-mapper/json-mapper` (^5.6), so it cannot be upgraded past the
205             * deprecation. The notice originates entirely within third-party code parsing
206             * third-party code â€” nothing Strauss can change at the call site â€” so it is swallowed
207             * here. Left unhandled it fails CI under PHPUnit 10's `--fail-on-deprecation` (the
208             * `E_DEPRECATED | E_USER_DEPRECATED` mask means Strauss's own deprecations still surface).
209             *
210             * Two handlers are pushed because `PhpCodeParser::getPhpFiles()` calls
211             * `restore_error_handler()` once internally, before it parses the docblocks. That
212             * internal call removes the inner, sacrificial handler, leaving the outer
213             * deprecation-swallowing handler active while the docblocks are actually parsed. It also
214             * keeps the handler stack balanced, avoiding PHPUnit's risky-test warning "Test code or
215             * tested code removed error handlers other than its own".
216             *
217             * @see PhpCodeParser::getPhpFiles()
218             */
219            set_error_handler(
220                function (int $errNo, string $errStr, string $errFile, int $errLine): bool {
221                    return true;
222                },
223                E_DEPRECATED | E_USER_DEPRECATED
224            );
225            // Sacrificial handler; removed by PhpCodeParser::getPhpFiles()'s internal restore_error_handler().
226            set_error_handler(function (): bool {
227                return false;
228            });
229
230            try {
231                $phpCode = PhpCodeParser::getFromString($contents);
232            } catch (ParserErrorException $e) {
233                $this->logger->warning('Failed to parse namespace {namespaceName} in file {filePath} with error: {errorMessage}', [
234                    'namespaceName' => $namespaceName,
235                    'filePath' => $file->getSourcePath(),
236                    'errorMessage' => $e->getMessage(),
237                ]);
238                continue;
239            } finally {
240                restore_error_handler();
241            }
242
243            PhpCodeParser::$classExistsAutoload = false;
244
245            /** @var PHPClass[] $phpClasses */
246            $phpClasses = $phpCode->getClasses();
247            foreach ($phpClasses as $fqdnClassname => $class) {
248                // Skip classes defined in other files.
249                // I tried to use the $class->file property but it was autoloading from Strauss so incorrectly setting
250                // the path, different to the file being scanned.
251                // TODO this was causing false positives when found in comments.
252//                if (false !== strpos($contents, "use {$fqdnClassname};")) {
253//                    continue;
254//                }
255
256                $isAbstract = (bool) $class->is_abstract;
257                $extends     = $class->parentClass;
258                $interfaces  = $class->interfaces;
259                $classSymbol = $this->addDiscoveredClassChange($fqdnClassname, $isAbstract, $file, $extends, $namespaceSymbol, $interfaces);
260                if ($classSymbol) {
261                    $classSymbol->setDoRename($file->isDoPrefix());
262                }
263            }
264
265            /** @var PHPFunction[] $phpFunctions */
266            $phpFunctions = $phpCode->getFunctions();
267            foreach ($phpFunctions as $functionName => $function) {
268                if ($this->isBuiltInSymbol($functionName)) {
269                    continue;
270                }
271                $functionSymbol = $this->discoveredSymbols->getFunction($functionName);
272                if (is_null($functionSymbol)) {
273                    $functionSymbol = new FunctionSymbol($functionName, $file, $namespaceSymbol, $package);
274                    $this->add($functionSymbol);
275                }
276                $functionSymbol->addSourceFile($file);
277                $functionSymbol->setDoRename($file->isDoPrefix());
278            }
279
280            /** @var PHPConst[] $phpConstants */
281            $phpConstants = $phpCode->getConstants();
282            foreach ($phpConstants as $constantName => $constant) {
283                if (empty(trim($constantName, '\\'))) {
284                    continue;
285                }
286                $constantSymbol = $this->discoveredSymbols->getConst($constantName);
287                if (is_null($constantSymbol)) {
288                    $constantSymbol = new ConstantSymbol($constantName, $file, $namespaceSymbol);
289                    $this->add($constantSymbol, $file);
290                }
291                $constantSymbol->addSourceFile($file);
292                $constantSymbol->setDoRename($file->isDoPrefix());
293            }
294
295            $phpInterfaces = $phpCode->getInterfaces();
296            foreach ($phpInterfaces as $interfaceName => $interface) {
297                $interfaceSymbol = $this->discoveredSymbols->getInterface($interfaceName);
298                if (is_null($interfaceSymbol)) {
299                    $interfaceSymbol = new InterfaceSymbol($interfaceName, $file, $namespaceSymbol);
300                    $this->add($interfaceSymbol);
301                }
302                $interfaceSymbol->addSourceFile($file);
303                $interfaceSymbol->setDoRename($file->isDoPrefix());
304            }
305
306            $phpTraits = $phpCode->getTraits();
307            foreach ($phpTraits as $traitName => $trait) {
308                $traitSymbol = $this->discoveredSymbols->getTrait($traitName);
309                if (is_null($traitSymbol)) {
310                    $traitSymbol = new TraitSymbol($traitName, $file, $namespaceSymbol);
311                    $this->add($traitSymbol);
312                }
313                $traitSymbol->addSourceFile($file);
314                $traitSymbol->setDoRename($file->isDoPrefix());
315            }
316
317            $phpEnums = $phpCode->getEnums();
318            foreach ($phpEnums as $enumName => $enum) {
319                if ($this->isBuiltInSymbol($enumName)) {
320                    continue;
321                }
322                $enumSymbol = $this->discoveredSymbols->getEnum($enumName);
323                if (is_null($enumSymbol)) {
324                    $enumSymbol = new EnumSymbol($enumName, $file, $namespaceSymbol, $enum->backingType, $enum->interfaces);
325                    $this->add($enumSymbol, $file);
326                }
327                $enumSymbol->addSourceFile($file);
328                $enumSymbol->setDoRename($file->isDoPrefix());
329            }
330        }
331    }
332
333    /**
334     * @param string $contents
335     * @return array<string,string>
336     */
337    protected function splitByNamespace(string $contents):array
338    {
339        $namespaceDeclarations = $this->getNamespaceDeclarations($contents);
340
341        if (count($namespaceDeclarations) === 0) {
342            if ($this->hasMalformedNamespaceDeclaration($contents)) {
343                return [];
344            }
345
346            return ['\\' => '<?php' . PHP_EOL . PHP_EOL . $contents];
347        }
348
349        if (count($namespaceDeclarations) === 1) {
350            $namespaceName = $namespaceDeclarations[0] ?? '\\';
351            $namespaceName = empty($namespaceName) ? '\\' : $namespaceName;
352            return [$namespaceName => $contents];
353        }
354
355        $result = [];
356        try {
357            $ast = $this->getParser()->parse(trim($contents)) ?? [];
358        } catch (\PhpParser\Error $e) {
359            $this->logger->error('Parse error: ' . $e->getMessage());
360            return [];
361        }
362
363        foreach ($ast as $rootNode) {
364            if ($rootNode instanceof Node\Stmt\Namespace_) {
365                if (is_null($rootNode->name)) {
366                    if (count($ast) === 1) {
367                        $result['\\'] = $contents;
368                    } else {
369                        $result['\\'] = '<?php' . PHP_EOL . PHP_EOL . $this->getPrettyPrinter()->prettyPrintFile($rootNode->stmts);
370                    }
371                } else {
372                    $namespaceName = $rootNode->name->name;
373                    if (count($ast) === 1) {
374                        $result[$namespaceName] = $contents;
375                    } else {
376                        // This was failing for `phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php`
377                        $result[$namespaceName] = '<?php' . PHP_EOL . PHP_EOL . 'namespace ' . $namespaceName . ';' . PHP_EOL . PHP_EOL . $this->getPrettyPrinter()->prettyPrintFile($rootNode->stmts);
378                    }
379                }
380            }
381        }
382
383        // TODO: is this necessary?
384        if (empty($result)) {
385            $result['\\'] = '<?php' . PHP_EOL . PHP_EOL . $contents;
386        }
387
388        return $result;
389    }
390
391    /**
392     * @param string $fqdnClassname
393     * @param bool $isAbstract
394     * @param FileBase $file
395     * @param ?string $extends
396     * @param NamespaceSymbol|null $namespace
397     * @param string[] $interfaces
398     */
399    protected function addDiscoveredClassChange(
400        string $fqdnClassname,
401        bool $isAbstract,
402        FileBase $file,
403        ?string $extends,
404        ?NamespaceSymbol $namespace,
405        array $interfaces
406    ): ?ClassSymbol {
407        // TODO: This should be included but marked not to prefix.
408        if ($this->isBuiltInSymbol($fqdnClassname)) {
409            $this->logger->debug('Skipping built-in symbol {symbolName}, possible a polyfill.', [
410                'symbolName' => $fqdnClassname,
411            ]);
412            return null;
413        }
414
415        $classSymbol = $this->discoveredSymbols->getClass($fqdnClassname);
416        if (is_null($classSymbol)) {
417            $classSymbol = new ClassSymbol($fqdnClassname, $file, $namespace, $isAbstract, $extends, $interfaces);
418            $this->add($classSymbol, $file);
419        }
420        $classSymbol->addSourceFile($file);
421        if ($file instanceof FileWithDependency) {
422            $file->addDiscoveredSymbol($classSymbol);
423        }
424        return $classSymbol;
425    }
426
427    protected function addDiscoveredNamespaceChange(string $fqdnNamespace, FileBase $file, ?ComposerPackage $package = null): NamespaceSymbol
428    {
429        $namespaceObj = $this->discoveredSymbols->getNamespace($fqdnNamespace);
430        if (is_null($namespaceObj)) {
431            $namespaceObj = new NamespaceSymbol($fqdnNamespace, $file);
432            $this->add($namespaceObj);
433        }
434        $namespaceObj->addSourceFile($file);
435        if ($file instanceof FileWithDependency) {
436            $file->addDiscoveredSymbol($namespaceObj);
437        }
438        return $namespaceObj;
439    }
440
441    /**
442     * Get a list of PHP built-in classes etc. so they are not prefixed.
443     *
444     * Polyfilled classes were being prefixed, but the polyfills are only active when the PHP version is below X,
445     * so calls to those prefixed polyfilled classnames would fail on newer PHP versions.
446     *
447     * NB: This list is not exhaustive. Any unloaded PHP extensions are not included.
448     *
449     * @see https://github.com/BrianHenryIE/strauss/issues/79
450     *
451     * ```
452     * array_filter(
453     *   get_declared_classes(),
454     *   function(string $className): bool {
455     *     $reflector = new \ReflectionClass($className);
456     *     return empty($reflector->getFileName());
457     *   }
458     * );
459     * ```
460     *
461     * @return string[]
462     */
463    protected function getBuiltIns(): array
464    {
465        if (empty($this->builtIns)) {
466            $this->loadBuiltIns();
467        }
468
469        return $this->builtIns;
470    }
471
472    /**
473     * Load the file containing the built-in PHP classes etc. and flatten to a single array of strings and store.
474     */
475    protected function loadBuiltIns(): void
476    {
477        $builtins = include __DIR__ . '/FileSymbol/builtinsymbols.php';
478
479        $flatArray = array();
480        array_walk_recursive(
481            $builtins,
482            function ($array) use (&$flatArray) {
483                if (is_array($array)) {
484                    $flatArray = array_merge($flatArray, array_values($array));
485                } else {
486                    $flatArray[] = $array;
487                }
488            }
489        );
490
491        $this->builtIns = $flatArray;
492        $this->builtInsLookup = array_fill_keys($this->builtIns, true);
493    }
494
495    protected function isBuiltInSymbol(string $symbolName): bool
496    {
497        if (empty($this->builtInsLookup)) {
498            $this->loadBuiltIns();
499        }
500
501        return isset($this->builtInsLookup[$symbolName]);
502    }
503
504    protected function getParser(): Parser
505    {
506        return $this->parser ??= (new ParserFactory())->createForNewestSupportedVersion();
507    }
508
509    protected function getPrettyPrinter(): Standard
510    {
511        return $this->prettyPrinter ??= new Standard();
512    }
513
514    protected function parsePhpCode(string $contents): ParserContainer
515    {
516        $parserContainer = new ParserContainer();
517        $visitor = new ASTVisitor($parserContainer);
518
519        /**
520         * PhpUnit error "Test code or tested code removed error handlers other than its own" caused by the
521         * code parser removing an error handler. TODO: Fix this in the library then remove this line.
522         *
523         * @see PhpCodeParser::getPhpFiles()
524         */
525        set_error_handler(function () {
526            return true;
527        });
528
529        $result = PhpCodeParser::process($contents, null, $parserContainer, $visitor);
530        if ($result instanceof ParserErrorHandler) {
531            throw new ParserErrorException($result);
532        }
533
534        $interfaces = $parserContainer->getInterfaces();
535        foreach ($interfaces as &$interface) {
536            $interface->parentInterfaces = $visitor->combineParentInterfaces($interface);
537        }
538        unset($interface);
539
540        $classes = &$parserContainer->getClassesByReference();
541        foreach ($classes as &$class) {
542            $class->interfaces = Utils::flattenArray(
543                $visitor->combineImplementedInterfaces($class),
544                false
545            );
546        }
547        unset($class);
548
549        return $parserContainer;
550    }
551
552    /**
553     * Collect declared namespaces in source order. Namespace operators (namespace\foo) are ignored.
554     *
555     * @return array<int,string|null> Null indicates the explicit global namespace.
556     */
557    protected function getNamespaceDeclarations(string $contents): array
558    {
559        $tokens = token_get_all($contents);
560
561        $declarations = [];
562        $tokenCount = count($tokens);
563        for ($i = 0; $i < $tokenCount; $i++) {
564            $token = $tokens[$i];
565
566            if (!is_array($token) || $token[0] !== T_NAMESPACE) {
567                continue;
568            }
569
570            $nextIndex = $this->nextSignificantTokenIndex($tokens, $i + 1);
571            if (is_null($nextIndex)) {
572                continue;
573            }
574
575            $next = $tokens[$nextIndex];
576            if (is_string($next) && ($next === ';' || $next === '{')) {
577                $declarations[] = null;
578                continue;
579            }
580
581            $namespaceName = '';
582            while (!is_null($nextIndex)) {
583                $current = $tokens[$nextIndex];
584
585                if (is_array($current) && $this->isNamespaceNameToken($current[0])) {
586                    $namespaceName .= $current[1];
587                    $nextIndex = $this->nextSignificantTokenIndex($tokens, $nextIndex + 1);
588                    continue;
589                }
590
591                if (is_string($current) && $current === '\\') {
592                    $namespaceName .= '\\';
593                    $nextIndex = $this->nextSignificantTokenIndex($tokens, $nextIndex + 1);
594                    continue;
595                }
596
597                if (is_string($current) && ($current === ';' || $current === '{')) {
598                    if ($namespaceName !== '') {
599                        $declarations[] = trim($namespaceName, '\\');
600                    }
601                }
602                break;
603            }
604        }
605
606        return $declarations;
607    }
608
609    protected function hasMalformedNamespaceDeclaration(string $contents): bool
610    {
611        $tokens = token_get_all($contents);
612        $tokenCount = count($tokens);
613        for ($i = 0; $i < $tokenCount; $i++) {
614            $token = $tokens[$i];
615
616            if (!is_array($token) || $token[0] !== T_NAMESPACE) {
617                continue;
618            }
619
620            $nextIndex = $this->nextSignificantTokenIndex($tokens, $i + 1);
621            if (is_null($nextIndex)) {
622                continue;
623            }
624
625            $next = $tokens[$nextIndex];
626            if (is_string($next) && ($next === ';' || $next === '{')) {
627                continue;
628            }
629
630            if (is_array($next) && $this->isNamespaceNameToken($next[0])) {
631                continue;
632            }
633
634            return true;
635        }
636
637        return false;
638    }
639
640    /**
641     * @param array<int, array<int, mixed>|string> $tokens
642     */
643    protected function nextSignificantTokenIndex(array $tokens, int $start): ?int
644    {
645        $tokenCount = count($tokens);
646        for ($i = $start; $i < $tokenCount; $i++) {
647            $token = $tokens[$i];
648            if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
649                continue;
650            }
651
652            return $i;
653        }
654
655        return null;
656    }
657
658    protected function isNamespaceNameToken(int $tokenType): bool
659    {
660        if ($tokenType === T_STRING || $tokenType === T_NS_SEPARATOR) {
661            return true;
662        }
663
664        return
665            (defined('T_NAME_QUALIFIED') && $tokenType === T_NAME_QUALIFIED)
666            || (defined('T_NAME_FULLY_QUALIFIED') && $tokenType === T_NAME_FULLY_QUALIFIED)
667            || (defined('T_NAME_RELATIVE') && $tokenType === T_NAME_RELATIVE);
668    }
669}