Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.57% covered (warning)
82.57%
90 / 109
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstalledJson
82.57% covered (warning)
82.57%
90 / 109
37.50% covered (danger)
37.50%
3 / 8
36.09
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
 copyInstalledJson
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getJsonFile
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
2.02
 updatePackagePaths
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
5.46
 removeMovedPackagesAutoloadKey
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 updateNamespaces
75.68% covered (warning)
75.68%
28 / 37
0.00% covered (danger)
0.00%
0 / 1
18.24
 cleanTargetDirInstalledJson
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 cleanupVendorInstalledJson
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Changes "install-path" to point to vendor-prefixed target directory.
4 *
5 * * create new vendor-prefixed/composer/installed.json file with copied packages
6 * * when delete is enabled, update package paths in the original vendor/composer/installed.json
7 * * when delete is enabled, remove dead entries in the original vendor/composer/installed.json
8 * * update psr-0 autoload keys to have matching classmap entries
9 *
10 * @see vendor/composer/installed.json
11 *
12 * TODO: when delete_vendor_files is used, the original directory still exists so the paths are not updated.
13 *
14 * @package brianhenryie/strauss
15 */
16
17namespace BrianHenryIE\Strauss\Pipeline\Cleanup;
18
19use BrianHenryIE\Strauss\Composer\ComposerPackage;
20use BrianHenryIE\Strauss\Config\CleanupConfigInterface;
21use BrianHenryIE\Strauss\Helpers\FileSystem;
22use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
23use Composer\Json\JsonFile;
24use Composer\Json\JsonValidationException;
25use Psr\Log\LoggerAwareTrait;
26use Psr\Log\LoggerInterface;
27use Seld\JsonLint\ParsingException;
28
29/**
30 * @phpstan-type InstalledJsonPackageSourceArray array{type:string, url:string, reference:string}
31 * @phpstan-type InstalledJsonPackageDistArray array{type:string, url:string, reference:string, shasum:string}
32 * @phpstan-type InstalledJsonPackageAutoloadArray array<string,array<string,string>>
33 * @phpstan-type InstalledJsonPackageAuthorArray array{name:string,email:string}
34 * @phpstan-type InstalledJsonPackageSupportArray array{issues:string, source:string}
35 *
36 * @phpstan-type InstalledJsonPackageArray array{name:string, version:string, version_normalized:string, source:InstalledJsonPackageSourceArray, dist:InstalledJsonPackageDistArray, require:array<string,string>, require-dev:array<string,string>, time:string, type:string, installation-source:string, autoload:InstalledJsonPackageAutoloadArray, notification-url:string, license:array<string>, authors:array<InstalledJsonPackageAuthorArray>, description:string, homepage:string, keywords:array<string>, support:InstalledJsonPackageSupportArray, install-path:string}
37 *
38 * @phpstan-type InstalledJsonArray array{packages:array<InstalledJsonPackageArray>, dev:bool, dev-package-names:array<string>}
39 */
40class InstalledJson
41{
42    use LoggerAwareTrait;
43
44    protected CleanupConfigInterface $config;
45
46    protected FileSystem $filesystem;
47
48    public function __construct(
49        CleanupConfigInterface $config,
50        FileSystem $filesystem,
51        LoggerInterface $logger
52    ) {
53        $this->config = $config;
54        $this->filesystem = $filesystem;
55
56        $this->setLogger($logger);
57    }
58
59    public function copyInstalledJson(): void
60    {
61        $this->logger->info('Copying vendor/composer/installed.json to vendor-prefixed/composer/installed.json');
62
63        $this->filesystem->copy(
64            $this->config->getVendorDirectory() . 'composer/installed.json',
65            $this->config->getTargetDirectory() . 'composer/installed.json'
66        );
67
68        $this->logger->debug('Copied vendor/composer/installed.json to vendor-prefixed/composer/installed.json');
69        $this->logger->debug($this->filesystem->read($this->config->getTargetDirectory() . 'composer/installed.json'));
70    }
71
72    /**
73     * @throws JsonValidationException
74     * @throws ParsingException
75     */
76    protected function getJsonFile(string $vendorDir): JsonFile
77    {
78        $installedJsonFile = new JsonFile(
79            sprintf(
80                '%scomposer/installed.json',
81                $vendorDir
82            )
83        );
84        if (!$installedJsonFile->exists()) {
85            $this->logger->error('Expected vendor/composer/installed.json does not exist.');
86            throw new \Exception('Expected vendor/composer/installed.json does not exist.');
87        }
88
89        $installedJsonFile->validateSchema(JsonFile::LAX_SCHEMA);
90
91        $this->logger->info('Loaded installed.json file: ' . $installedJsonFile->getPath());
92
93        return $installedJsonFile;
94    }
95
96    /**
97     * @param InstalledJsonArray $installedJsonArray
98     * @param array<string,ComposerPackage> $flatDependencyTree
99     */
100    protected function updatePackagePaths(array $installedJsonArray, array $flatDependencyTree): array
101    {
102
103        foreach ($installedJsonArray['packages'] as $key => $package) {
104            // Skip packages that were never copied in the first place.
105            if (!in_array($package['name'], array_keys($flatDependencyTree))) {
106                $this->logger->debug('Skipping package: ' . $package['name']);
107                continue;
108            }
109            $this->logger->info('Checking package: ' . $package['name']);
110
111            // `composer/` is here because the install-path is relative to the `vendor/composer` directory.
112            $packageDir = $this->config->getVendorDirectory() . 'composer/' . $package['install-path'] . '/';
113            if (!$this->filesystem->directoryExists($packageDir)) {
114                $this->logger->debug('Original package directory does not exist at : ' . $packageDir);
115
116                $newInstallPath = $this->config->getTargetDirectory() . str_replace('../', '', $package['install-path']);
117
118                if (!$this->filesystem->directoryExists($newInstallPath)) {
119                    $this->logger->warning('Target package directory unexpectedly DOES NOT exist: ' . $newInstallPath);
120                    continue;
121                }
122
123                $newRelativePath = $this->filesystem->getRelativePath(
124                    $this->config->getVendorDirectory() . 'composer/',
125                    $newInstallPath
126                );
127
128                $installedJsonArray['packages'][$key]['install-path'] = $newRelativePath;
129            } else {
130                $this->logger->debug('Original package directory exists at : ' . $packageDir);
131            }
132        }
133        return $installedJsonArray;
134    }
135
136
137    /**
138     * Remove the autoload key for packages from `installed.json` whose target directory does not exist after deleting.
139     *
140     * E.g. after the file is copied to the target directory, this will remove dev dependencies and unmodified dependencies from the second installed.json
141     */
142    protected function removeMovedPackagesAutoloadKey(array $installedJsonArray, string $vendorDir): array
143    {
144        foreach ($installedJsonArray['packages'] as $key => $package) {
145            $path = $vendorDir . 'composer/' . $package['install-path'];
146            // TODO: Use a getter on the package.
147            $pathExists = $this->filesystem->directoryExists($path);
148            if (!$pathExists) {
149                $this->logger->info('Removing package autoload key from installed.json: ' . $package['name']);
150                $installedJsonArray['packages'][$key]['autoload'] = [];
151            }
152        }
153        return $installedJsonArray;
154    }
155
156    protected function updateNamespaces(array $installedJsonArray, DiscoveredSymbols $discoveredSymbols): array
157    {
158        $discoveredNamespaces = $discoveredSymbols->getNamespaces();
159
160        foreach ($installedJsonArray['packages'] as $key => $package) {
161            if (!isset($package['autoload'])) {
162                // woocommerce/action-scheduler
163                $this->logger->debug('Package has no autoload key: ' . $package['name'] . ' ' . $package['type']);
164                continue;
165            }
166
167            $autoload_key = $package['autoload'];
168            if (!isset($autoload_key['classmap'])) {
169                $autoload_key['classmap'] = [];
170            }
171            foreach ($autoload_key as $type => $autoload) {
172                switch ($type) {
173                    case 'psr-0':
174                        foreach (array_values((array) $autoload_key['psr-0']) as $relativePath) {
175                            $packageRelativePath = $package['install-path'];
176                            if (1 === preg_match('#.*'.preg_quote($this->filesystem->normalize($this->config->getTargetDirectory()), '#').'/(.*)#', $packageRelativePath, $matches)) {
177                                $packageRelativePath = $matches[1];
178                            }
179                            if ($this->filesystem->directoryExists($this->config->getTargetDirectory() . 'composer/' . $packageRelativePath . $relativePath)) {
180                                $autoload_key['classmap'][] = $relativePath;
181                            }
182                        }
183                        // Intentionally fall through
184                        // Although the PSR-0 implementation here is a bit of a hack.
185                    case 'psr-4':
186                        /**
187                         * e.g.
188                         * * {"psr-4":{"Psr\\Log\\":"Psr\/Log\/"}}
189                         * * {"psr-4":{"":"src\/"}}
190                         * * {"psr-4":{"Symfony\\Polyfill\\Mbstring\\":""}}
191                         * * {"psr-0":{"PayPal":"lib\/"}}
192                         */
193                        foreach ($autoload_key[$type] as $originalNamespace => $packageRelativeDirectory) {
194                            // Replace $originalNamespace with updated namespace
195
196                            // Just for dev â€“ find a package like this and write a test for it.
197                            if (empty($originalNamespace)) {
198                                // In the case of `nesbot/carbon`, it uses an empty namespace but the classes are in the `Carbon`
199                                // namespace, so using `override_autoload` should be a good solution if this proves to be an issue.
200                                // The package directory will be updated, so for whatever reason the original empty namespace
201                                // works, maybe the updated namespace will work too.
202                                $this->logger->warning('Empty namespace found in autoload. Behaviour is not fully documented: ' . $package['name']);
203                                continue;
204                            }
205
206                            $trimmedOriginalNamespace = trim($originalNamespace, '\\');
207
208                            $this->logger->info('Checking '.$type.' namespace: ' . $trimmedOriginalNamespace);
209
210                            if (isset($discoveredNamespaces[$trimmedOriginalNamespace])) {
211                                $namespaceSymbol = $discoveredNamespaces[$trimmedOriginalNamespace];
212                            } else {
213                                $this->logger->debug('Namespace not found in list of changes: ' . $trimmedOriginalNamespace);
214                                continue;
215                            }
216
217                            if ($trimmedOriginalNamespace === trim($namespaceSymbol->getReplacement(), '\\')) {
218                                $this->logger->debug('Namespace is unchanged: ' . $trimmedOriginalNamespace);
219                                continue;
220                            }
221
222                            // Update the namespace if it has changed.
223                            $this->logger->info('Updating namespace: ' . $trimmedOriginalNamespace . ' => ' . $namespaceSymbol->getReplacement());
224                            $autoload_key[$type][str_replace($trimmedOriginalNamespace, $namespaceSymbol->getReplacement(), $originalNamespace)] = $autoload_key[$type][$originalNamespace];
225                            unset($autoload_key[$type][$originalNamespace]);
226
227//                            if (is_array($packageRelativeDirectory)) {
228//                                $autoload_key[$type][$originalNamespace] = array_filter(
229//                                    $packageRelativeDirectory,
230//                                    function ($dir) use ($packageDir) {
231//                                                $dir = $packageDir . $dir;
232//                                                $exists = $this->filesystem->directoryExists($dir) || $this->filesystem->fileExists($dir);
233//                                        if (!$exists) {
234//                                            $this->logger->info('Removing non-existent directory from autoload: ' . $dir);
235//                                        } else {
236//                                            $this->logger->debug('Keeping directory in autoload: ' . $dir);
237//                                        }
238//                                        return $exists;
239//                                    }
240//                                );
241//                            } else {
242//                                $dir = $packageDir . $packageRelativeDirectory;
243//                                if (! ($this->filesystem->directoryExists($dir) || $this->filesystem->fileExists($dir))) {
244//                                    $this->logger->info('Removing non-existent directory from autoload: ' . $dir);
245//                                    // /../../../vendor-prefixed/lib
246//                                    unset($autoload_key[$type][$originalNamespace]);
247//                                } else {
248//                                    $this->logger->debug('Keeping directory in autoload: ' . $dir);
249//                                }
250//                            }
251                        }
252                        break;
253                    default: // files, classmap, psr-0
254                        /**
255                         * E.g.
256                         *
257                         * * {"classmap":["src\/"]}
258                         * * {"files":["src\/functions.php"]}
259                         *
260                         * Also:
261                         * * {"exclude-from-classmap":["\/Tests\/"]}
262                         */
263
264//                        $autoload_key[$type] = array_filter($autoload, function ($file) use ($packageDir) {
265//                            $filename = $packageDir . '/' . $file;
266//                            $exists = $this->filesystem->directoryExists($filename) || $this->filesystem->fileExists($filename);
267//                            if (!$exists) {
268//                                $this->logger->info('Removing non-existent file from autoload: ' . $filename);
269//                            } else {
270//                                $this->logger->debug('Keeping file in autoload: ' . $filename);
271//                            }
272//                        });
273                        break;
274                }
275            }
276            $installedJsonArray['packages'][$key]['autoload'] = array_filter($autoload_key);
277        }
278
279        return $installedJsonArray;
280    }
281    /**
282     * @param array<string,ComposerPackage> $flatDependencyTree
283     * @param DiscoveredSymbols $discoveredSymbols
284     */
285    public function cleanTargetDirInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void
286    {
287        $targetDir = $this->config->getTargetDirectory();
288
289        $installedJsonFile = $this->getJsonFile($targetDir);
290
291        /**
292         * @var InstalledJsonArray $installedJsonArray
293         */
294        $installedJsonArray = $installedJsonFile->read();
295
296        $this->logger->debug('Installed.json before: ' . json_encode($installedJsonArray));
297
298        $installedJsonArray = $this->updatePackagePaths($installedJsonArray, $flatDependencyTree);
299
300        $installedJsonArray = $this->removeMovedPackagesAutoloadKey($installedJsonArray, $targetDir);
301
302        $installedJsonArray = $this->updateNamespaces($installedJsonArray, $discoveredSymbols);
303
304        foreach ($installedJsonArray['packages'] as $index => $package) {
305            if (!in_array($package['name'], array_keys($flatDependencyTree))) {
306                unset($installedJsonArray['packages'][$index]);
307            }
308        }
309        $installedJsonArray['dev'] = false;
310        $installedJsonArray['dev-package-names'] = [];
311
312        $this->logger->debug('Installed.json after: ' . json_encode($installedJsonArray));
313
314        $this->logger->info('Writing installed.json to ' . $targetDir);
315
316        $installedJsonFile->write($installedJsonArray);
317
318        $this->logger->info('Installed.json written to ' . $targetDir);
319    }
320
321
322    /**
323     * Composer creates a file `vendor/composer/installed.json` which is uses when running `composer dump-autoload`.
324     * When `delete-vendor-packages` or `delete-vendor-files` is true, files and directories which have been deleted
325     * must also be removed from `installed.json` or Composer will throw an error.
326     *
327     * @param array<string,ComposerPackage> $flatDependencyTree
328     */
329    public function cleanupVendorInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void
330    {
331        $this->logger->info('Cleaning up installed.json');
332
333        $vendorDir = $this->config->getVendorDirectory();
334
335        $vendorInstalledJsonFile = $this->getJsonFile($vendorDir);
336
337        /**
338         * @var InstalledJsonArray $installedJsonArray
339         */
340        $installedJsonArray = $vendorInstalledJsonFile->read();
341
342        $installedJsonArray = $this->removeMovedPackagesAutoloadKey($installedJsonArray, $vendorDir);
343
344        $installedJsonArray = $this->updatePackagePaths($installedJsonArray, $flatDependencyTree);
345
346        // Only relevant when source = target.
347        $installedJsonArray = $this->updateNamespaces($installedJsonArray, $discoveredSymbols);
348
349        $vendorInstalledJsonFile->write($installedJsonArray);
350    }
351}