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