Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.45% covered (danger)
6.45%
8 / 124
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cleanup
6.45% covered (danger)
6.45%
8 / 124
25.00% covered (danger)
25.00%
2 / 8
1220.16
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 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 rebuildVendorAutoloader
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 33
0.00% covered (danger)
0.00%
0 / 1
72
 doIsDeleteVendorPackages
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 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->getTargetDirectory() !== $config->getVendorDirectory();
50        $this->isDeleteVendorPackages = $config->isDeleteVendorPackages() && $config->getTargetDirectory() !== $config->getVendorDirectory();
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->getTargetDirectory() !== $this->config->getVendorDirectory()
96        && !$this->config->isDeleteVendorFiles() && !$this->config->isDeleteVendorPackages()
97        ) {
98            $installedJson->cleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols);
99        } elseif ($this->config->getTargetDirectory() !== $this->config->getVendorDirectory()
100            &&
101            ($this->config->isDeleteVendorFiles() ||$this->config->isDeleteVendorPackages())
102        ) {
103            $installedJson->cleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols);
104            $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols);
105        } elseif ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) {
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($this->config->getProjectDirectory() . 'composer.json');
129        $projectComposerJsonArray = $projectComposerJson->read();
130        $composer = Factory::create(new NullIO(), $projectComposerJsonArray);
131        $installationManager = $composer->getInstallationManager();
132        $package = $composer->getPackage();
133        $config = $composer->getConfig();
134        $generator = new AutoloadGenerator($composer->getEventDispatcher());
135        $isOptimize = $this->isOptimizeAutoloaderEnabled();
136        $generator->setClassMapAuthoritative($isOptimize);
137        $generator->setRunScripts(false);
138//        $generator->setApcu($apcu, $apcuPrefix);
139//        $generator->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input));
140        $installedJson = new JsonFile($this->config->getVendorDirectory() . 'composer/installed.json');
141        $localRepo = new InstalledFilesystemRepository($installedJson);
142        $strictAmbiguous = false; // $input->getOption('strict-ambiguous')
143        /** @var InstalledJsonArray $installedJsonArray */
144        $installedJsonArray = $installedJson->read();
145        $generator->setDevMode($installedJsonArray['dev'] ?? false);
146        // This will output the autoload_static.php etc. files to `vendor/composer`.
147        $generator->dump(
148            $config,
149            $localRepo,
150            $package,
151            $installationManager,
152            'composer',
153            $isOptimize,
154            null,
155            $composer->getLocker(),
156            $strictAmbiguous
157        );
158    }
159
160    /**
161     * Keep backward compatibility with configs implementing only CleanupConfigInterface.
162     */
163    protected function isOptimizeAutoloaderEnabled(): bool
164    {
165        return $this->config instanceof OptimizeAutoloaderConfigInterface
166            ? $this->config->isOptimizeAutoloader()
167            : true;
168    }
169
170    /**
171     * @param FileBase[] $files
172     * @throws FilesystemException
173     */
174    protected function deleteEmptyDirectories(array $files): void
175    {
176        $this->logger->info('Deleting empty directories.');
177
178        $sourceFiles = array_map(
179            fn($file) => $file->getSourcePath(),
180            $files
181        );
182
183        // Get the root folders of the moved files.
184        $rootSourceDirectories = [];
185        foreach ($sourceFiles as $sourceFile) {
186            $arr = explode("/", $sourceFile, 2);
187            $dir = $arr[0];
188            $rootSourceDirectories[ $dir ] = $dir;
189        }
190        $rootSourceDirectories = array_map(
191            function (string $path): string {
192                return $this->config->getVendorDirectory() . $path;
193            },
194            array_keys($rootSourceDirectories)
195        );
196
197        foreach ($rootSourceDirectories as $rootSourceDirectory) {
198            if (!$this->filesystem->directoryExists($rootSourceDirectory) || is_link($rootSourceDirectory)) {
199                continue;
200            }
201
202            $dirList = $this->filesystem->listContents($rootSourceDirectory, true);
203
204            $allFilePaths = array_map(
205                fn($file) => $file->path(),
206                $dirList->toArray()
207            );
208
209            // Sort by longest path first, so subdirectories are deleted before the parent directories are checked.
210            usort(
211                $allFilePaths,
212                fn($a, $b) => count(explode('/', $b)) - count(explode('/', $a))
213            );
214
215            foreach ($allFilePaths as $filePath) {
216                if ($this->filesystem->directoryExists($filePath)
217                    && $this->filesystem->isDirectoryEmpty($filePath)
218                ) {
219                    $this->logger->debug('Deleting empty directory ' . $filePath);
220                    $this->filesystem->deleteDirectory($filePath);
221                }
222            }
223        }
224
225//        foreach ($this->filesystem->listContents($this->getAbsoluteVendorDir()) as $dirEntry) {
226//            if ($dirEntry->isDir() && $this->dirIsEmpty($dirEntry->path()) && !is_link($dirEntry->path())) {
227//                $this->logger->info('Deleting empty directory ' .  $dirEntry->path());
228//                $this->filesystem->deleteDirectory($dirEntry->path());
229//            } else {
230//                $this->logger->debug('Skipping non-empty directory ' . $dirEntry->path());
231//            }
232//        }
233    }
234
235    /**
236     * @param array<string,ComposerPackage> $flatDependencyTree
237     * @throws FilesystemException
238     */
239    protected function doIsDeleteVendorPackages(array $flatDependencyTree, DiscoveredFiles $discoveredFiles): void
240    {
241        $this->logger->info('Deleting original vendor packages.');
242
243//        if ($this->isDeleteVendorPackages) {
244//            foreach ($flatDependencyTree as $packageName => $package) {
245//                if ($package->isDoDelete()) {
246//                    $this->filesystem->deleteDirectory($package->getPackageAbsolutePath());
247//                    $package->setDidDelete(true);
248////                $files = $package->getFiles();
249////                foreach($files as $file){
250////                    $file->setDidDelete(true);
251////                }
252//                }
253//            }
254//        }
255
256        foreach ($flatDependencyTree as $package) {
257            // Skip packages excluded from copy - they should remain in vendor/
258            if (in_array($package->getPackageName(), $this->config->getExcludePackagesFromCopy(), true)) {
259                $this->logger->debug('Skipping deletion of excluded package: ' . $package->getPackageName());
260                continue;
261            }
262
263            // Normal package.
264            if ($this->filesystem->isSubDirOf($this->config->getVendorDirectory(), $package->getPackageAbsolutePath())) {
265                $this->logger->info('Deleting ' . $package->getPackageAbsolutePath());
266
267                $this->filesystem->deleteDirectory($package->getPackageAbsolutePath());
268
269                $package->setDidDelete(true);
270            } else {
271                // TODO: log _where_ the symlink is pointing to.
272                $this->logger->info('Deleting symlink at ' . $package->getRelativePath());
273
274                // If it's a symlink, remove the symlink in the directory
275                $symlinkPath =
276                    rtrim(
277                        $this->config->getVendorDirectory() . $package->getRelativePath(),
278                        '/'
279                    );
280
281                if (false !== strpos('WIN', PHP_OS)) {
282                    /**
283                     * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory.
284                     * "On windows, take care that `is_link()` returns false for Junctions."
285                     *
286                     * @see https://www.php.net/manual/en/function.is-link.php#113263
287                     * @see https://stackoverflow.com/a/18262809/336146
288                     */
289                    rmdir($symlinkPath);
290                } else {
291                    unlink($symlinkPath);
292                }
293
294                $package->setDidDelete(true);
295            }
296            if ($this->filesystem->directoryExists(dirname($package->getPackageAbsolutePath()))
297                &&
298                $this->filesystem->isDirectoryEmpty(dirname($package->getPackageAbsolutePath()))
299            ) {
300                $this->logger->info('Deleting empty directory ' . dirname($package->getPackageAbsolutePath()));
301                $this->filesystem->deleteDirectory(dirname($package->getPackageAbsolutePath()));
302            }
303        }
304    }
305
306    /**
307     * @param FileBase[] $files
308     *
309     * @throws FilesystemException
310     */
311    public function doIsDeleteVendorFiles(array $files): void
312    {
313        $this->logger->info('Deleting original vendor files.');
314
315        foreach ($files as $file) {
316            if (! $file->isDoDelete()) {
317                $this->logger->debug('Skipping/preserving ' . $file->getSourcePath());
318                continue;
319            }
320
321            $sourceRelativePath = $file->getSourcePath();
322
323            $this->logger->info('Deleting ' . $sourceRelativePath);
324
325            $this->filesystem->delete($file->getSourcePath());
326
327            $file->setDidDelete(true);
328        }
329    }
330}