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