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