Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.03% covered (warning)
88.03%
103 / 117
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Aliases
88.03% covered (warning)
88.03%
103 / 117
50.00% covered (danger)
50.00%
4 / 8
29.34
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
 getTemplate
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
3
 writeAliasesFileForSymbols
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getAliasFilepath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildStringOfAliases
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getAliasesArray
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 getFunctionAliasesString
81.48% covered (warning)
81.48%
44 / 54
0.00% covered (danger)
0.00%
0 / 1
16.43
 aliasedFunctionTemplate
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
1.00
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\Aliases;
15
16use BrianHenryIE\Strauss\Config\AliasesConfigInterface;
17use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
18use BrianHenryIE\Strauss\Types\AutoloadAliasInterface;
19use BrianHenryIE\Strauss\Types\ConstantSymbol;
20use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
21use BrianHenryIE\Strauss\Types\FunctionSymbol;
22use BrianHenryIE\Strauss\Types\NamespaceSymbol;
23use League\Flysystem\FilesystemException;
24use Psr\Log\LoggerAwareTrait;
25use Psr\Log\LoggerInterface;
26use RuntimeException;
27
28/**
29 * @phpstan-import-type ClassAliasArray from AutoloadAliasInterface
30 * @phpstan-import-type InterfaceAliasArray from AutoloadAliasInterface
31 * @phpstan-import-type TraitAliasArray from AutoloadAliasInterface
32 * @phpstan-import-type EnumAliasArray from AutoloadAliasInterface
33 */
34class Aliases
35{
36    use LoggerAwareTrait;
37
38    protected AliasesConfigInterface $config;
39
40    protected FileSystem $fileSystem;
41
42    public function __construct(
43        AliasesConfigInterface $config,
44        FileSystem $fileSystem,
45        LoggerInterface $logger
46    ) {
47        $this->config = $config;
48        $this->fileSystem = $fileSystem;
49        $this->setLogger($logger);
50    }
51
52    /**
53     * @param array<string, ClassAliasArray|InterfaceAliasArray|TraitAliasArray|EnumAliasArray> $aliasesArray
54     * @param string|null $autoloadAliasesFunctionsString
55     * @return string
56     * @throws RuntimeException
57     */
58    protected function getTemplate(array $aliasesArray, ?string $autoloadAliasesFunctionsString): string
59    {
60        $namespace = $this->config->getNamespacePrefix();
61        $autoloadAliases = var_export($aliasesArray, true);
62
63        $globalFunctionsString = !$autoloadAliasesFunctionsString ? ''
64                : <<<GLOBAL
65                // Functions and constants
66                $autoloadAliasesFunctionsString
67                GLOBAL;
68
69        $template = file_get_contents(__DIR__ . '/autoload_aliases.template.php');
70
71        if ($template === false) {
72            throw new RuntimeException('Expected file not found at: ' . __DIR__ . '/autoload_aliases.template.php');
73        }
74
75        /**
76         * `// FunctionsAndConstants` serves as a placeholder in the template which we replace with the code.
77         */
78        $template = str_replace(
79            '// FunctionsAndConstants',
80            $globalFunctionsString,
81            $template
82        );
83
84        $template = str_replace(
85            'namespace BrianHenryIE\Strauss {',
86            'namespace ' . trim($namespace, '\\') . ' {',
87            $template
88        );
89
90        return str_replace(
91            'private array $autoloadAliases = [];',
92            "private array \$autoloadAliases = $autoloadAliases;",
93            $template
94        );
95    }
96
97    public function writeAliasesFileForSymbols(DiscoveredSymbols $symbols): void
98    {
99        $outputFilepath = $this->getAliasFilepath();
100
101        $fileString = $this->buildStringOfAliases($symbols, basename($outputFilepath));
102
103        $this->fileSystem->write($outputFilepath, $fileString);
104    }
105
106    /**
107     * We will create `vendor/composer/autoload_aliases.php` alongside other autoload files, e.g. `autoload_real.php`.
108     */
109    protected function getAliasFilepath(): string
110    {
111        return  sprintf(
112            '%s/composer/autoload_aliases.php',
113            $this->config->getAbsoluteVendorDirectory()
114        );
115    }
116
117    protected function buildStringOfAliases(DiscoveredSymbols $modifiedSymbols, string $outputFilename): string
118    {
119        // TODO: When target !== vendor, there should be a test here to ensure the target autoloader is included, with instructions to add it.
120
121        $autoloadAliasesFunctionsString = $this->getFunctionAliasesString($modifiedSymbols);
122
123        $aliasesArray = $this->getAliasesArray($modifiedSymbols);
124
125        return $this->getTemplate($aliasesArray, $autoloadAliasesFunctionsString);
126    }
127
128    /**
129     * @return array<string, ClassAliasArray|InterfaceAliasArray|TraitAliasArray|EnumAliasArray>
130     * @throws FilesystemException
131     */
132    protected function getAliasesArray(DiscoveredSymbols $symbols): array
133    {
134        $result = [];
135
136        foreach ($symbols->toArray() as $originalSymbolFqdn => $symbol) {
137            if (!$symbol->isDoRename()) {
138                continue;
139            }
140            // E.g. a global symbol when `classmap_prefix` is disabled: aliasing an unchanged name to itself
141            // would recurse into the autoloader (`class_alias()`) or redeclare the symbol (`extends` shim).
142            if ($originalSymbolFqdn === $symbol->getReplacementFqdnName()) {
143                continue;
144            }
145            if (!($symbol instanceof AutoloadAliasInterface)) {
146                continue;
147            }
148            $result[$originalSymbolFqdn] = $symbol->getAutoloadAliasArray();
149        }
150
151        return $result;
152    }
153
154    protected function getFunctionAliasesString(DiscoveredSymbols $discoveredSymbols): string
155    {
156        $modifiedSymbols = $discoveredSymbols->getSymbols();
157
158        $autoloadAliasesFileString = '';
159
160        $symbolsByNamespace = ['\\' => []];
161        foreach ($modifiedSymbols as $symbol) {
162            if ($symbol instanceof FunctionSymbol) {
163                if (!isset($symbolsByNamespace[$symbol->getNamespaceName()])) {
164                    $symbolsByNamespace[$symbol->getNamespaceName()] = [];
165                }
166                $symbolsByNamespace[$symbol->getNamespaceName()][] = $symbol;
167            }
168            /**
169             * "define() will define constants exactly as specified.  So, if you want to define a constant in a
170             * namespace, you will need to specify the namespace in your call to define(), even if you're calling
171             * define() from within a namespace."
172             * @see https://www.php.net/manual/en/function.define.php
173             */
174            if ($symbol instanceof ConstantSymbol) {
175                $symbolsByNamespace['\\'][] = $symbol;
176            }
177        }
178
179        if (!empty($symbolsByNamespace['\\'])) {
180            $globalAliasesPhpString = 'namespace {' . PHP_EOL;
181
182            /** @var FunctionSymbol | ConstantSymbol $symbol */
183            foreach ($symbolsByNamespace['\\'] as $symbol) {
184                $aliasesPhpString = '';
185
186                $originalLocalSymbol = $symbol->getOriginalFqdnName();
187                $replacementSymbol   = $symbol->getLocalReplacement();
188
189                if ($originalLocalSymbol === $replacementSymbol) {
190                    continue;
191                }
192
193                switch (get_class($symbol)) {
194                    case FunctionSymbol::class:
195                        // TODO: Do we need to check for `void`? Or will it just be ignored?
196                        // Is it possible to inherit PHPDoc from the original function?
197                        $aliasesPhpString = $this->aliasedFunctionTemplate($originalLocalSymbol, $replacementSymbol);
198                        break;
199                    case ConstantSymbol::class:
200                        /**
201                         * https://stackoverflow.com/questions/19740621/namespace-constants-and-use-as
202                         */
203                        // Ideally this would somehow be loaded after everything else.
204                        // Maybe some Patchwork style redefining of `define()` to add the alias?
205                        // Does it matter since all references to use the constant should have been updated to the new name anyway.
206                        // TODO: global `const`.
207                        $aliasesPhpString = <<<EOD
208        if(!defined('$originalLocalSymbol') && defined('$replacementSymbol')) {
209            define('$originalLocalSymbol', $replacementSymbol);
210        }
211        EOD;
212                        break;
213                    default:
214                        /**
215                         * Should be addressed above.
216                         *
217                         * @see self::appendAliasString())
218                         */
219                        break;
220                }
221
222                $globalAliasesPhpString .= $aliasesPhpString;
223            }
224
225            $globalAliasesPhpString .= PHP_EOL . '}' . PHP_EOL; // Close global namespace.
226
227            $autoloadAliasesFileString = $autoloadAliasesFileString . PHP_EOL . $globalAliasesPhpString;
228        }
229
230        unset($symbolsByNamespace['\\']);
231        foreach ($symbolsByNamespace as $namespaceSymbol => $symbols) {
232            $aliasesPhpString = "namespace $namespaceSymbol {" . PHP_EOL;
233
234            foreach ($symbols as $symbol) {
235                $originalLocalSymbol = $symbol->getOriginalLocalName();
236
237                /** @var NamespaceSymbol $namespaceSymbol */
238                $namespaceSymbol = $discoveredSymbols->getNamespaceSymbolByString($symbol->getNamespaceName());
239
240                if (!($symbol instanceof FunctionSymbol
241                   &&
242                   $namespaceSymbol->isChangedNamespace())
243                ) {
244                    $this->logger->debug("Skipping {$originalLocalSymbol} because it is not being changed.");
245                    continue;
246                }
247
248                $unNamespacedOriginalSymbol = trim(str_replace($symbol->getNamespaceName(), '', $originalLocalSymbol), '\\');
249                $namespacedOriginalSymbol = $symbol->getNamespaceName() . '\\' . $unNamespacedOriginalSymbol;
250
251                $replacementSymbol = str_replace(
252                    $namespaceSymbol->getOriginalFqdnName(),
253                    $namespaceSymbol->getLocalReplacement(),
254                    $namespacedOriginalSymbol
255                );
256
257                $aliasesPhpString .= $this->aliasedFunctionTemplate(
258                    $namespacedOriginalSymbol,
259                    $replacementSymbol,
260                );
261            }
262            $aliasesPhpString .= "}" . PHP_EOL; // Close namespace.
263
264            $autoloadAliasesFileString .= $aliasesPhpString;
265        }
266
267        return $autoloadAliasesFileString;
268    }
269
270    /**
271     * Returns the PHP for `if(!function_exists...` for an aliased function.
272     *
273     * Ensures the correct leading backslashes.
274     *
275     * @param string $namespacedOriginalFunction
276     * @param string $namespacedReplacementFunction
277     */
278    protected function aliasedFunctionTemplate(
279        string $namespacedOriginalFunction,
280        string $namespacedReplacementFunction
281    ): string {
282        $namespacedOriginalFunction = '\\\\' . trim($namespacedOriginalFunction, '\\');
283        $namespacedOriginalFunction = preg_replace('/\\\\+/', '\\\\\\\\', $namespacedOriginalFunction) ?? (function () {
284            throw new \Exception(preg_last_error_msg(), preg_last_error());
285        })();
286
287        $localOriginalFunction = array_reverse(explode('\\', $namespacedOriginalFunction))[0];
288
289        $namespacedReplacementFunction = '\\' . trim($namespacedReplacementFunction, '\\');
290        $namespacedReplacementFunction = preg_replace('/\\\\+/', '\\', $namespacedReplacementFunction)
291                                         ?? (function () {
292                                             throw new \Exception(preg_last_error_msg(), preg_last_error());
293                                         })();
294
295        return <<<EOD
296                    if(!function_exists('$namespacedOriginalFunction')){
297                        function $localOriginalFunction(...\$args) {
298                            return $namespacedReplacementFunction(...func_get_args());
299                        }
300                    }
301                EOD . PHP_EOL;
302    }
303}