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