Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.87% covered (warning)
76.87%
585 / 761
32.00% covered (danger)
32.00%
8 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Prefixer
76.87% covered (warning)
76.87%
585 / 761
32.00% covered (danger)
32.00%
8 / 25
774.23
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
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 replaceInFile
55.56% covered (warning)
55.56%
20 / 36
0.00% covered (danger)
0.00%
0 / 1
16.11
 replaceInProjectFiles
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 replaceInString
94.52% covered (success)
94.52%
69 / 73
0.00% covered (danger)
0.00%
0 / 1
14.03
 findPositionsOfUsesOfNamespacedConstants
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 replaceUseStatementsForNamespacedClasses
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
9.02
 checkPregError
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 replaceNamespaces
86.82% covered (warning)
86.82%
112 / 129
0.00% covered (danger)
0.00%
0 / 1
36.65
 findGlobalSymbolsPositionsInComment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 findGlobalSymbolPositionInComment
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 replaceSingleClassnameInString
96.36% covered (success)
96.36%
53 / 55
0.00% covered (danger)
0.00%
0 / 1
17
 findGlobalSymbolsPositionsInAst
93.90% covered (success)
93.90%
77 / 82
0.00% covered (danger)
0.00%
0 / 1
28.18
 hasGlobalSymbolForNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGlobalSymbolForNode
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
9.00
 getReplacementStringForNode
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 replaceConstants
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 replaceConstant
77.11% covered (warning)
77.11%
64 / 83
0.00% covered (danger)
0.00%
0 / 1
37.40
 findFunctionPositionsInAst
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
13
 findDocCommentPositionsInAst
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
10
 getModifiedFiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prefixComposerAutoloadFiles
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
72
 getNamespaceFromFqdn
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getParser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getNodeFinder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace BrianHenryIE\Strauss\Pipeline;
6
7use BrianHenryIE\Strauss\Composer\ComposerPackage;
8use BrianHenryIE\Strauss\Config\PrefixerConfigInterface;
9use BrianHenryIE\Strauss\Files\DiscoveredFiles;
10use BrianHenryIE\Strauss\Files\File;
11use BrianHenryIE\Strauss\Files\FileBase;
12use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
13use BrianHenryIE\Strauss\Types\ClassSymbol;
14use BrianHenryIE\Strauss\Types\ConstantSymbol;
15use BrianHenryIE\Strauss\Types\DiscoveredSymbol;
16use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
17use BrianHenryIE\Strauss\Types\NamespacedSymbol;
18use BrianHenryIE\Strauss\Types\NamespaceSymbol;
19use Composer\ClassMapGenerator\ClassMapGenerator;
20use Exception;
21use League\Flysystem\FilesystemException;
22use PhpParser\Comment;
23use PhpParser\Comment\Doc;
24use PhpParser\Error;
25use PhpParser\Node;
26use PhpParser\Node\Arg;
27use PhpParser\Node\Expr\ConstFetch;
28use PhpParser\Node\Expr\FuncCall;
29use PhpParser\Node\Identifier;
30use PhpParser\Node\Name;
31use PhpParser\Node\Name\FullyQualified;
32use PhpParser\Node\Scalar\String_;
33use PhpParser\Node\Stmt\Class_;
34use PhpParser\Node\Stmt\Enum_;
35use PhpParser\Node\Stmt\Const_;
36use PhpParser\Node\Stmt\Function_;
37use PhpParser\Node\Stmt\GroupUse;
38use PhpParser\Node\Stmt\Interface_;
39use PhpParser\Node\Stmt\Namespace_;
40use PhpParser\Node\Stmt\Trait_;
41use PhpParser\Node\Stmt\Use_;
42use PhpParser\Node\UseItem;
43use PhpParser\NodeFinder;
44use PhpParser\NodeTraverser;
45use PhpParser\Parser;
46use PhpParser\ParserFactory;
47use Psr\Log\LoggerAwareTrait;
48use Psr\Log\LoggerInterface;
49use Psr\Log\NullLogger;
50
51class Prefixer
52{
53    use LoggerAwareTrait;
54
55    protected PrefixerConfigInterface $config;
56
57    protected FileSystem $filesystem;
58
59    /**
60     * array<$filePath, $package> or null if the file is not from a dependency (i.e. a project file).
61     *
62     * @var array<string, ?ComposerPackage>
63     */
64    protected array $changedFiles = array();
65
66    protected ?Parser $parser = null;
67
68    protected ?NodeFinder $nodeFinder = null;
69
70    public function __construct(
71        PrefixerConfigInterface $config,
72        FileSystem              $filesystem,
73        ?LoggerInterface        $logger = null
74    ) {
75        $this->config = $config;
76        $this->filesystem = $filesystem;
77        $this->logger = $logger ?? new NullLogger();
78    }
79
80    // Don't replace a classname if there's an import for a class with the same name.
81    // but do replace \Classname always
82
83    /**
84     * @param DiscoveredSymbols $discoveredSymbols
85     * @param array<FileBase> $files
86     *
87     * @throws FilesystemException
88     * @throws FilesystemException
89     */
90    public function replaceInFiles(DiscoveredSymbols $discoveredSymbols, array $files): void
91    {
92        foreach ($files as $file) {
93            $this->replaceInFile($discoveredSymbols, $file);
94        }
95    }
96
97    protected function replaceInFile(DiscoveredSymbols $discoveredSymbols, FileBase $file): void
98    {
99        if (!$this->config->isTargetDirectoryVendor()
100            && !$file->isDoCopy()
101        ) {
102            return;
103        }
104
105        if (!$file->getDoUpdate()) {
106            return;
107        }
108
109        if ($this->filesystem->directoryExists($file->getTargetAbsolutePath())) {
110            $this->logger->debug("is_dir() / nothing to do : {targetAbsolutePath}", [
111                'targetAbsolutePath' => $file->getTargetAbsolutePath()
112            ]);
113            return;
114        }
115
116        if (!$file->isPhpFile()) {
117            return;
118        }
119
120        if (!$this->filesystem->fileExists($file->getTargetAbsolutePath())) {
121            // Some files are only sometimes present.
122            if (in_array($file->getTargetAbsolutePath(), [
123                $this->config->getAbsoluteTargetDirectory() . '/composer/autoload_files.php',
124                $this->config->getAbsoluteTargetDirectory() . '/composer/platform_check.php',
125            ], true)) {
126                return;
127            }
128            $this->logger->warning("Expected file does not exist: {targetAbsolutePath}", [
129                'targetAbsolutePath' => $file->getTargetAbsolutePath()
130            ]);
131            return;
132        }
133
134        $this->logger->debug("Updating contents of file: {targetAbsolutePath}", [
135            'targetAbsolutePath' => $file->getTargetAbsolutePath()
136        ]);
137
138        /**
139         * Throws an exception, but unlikely to happen.
140         */
141        $contents = $this->filesystem->read($file->getTargetAbsolutePath());
142
143        $updatedContents = $this->replaceInString($discoveredSymbols, $contents, $file);
144
145        if ($updatedContents !== $contents) {
146            // TODO: diff here and debug log.
147            $file->setDidUpdate();
148            $this->filesystem->write($file->getTargetAbsolutePath(), $updatedContents);
149            $this->logger->info("Updated contents of file: {targetAbsolutePath}", [
150                'targetAbsolutePath' => $file->getTargetAbsolutePath()
151            ]);
152        } else {
153            $this->logger->debug("No changes to file: {targetAbsolutePath}", [
154                'targetAbsolutePath' => $file->getTargetAbsolutePath()
155            ]);
156        }
157    }
158
159    /**
160     * @param DiscoveredSymbols $discoveredSymbols
161     * @param DiscoveredFiles $projectFiles
162     *
163     * @return void
164     * @throws FilesystemException
165     */
166    public function replaceInProjectFiles(DiscoveredSymbols $discoveredSymbols, DiscoveredFiles $projectFiles): void
167    {
168        $phpFiles = array_filter(
169            $projectFiles->getFiles(),
170            fn($file) => $file->isPhpFile()
171        );
172
173        foreach ($phpFiles as $file) {
174            $fileAbsolutePath = $file->getSourcePath();
175
176            $relativeFilePath = $this->filesystem->getRelativePath(dirname($this->config->getAbsoluteTargetDirectory()), $fileAbsolutePath);
177
178            if ($this->filesystem->directoryExists($fileAbsolutePath)) {
179                $this->logger->debug("is_dir() / nothing to do : {relativeFilePath}", [
180                    'relativeFilePath' => $relativeFilePath
181                ]);
182                continue;
183            }
184
185            if (!$this->filesystem->fileExists($fileAbsolutePath)) {
186                // Some files are only sometimes present.
187                if (in_array($fileAbsolutePath, [
188                    $this->config->getAbsoluteTargetDirectory() . '/composer/autoload_files.php',
189                    $this->config->getAbsoluteTargetDirectory() . '/composer/platform_check.php',
190                    ], true)) {
191                    continue;
192                }
193                $this->logger->warning("Expected file does not exist: {relativeFilePath}", [
194                    'relativeFilePath' => $relativeFilePath
195                ]);
196                continue;
197            }
198
199            $this->logger->debug("Updating contents of file (project): {fileAbsolutePath}", [
200                'fileAbsolutePath' => $fileAbsolutePath,
201            ]);
202
203            // Throws an exception, but unlikely to happen.
204            $contents = $this->filesystem->read($fileAbsolutePath);
205
206            $updatedContents = $this->replaceInString($discoveredSymbols, $contents, $file);
207
208            if ($updatedContents !== $contents) {
209                $this->changedFiles[$fileAbsolutePath] = null;
210                $this->filesystem->write($fileAbsolutePath, $updatedContents);
211                $this->logger->info('Updated contents of file: ' . $relativeFilePath);
212            } else {
213                $this->logger->debug('No changes to file: ' . $relativeFilePath);
214            }
215        }
216    }
217
218    /**
219     * @param DiscoveredSymbols $discoveredSymbols
220     * @param string $contents
221     *
222     * @throws Exception
223     */
224    public function replaceInString(DiscoveredSymbols $discoveredSymbols, string $contents, ?FileBase $file = null): string
225    {
226        $fileAbsolutePath = is_null($file) ? null : $file->getTargetAbsolutePath();
227
228//        $namespacesChanges = $discoveredSymbols->getNamespaces()->getToRename();
229        $constants = $discoveredSymbols->getDiscoveredConstants()->getToRename();
230        $functionsToRename = $discoveredSymbols->getDiscoveredFunctions()->getToRename();
231
232        $openingString = '';
233
234        // If the document contains PHP but does not begin with the PHP opener.
235        if (stristr($contents, "<?") && stripos($contents, "?>") !== 0) {
236            $parts = explode('<?', $contents, 2);
237            $openingString = $parts[0];
238            $contents = '<?'.$parts[1];
239            unset($parts);
240        }
241
242        // Prepend <?php if absent so php-parser treats the content as PHP code rather
243        // than inline HTML. The offset is subtracted from all collected positions below.
244        $phpOpenerLen = 0;
245        $parseContent = $contents;
246        if (stripos(ltrim($contents), '<?') !== 0) {
247            $phpOpenerLen = strlen("<?php\n");
248            $parseContent = "<?php\n" . $contents;
249        }
250
251        $parser = (new ParserFactory())->createForNewestSupportedVersion();
252        $errorHandler = new \PhpParser\ErrorHandler\Collecting();
253        $ast = null;
254
255        $positions = [];
256
257        try {
258            $this->logger->debug("Parsing {filePath} AST", [
259                'filePath' => $fileAbsolutePath ?? 'file',
260            ]);
261            $ast = $parser->parse($parseContent);
262//                $ast = $parser->parse($parseContent, $errorHandler);
263        } catch (Error $e) {
264            // This happens in template files, E.g `x.blade.php`.
265            $this->logger->warning("Skipping Prefixing in {filePath} due to parse error: " . $e->getMessage(), [
266                'filePath' => $fileAbsolutePath ?? 'file',
267            ]);
268            return $openingString.$contents;
269        }
270
271        if (is_null($ast)) {
272            $this->logger->warning("AST parse failed for {filePath}, returning.", [
273                'filePath' => $fileAbsolutePath ?? 'file',
274            ]);
275            return $openingString.$contents;
276        }
277
278        $positions = array_merge(
279            $positions,
280            $this->replaceUseStatementsForNamespacedClasses($ast, $discoveredSymbols),
281            $this->replaceNamespaces($ast, $discoveredSymbols, $file),
282            $this->findFunctionPositionsInAst($ast, $functionsToRename),
283            $this->findDocCommentPositionsInAst($ast, $discoveredSymbols),
284            $this->findPositionsOfUsesOfNamespacedConstants($discoveredSymbols, $ast),
285            $this->findGlobalSymbolsPositionsInAst($ast, $discoveredSymbols),
286        );
287
288        // Adjust positions to be relative to the original $contents (before any <?php prepend).
289        if ($phpOpenerLen > 0) {
290            $positions = array_values(array_filter(
291                array_map(function ($pos) use ($phpOpenerLen) {
292                    $pos['start'] -= $phpOpenerLen;
293                    $pos['end'] -= $phpOpenerLen;
294                    return $pos;
295                }, $positions),
296                fn($pos) => $pos['start'] >= 0
297            ));
298        }
299
300        usort($positions, fn($a, $b) => $b['start'] <=> $a['start']);
301
302        $removeDuplicatePositions = [];
303        foreach ($positions as $position) {
304//            if(isset($removeDuplicatePositions[$position['start']])){
305//            }
306            $removeDuplicatePositions[$position['start']] = $position;
307        }
308        $positions = $removeDuplicatePositions;
309
310        foreach ($positions as $pos) {
311            $contents = substr_replace($contents, $pos['replacement'], $pos['start'], $pos['end'] - $pos['start']);
312        }
313
314        // TODO: Use AST.
315        if (!is_null($this->config->getConstantsPrefix())) {
316            $contents = $this->replaceConstants($contents, $constants, $this->config->getConstantsPrefix());
317        }
318
319        // The following are for replacing symbols inside strings.
320        // TODO: When functions and constants are implemented via AST, their respective string replacement part will need to be added here.
321
322        // Only search for symbols that might possibly be in this file.
323        // TODO: During the initial AST parse which now find symbols defined in a file, also record the symbols used in the file
324        // TODO: bug: this is dropping all non-class symbols, presumably never registered against the file.
325//        if ($file instanceof FileWithDependency && !($file->getDependency() instanceof ProjectComposerPackage)) {
326//            $discoveredSymbols = $file->getDependency()->getDiscoveredSymbolsDeep();
327//        }
328
329        $discoveredSymbolsCount = count($discoveredSymbols->toArray());
330        $this->logger->debug(sprintf(
331            'Searching in {filename} for {count} symbol%s as string',
332            $discoveredSymbolsCount === 0 ? '' : 's'
333        ), [
334            'filename' => basename($fileAbsolutePath),
335            'count' => $discoveredSymbolsCount,
336        ]);
337
338        // TODO: optionally filter to only namespaces of more than a single depth.
339        $namespaceSymbolsToRename = $discoveredSymbols->getNamespaces()->getToRename();
340        /** @var NamespaceSymbol $namespaceSymbol */
341        foreach ($namespaceSymbolsToRename as $namespaceSymbol) {
342//            $this->logger->debug('Searching in {filename} for {type}: {name}', [
343//                'filename' => basename($fileAbsolutePath),
344//                'type' => array_reverse(explode('\\', get_class($namespaceSymbol)))[0],
345//                'name' => $namespaceSymbol->getOriginalLocalName()
346//            ]);
347
348            $contents = $this->replaceSingleClassnameInString($contents, $namespaceSymbol);
349        }
350
351        /** @var ClassSymbol $classSymbol */
352        foreach ($discoveredSymbols->getNamespacedSymbols()->getToRename() as $classSymbol) {
353//            $this->logger->debug('Searching in {filename} for {type}: {name}', [
354//                'filename' => basename($fileAbsolutePath),
355//                'type' => array_reverse(explode('\\', basename(get_class($classSymbol))))[0],
356//                'name' => $classSymbol->getOriginalLocalName(),
357//            ]);
358
359            $contents = $this->replaceSingleClassnameInString($contents, $classSymbol);
360        }
361
362        return $openingString.$contents;
363    }
364
365    /**
366     * @param array<\PhpParser\Node\Stmt> $ast
367     *
368     * @return array{start:int,end:int,replacement:string}|array{}
369     */
370    protected function findPositionsOfUsesOfNamespacedConstants(DiscoveredSymbols $symbols, array $ast): array
371    {
372        $namespaceSymbols = $symbols->getNamespaces();
373        if (count($namespaceSymbols) === 0) {
374            return [];
375        }
376
377        $nodeFinder = new NodeFinder();
378        $positions = [];
379
380        /** @var ConstFetch[] $constFetches */
381        $constFetches = $this->getNodeFinder()->find($ast, function (Node $node) {
382            return $node instanceof ConstFetch
383                && $node->name instanceof FullyQualified;
384        });
385
386        foreach ($constFetches as $fetch) {
387            $full = $fetch->name->toString();
388            $parts = explode('\\', $full);
389            $local = array_pop($parts);
390            if (empty($parts)) {
391                // not namespaced;
392                continue;
393            }
394            $namespaceName = implode('\\', $parts);
395            $namespace = $namespaceSymbols->get($namespaceName);
396
397            if ($namespace) {
398                $replacementNamespace = $namespace->getLocalReplacement();
399                $newName = '\\' . $replacementNamespace . '\\' . $local;
400
401                $positions[] = [
402                    'start' => $fetch->name->getStartFilePos(),
403                    'end' => $fetch->name->getEndFilePos() + 1,
404                    'replacement' => $newName,
405                ];
406            }
407        }
408
409        return $positions;
410    }
411
412    /**
413     * Replace class/interface/trait `use` statements driven by registered ClassSymbols.
414     *
415     * A namespace is "active" when at least one ClassSymbol is registered within it.
416     * For exact-match ClassSymbols the symbol's own replacement is used; for other classes
417     * in the namespace, namespace-prefix replacement is applied.
418     * Namespaces with no registered ClassSymbol are left alone.
419     *
420     * @param array<\PhpParser\Node\Stmt> $ast
421     *
422     * @return array{start:int,end:int,replacement:string}|array{}
423     */
424    protected function replaceUseStatementsForNamespacedClasses(array $ast, DiscoveredSymbols $discoveredSymbols): array
425    {
426        $activeNamespaces = [];
427        /** @var NamespacedSymbol $symbol */
428        foreach ($discoveredSymbols->getNamespacedSymbols()->getToRename()->notGlobal()->toArray() as $symbol) {
429            $ns = $symbol->getNamespace();
430            $original = rtrim($ns->getOriginalFqdnName(), '\\');
431            $replacement = rtrim($ns->getReplacementFqdnName(), '\\');
432            $activeNamespaces[$original] = $replacement;
433        }
434
435        if (empty($activeNamespaces)) {
436            return [];
437        }
438
439        uksort($activeNamespaces, fn($a, $b) => strlen($b) - strlen($a));
440
441        $nodeFinder = new NodeFinder();
442        $positions = [];
443
444        $namespacedSymbols = $discoveredSymbols->getNamespacedSymbols()->getToRename()->notGlobal();
445        $useItems = $nodeFinder->findInstanceOf($ast, UseItem::class);
446        foreach ($useItems as $item) {
447            $nameStr = $item->name->toString();
448            // Full match.
449            if ($namespacedSymbols->get($nameStr)) {
450                $positions[] = [
451                    'start'       => $item->name->getStartFilePos(),
452                    'end'         => $item->name->getEndFilePos() + 1,
453                    'replacement' => $namespacedSymbols->get($nameStr)->getReplacementFqdnName(),
454                ];
455            } else { // Partial match (group)
456                foreach ($activeNamespaces as $original => $replacement) {
457                    if (str_starts_with($nameStr, $original . '\\')) {
458//                if ($nameStr === $original) {
459                        /** @var ?NamespacedSymbol $classSymbol */
460                        $classSymbol = $namespacedSymbols->get($nameStr);
461                        if ($classSymbol && $classSymbol->isDoRename()) {
462                            $nsReplacement = rtrim($classSymbol->getNamespace()->getReplacementFqdnName(), '\\');
463                            $newName       = $nsReplacement . '\\' . $classSymbol->getLocalReplacement();
464//                        $newName = $nsReplacement . $classSymbol->getLocalReplacement();
465                        } else {
466                            $newName = $replacement . substr($nameStr, strlen($original));
467                        }
468
469                        $positions[] = [
470                            'start'       => $item->name->getStartFilePos(),
471                            'end'         => $item->name->getEndFilePos() + 1,
472                            'replacement' => $newName,
473                        ];
474                    }
475                }
476            }
477        }
478
479        return $positions;
480    }
481
482
483    protected function checkPregError(): void
484    {
485        $matchingError = preg_last_error();
486        if (0 !== $matchingError) {
487            throw new Exception(preg_last_error_msg());
488        }
489    }
490
491    /**
492     * @param array<\PhpParser\Node\Stmt> $ast
493     *
494     * @return array{start:int,end:int,replacement:string}|array{}
495     */
496    protected function replaceNamespaces(array $ast, DiscoveredSymbols $discoveredSymbols, FileBase $file): array
497    {
498        $namespaces = $discoveredSymbols->getNamespaces();
499        $namespacedChanges = $discoveredSymbols->getNamespacedSymbols()->notGlobal();
500        if (count($namespaces->getToRename()) === 0) {
501            return [];
502        }
503
504        /** @var NamespaceSymbol[] $symbolMap indexed by exact original symbol (no trailing \) */
505        $symbolMap = [];
506//        foreach ($namespaceChanges->getNamespaces()->notGlobal() as $symbol) {
507        foreach ($namespaces->getToRename() as $symbol) {
508            if (isset($symbolMap[rtrim($symbol->getOriginalFqdnName(), '\\')])) {
509                throw new Exception('losing data');
510            }
511            $symbolMap[rtrim($symbol->getOriginalFqdnName(), '\\')] = $symbol;
512        }
513        uksort($symbolMap, fn($a, $b) => strlen($b) - strlen($a));
514
515        $nodeFinder = new NodeFinder();
516        $positions = [];
517        $handled = [];
518
519        /**
520         * Prefix lookup for qualified names like Aws\SomeClass or Aws\boolean_value():
521         * walks from the longest prefix down to length-1, doing exact key lookups.
522         * Returns ['symbol' => DiscoveredSymbol, 'suffix' => 'remaining\parts'] or null.
523         *
524         * @param string[] $parts
525         */
526        $findPrefixSymbol = function (array $parts) use ($symbolMap, $discoveredSymbols): ?array {
527            for ($len = count($parts) - 1; $len >= 1; $len--) {
528                $prefix = implode('\\', array_slice($parts, 0, $len));
529
530                $discoveredNamespace = $discoveredSymbols->getNamespace($prefix);
531                if (isset($symbolMap[$prefix])) {
532//                if ($discoveredNamespace) {
533                    return [
534                        'symbol' => $symbolMap[$prefix],
535                        'suffix' => implode('\\', array_slice($parts, $len)),
536                    ];
537                }
538            }
539            return null;
540        };
541
542        // A: namespace declarations â€” keep relative (no leading \)
543        foreach ($nodeFinder->findInstanceOf($ast, Namespace_::class) as $ns) {
544            if ($ns->name === null) {
545                continue;
546            }
547            if (!$file->isDoPrefix()) {
548                $handled[$ns->name->getStartFilePos()] = true;
549                continue;
550            }
551            $nameStr = $ns->name->toString();
552
553            if (isset($symbolMap[$nameStr])) {
554                $namespaceSymbol = $symbolMap[$nameStr];
555                $positions[] = [
556                    'start' => $ns->name->getStartFilePos(),
557                    'end' => $ns->name->getEndFilePos() + 1,
558                    'replacement' => $namespaceSymbol->getReplacementFqdnName(),
559                ];
560                $handled[$ns->name->getStartFilePos()] = true;
561            }
562
563            if ($symbol = $namespacedChanges->get($nameStr)) {
564                $replacement = $symbol->getReplacementFqdnName();
565            } elseif ($match = $findPrefixSymbol($ns->name->getParts())) {
566                $replacement = rtrim($match['symbol']->getReplacementFqdnName(), '\\') . '\\' . $match['suffix'];
567            } else {
568                continue;
569            }
570            $positions[] = [
571                'start' => $ns->name->getStartFilePos(),
572                'end' => $ns->name->getEndFilePos() + 1,
573                'replacement' => $replacement,
574            ];
575            $handled[$ns->name->getStartFilePos()] = true;
576        }
577
578        // B: use items.
579        // Class/interface/trait use items are always marked as handled to prevent section D from
580        // prepending '\'; their replacement is produced by replaceUseStatementsForNamespacedClasses.
581        // Function and constant use items keep namespace-prefix replacement here.
582        foreach ($nodeFinder->findInstanceOf($ast, Use_::class) as $useStmt) {
583            foreach ($useStmt->uses as $item) {
584                $nameStr = $item->name->toString();
585                // Always mark use item names as handled so section D never adds a spurious '\' prefix.
586                $handled[$item->name->getStartFilePos()] = true;
587                if ($useStmt->type !== Use_::TYPE_NORMAL) {
588                    // TYPE_FUNCTION / TYPE_CONSTANT: replace directly here.
589                    if ($symbol = $discoveredSymbols->get($nameStr)) {
590                        $replacement = $symbol->getReplacementFqdnName();
591                    } elseif ($match = $findPrefixSymbol($item->name->getParts())) {
592                        // groups
593                        $replacement = rtrim($match['symbol']->getReplacementFqdnName(), '\\') . '\\' . $match['suffix'];
594                    } else {
595                        continue;
596                    }
597                    $positions[] = [
598                        'start' => $item->name->getStartFilePos(),
599                        'end' => $item->name->getEndFilePos() + 1,
600                        'replacement' => $replacement,
601                    ];
602                    $handled[$item->getStartFilePos()] = true;
603                } elseif (isset($symbolMap[$nameStr])) {
604                    // TYPE_NORMAL with an exact namespace match: replaceUseStatementsForNamespacedClasses
605                    // only handles class/trait/interface/enum symbols, so handle pure namespace use items here.
606                    $positions[] = [
607                        'start' => $item->name->getStartFilePos(),
608                        'end' => $item->name->getEndFilePos() + 1,
609                        'replacement' => $symbolMap[$nameStr]->getReplacementFqdnName(),
610                    ];
611                }
612            }
613        }
614        // It would be necessary to split `use My\Namespace\{Class1, Class2};` into individual lines if one of
615        // those classes is excluded and one should be updated.
616        foreach ($nodeFinder->findInstanceOf($ast, GroupUse::class) as $groupUse) {
617            if ($groupUse->prefix === null) {
618                continue;
619            }
620            $nameStr = $groupUse->prefix->toString();
621            if ($symbol = $discoveredSymbols->get($nameStr)) {
622                $replacement = $symbol->getReplacementFqdnName();
623            } elseif ($match = $findPrefixSymbol($groupUse->prefix->getParts())) {
624                $replacement = rtrim($match['symbol']->getReplacementFqdnName(), '\\') . '\\' . $match['suffix'];
625            } else {
626                continue;
627            }
628            $positions[] = [
629                'start' => $groupUse->prefix->getStartFilePos(),
630                'end' => $groupUse->prefix->getEndFilePos() + 1,
631                'replacement' => $replacement,
632            ];
633            $handled[$groupUse->prefix->getStartFilePos()] = true;
634        }
635
636        // C: fully-qualified Name nodes â€” retain leading \
637        foreach ($nodeFinder->findInstanceOf($ast, FullyQualified::class) as $name) {
638            if (isset($handled[$name->getStartFilePos()])) {
639                continue;
640            }
641            if ($symbol = $namespacedChanges->get($name->toString())) {
642                $replacement = $symbol->getReplacementFqdnName();
643            } elseif ($match = $findPrefixSymbol($name->getParts())) {
644                $replacement = rtrim($match['symbol']->getReplacementFqdnName(), '\\') . '\\' . $match['suffix'];
645            } else {
646                continue;
647            }
648            $positions[] = [
649                'start' => $name->getStartFilePos(),
650                'end' => $name->getEndFilePos() + 1,
651                'replacement' => '\\' . $replacement,
652            ];
653            $handled[$name->getStartFilePos()] = true;
654        }
655
656        // D: relative qualified Name nodes (e.g. Aws\boolean_value, Aws\SomeClass) â€” promote to FQ.
657        // Uses part-by-part prefix lookup so only full namespace-segment boundaries are matched.
658        foreach ($nodeFinder->find($ast, function (Node $node) {
659            return $node instanceof Name
660                && !($node instanceof FullyQualified)
661                && count($node->getParts()) >= 2;
662        }) as $name) {
663            // This needs to be available to other functions.
664            if (isset($handled[$name->getStartFilePos()])) {
665                continue;
666            }
667
668            if (isset($symbolMap[$name->toString()])) {
669                $namespaceSymbol = $symbolMap[$name->toString()];
670                $positions[] = [
671                    'start' => $name->getStartFilePos(),
672                    'end' => $name->getEndFilePos() + 1,
673                    'replacement' => $namespaceSymbol->getReplacementFqdnName(),
674                ];
675                continue;
676            }
677
678            $match = $findPrefixSymbol($name->getParts());
679            if (!$match) {
680                continue;
681            }
682            $namespaceSymbol = $match['symbol'];
683
684            if (!$file->isDoPrefix() && $file->getDiscoveredSymbols()->has($namespaceSymbol)) {
685                continue;
686            }
687
688            $positions[] = [
689                'start' => $name->getStartFilePos(),
690                'end' => $name->getEndFilePos() + 1,
691                'replacement' => '\\' . $namespaceSymbol->getReplacementFqdnName() . '\\' . $match['suffix'],
692//                'replacement' => '\\' . $match['symbol']->getReplacementFqdnName() . '\\' . $match['suffix'],
693            ];
694        }
695
696        return $positions;
697    }
698
699    protected function findGlobalSymbolsPositionsInComment(Comment $comment, DiscoveredSymbols $globalSymbols): array
700    {
701        $positions = [];
702        foreach ($globalSymbols->getGlobalClassesInterfacesTraitsToRename() as $discoveredSymbol) {
703            $positions = array_merge($positions, $this->findGlobalSymbolPositionInComment($comment, $discoveredSymbol));
704        }
705        return $positions;
706    }
707
708    protected function findGlobalSymbolPositionInComment(Comment $comment, DiscoveredSymbol $globalSymbol): array
709    {
710        $positions = [];
711
712        $searchStr = '\\' . $globalSymbol->getOriginalFqdnName();
713        $searchLen = strlen($searchStr);
714
715        $commentText = $comment->getText();
716        $startFilePos = $comment->getStartFilePos();
717        $offset = 0;
718        while (($pos = strpos($commentText, $searchStr, $offset)) !== false) {
719            $nextPos = $pos + $searchLen;
720            if ($nextPos >= strlen($commentText)
721                || !preg_match('/[a-zA-Z0-9_\x7f-\xff\\\\]/', $commentText[$nextPos])
722            ) {
723                $positions[] = [
724                    'start' => $startFilePos + $pos,
725                    'end' => $startFilePos + $nextPos,
726                    'replacement' => '\\' . $globalSymbol->getLocalReplacement(),
727                ];
728            }
729            $offset = $pos + 1;
730        }
731
732        return $positions;
733    }
734
735    /**
736     * TODO: filter the changes in a file to something like `$symbol->getPackages()[0]->getFlatDependencyTree()`.
737     * The file should not be using symbols that are not defined in their required dependencies.
738     *
739     * @param string $contents
740     * @param DiscoveredSymbol $symbol
741     *
742     * @return string
743     * @throws Exception
744     */
745    protected function replaceSingleClassnameInString(string $contents, DiscoveredSymbol $symbol, bool $requireSurroundingQuotes = true): string
746    {
747        $alsoSearchForVariableClassname = false;
748        $alsoSearchForStaticProperty = false;
749
750        if ($symbol instanceof NamespacedSymbol && $symbol->getNamespace()->isGlobal()) {
751            $replacementSymbolString = $symbol->getLocalReplacement();
752            $originalSymbolString    = $symbol->getOriginalSymbolStripPrefix($this->config->getClassmapPrefix());
753        } elseif ($symbol instanceof NamespaceSymbol) {
754            if ($symbol->isGlobal()) {
755                return $contents;
756            }
757            $originalSymbolString = $symbol->getOriginalFqdnName();
758            $replacementSymbolString = $symbol->getReplacementFqdnName();
759
760            // E.g. `My\Namespace\$var` is used in some libraries.
761            $alsoSearchForVariableClassname = true;
762
763            /**
764             * Handle special case with null character `\0`.
765             *
766             * @see \Composer\Autoload\AutoloadGenerator
767             */
768            $unprefixedNamespace = implode('\\', ['Comp'.'oser','Autoload']);
769            $isComposerAutoloadNamespace = str_ends_with($originalSymbolString, $unprefixedNamespace);
770            $hasComposerAutoloadNamespace = str_contains($contents, $unprefixedNamespace);
771            if ($isComposerAutoloadNamespace && $hasComposerAutoloadNamespace) {
772                /**
773                 * TODO: I'm worried that dump-autoload when running via `.phar` will include `BrianHenryIE\Strauss` prefix. I don't think I have addressed that issue here.
774                 *
775                 * @see strauss.phar/src/Pipeline/Prefixer.php
776                 * @see strauss.phar/vendor/composer/composer/src/Composer/Autoload/AutoloadGenerator.php
777                 */
778                $prefixLinePrefixesArrays = [
779                    array_merge(explode("\\", $this->config->getNamespacePrefix()), ['Comp'.'oser','Autoload','ClassLoader']),
780                    ['BrianHenryIE','Strauss','Comp'.'oser','Autoload','ClassLoader'],
781                    ['Comp'.'oser','Autoload','ClassLoader'],
782                ];
783                foreach ($prefixLinePrefixesArrays as $prefixLinePrefixArray) {
784                    $findPrefixLine = '$prefix = "\0' . implode("\\", $prefixLinePrefixArray) . '\0";';
785                    $replaceWithUpdatedPrefixLine = str_replace($originalSymbolString, $replacementSymbolString, $findPrefixLine);
786                    $replacedCount = 0;
787                    $contents = str_replace($findPrefixLine, $replaceWithUpdatedPrefixLine, $contents, $replacedCount);
788                    if ($replacedCount && $originalSymbolString !== $replacementSymbolString) {
789                        break;
790                    }
791                }
792            }
793        } elseif ($symbol instanceof NamespacedSymbol) {
794            $originalSymbolString = $symbol->getOriginalFqdnName();
795            $replacementSymbolString = $symbol->getReplacementFqdnName();
796            $alsoSearchForStaticProperty = true;
797        } else {
798            throw new Exception('I dont think we can reach here');
799        }
800
801        /**
802         * Replace classnames in strings, e.g. `is_a( $recurrence, 'CronExpression' )`.
803         *
804         * `[^a-zA-Z0-9_\x7f-\xff\\\\]+` is anything but classname valid characters.
805         *
806         * TODO: Run this without the classname characters, log everytime a replacement is made across all test cases, add those to the test assertions, ensure this is always correct.
807         */
808        $pattern =    '/
809(
810                            [^a-zA-Z0-9_\x7f-\xff\\\\]
811                             ' . ($requireSurroundingQuotes ? '[\'"]' : '' ) .'
812                            [\\\\]{0,2}
813)
814                        ('
815                            . str_replace('\\', '[\\\\]{1,2}', $originalSymbolString) .
816                        ')(
817                        '
818                      . ( $alsoSearchForVariableClassname ? '([\\\\]{1,2}\$[a-zA-Z0-9_\x7f-\xff]*)?' : '' ) .
819                      ( $alsoSearchForStaticProperty ? '(:{2}\$[a-zA-Z0-9_\x7f-\xff]*)?' : '' ) .
820                      '
821                            ' . ($requireSurroundingQuotes ? '[\'"]' : '' ) .'
822                            [^a-zA-Z0-9_\x7f-\xff\\\\]
823)
824                        /Ux';       // U: Non-greedy matching, x: ignore whitespace in pattern.
825
826        /**
827         * If $alsoSearchForVariableClassname the number of elements in the array is more
828         *
829         * @param array<array<string>> $capture
830         */
831        $replacement = function (array $capture) use ($originalSymbolString, $replacementSymbolString): string {
832
833            if ($capture[2] === $originalSymbolString) {
834                $capture[2] = $replacementSymbolString;
835            }
836
837            if ($capture[2] === str_replace('\\', '\\\\', $originalSymbolString)) {
838                $capture[2] = str_replace('\\', '\\\\', $replacementSymbolString);
839            }
840
841            unset($capture[0]);
842            unset($capture[4]);
843
844            return implode('', $capture);
845        };
846
847        $result = preg_replace_callback($pattern, $replacement, $contents);
848
849        $this->checkPregError();
850
851        return $result;
852    }
853
854    /**
855     * In a namespace:
856     * * use \Classname;
857     * * new \Classname()
858     *
859     * In a global namespace:
860     * * new Classname()
861     *
862     * @param array<\PhpParser\Node\Stmt> $ast
863     *
864     * @return array{start:int,end:int,replacement:string}|array{}
865     */
866    public function findGlobalSymbolsPositionsInAst(array $ast, DiscoveredSymbols $discoveredSymbols): array
867    {
868        $globalClassesInterfacesTraitsToRename = $discoveredSymbols->getGlobalClassesInterfacesTraits()->getToRename();
869
870        if (count($globalClassesInterfacesTraitsToRename) === 0) {
871            return [];
872        }
873
874        $nodeFinder = new NodeFinder();
875        $positions = [];
876
877        // Replace \Classname (fully qualified) references in any namespace context.
878        $fqNodes = $nodeFinder->find($ast, function (Node $node) use ($discoveredSymbols, &$positions) {
879            if ($node->getAttribute('comments')) {
880                // TODO. This is recording comments repeatedly. Duplicates are later removed, but it'd be better to just not add them.
881                /** @var Doc $comment */
882                foreach ($node->getAttribute('comments') as $comment) {
883                    $positions = array_merge(
884                        $positions,
885                        $this->findGlobalSymbolsPositionsInComment($comment, $discoveredSymbols)
886                    );
887                }
888            }
889            if (!( $node instanceof FullyQualified )) {
890                return false;
891            }
892            return $this->hasGlobalSymbolForNode($node, $discoveredSymbols);
893        });
894
895        foreach ($fqNodes as $node) {
896            $positions[] = [
897                'start' => $node->getStartFilePos(),
898                'end' => $node->getEndFilePos() + 1,
899                'replacement' => '\\' . $discoveredSymbols->getNamespacedSymbols()->get($node->toString())->getReplacementFqdnName(),
900            ];
901        }
902
903        // In named namespaces, `use Classname;` must become `use PrefixedClassname as Classname;`
904        // so that unqualified references within the namespace continue to resolve correctly.
905        $namedNamespaces = array_filter(
906            $nodeFinder->findInstanceOf($ast, Namespace_::class),
907            fn($ns) => $ns->name !== null
908        );
909        foreach ($namedNamespaces as $nsStmt) {
910            $useItems = $nodeFinder->findInstanceOf($nsStmt->stmts ?? [], UseItem::class);
911            foreach ($useItems as $useItem) {
912                $fqdn_name = $useItem->name->toString();
913                $discoveredSymbol = $globalClassesInterfacesTraitsToRename->get($fqdn_name);
914                if (!($useItem->name instanceof FullyQualified) && $discoveredSymbol && $discoveredSymbol->isDoRename()) {
915                        $replacementClassname = $discoveredSymbol->getLocalReplacement();
916                        $useClassname = array_reverse(explode('\\', $fqdn_name))[0];
917
918                        $replacementString = $discoveredSymbol->getLocalReplacement();
919                    if ($replacementClassname !== $useClassname && !$useItem->alias) {
920                        $replacementString .= ' as ' . $useClassname;
921                    }
922
923                        $positions[] = [
924                            'start' => $useItem->name->getStartFilePos(),
925                            'end' => $useItem->name->getEndFilePos() + 1,
926                            'replacement' => $replacementString,
927                        ];
928                }
929            }
930        }
931
932        // In global namespace context (either implicit, or explicit `namespace {}`), replace
933        // unqualified class name references and class/interface/trait/enum declarations.
934        $globalStmts = [];
935        foreach ($ast as $node) {
936            if ($node instanceof Namespace_) {
937                if ($node->name === null) {
938                    $globalStmts = array_merge($globalStmts, $node->stmts ?? []);
939                }
940            } else {
941                $globalStmts[] = $node;
942            }
943        }
944
945        $classLike = $nodeFinder->find($globalStmts, function (Node $node) use ($globalClassesInterfacesTraitsToRename) {
946            return ($node instanceof Class_
947                || $node instanceof Interface_
948                || $node instanceof Trait_
949                || $node instanceof Enum_)
950                && isset($node->name)
951                && $node->name instanceof Identifier
952                && (
953                       $globalClassesInterfacesTraitsToRename->getClass($node->name->name)
954                       || $globalClassesInterfacesTraitsToRename->getInterface($node->name->name)
955                       || $globalClassesInterfacesTraitsToRename->getTrait($node->name->name)
956                   );
957        });
958        foreach ($classLike as $node) {
959            $replacement = $this->getReplacementStringForNode($node, $globalClassesInterfacesTraitsToRename);
960            $positions[] = [
961                'start' => $node->name->getStartFilePos(),
962                'end' => $node->name->getEndFilePos() + 1,
963                'replacement' => $replacement
964            ];
965        }
966
967        $unqualifiedNameNodes = $nodeFinder->find($globalStmts, function (Node $node) use ($globalClassesInterfacesTraitsToRename) {
968            return $node instanceof Name
969                && !($node instanceof FullyQualified)
970                   && $this->hasGlobalSymbolForNode($node, $globalClassesInterfacesTraitsToRename);
971        });
972        foreach ($unqualifiedNameNodes as $node) {
973            $replacement = $globalClassesInterfacesTraitsToRename->get($node->name)->getReplacementFqdnName();
974            $positions[] = [
975                'start' => $node->getStartFilePos(),
976                'end' => $node->getEndFilePos() + 1,
977                'replacement' => $replacement,
978//                'replacement' => $this->getReplacementStringForNode($node, $globalClassesInterfacesTraitsToRename)
979            ];
980        }
981
982        return $positions;
983    }
984
985    protected function hasGlobalSymbolForNode(Node $node, DiscoveredSymbols $discoveredSymbols): bool
986    {
987        return (bool) $this->getGlobalSymbolForNode($node, $discoveredSymbols);
988    }
989
990    /**
991     * Try to get the specific class/interface/trait symbol by type and name.
992     * Failing that, return whichever of class/interface/trait is found first by name, or null.
993     *
994     * There should just be one of any global name: `use MyABC;` (a class) is indistinguishable from `use MyABC;` (an interface).
995     *
996     * @param Node\Stmt $node
997     * @param DiscoveredSymbols $discoveredSymbols
998     *
999     * @return DiscoveredSymbol|null
1000     */
1001    protected function getGlobalSymbolForNode(Node $node, DiscoveredSymbols $discoveredSymbols): ?DiscoveredSymbol
1002    {
1003        if ($node instanceof Class_) {
1004            return $discoveredSymbols->getClass($node->name->toString());
1005        }
1006        if ($node instanceof Interface_) {
1007            return $discoveredSymbols->getInterface($node->name->toString());
1008        }
1009        if ($node instanceof Trait_) {
1010            return $discoveredSymbols->getTrait($node->name->toString());
1011        }
1012        if ($node instanceof Enum_) {
1013            return $discoveredSymbols->getEnum($node->name->toString());
1014        }
1015        switch (true) {
1016            case $node->name instanceof Name:
1017                $nodeNameString = $node->name->toString();
1018                // TODO: tidy this up. This function probably has a better alternative in `DiscoveredSymbols()`.
1019            case $node instanceof Name:
1020                $nodeNameString = $nodeNameString ?? $node->toString();
1021                return $discoveredSymbols->getClass($nodeNameString)
1022                       ?? $discoveredSymbols->getInterface($nodeNameString)
1023                          ?? $discoveredSymbols->getTrait($nodeNameString);
1024            default:
1025                // TODO: enums.
1026                return null;
1027        }
1028    }
1029
1030    protected function getReplacementStringForNode(Node $node, DiscoveredSymbols $discoveredSymbols): string
1031    {
1032        $globalSymbol = $this->getGlobalSymbolForNode($node, $discoveredSymbols);
1033        if ($globalSymbol) {
1034            return $globalSymbol->getLocalReplacement();
1035        }
1036        return $node->toString();
1037    }
1038
1039    /**
1040     * TODO: This should be split and brought to FileScanner.
1041     */
1042    protected function replaceConstants(string $contents, DiscoveredSymbols $originalConstants, string $prefix): string
1043    {
1044        $originalConstantsArray = $originalConstants->toArray();
1045        usort($originalConstantsArray, fn(ConstantSymbol $a, ConstantSymbol $b) => strlen($b->getOriginalLocalName()) <=> strlen($a->getOriginalLocalName()));
1046
1047        foreach ($originalConstantsArray as $constant) {
1048//            $contents = $this->replaceConstant($contents, $constant, $prefix . $constant);
1049            $contents = $this->replaceConstant($contents, $constant->getOriginalFqdnName(), $constant->getReplacementFqdnName());
1050        }
1051
1052        return $contents;
1053    }
1054
1055    /**
1056     * TODO: Use php-parser for this.
1057     */
1058    protected function replaceConstant(string $contents, string $originalConstant, string $replacementConstant): string
1059    {
1060//        return preg_replace(
1061//            '/([^A-Z0-9_])('.$originalConstant.')([^A-Z0-9_])/',
1062//            '$1'.$replacementConstant.'$3',
1063//            $contents
1064//        );
1065        if ($originalConstant === $replacementConstant) {
1066            return $contents;
1067        }
1068
1069        $needsPhpTag = false === stripos($contents, '<?php');
1070        $parseContents = $needsPhpTag ? "<?php\n" . $contents : $contents;
1071
1072        $nodeFinder = new NodeFinder();
1073        $parser = (new ParserFactory())->createForNewestSupportedVersion();
1074        try {
1075            $ast = $parser->parse($parseContents);
1076        } catch (\PhpParser\Error $e) {
1077            $this->logger->warning("Skipping ::replaceConstant() AST replacement due to parse error: " . $e->getMessage());
1078
1079            return preg_replace(
1080                '/\b' . preg_quote($originalConstant, '/') . '\b/',
1081                $replacementConstant,
1082                $contents
1083            );
1084        }
1085
1086        if (null === $ast) {
1087            return $contents;
1088        }
1089
1090        $positions = [];
1091
1092        /** @var ConstFetch[] $constFetches */
1093        $constFetches = $nodeFinder->findInstanceOf($ast, ConstFetch::class);
1094        foreach ($constFetches as $fetch) {
1095            if ($fetch->name instanceof Name
1096                && (!$fetch->name->isFullyQualified() || 1 === count($fetch->name->getParts()))
1097                && $fetch->name->toString() === $originalConstant
1098            ) {
1099                $positions[] = [
1100                    'start' => $fetch->name->getStartFilePos(),
1101                    'end' => $fetch->name->getEndFilePos() + 1,
1102                    'replacement' => $fetch->name->isFullyQualified()
1103                        ? '\\' . $replacementConstant
1104                        : $replacementConstant,
1105                ];
1106            }
1107        }
1108
1109        /** @var Use_[] $uses */
1110        $uses = $nodeFinder->findInstanceOf($ast, Use_::class);
1111        foreach ($uses as $use) {
1112            if (Use_::TYPE_CONSTANT !== $use->type) {
1113                continue;
1114            }
1115
1116            foreach ($use->uses as $useItem) {
1117                if ($useItem->name->toString() !== $originalConstant) {
1118                    continue;
1119                }
1120
1121                $positions[] = [
1122                    'start' => $useItem->name->getStartFilePos(),
1123                    'end' => $useItem->name->getEndFilePos() + 1,
1124                    'replacement' => $replacementConstant,
1125                ];
1126            }
1127        }
1128
1129        /** @var Const_[] $constDeclarations */
1130        $constDeclarations = $nodeFinder->findInstanceOf($ast, Const_::class);
1131        foreach ($constDeclarations as $constDeclaration) {
1132            foreach ($constDeclaration->consts as $const) {
1133                if ($const->name->toString() === $originalConstant) {
1134                    $positions[] = [
1135                        'start' => $const->name->getStartFilePos(),
1136                        'end' => $const->name->getEndFilePos() + 1,
1137                        'replacement' => $replacementConstant,
1138                    ];
1139                }
1140            }
1141        }
1142
1143        $functionsUsingConstantName = [
1144            'define',
1145            'defined',
1146        ];
1147
1148        /** @var FuncCall[] $funcCalls */
1149        $funcCalls = $nodeFinder->findInstanceOf($ast, FuncCall::class);
1150        foreach ($funcCalls as $call) {
1151            if (! $call->name instanceof Name
1152                 || ! in_array($call->name->toString(), $functionsUsingConstantName, true)
1153                 || ! isset($call->args[0])
1154                 || ! $call->args[0] instanceof Arg
1155                 || ! $call->args[0]->value instanceof String_
1156            ) {
1157                continue;
1158            }
1159
1160            $stringNode = $call->args[0]->value;
1161            if ($stringNode->value !== $originalConstant) {
1162                continue;
1163            }
1164
1165            $positions[] = [
1166                'start' => $stringNode->getStartFilePos() + 1,
1167                'end' => $stringNode->getEndFilePos(),
1168                'replacement' => $replacementConstant,
1169            ];
1170        }
1171
1172        if (empty($positions)) {
1173            return $contents;
1174        }
1175
1176        usort($positions, fn($a, $b) => $b['start'] <=> $a['start']);
1177
1178        foreach ($positions as $pos) {
1179            $parseContents = substr_replace(
1180                $parseContents,
1181                $pos['replacement'],
1182                $pos['start'],
1183                $pos['end'] - $pos['start']
1184            );
1185        }
1186
1187        if ($needsPhpTag) {
1188            return substr($parseContents, strlen("<?php\n"));
1189        }
1190
1191        return $parseContents;
1192    }
1193
1194    /**
1195     * Look for declared functions, function calls, and built-in functions that accept a function as their parameter.
1196     *
1197     * @see Function_
1198     * @see FuncCall
1199     */
1200    protected function findFunctionPositionsInAst(array $ast, DiscoveredSymbols $discoveredSymbols): array
1201    {
1202        $positions = [];
1203
1204        $nodeFinder = new NodeFinder();
1205
1206        // Function declarations (global only)
1207        $functionDefs = $this->getNodeFinder()->findInstanceOf($ast, Function_::class);
1208        foreach ($functionDefs as $func) {
1209            $functionSymbol = $discoveredSymbols->getFunction($func->name->name);
1210            if ($functionSymbol && $functionSymbol->isDoRename()) {
1211                $positions[] = [
1212                    'start'       => $func->name->getStartFilePos(),
1213                    'end'         => $func->name->getEndFilePos() + 1,
1214                    'replacement' => $functionSymbol->getReplacementFqdnName(),
1215                ];
1216            }
1217        }
1218
1219        // If it is a build-in function that accepts a function name as its argument.
1220        $functionsUsingCallable = [
1221            'function_exists' => true,
1222            'call_user_func' => true,
1223            'call_user_func_array' => true,
1224            'forward_static_call' => true,
1225            'forward_static_call_array' => true,
1226            'register_shutdown_function' => true,
1227            'register_tick_function' => true,
1228            'unregister_tick_function' => true,
1229        ];
1230
1231        // Calls (global only)
1232        $functionCalls = $nodeFinder->findInstanceOf($ast, FuncCall::class);
1233        foreach ($functionCalls as $call) {
1234            if (! ( $call->name instanceof Name )) {
1235                // E.g. `$formatToPhpVersionId = static function (Bound $bound): int {}
1236                continue;
1237            }
1238            // If the function call is one that we found earlier.
1239            $functionSymbol = $discoveredSymbols->getFunction($call->name->toString());
1240            if ($functionSymbol) {
1241                if (str_contains($call->name->toString(), '\\')) {
1242                    $replacement = '\\' . $functionSymbol->getReplacementFqdnName();
1243                } else {
1244                    $replacement = $functionSymbol->getLocalReplacement();
1245                }
1246                $positions[] = [
1247                    'start'       => $call->name->getStartFilePos(),
1248                    'end'         => $call->name->getEndFilePos() + 1,
1249                    'replacement' => $replacement,
1250                ];
1251                continue;
1252            }
1253
1254            if (isset($functionsUsingCallable[$call->name->toString()])
1255                 && isset($call->args[0])
1256                 && $call->args[0] instanceof Arg
1257                 && $call->args[0]->value instanceof String_
1258                 && $discoveredSymbols->getFunction($call->args[0]->value->value)
1259            ) {
1260                $positions[] = [
1261                    'start'       => $call->args[0]->value->getStartFilePos() + 1, // do not change quotes
1262                    'end'         => $call->args[0]->value->getEndFilePos(),
1263                    'replacement' => $discoveredSymbols->getFunction($call->args[0]->value->value)->getReplacementFqdnName(),
1264                ];
1265            }
1266        }
1267
1268        return $positions;
1269    }
1270    protected function findDocCommentPositionsInAst(array $ast, DiscoveredSymbols $discoveredSymbols): array
1271    {
1272        $positions = [];
1273
1274        $nodeFinder = new NodeFinder();
1275
1276        $commentNodes = $nodeFinder->find($ast, function (Node $node) {
1277            return $node->getDocComment() !== null;
1278        });
1279
1280        if (sizeof($commentNodes)===0) {
1281            return [];
1282        }
1283
1284        $namespacedSymbols = $discoveredSymbols->getNamespacedSymbols()->getToRename();
1285
1286        $this->logger->debug('Searching {number_of_comments} comments for {number_of_symbols} symbols', [
1287            'number_of_comments' => sizeof($commentNodes),
1288            'number_of_symbols' => count($namespacedSymbols),
1289        ]);
1290
1291        // Doc comments: scan for \OriginalNamespace references in @param/@return/etc.
1292        foreach ($commentNodes as $node) {
1293            $doc = $node->getDocComment();
1294            $text = $doc->getText();
1295            /** @var NamespacedSymbol $symbol */
1296            foreach ($namespacedSymbols as $symbol) {
1297                $replacement = $symbol->getReplacementFqdnName();
1298//                $docSearchStr = '\\' . $symbol->getOriginalSymbol();
1299                $docSearchStr = $symbol->getOriginalFqdnName();
1300                $docSearchLen = strlen($docSearchStr);
1301                // For global symbols (no \ in name), also treat \ as a boundary character
1302                // so \GlobalClass is left to findGlobalSymbolPositionInComment.
1303                $beforePattern = strpos($docSearchStr, '\\') === false
1304                    ? '/[a-zA-Z0-9_\x7f-\xff\\\\]/'
1305                    : '/[a-zA-Z0-9_\x7f-\xff]/';
1306                $offset = 0;
1307                while (($pos = strpos($text, $docSearchStr, $offset)) !== false) {
1308                    $after = $pos + $docSearchLen;
1309                    $beforeOk = $pos === 0 || !preg_match($beforePattern, $text[$pos - 1]);
1310                    $afterOk  = $after >= strlen($text) || !preg_match('/[a-zA-Z0-9_\x7f-\xff]/', $text[$after]);
1311                    if ($beforeOk && $afterOk) {
1312                        $positions[] = [
1313                            'start' => $doc->getStartFilePos() + $pos,
1314                            'end' => $doc->getStartFilePos() + $after,
1315//                            'replacement' => '\\' . $replacement,
1316                            'replacement' => $replacement,
1317                        ];
1318                    }
1319                    $offset = $pos + 1;
1320                }
1321            }
1322        }
1323
1324        return $positions;
1325    }
1326
1327    /**
1328     * TODO: This should be a function on {@see DiscoveredFiles}.
1329     *
1330     * @return array<string, ComposerPackage>
1331     */
1332    public function getModifiedFiles(): array
1333    {
1334        return $this->changedFiles;
1335    }
1336
1337    public function prefixComposerAutoloadFiles(string $absoluteDirectory): void
1338    {
1339        $this->logger->debug("Prefixing the Composer autoload files in {path}.", [
1340            'path' => $absoluteDirectory,
1341        ]);
1342
1343        $composerFilePaths = [
1344            'InstalledVersions.php',
1345            'autoload_classmap.php',
1346            'autoload_files.php',
1347            'autoload_namespaces.php',
1348            'autoload_psr4.php',
1349            'autoload_real.php',
1350            'autoload_static.php',
1351            'ClassLoader.php',
1352            'installed.json',
1353            'installed.php',
1354            'InstalledVersions.php',
1355            'platform_check.php',
1356        ];
1357
1358        $composerFiles = [];
1359
1360        $discoveredFiles = new DiscoveredFiles();
1361
1362        foreach ($composerFilePaths as $filePath) {
1363            if ($this->filesystem->fileExists($absoluteDirectory . '/composer/' . $filePath)) {
1364                $file = new File(
1365                    $absoluteDirectory . '/composer/' . $filePath,
1366                    $filePath,
1367                    $absoluteDirectory . '/composer/' . $filePath,
1368                );
1369                $discoveredFiles->add($file);
1370                $composerFiles[ $filePath ] = $file;
1371            }
1372        }
1373
1374        // During `--dry-run`, until Composer fully supports streamwrappers.
1375        if ($this->config->isDryRun()) {
1376            return;
1377        }
1378
1379        $classMapGenerator = new ClassMapGenerator();
1380        $classMapGenerator->scanPaths(array_map(
1381            fn(File $file) => new \SplFileInfo(
1382                $this->filesystem->makeAbsolute(
1383                    $file->getTargetAbsolutePath()
1384                )
1385            ),
1386            $composerFiles
1387        ));
1388
1389        $classMap = $classMapGenerator->getClassMap();
1390
1391        $discoveredSymbols = new DiscoveredSymbols();
1392
1393        foreach ($classMap->getMap() as $fqdnClass => $absolutePath) {
1394            $namespaceString = $this->getNamespaceFromFqdn($fqdnClass);
1395            if (!$namespaceString) {
1396                continue;
1397            }
1398            if ($discoveredSymbols->getNamespace($namespaceString)) {
1399                continue;
1400            }
1401            $namespaceSymbol = new NamespaceSymbol($namespaceString);
1402            $namespaceSymbol->setLocalReplacement(
1403                $this->config->getNamespacePrefix() . '\\' . preg_replace('#^(BrianHenryIE\\\\Strauss\\\\)*#', '', $namespaceString)
1404            );
1405            $discoveredSymbols->add($namespaceSymbol);
1406        }
1407
1408        $globalNamespace = new NamespaceSymbol('\\');
1409        foreach ($classMap->getMap() as $fqdnClass => $absolutePath) {
1410            $namespace = $discoveredSymbols->getNamespace(
1411                $this->getNamespaceFromFqdn($fqdnClass) ?? '\\'
1412            ) ?? $globalNamespace;
1413            $classLoaderSymbol = new ClassSymbol(
1414                $fqdnClass,
1415                $composerFiles[ basename($absolutePath) ],
1416                $namespace,
1417            );
1418            $discoveredSymbols->add($classLoaderSymbol);
1419        }
1420
1421        $this->replaceInFiles($discoveredSymbols, $discoveredFiles->getFiles());
1422    }
1423
1424    protected function getNamespaceFromFqdn(string $namespacedString): ?string
1425    {
1426        if (1 === preg_match('/(.*)(\\\\[^\\\\]*$)/', $namespacedString, $output_array)) {
1427            return $output_array[1];
1428        }
1429        return null;
1430    }
1431
1432    protected function getParser(): Parser
1433    {
1434        if (!isset($this->parser)) {
1435            $this->parser = (new ParserFactory())->createForNewestSupportedVersion();
1436        }
1437
1438        return $this->parser;
1439    }
1440
1441    protected function getNodeFinder(): NodeFinder
1442    {
1443        if (!isset($this->nodeFinder)) {
1444            $this->nodeFinder = new NodeFinder();
1445        }
1446
1447        return $this->nodeFinder;
1448    }
1449}