Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.48% covered (warning)
84.48%
283 / 335
42.86% covered (danger)
42.86%
6 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Prefixer
84.48% covered (warning)
84.48%
283 / 335
42.86% covered (danger)
42.86%
6 / 14
162.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 replaceInFiles
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 replaceInProjectFiles
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 replaceInString
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
8.04
 replaceConstFetchNamespaces
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
7
 replaceNamespace
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 replaceClassname
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 replaceGlobalClassInsideNamedNamespace
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 checkPregError
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 replaceConstants
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 replaceConstant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceFunctions
96.00% covered (success)
96.00%
48 / 50
0.00% covered (danger)
0.00%
0 / 1
16
 getModifiedFiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepareRelativeNamespaces
92.92% covered (success)
92.92%
105 / 113
0.00% covered (danger)
0.00%
0 / 1
55.03
1<?php
2
3namespace BrianHenryIE\Strauss\Pipeline;
4
5use BrianHenryIE\Strauss\Composer\ComposerPackage;
6use BrianHenryIE\Strauss\Config\PrefixerConfigInterface;
7use BrianHenryIE\Strauss\Files\File;
8use BrianHenryIE\Strauss\Files\FileBase;
9use BrianHenryIE\Strauss\Helpers\FileSystem;
10use BrianHenryIE\Strauss\Helpers\NamespaceSort;
11use BrianHenryIE\Strauss\Types\ClassSymbol;
12use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
13use BrianHenryIE\Strauss\Types\FunctionSymbol;
14use BrianHenryIE\Strauss\Types\NamespaceSymbol;
15use Exception;
16use League\Flysystem\FilesystemException;
17use PhpParser\Node;
18use PhpParser\Node\Arg;
19use PhpParser\Node\Expr\ClassConstFetch;
20use PhpParser\Node\Expr\ConstFetch;
21use PhpParser\Node\Expr\FuncCall;
22use PhpParser\Node\Name;
23use PhpParser\Node\Scalar\String_;
24use PhpParser\Node\Stmt\Function_;
25use PhpParser\NodeFinder;
26use PhpParser\NodeTraverser;
27use PhpParser\ParserFactory;
28use PhpParser\PrettyPrinter\Standard;
29use Psr\Log\LoggerAwareTrait;
30use Psr\Log\LoggerInterface;
31use Psr\Log\NullLogger;
32
33class Prefixer
34{
35    use LoggerAwareTrait;
36
37    protected PrefixerConfigInterface $config;
38
39    protected FileSystem $filesystem;
40
41    /**
42     * array<$filePath, $package> or null if the file is not from a dependency (i.e. a project file).
43     *
44     * @var array<string, ?ComposerPackage>
45     */
46    protected array $changedFiles = array();
47
48    public function __construct(
49        PrefixerConfigInterface $config,
50        FileSystem              $filesystem,
51        ?LoggerInterface        $logger = null
52    ) {
53        $this->config = $config;
54        $this->filesystem = $filesystem;
55        $this->logger = $logger ?? new NullLogger();
56    }
57
58    // Don't replace a classname if there's an import for a class with the same name.
59    // but do replace \Classname always
60
61    /**
62     * @param DiscoveredSymbols $discoveredSymbols
63     * ///param array<string,array{dependency:ComposerPackage,sourceAbsoluteFilepath:string,targetRelativeFilepath:string}> $phpFileArrays
64     * @param array<string, FileBase> $files
65     *
66     * @throws FilesystemException
67     * @throws FilesystemException
68     */
69    public function replaceInFiles(DiscoveredSymbols $discoveredSymbols, array $files): void
70    {
71        foreach ($files as $file) {
72            if ($this->config->getVendorDirectory() !== $this->config->getTargetDirectory()
73                && !$file->isDoCopy()
74            ) {
75                continue;
76            }
77
78            if ($this->filesystem->directoryExists($file->getAbsoluteTargetPath())) {
79                $this->logger->debug("is_dir() / nothing to do : {$file->getAbsoluteTargetPath()}");
80                continue;
81            }
82
83            if (!$file->isPhpFile()) {
84                continue;
85            }
86
87            if (!$this->filesystem->fileExists($file->getAbsoluteTargetPath())) {
88                $this->logger->warning("Expected file does not exist: {$file->getAbsoluteTargetPath()}");
89                continue;
90            }
91
92            $relativeFilePath = $this->filesystem->getRelativePath(dirname($this->config->getTargetDirectory()), $file->getAbsoluteTargetPath());
93
94            $this->logger->debug("Updating contents of file: {$relativeFilePath}");
95
96            /**
97             * Throws an exception, but unlikely to happen.
98             */
99            $contents = $this->filesystem->read($file->getAbsoluteTargetPath());
100
101            $updatedContents = $this->replaceInString($discoveredSymbols, $contents);
102
103            if ($updatedContents !== $contents) {
104                // TODO: diff here and debug log.
105                $file->setDidUpdate();
106                $this->filesystem->write($file->getAbsoluteTargetPath(), $updatedContents);
107                $this->logger->info("Updated contents of file: {$relativeFilePath}");
108            } else {
109                $this->logger->debug("No changes to file: {$relativeFilePath}");
110            }
111        }
112    }
113
114    /**
115     * @param DiscoveredSymbols $discoveredSymbols
116     * @param string[] $absoluteFilePathsArray
117     *
118     * @return void
119     * @throws FilesystemException
120     */
121    public function replaceInProjectFiles(DiscoveredSymbols $discoveredSymbols, array $absoluteFilePathsArray): void
122    {
123
124        foreach ($absoluteFilePathsArray as $fileAbsolutePath) {
125            $relativeFilePath = $this->filesystem->getRelativePath(dirname($this->config->getTargetDirectory()), $fileAbsolutePath);
126
127            if ($this->filesystem->directoryExists($fileAbsolutePath)) {
128                $this->logger->debug("is_dir() / nothing to do : {$relativeFilePath}");
129                continue;
130            }
131
132            if (!$this->filesystem->fileExists($fileAbsolutePath)) {
133                $this->logger->warning("Expected file does not exist: {$relativeFilePath}");
134                continue;
135            }
136
137            $this->logger->debug("Updating contents of file: {$relativeFilePath}");
138
139            // Throws an exception, but unlikely to happen.
140            $contents = $this->filesystem->read($fileAbsolutePath);
141
142            $updatedContents = $this->replaceInString($discoveredSymbols, $contents);
143
144            if ($updatedContents !== $contents) {
145                $this->changedFiles[$fileAbsolutePath] = null;
146                $this->filesystem->write($fileAbsolutePath, $updatedContents);
147                $this->logger->info('Updated contents of file: ' . $relativeFilePath);
148            } else {
149                $this->logger->debug('No changes to file: ' . $relativeFilePath);
150            }
151        }
152    }
153
154    /**
155     * @param DiscoveredSymbols $discoveredSymbols
156     * @param string $contents
157     *
158     * @throws Exception
159     */
160    public function replaceInString(DiscoveredSymbols $discoveredSymbols, string $contents): string
161    {
162        $classmapPrefix = $this->config->getClassmapPrefix();
163
164        $namespacesChanges = $discoveredSymbols->getDiscoveredNamespaceChanges($this->config->getNamespacePrefix());
165        $constants = $discoveredSymbols->getDiscoveredConstantChanges($this->config->getConstantsPrefix());
166        $classes = $discoveredSymbols->getGlobalClassChanges();
167        $functions = $discoveredSymbols->getDiscoveredFunctionChanges();
168
169        $contents = $this->prepareRelativeNamespaces($contents, $namespacesChanges);
170
171        if ($classmapPrefix) {
172            foreach ($classes as $classSymbol) {
173                $contents = $this->replaceClassname($contents, $classSymbol->getOriginalSymbolStripPrefix($classmapPrefix), $classmapPrefix);
174            }
175        }
176
177        // TODO: Move this out of the loop.
178        $namespacesChangesStrings = [];
179        foreach ($namespacesChanges as $originalNamespace => $namespaceSymbol) {
180            if (in_array($originalNamespace, $this->config->getExcludeNamespacesFromPrefixing())) {
181                $this->logger->info("Skipping namespace: $originalNamespace");
182                continue;
183            }
184            $namespacesChangesStrings[$originalNamespace] = $namespaceSymbol->getReplacement();
185        }
186        // This matters... it shouldn't.
187        uksort($namespacesChangesStrings, new NamespaceSort(NamespaceSort::SHORTEST));
188        foreach ($namespacesChangesStrings as $originalNamespace => $replacementNamespace) {
189            $contents = $this->replaceNamespace($contents, $originalNamespace, $replacementNamespace);
190        }
191
192        if (!is_null($this->config->getConstantsPrefix())) {
193            $contents = $this->replaceConstants($contents, $constants, $this->config->getConstantsPrefix());
194        }
195
196        foreach ($functions as $functionSymbol) {
197            $contents = $this->replaceFunctions($contents, $functionSymbol);
198        }
199
200        $contents = $this->replaceConstFetchNamespaces($discoveredSymbols, $contents);
201
202        return $contents;
203    }
204
205    protected function replaceConstFetchNamespaces(DiscoveredSymbols $symbols, string $contents): string
206    {
207        $parser = (new ParserFactory())->createForNewestSupportedVersion();
208        $ast = $parser->parse($contents);
209
210        $namespaceSymbols = $symbols->getDiscoveredNamespaces($this->config->getNamespacePrefix());
211        if (empty($namespaceSymbols)) {
212            return $contents;
213        }
214
215        $nodeFinder = new NodeFinder();
216        $positions = [];
217
218        /** @var ConstFetch[] $constFetches */
219        $constFetches = $nodeFinder->find($ast, function (Node $node) {
220            return $node instanceof ConstFetch
221                && $node->name instanceof Name\FullyQualified;
222        });
223
224        foreach ($constFetches as $fetch) {
225            $full = $fetch->name->toString();
226            $parts = explode('\\', $full);
227            $namespace = $parts[0] ?? null;
228
229            if ($namespace && isset($namespaceSymbols[$namespace])) {
230                $replacementNamespace = $namespaceSymbols[$namespace]->getReplacement();
231                $parts[0] = $replacementNamespace;
232                $newName = '\\' . implode('\\', $parts);
233
234                $positions[] = [
235                    'start' => $fetch->name->getStartFilePos(),
236                    'end' => $fetch->name->getEndFilePos() + 1,
237                    'replacement' => $newName,
238                ];
239            }
240        }
241
242        usort($positions, fn($a, $b) => $b['start'] <=> $a['start']);
243
244        foreach ($positions as $pos) {
245            $contents = substr_replace($contents, $pos['replacement'], $pos['start'], $pos['end'] - $pos['start']);
246        }
247
248        return $contents;
249    }
250
251    /**
252     * TODO: Test against traits.
253     *
254     * @param string $contents The text to make replacements in.
255     * @param string $originalNamespace
256     * @param string $replacement
257     *
258     * @return string The updated text.
259     * @throws Exception
260     */
261    public function replaceNamespace(string $contents, string $originalNamespace, string $replacement): string
262    {
263
264        $searchNamespace = '\\' . rtrim($originalNamespace, '\\') . '\\';
265        $searchNamespace = str_replace('\\\\', '\\', $searchNamespace);
266        $searchNamespace = str_replace('\\', '\\\\{0,2}', $searchNamespace);
267
268        $pattern = "
269            /                              # Start the pattern
270            (
271            ^\s*                          # start of the string
272            |\\n\s*                        # start of the line
273            |(<?php\s+namespace|^\s*namespace|[\r\n]+\s*namespace)\s+                  # the namespace keyword
274            |use\s+                        # the use keyword
275            |use\s+function\s+               # the use function syntax
276            |new\s+
277            |static\s+
278            |\"                            # inside a string that does not contain spaces - needs work
279            |'                             #   right now its just inside a string that doesnt start with a space
280            |implements\s+\\\\             # when the interface being implemented is namespaced inline
281            |extends\s+\\\\                    # when the class being extended is namespaced inline
282            |return\s+
283            |instanceof\s+                 # when checking the class type of an object in a conditional
284            |\(\s*                         # inside a function declaration as the first parameters type
285            |,\s*                          # inside a function declaration as a subsequent parameter type
286            |\.\s*                         # as part of a concatenated string
287            |=\s*                          # as the value being assigned to a variable
288            |\*\s+@\w+\s*                  # In a comments param etc
289            |&\s*                             # a static call as a second parameter of an if statement
290            |\|\s*
291            |!\s*                             # negating the result of a static call
292            |=>\s*                            # as the value in an associative array
293            |\[\s*                         # In a square array
294            |\?\s*                         # In a ternary operator
295            |:\s*                          # In a ternary operator
296            |<                             # In a generic type declaration
297            |\(string\)\s*                 # casting a namespaced class to a string
298            )
299            @?                             # Maybe preceded by the @ symbol for error suppression
300            (?<searchNamespace>
301            {$searchNamespace}             # followed by the namespace to replace
302            )
303            (?!:)                          # Not followed by : which would only be valid after a classname
304            (
305            \s*;                           # followed by a semicolon
306            |\s*{                          # or an opening brace for multiple namespaces per file
307            |\\\\{1,2}[a-zA-Z0-9_\x7f-\xff]{1,}         # or a classname no slashes
308            |\s+as                         # or the keyword as
309            |\"                            # or quotes
310            |'                             # or single quote
311            |:                             # or a colon to access a static
312            |\\\\{
313            |>                             # In a generic type declaration (end)
314            )
315            /Ux";                          // U: Non-greedy matching, x: ignore whitespace in pattern.
316
317        $replacingFunction = function ($matches) use ($originalNamespace, $replacement) {
318            $singleBackslash = '\\';
319            $doubleBackslash = '\\\\';
320
321            if (false !== strpos($matches['0'], $doubleBackslash)) {
322                $originalNamespace = str_replace($singleBackslash, $doubleBackslash, $originalNamespace);
323                $replacement = str_replace($singleBackslash, $doubleBackslash, $replacement);
324            }
325
326            return str_replace($originalNamespace, $replacement, $matches[0]);
327        };
328
329        $result = preg_replace_callback($pattern, $replacingFunction, $contents);
330
331        $this->checkPregError();
332
333        // For prefixed functions which do not begin with a backslash, add one.
334        // I'm not certain this is a good idea.
335        // @see https://github.com/BrianHenryIE/strauss/issues/65
336        $functionReplacingPattern = '/\\\\?(' . preg_quote(ltrim($replacement, '\\'), '/') . '\\\\(?:[a-zA-Z0-9_\x7f-\xff]+\\\\)*[a-zA-Z0-9_\x7f-\xff]+\\()/';
337
338        return preg_replace(
339            $functionReplacingPattern,
340            "\\\\$1",
341            $result
342        );
343    }
344
345    /**
346     * In a namespace:
347     * * use \Classname;
348     * * new \Classname()
349     *
350     * In a global namespace:
351     * * new Classname()
352     *
353     * @param string $contents
354     * @param string $originalClassname
355     * @param string $classnamePrefix
356     *
357     * @throws Exception
358     */
359    public function replaceClassname(string $contents, string $originalClassname, string $classnamePrefix): string
360    {
361        $searchClassname = preg_quote($originalClassname, '/');
362
363        // This could be more specific if we could enumerate all preceding and proceeding words ("new", "("...).
364        $pattern = '
365            /                                            # Start the pattern
366                (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*{(.*?)(namespace|\z) 
367                                                        # Look for a preceding namespace declaration, up until a 
368                                                        # potential second namespace declaration.
369                |                                        # if found, match that much before continuing the search on
370                                                        # the remainder of the string.
371                (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*;(.*) # Skip lines just declaring the namespace.
372                |                    
373                ([^a-zA-Z0-9_\x7f-\xff\$\\\])(' . $searchClassname . ')([^a-zA-Z0-9_\x7f-\xff\\\]) # outside a namespace the class will not be prefixed with a slash
374                
375            /xsm'; //                                    # x: ignore whitespace in regex.  s dot matches newline, m: ^ and $ match start and end of line
376
377        $replacingFunction = function ($matches) use ($originalClassname, $classnamePrefix) {
378
379            // If we're inside a namespace other than the global namespace:
380            if (1 === preg_match('/\s*namespace\s+[a-zA-Z0-9_\x7f-\xff\\\\]+[;{\s\n]{1}.*/', $matches[0])) {
381                return $this->replaceGlobalClassInsideNamedNamespace(
382                    $matches[0],
383                    $originalClassname,
384                    $classnamePrefix
385                );
386            } else {
387                $newContents = '';
388                foreach ($matches as $index => $captured) {
389                    if (0 === $index) {
390                        continue;
391                    }
392
393                    if ($captured == $originalClassname) {
394                        $newContents .= $classnamePrefix;
395                    }
396
397                    $newContents .= $captured;
398                }
399                return $newContents;
400            }
401//            return $matches[1] . $matches[2] . $matches[3] . $classnamePrefix . $originalClassname . $matches[5];
402        };
403
404        $result = preg_replace_callback($pattern, $replacingFunction, $contents);
405
406        if (is_null($result)) {
407            throw new Exception('preg_replace_callback returned null');
408        }
409
410        $this->checkPregError();
411
412        return $result;
413    }
414
415    /**
416     * Pass in a string and look for \Classname instances.
417     *
418     * @param string $contents
419     * @param string $originalClassname
420     * @param string $classnamePrefix
421     * @return string
422     */
423    protected function replaceGlobalClassInsideNamedNamespace(
424        string $contents,
425        string $originalClassname,
426        string $classnamePrefix
427    ): string {
428        $replacement = $classnamePrefix . $originalClassname;
429
430        // use Prefixed_Class as Class;
431        $usePattern = '/
432            (\s*use\s+)
433            (' . $originalClassname . ')   # Followed by the classname
434            \s*;
435            /x'; //                    # x: ignore whitespace in regex.
436
437        $contents = preg_replace_callback(
438            $usePattern,
439            function ($matches) use ($replacement) {
440                return $matches[1] . $replacement . ' as ' . $matches[2] . ';';
441            },
442            $contents
443        );
444
445        $this->checkPregError();
446
447        $bodyPattern =
448            '/([^a-zA-Z0-9_\x7f-\xff]  # Not a class character
449            \\\)                       # Followed by a backslash to indicate global namespace
450            (' . $originalClassname . ')   # Followed by the classname
451            ([^\\\;]{1})               # Not a backslash or semicolon which might indicate a namespace
452            /x'; //                    # x: ignore whitespace in regex.
453
454        $result = preg_replace_callback(
455            $bodyPattern,
456            function ($matches) use ($replacement) {
457                return $matches[1] . $replacement . $matches[3];
458            },
459            $contents
460        ) ?? $contents; // TODO: If this happens, it should raise an exception.
461
462        $this->checkPregError();
463
464        return $result;
465    }
466
467    protected function checkPregError(): void
468    {
469        $matchingError = preg_last_error();
470        if (0 !== $matchingError) {
471            throw new Exception(preg_last_error_msg());
472        }
473    }
474
475    /**
476     * TODO: This should be split and brought to FileScanner.
477     *
478     * @param string $contents
479     * @param string[] $originalConstants
480     * @param string $prefix
481     */
482    protected function replaceConstants(string $contents, array $originalConstants, string $prefix): string
483    {
484
485        foreach ($originalConstants as $constant) {
486            $contents = $this->replaceConstant($contents, $constant, $prefix . $constant);
487        }
488
489        return $contents;
490    }
491
492    protected function replaceConstant(string $contents, string $originalConstant, string $replacementConstant): string
493    {
494        return str_replace($originalConstant, $replacementConstant, $contents);
495    }
496
497    protected function replaceFunctions(string $contents, FunctionSymbol $functionSymbol): string
498    {
499        $originalFunctionString = $functionSymbol->getOriginalSymbol();
500        $replacementFunctionString = $functionSymbol->getReplacement();
501
502        if ($originalFunctionString === $replacementFunctionString) {
503            return $contents;
504        }
505
506        $nodeFinder = new NodeFinder();
507        $parser = (new ParserFactory())->createForNewestSupportedVersion();
508        $ast = $parser->parse($contents);
509
510        $positions = [];
511
512        // Function declarations (global only)
513        $functionDefs = $nodeFinder->findInstanceOf($ast, Function_::class);
514        foreach ($functionDefs as $func) {
515            if ($func->name->name === $originalFunctionString) {
516                $positions[] = [
517                    'start' => $func->name->getStartFilePos(),
518                    'end' => $func->name->getEndFilePos() + 1,
519                ];
520            }
521        }
522
523        // Calls (global only)
524        $calls = $nodeFinder->findInstanceOf($ast, FuncCall::class);
525        foreach ($calls as $call) {
526            if ($call->name instanceof Name &&
527                $call->name->toString() === $originalFunctionString
528            ) {
529                $positions[] = [
530                    'start' => $call->name->getStartFilePos(),
531                    'end' => $call->name->getEndFilePos() + 1,
532                ];
533            }
534        }
535
536        $functionsUsingCallable = [
537            'function_exists',
538            'call_user_func',
539            'call_user_func_array',
540            'forward_static_call',
541            'forward_static_call_array',
542            'register_shutdown_function',
543            'register_tick_function',
544            'unregister_tick_function',
545        ];
546
547        foreach ($calls as $call) {
548            if ($call->name instanceof Name &&
549                in_array($call->name->toString(), $functionsUsingCallable)
550                && isset($call->args[0])
551                && $call->args[0] instanceof Arg
552                && $call->args[0]->value instanceof String_
553                && $call->args[0]->value->value === $originalFunctionString
554            ) {
555                $positions[] = [
556                    'start' => $call->args[0]->value->getStartFilePos() + 1, // do not change quotes
557                    'end' => $call->args[0]->value->getEndFilePos(),
558                ];
559            }
560        }
561
562        if (empty($positions)) {
563            return $contents;
564        }
565
566        // We sort by start, from the end - so as not to break the positions after the substitution
567        usort($positions, fn($a, $b) => $b['start'] <=> $a['start']);
568
569        foreach ($positions as $pos) {
570            $contents = substr_replace($contents, $replacementFunctionString, $pos['start'], $pos['end'] - $pos['start']);
571        }
572        return $contents;
573    }
574
575    /**
576     * TODO: This should be a function on {@see DiscoveredFiles}.
577     *
578     * @return array<string, ComposerPackage>
579     */
580    public function getModifiedFiles(): array
581    {
582        return $this->changedFiles;
583    }
584
585    /**
586     * In the case of `use Namespaced\Traitname;` by `nette/latte`, the trait uses the full namespace but it is not
587     * preceded by a backslash. When everything is moved up a namespace level, this is a problem. I think being
588     * explicit about the namespace being a full namespace rather than a relative one should fix this.
589     *
590     * We will scan the file for `use Namespaced\Traitname` and replace it with `use \Namespaced\Traitname;`.
591     *
592     * @see https://github.com/nette/latte/blob/0ac0843a459790d471821f6a82f5d13db831a0d3/src/Latte/Loaders/FileLoader.php#L20
593     *
594     * @param string $phpFileContent
595     * @param NamespaceSymbol[] $discoveredNamespaceSymbols
596     */
597    protected function prepareRelativeNamespaces(string $phpFileContent, array $discoveredNamespaceSymbols): string
598    {
599        $parser = (new ParserFactory())->createForNewestSupportedVersion();
600
601        $ast = $parser->parse($phpFileContent);
602
603        $traverser = new NodeTraverser();
604        $visitor = new class($discoveredNamespaceSymbols) extends \PhpParser\NodeVisitorAbstract {
605
606            public int $countChanges = 0;
607            /** @var string[] */
608            protected array $discoveredNamespaces;
609
610            protected Node $lastNode;
611
612            /**
613             * The list of `use Namespace\Subns;` statements in the file.
614             *
615             * @var string[]
616             */
617            protected array $using = [];
618
619            /**
620             * @param NamespaceSymbol[] $discoveredNamespaceSymbols
621             */
622            public function __construct(array $discoveredNamespaceSymbols)
623            {
624
625                $this->discoveredNamespaces = array_map(
626                    fn(NamespaceSymbol $symbol) => $symbol->getOriginalSymbol(),
627                    $discoveredNamespaceSymbols
628                );
629            }
630
631            public function leaveNode(Node $node)
632            {
633
634                if ($node instanceof \PhpParser\Node\Stmt\Namespace_) {
635                    $this->using[] = $node->name->name;
636                    $this->lastNode = $node;
637                    return $node;
638                }
639                // Probably the namespace declaration
640                if (empty($this->lastNode) && $node instanceof Name) {
641                    $this->using[] = $node->name;
642                    $this->lastNode = $node;
643                    return $node;
644                }
645                if ($node instanceof Name) {
646                    return $node;
647                }
648                if ($node instanceof \PhpParser\Node\Stmt\Use_) {
649                    foreach ($node->uses as $use) {
650                        $use->name->name = ltrim($use->name->name, '\\') ?: (function () {
651                            throw new Exception('$use->name->name was empty');
652                        })();
653                        $this->using[] = $use->name->name;
654                    }
655                    $this->lastNode = $node;
656                    return $node;
657                }
658                if ($node instanceof \PhpParser\Node\UseItem) {
659                    return $node;
660                }
661
662                $nameNodes = [];
663
664                $docComment = $node->getDocComment();
665                if ($docComment) {
666                    foreach ($this->discoveredNamespaces as $namespace) {
667                        $updatedDocCommentText = preg_replace(
668                            '/(.*\*\s*@\w+\s+)(' . preg_quote($namespace, '/') . ')/',
669                            '$1\\\\$2',
670                            $docComment->getText(),
671                            1,
672                            $count
673                        );
674                        if ($count > 0) {
675                            $this->countChanges++;
676                            $node->setDocComment(new \PhpParser\Comment\Doc($updatedDocCommentText));
677                            break;
678                        }
679                    }
680                }
681
682                if ($node instanceof \PhpParser\Node\Stmt\TraitUse) {
683                    $nameNodes = array_merge($nameNodes, $node->traits);
684                }
685
686                if ($node instanceof \PhpParser\Node\Param
687                    && $node->type instanceof Name
688                    && !($node->type instanceof \PhpParser\Node\Name\FullyQualified)) {
689                    $nameNodes[] = $node->type;
690                }
691
692                if ($node instanceof \PhpParser\Node\NullableType
693                    && $node->type instanceof Name
694                    && !($node->type instanceof \PhpParser\Node\Name\FullyQualified)) {
695                    $nameNodes[] = $node->type;
696                }
697
698                if ($node instanceof \PhpParser\Node\Stmt\ClassMethod
699                    && $node->returnType instanceof Name
700                    && !($node->returnType instanceof \PhpParser\Node\Name\FullyQualified)) {
701                    $nameNodes[] = $node->returnType;
702                }
703
704                if ($node instanceof ClassConstFetch
705                    && $node->class instanceof Name
706                    && !($node->class instanceof \PhpParser\Node\Name\FullyQualified)) {
707                    $nameNodes[] = $node->class;
708                }
709
710                if ($node instanceof \PhpParser\Node\Expr\StaticPropertyFetch
711                    && $node->class instanceof Name
712                    && !($node->class instanceof \PhpParser\Node\Name\FullyQualified)) {
713                    $nameNodes[] = $node->class;
714                }
715
716                if (property_exists($node, 'name')
717                    && $node->name instanceof Name
718                    && !($node->name instanceof \PhpParser\Node\Name\FullyQualified)
719                ) {
720                    $nameNodes[] = $node->name;
721                }
722
723                if ($node instanceof \PhpParser\Node\Expr\StaticCall) {
724                    if (!method_exists($node->class, 'isFullyQualified') || !$node->class->isFullyQualified()) {
725                        $nameNodes[] = $node->class;
726                    }
727                }
728
729                if ($node instanceof \PhpParser\Node\Stmt\TryCatch) {
730                    foreach ($node->catches as $catch) {
731                        foreach ($catch->types as $catchType) {
732                            if ($catchType instanceof Name
733                                && !($catchType instanceof \PhpParser\Node\Name\FullyQualified)
734                            ) {
735                                $nameNodes[] = $catchType;
736                            }
737                        }
738                    }
739                }
740
741                if ($node instanceof \PhpParser\Node\Stmt\Class_) {
742                    foreach ($node->implements as $implement) {
743                        if ($implement instanceof Name
744                            && !($implement instanceof \PhpParser\Node\Name\FullyQualified)) {
745                            $nameNodes[] = $implement;
746                        }
747                    }
748                }
749                if ($node instanceof \PhpParser\Node\Expr\Instanceof_
750                    && $node->class instanceof Name
751                    && !($node->class instanceof \PhpParser\Node\Name\FullyQualified)) {
752                    $nameNodes[] = $node->class;
753                }
754
755                foreach ($nameNodes as $nameNode) {
756                    if (!property_exists($nameNode, 'name')) {
757                        continue;
758                    }
759                    // If the name contains a `\` but does not begin with one, it may be a relative namespace;
760                    if (false !== strpos($nameNode->name, '\\') && 0 !== strpos($nameNode->name, '\\')) {
761                        $parts = explode('\\', $nameNode->name);
762                        array_pop($parts);
763                        $namespace = implode('\\', $parts);
764                        if (in_array($namespace, $this->discoveredNamespaces)) {
765                            $nameNode->name = '\\' . $nameNode->name;
766                            $this->countChanges++;
767                        } else {
768                            foreach ($this->using as $namespaceBase) {
769                                if (in_array($namespaceBase . '\\' . $namespace, $this->discoveredNamespaces)) {
770                                    $nameNode->name = '\\' . $namespaceBase . '\\' . $nameNode->name;
771                                    $this->countChanges++;
772                                    break;
773                                }
774                            }
775                        }
776                    }
777                }
778                $this->lastNode = $node;
779                return $node;
780            }
781        };
782        $traverser->addVisitor($visitor);
783
784        $modifiedStmts = $traverser->traverse($ast);
785
786        if ($visitor->countChanges === 0) {
787            return $phpFileContent;
788        }
789
790        $updatedContent = (new Standard())->prettyPrintFile($modifiedStmts);
791
792        $updatedContent = str_replace('namespace \\', 'namespace ', $updatedContent);
793        $updatedContent = str_replace('use \\\\', 'use \\', $updatedContent);
794
795        return $updatedContent;
796    }
797}