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