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