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