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