Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.56% covered (danger)
5.56%
8 / 144
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cleanup
5.56% covered (danger)
5.56%
8 / 144
25.00% covered (danger)
25.00%
2 / 8
1528.03
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 deleteFiles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 cleanupVendorInstalledJson
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 rebuildVendorAutoloader
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
20
 isOptimizeAutoloaderEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 deleteEmptyDirectories
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 doIsDeleteVendorPackages
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 doIsDeleteVendorFiles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Deletes source files and empty directories.
4 */
5
6namespace BrianHenryIE\Strauss\Pipeline\Cleanup;
7
8use BrianHenryIE\Strauss\Composer\ComposerPackage;
9use BrianHenryIE\Strauss\Config\CleanupConfigInterface;
10use BrianHenryIE\Strauss\Config\OptimizeAutoloaderConfigInterface;
11use BrianHenryIE\Strauss\Files\DiscoveredFiles;
12use BrianHenryIE\Strauss\Files\FileBase;
13use BrianHenryIE\Strauss\Helpers\FileSystem;
14use BrianHenryIE\Strauss\Pipeline\Autoload\DumpAutoload;
15use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
16use Composer\Autoload\AutoloadGenerator;
17use Composer\Factory;
18use Composer\IO\NullIO;
19use Composer\Json\JsonFile;
20use Composer\Repository\InstalledFilesystemRepository;
21use Exception;
22use League\Flysystem\FilesystemException;
23use Psr\Log\LoggerAwareTrait;
24use Psr\Log\LoggerInterface;
25use Seld\JsonLint\ParsingException;
26
27/**
28 * @phpstan-import-type InstalledJsonArray from InstalledJson
29 */
30class Cleanup
31{
32    use LoggerAwareTrait;
33
34    protected Filesystem $filesystem;
35
36    protected bool $isDeleteVendorFiles;
37    protected bool $isDeleteVendorPackages;
38
39    protected CleanupConfigInterface $config;
40
41    public function __construct(
42        CleanupConfigInterface $config,
43        Filesystem $filesystem,
44        LoggerInterface $logger
45    ) {
46        $this->config = $config;
47        $this->logger = $logger;
48
49        $this->isDeleteVendorFiles = $config->isDeleteVendorFiles() && $config->getAbsoluteTargetDirectory() !== $config->getAbsoluteVendorDirectory();
50        $this->isDeleteVendorPackages = $config->isDeleteVendorPackages() && $config->getAbsoluteTargetDirectory() !== $config->getAbsoluteVendorDirectory();
51
52        $this->filesystem = $filesystem;
53    }
54
55    /**
56     * Maybe delete the source files that were copied (depending on config),
57     * then delete empty directories.
58     *
59     * @param array<string,ComposerPackage> $flatDependencyTree
60     *
61     * @throws FilesystemException
62     */
63    public function deleteFiles(array $flatDependencyTree, DiscoveredFiles $discoveredFiles): void
64    {
65        if (!$this->isDeleteVendorPackages && !$this->isDeleteVendorFiles) {
66            $this->logger->info('No cleanup required.');
67            return;
68        }
69
70        $this->logger->info('Beginning cleanup.');
71
72        if ($this->isDeleteVendorPackages) {
73            $this->doIsDeleteVendorPackages($flatDependencyTree, $discoveredFiles);
74        }
75
76        if ($this->isDeleteVendorFiles) {
77            $this->doIsDeleteVendorFiles($discoveredFiles->getFiles());
78        }
79
80        $this->deleteEmptyDirectories($discoveredFiles->getFiles());
81    }
82
83    /** @param array<string,ComposerPackage> $flatDependencyTree
84     * @throws Exception
85     * @throws FilesystemException
86     */
87    public function cleanupVendorInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void
88    {
89        $installedJson = new InstalledJson(
90            $this->config,
91            $this->filesystem,
92            $this->logger
93        );
94
95        if (!$this->config->isTargetDirectoryVendor()
96            && !$this->config->isDeleteVendorFiles()
97            && !$this->config->isDeleteVendorPackages()
98        ) {
99            $installedJson->cleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols);
100        } elseif (!$this->config->isTargetDirectoryVendor()
101            && ($this->config->isDeleteVendorFiles() || $this->config->isDeleteVendorPackages())
102        ) {
103            $installedJson->cleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols);
104            $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols);
105        } elseif ($this->config->isTargetDirectoryVendor()) {
106            $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols);
107        }
108    }
109
110    /**
111     * After packages or files have been deleted, the autoloader still contains references to them, in particular
112     * `files` are `require`d on boot (whereas classes are on demand) so that must be fixed.
113     *
114     * Assumes {@see Cleanup::cleanupVendorInstalledJson()} has been called first.
115     *
116     * TODO refactor so this object is passed around rather than reloaded.
117     *
118     * Shares a lot of code with {@see DumpAutoload::generatedPrefixedAutoloader()} but I've done lots of work
119     * on that in another branch so I don't want to cause merge conflicts.
120     * @throws ParsingException
121     */
122    public function rebuildVendorAutoloader(): void
123    {
124        if ($this->config->isDryRun()) {
125            return;
126        }
127
128        $projectComposerJson = new JsonFile(
129            $this->filesystem->makeAbsolute(
130                $this->config->getProjectDirectory() . '/composer.json'
131            )
132        );
133        $projectComposerJsonArray = $projectComposerJson->read();
134        if (!isset($projectComposerJsonArray['require'])) {
135            $projectComposerJsonArray['require'] = [];
136        }
137        // Composer only autoloads packages reachable from root requirements.
138        foreach ($this->config->getExcludePackagesFromCopy() as $packageName) {
139            $projectComposerJsonArray['require'][$packageName] ??= '*';
140        }
141        $composer = Factory::create(new NullIO(), $projectComposerJsonArray);
142        $installationManager = $composer->getInstallationManager();
143        $package = $composer->getPackage();
144        $config = $composer->getConfig();
145        $generator = new AutoloadGenerator($composer->getEventDispatcher());
146        $isOptimize = $this->isOptimizeAutoloaderEnabled();
147        $generator->setClassMapAuthoritative($isOptimize);
148        $generator->setRunScripts(false);
149//        $generator->setApcu($apcu, $apcuPrefix);
150//        $generator->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input));
151        $installedJson = new JsonFile(
152            $this->filesystem->makeAbsolute(
153                $this->config->getAbsoluteVendorDirectory() . '/composer/installed.json'
154            )
155        );
156        $localRepo = new InstalledFilesystemRepository($installedJson);
157        $strictAmbiguous = false; // $input->getOption('strict-ambiguous')
158        /** @var InstalledJsonArray $installedJsonArray */
159        $installedJsonArray = $installedJson->read();
160        $generator->setDevMode($installedJsonArray['dev'] ?? false);
161        // This will output the autoload_static.php etc. files to `vendor/composer`.
162        $generator->dump(
163            $config,
164            $localRepo,
165            $package,
166            $installationManager,
167            'composer',
168            $isOptimize,
169            null,
170            $composer->getLocker(),
171            $strictAmbiguous
172        );
173    }
174
175    /**
176     * Keep backward compatibility with configs implementing only CleanupConfigInterface.
177     */
178    protected function isOptimizeAutoloaderEnabled(): bool
179    {
180        return $this->config instanceof OptimizeAutoloaderConfigInterface
181            ? $this->config->isOptimizeAutoloader()
182            : true;
183    }
184
185    /**
186     * @param FileBase[] $files
187     * @throws FilesystemException
188     */
189    protected function deleteEmptyDirectories(array $files): void
190    {
191        $this->logger->info('Deleting empty directories.');
192
193        $sourceFiles = array_map(
194            fn($file) => $file->getSourcePath(),
195            $files
196        );
197
198        // Get the root folders of the moved files.
199        $rootSourceDirectories = [];
200        foreach ($sourceFiles as $sourceFile) {
201            $arr = explode("/", $sourceFile, 2);
202            $dir = $arr[0];
203            $rootSourceDirectories[ $dir ] = $dir;
204        }
205        $rootSourceDirectories = array_map(
206            function (string $path): string {
207                return $this->config->getAbsoluteVendorDirectory() . '/' . $path;
208            },
209            array_keys($rootSourceDirectories)
210        );
211
212        foreach ($rootSourceDirectories as $rootSourceDirectory) {
213            if (!$this->filesystem->directoryExists($rootSourceDirectory) || is_link($rootSourceDirectory)) {
214                continue;
215            }
216
217            $dirList = $this->filesystem->listContents($rootSourceDirectory, true);
218
219            $allFilePaths = array_map(
220                fn($file) => $file->path(),
221                $dirList->toArray()
222            );
223
224            // Sort by longest path first, so subdirectories are deleted before the parent directories are checked.
225            usort(
226                $allFilePaths,
227                fn($a, $b) => count(explode('/', $b)) - count(explode('/', $a))
228            );
229
230            foreach ($allFilePaths as $filePath) {
231                if ($this->filesystem->directoryExists($filePath)
232                    && $this->filesystem->isDirectoryEmpty($filePath)
233                ) {
234                    $this->logger->debug('Deleting empty directory ' . $filePath);
235                    $this->filesystem->deleteDirectory($filePath);
236                }
237            }
238        }
239
240//        foreach ($this->filesystem->listContents($this->getAbsoluteVendorDir()) as $dirEntry) {
241//            if ($dirEntry->isDir() && $this->dirIsEmpty($dirEntry->path()) && !is_link($dirEntry->path())) {
242//                $this->logger->info('Deleting empty directory ' .  $dirEntry->path());
243//                $this->filesystem->deleteDirectory($dirEntry->path());
244//            } else {
245//                $this->logger->debug('Skipping non-empty directory ' . $dirEntry->path());
246//            }
247//        }
248        $this->logger->debug('Finished Cleanup::deleteEmptyDirectories()');
249    }
250
251    /**
252     * @param array<string,ComposerPackage> $flatDependencyTree
253     * @throws FilesystemException
254     */
255    protected function doIsDeleteVendorPackages(array $flatDependencyTree, DiscoveredFiles $discoveredFiles): void
256    {
257        $this->logger->info('Deleting original vendor packages.');
258
259//        if ($this->isDeleteVendorPackages) {
260//            foreach ($flatDependencyTree as $packageName => $package) {
261//                if ($package->isDoDelete()) {
262//                    $this->filesystem->deleteDirectory($package->getPackageAbsolutePath());
263//                    $package->setDidDelete(true);
264////                $files = $package->getFiles();
265////                foreach($files as $file){
266////                    $file->setDidDelete(true);
267////                }
268//                }
269//            }
270//        }
271
272        foreach ($flatDependencyTree as $package) {
273            // Skip packages excluded from copy - they should remain in vendor/
274            if (in_array($package->getPackageName(), $this->config->getExcludePackagesFromCopy(), true)) {
275                $this->logger->debug('Skipping deletion of excluded package: ' . $package->getPackageName());
276                continue;
277            }
278
279            // Normal package.
280//            if (!$this->filesystem->isSymlinked($package->getPackageAbsolutePath())) {
281            if ($this->filesystem->isSubDirOf($this->config->getAbsoluteVendorDirectory(), $package->getPackageAbsolutePath())) {
282                $this->logger->info('Deleting ' . $package->getPackageAbsolutePath());
283
284                $this->filesystem->deleteDirectory($package->getPackageAbsolutePath());
285
286                $package->setDidDelete(true);
287//            } elseif($this->filesystem->isSymlinked($package->getPackageAbsolutePath())) {
288            } else {
289                // TODO: log _where_ the symlink is pointing to.
290                $this->logger->info('Deleting symlink at ' . $package->getRelativePath());
291
292                // If it's a symlink, remove the symlink in the directory
293                $symlinkPath = $this->filesystem->makeAbsolute(
294                    FileSystem::normalizeDirSeparator(rtrim(
295                        $this->config->getAbsoluteVendorDirectory() . '/' . $package->getRelativePath(),
296                        '/'
297                    ))
298                );
299
300                if (PHP_OS_FAMILY === 'Windows') {
301                    /**
302                     * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory.
303                     * "On windows, take care that `is_link()` returns false for Junctions."
304                     *
305                     * @see https://www.php.net/manual/en/function.is-link.php#113263
306                     * @see https://stackoverflow.com/a/18262809/336146
307                     */
308                    try {
309                        (new \Composer\Util\Filesystem())->unlink($symlinkPath);
310                    } catch (\RuntimeException $exception) {
311                        $this->logger->warning('Failed to remove symlink at ' . $symlinkPath);
312                        $this->logger->warning('Please submit a PR to fix Windows symlink support.');
313                    }
314                } else {
315                    unlink($symlinkPath);
316                }
317
318                $package->setDidDelete(true);
319            }
320            $packageParentDir = dirname($package->getPackageAbsolutePath());
321            if ($packageParentDir
322                &&
323                $this->filesystem->directoryExists($packageParentDir)
324                 &&
325                 $this->filesystem->isDirectoryEmpty($packageParentDir)
326            ) {
327                $this->logger->info('Deleting empty directory ' . $packageParentDir);
328                $this->filesystem->deleteDirectory($packageParentDir);
329            }
330        }
331    }
332
333    /**
334     * @param FileBase[] $files
335     *
336     * @throws FilesystemException
337     */
338    public function doIsDeleteVendorFiles(array $files): void
339    {
340        $this->logger->info('Deleting original vendor files.');
341
342        foreach ($files as $file) {
343            if (! $file->isDoDelete()) {
344                $this->logger->debug('Skipping/preserving ' . $file->getSourcePath());
345                continue;
346            }
347
348            $sourceRelativePath = $file->getSourcePath();
349
350            $this->logger->info('Deleting ' . $sourceRelativePath);
351
352            // TODO: is this relative or absolute?
353            $this->filesystem->delete($file->getSourcePath());
354
355            $file->setDidDelete(true);
356        }
357    }
358}