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