Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 145
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Aliases
0.00% covered (danger)
0.00%
0 / 145
0.00% covered (danger)
0.00%
0 / 9
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 writeAliasesFileForSymbols
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getVendorClassmap
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getTargetClassmap
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getAliasFilepath
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getModifiedSymbols
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 buildStringOfAliases
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 appendAliasString
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
342
 appendFunctionAliases
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * When replacements are made in-situ in the vendor directory, add aliases for the original class fqdns so
4 * dev dependencies can still be used.
5 *
6 * We could make the replacements in the dev dependencies but it is preferable not to edit files unnecessarily.
7 * Composer would warn of changes before updating (although it should probably do that already).
8 * This approach allows symlinked dev dependencies to be used.
9 * It also should work without knowing anything about the dev dependencies
10 *
11 * @package brianhenryie/strauss
12 */
13
14namespace BrianHenryIE\Strauss\Pipeline;
15
16use BrianHenryIE\Strauss\Config\AliasesConfigInterace;
17use BrianHenryIE\Strauss\Files\File;
18use BrianHenryIE\Strauss\Helpers\FileSystem;
19use BrianHenryIE\Strauss\Helpers\NamespaceSort;
20use BrianHenryIE\Strauss\Types\ClassSymbol;
21use BrianHenryIE\Strauss\Types\ConstantSymbol;
22use BrianHenryIE\Strauss\Types\DiscoveredSymbol;
23use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
24use BrianHenryIE\Strauss\Types\FunctionSymbol;
25use BrianHenryIE\Strauss\Types\NamespaceSymbol;
26use Composer\ClassMapGenerator\ClassMapGenerator;
27use League\Flysystem\StorageAttributes;
28use Psr\Log\LoggerAwareTrait;
29use Psr\Log\LoggerInterface;
30use Psr\Log\NullLogger;
31
32class Aliases
33{
34    use LoggerAwareTrait;
35
36    protected AliasesConfigInterace $config;
37
38    protected FileSystem $fileSystem;
39
40    public function __construct(
41        AliasesConfigInterace $config,
42        FileSystem $fileSystem,
43        ?LoggerInterface $logger = null
44    ) {
45        $this->config = $config;
46        $this->fileSystem = $fileSystem;
47        $this->setLogger($logger ?? new NullLogger());
48    }
49
50    public function writeAliasesFileForSymbols(DiscoveredSymbols $symbols): void
51    {
52        $outputFilepath = $this->getAliasFilepath();
53
54        $fileString = $this->buildStringOfAliases($symbols, basename($outputFilepath));
55
56        if (empty($fileString)) {
57            // TODO: Check if no actual aliases were added (i.e. is it just an empty template).
58            // Log?
59            return;
60        }
61
62        $this->fileSystem->write($outputFilepath, $fileString);
63    }
64
65    /**
66     * @return array<string,string> FQDN => relative path
67     */
68    protected function getVendorClassmap(): array
69    {
70        $paths = array_map(
71            function ($file) {
72                return $this->config->isDryRun()
73                    ? new \SplFileInfo('mem://'.$file->path())
74                    : new \SplFileInfo('/'.$file->path());
75            },
76            array_filter(
77                $this->fileSystem->listContents($this->config->getVendorDirectory(), true)->toArray(),
78                fn(StorageAttributes $file) => $file->isFile() && in_array(substr($file->path(), -3), ['php', 'inc', '.hh'])
79            )
80        );
81
82        $vendorClassmap = ClassMapGenerator::createMap($paths);
83
84        $vendorClassmap = array_map(fn($path) => str_replace('mem://', '', $path), $vendorClassmap);
85
86        return $vendorClassmap;
87    }
88
89    /**
90     * @return array<string,string> FQDN => absolute path
91     */
92    protected function getTargetClassmap(): array
93    {
94        $paths =
95            array_map(
96                function ($file) {
97                    return $this->config->isDryRun()
98                        ? new \SplFileInfo('mem://'.$file->path())
99                        : new \SplFileInfo('/'.$file->path());
100                },
101                array_filter(
102                    $this->fileSystem->listContents($this->config->getTargetDirectory(), \League\Flysystem\FilesystemReader::LIST_DEEP)->toArray(),
103                    fn(StorageAttributes $file) => $file->isFile() && in_array(substr($file->path(), -3), ['php', 'inc', '.hh'])
104                )
105            );
106
107        $classMap = ClassMapGenerator::createMap($paths);
108
109        // To make it easier when viewing in xdebug.
110        uksort($classMap, new NamespaceSort());
111
112        $classMap = array_map(fn($path) => str_replace('mem://', '', $path), $classMap);
113
114        return $classMap;
115    }
116
117    /**
118     * We will create `vendor/composer/autoload_aliases.php` alongside other autoload files, e.g. `autoload_real.php`.
119     */
120    protected function getAliasFilepath(): string
121    {
122        return  sprintf(
123            '%scomposer/autoload_aliases.php',
124            $this->config->getVendorDirectory()
125        );
126    }
127
128    /**
129     * @param DiscoveredSymbol[] $symbols
130     * @return DiscoveredSymbol[]
131     */
132    protected function getModifiedSymbols(array $symbols): array
133    {
134        $modifiedSymbols = [];
135        foreach ($symbols as $symbol) {
136            if ($symbol->getOriginalSymbol() !== $symbol->getReplacement()) {
137                $modifiedSymbols[] = $symbol;
138            }
139        }
140        return $modifiedSymbols;
141    }
142
143    protected function buildStringOfAliases(DiscoveredSymbols $symbols, string $outputFilename): string
144    {
145
146        $sourceDirClassmap = $this->getVendorClassmap();
147
148        $autoloadAliasesFileString = '<?php' . PHP_EOL . PHP_EOL . '// ' . $outputFilename . ' @generated by Strauss' . PHP_EOL . PHP_EOL;
149
150        // TODO: When target !== vendor, there should be a test here to ensure the target autoloader is included, with instructions to add it.
151
152        $modifiedSymbols = $this->getModifiedSymbols($symbols->getSymbols());
153
154        $functionSymbols = array_filter($modifiedSymbols, fn(DiscoveredSymbol $symbol) => $symbol instanceof FunctionSymbol);
155        $otherSymbols = array_filter($modifiedSymbols, fn(DiscoveredSymbol $symbol) => !($symbol instanceof FunctionSymbol));
156
157        $targetDirClassmap = $this->getTargetClassmap();
158
159        if (count($otherSymbols)>0) {
160            $autoloadAliasesFileString .= 'function autoloadAliases( $classname ): void {' . PHP_EOL;
161            $autoloadAliasesFileString = $this->appendAliasString($otherSymbols, $sourceDirClassmap, $targetDirClassmap, $autoloadAliasesFileString);
162            $autoloadAliasesFileString .= '}' . PHP_EOL . PHP_EOL;
163            $autoloadAliasesFileString .= "spl_autoload_register( 'autoloadAliases' );" . PHP_EOL . PHP_EOL;
164        }
165
166        if (count($functionSymbols)>0) {
167            $autoloadAliasesFileString = $this->appendFunctionAliases($functionSymbols, $autoloadAliasesFileString);
168        }
169
170        return $autoloadAliasesFileString;
171    }
172
173    /**
174     * @param array<NamespaceSymbol|ClassSymbol> $modifiedSymbols
175     * @param array $sourceDirClassmap
176     * @param array $targetDirClasssmap
177     * @param string $autoloadAliasesFileString
178     * @return string
179     * @throws \League\Flysystem\FilesystemException
180     */
181    protected function appendAliasString(array $modifiedSymbols, array $sourceDirClassmap, array $targetDirClasssmap, string $autoloadAliasesFileString): string
182    {
183        $aliasesPhpString = '  switch( $classname ) {' . PHP_EOL;
184
185        foreach ($modifiedSymbols as $symbol) {
186            $originalSymbol = $symbol->getOriginalSymbol();
187            $replacementSymbol = $symbol->getReplacement();
188
189//            if (!$symbol->getSourceFile()->isDoDelete()) {
190//                $this->logger->debug("Skipping {$originalSymbol} because it is not marked for deletion.");
191//                continue;
192//            }
193
194            if ($originalSymbol === $replacementSymbol) {
195                $this->logger->debug("Skipping {$originalSymbol} because it is not being changed.");
196                continue;
197            }
198
199            switch (get_class($symbol)) {
200                case NamespaceSymbol::class:
201                    // TODO: namespaced constants?
202                    $namespace = $symbol->getOriginalSymbol();
203
204                    $symbolSourceFiles = $symbol->getSourceFiles();
205
206                    $namespacesInOriginalClassmap = array_filter(
207                        $sourceDirClassmap,
208                        fn($filepath) => in_array($filepath, array_keys($symbolSourceFiles))
209                    );
210
211                    foreach ($namespacesInOriginalClassmap as $originalFqdnClassName => $absoluteFilePath) {
212                        if ($symbol->getOriginalSymbol() === $symbol->getReplacement()) {
213                            continue;
214                        }
215
216                        $localName = array_reverse(explode('\\', $originalFqdnClassName))[0];
217
218                        if (0 !== strpos($originalFqdnClassName, $symbol->getReplacement())) {
219                            $newFqdnClassName = $symbol->getReplacement() . '\\' . $localName;
220                        } else {
221                            $newFqdnClassName = $originalFqdnClassName;
222                        }
223
224                        if (!isset($targetDirClasssmap[$newFqdnClassName]) && !isset($sourceDirClassmap[$originalFqdnClassName])) {
225                            $a = $symbol->getSourceFiles();
226                            /** @var File $b */
227                            $b = array_pop($a); // There's gotta be at least one.
228
229                            throw new \Exception("errorrrr " . ' ' . basename($b->getAbsoluteTargetPath()) . ' ' . $originalFqdnClassName . ' ' . $newFqdnClassName . PHP_EOL . PHP_EOL);
230                        }
231
232                        $symbolFilepath = $targetDirClasssmap[$newFqdnClassName] ?? $sourceDirClassmap[$originalFqdnClassName];
233                        $symbolFileString = $this->fileSystem->read($symbolFilepath);
234
235                        // This should be improved with a check for non-class-valid characters after the name.
236                        // Eventually it should be in the File object itself.
237                        $isClass = 1 === preg_match('/class ' . $localName . '/i', $symbolFileString);
238                        $isInterface = 1 === preg_match('/interface ' . $localName . '/i', $symbolFileString);
239                        $isTrait = 1 === preg_match('/trait ' . $localName . '/i', $symbolFileString);
240
241                        if (!$isClass && !$isInterface && !$isTrait) {
242                            $isEnum = 1 === preg_match('/enum ' . $localName . '/', $symbolFileString);
243
244                            if ($isEnum) {
245                                $this->logger->warning("Skipping $newFqdnClassName – enum aliasing not yet implemented.");
246                                // TODO: enums
247                                continue;
248                            }
249
250                            $this->logger->error("Skipping $newFqdnClassName because it doesn't exist.");
251                            throw new \Exception("Skipping $newFqdnClassName because it doesn't exist.");
252                        }
253
254                        $escapedOriginalFqdnClassName = str_replace('\\', '\\\\', $originalFqdnClassName);
255                        $aliasesPhpString .= "    case '$escapedOriginalFqdnClassName':" . PHP_EOL;
256
257                        if ($isClass) {
258                            $aliasesPhpString .= "      class_alias(\\$newFqdnClassName::class, \\$originalFqdnClassName::class);" . PHP_EOL;
259                        } elseif ($isInterface) {
260                            $aliasesPhpString .= "      \$includeFile = '<?php namespace $namespace; interface $localName extends \\$newFqdnClassName {};';" . PHP_EOL;
261                            $aliasesPhpString .= "      include \"data://text/plain;base64,\" . base64_encode(\$includeFile);" . PHP_EOL;
262                        } elseif ($isTrait) {
263                            $aliasesPhpString .= "      \$includeFile = '<?php namespace $namespace; trait $localName { use \\$newFqdnClassName };';" . PHP_EOL;
264                            $aliasesPhpString .= "      include \"data://text/plain;base64,\" . base64_encode(\$includeFile);" . PHP_EOL;
265                        }
266
267                        $aliasesPhpString .= "      break;" . PHP_EOL;
268                    }
269                    break;
270                case ClassSymbol::class:
271                    // TODO: Do we handle global traits or interfaces? at all?
272                    $alias = $symbol->getOriginalSymbol(); // We want the original to continue to work, so it is the alias.
273                    $concreteClass = $symbol->getReplacement();
274                    $aliasesPhpString .= <<<EOD
275    case '$alias':
276      class_alias($concreteClass::class, $alias::class);
277      break;
278EOD;
279                    break;
280
281                default:
282                    /**
283                     * Functions and constants addressed below.
284                     *
285                     * @see self::appendFunctionAliases())
286                     */
287                    break;
288            }
289        }
290
291        $autoloadAliasesFileString .= $aliasesPhpString;
292
293        $autoloadAliasesFileString .= '    default:' . PHP_EOL;
294        $autoloadAliasesFileString .= '      // Not in this autoloader.' . PHP_EOL;
295        $autoloadAliasesFileString .= '      break;' . PHP_EOL;
296        $autoloadAliasesFileString .= '  }' . PHP_EOL;
297
298        return $autoloadAliasesFileString;
299    }
300
301    protected function appendFunctionAliases(array $modifiedSymbols, string $autoloadAliasesFileString): string
302    {
303        $aliasesPhpString = '';
304
305        foreach ($modifiedSymbols as $symbol) {
306            $originalSymbol = $symbol->getOriginalSymbol();
307            $replacementSymbol = $symbol->getReplacement();
308
309//            if (!$symbol->getSourceFile()->isDoDelete()) {
310//                $this->logger->debug("Skipping {$originalSymbol} because it is not marked for deletion.");
311//                continue;
312//            }
313
314            if ($originalSymbol === $replacementSymbol) {
315                $this->logger->debug("Skipping {$originalSymbol} because it is not being changed.");
316                continue;
317            }
318
319            switch (get_class($symbol)) {
320                case FunctionSymbol::class:
321                    // TODO: Do we need to check for `void`? Or will it just be ignored?
322                    // Is it possible to inherit PHPDoc from the original function?
323                    $aliasesPhpString = <<<EOD
324        if(!function_exists('$originalSymbol')){
325            function $originalSymbol(...\$args) { return $replacementSymbol(func_get_args()); }
326        }
327        EOD;
328                    break;
329                case ConstantSymbol::class:
330                    /**
331                     * https://stackoverflow.com/questions/19740621/namespace-constants-and-use-as
332                     */
333                    // Ideally this would somehow be loaded after everything else.
334                    // Maybe some Patchwork style redefining of `define()` to add the alias?
335                    // Does it matter since all references to use the constant should have been updated to the new name anyway.
336                    // TODO: global `const`.
337                    $aliasesPhpString = <<<EOD
338        if(!defined('$originalSymbol') && defined('$replacementSymbol')) { 
339            define('$originalSymbol', $replacementSymbol); 
340        }
341        EOD;
342                    break;
343                default:
344                    /**
345                     * Should be addressed above.
346                     *
347                     * @see self::appendAliasString())
348                     */
349                    break;
350            }
351
352            $autoloadAliasesFileString .= $aliasesPhpString;
353        }
354
355        return $autoloadAliasesFileString;
356    }
357}