Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cleanup
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 5
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 cleanup
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
306
 dirIsEmpty
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cleanupInstalledJson
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
132
 cleanupFilesAutoloader
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2/**
3 * Deletes source files and empty directories.
4 */
5
6namespace BrianHenryIE\Strauss;
7
8use BrianHenryIE\Strauss\Composer\Extra\StraussConfig;
9use Composer\Json\JsonFile;
10use FilesystemIterator;
11use League\Flysystem\Filesystem;
12use League\Flysystem\Local\LocalFilesystemAdapter;
13use RecursiveDirectoryIterator;
14use RecursiveIteratorIterator;
15
16class Cleanup
17{
18
19    /** @var Filesystem */
20    protected Filesystem $filesystem;
21
22    protected string $workingDir;
23
24    protected bool $isDeleteVendorFiles;
25    protected bool $isDeleteVendorPackages;
26
27    protected string $vendorDirectory = 'vendor'. DIRECTORY_SEPARATOR;
28    protected string $targetDirectory;
29
30    public function __construct(StraussConfig $config, string $workingDir)
31    {
32        $this->vendorDirectory = $config->getVendorDirectory();
33        $this->targetDirectory = $config->getTargetDirectory();
34        $this->workingDir = $workingDir;
35
36        $this->isDeleteVendorFiles = $config->isDeleteVendorFiles() && $config->getTargetDirectory() !== $config->getVendorDirectory();
37        $this->isDeleteVendorPackages = $config->isDeleteVendorPackages() && $config->getTargetDirectory() !== $config->getVendorDirectory();
38
39        $this->filesystem = new Filesystem(new LocalFilesystemAdapter($workingDir));
40    }
41
42    /**
43     * Maybe delete the source files that were copied (depending on config),
44     * then delete empty directories.
45     *
46     * @param string[] $sourceFiles Relative filepaths.
47     */
48    public function cleanup(array $sourceFiles): void
49    {
50        if (!$this->isDeleteVendorPackages && !$this->isDeleteVendorFiles) {
51            return;
52        }
53
54        if ($this->isDeleteVendorPackages) {
55            $package_dirs = array_unique(array_map(function (string $relativeFilePath): string {
56                list( $vendor, $package ) = explode('/', $relativeFilePath);
57                return "{$vendor}/{$package}";
58            }, $sourceFiles));
59
60            foreach ($package_dirs as $package_dir) {
61                $relativeDirectoryPath = $this->vendorDirectory . $package_dir;
62
63                $absolutePath = $this->workingDir . $relativeDirectoryPath;
64
65                if ($absolutePath !== realpath($absolutePath)) {
66                    if (false !== strpos('WIN', PHP_OS)) {
67                        /**
68                         * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory.
69                         * "On windows, take care that `is_link()` returns false for Junctions."
70                         *
71                         * @see https://www.php.net/manual/en/function.is-link.php#113263
72                         * @see https://stackoverflow.com/a/18262809/336146
73                         */
74                        rmdir($absolutePath);
75                    } else {
76                        unlink($absolutePath);
77                    }
78
79                    continue;
80                }
81
82                $this->filesystem->deleteDirectory($relativeDirectoryPath);
83            }
84        } elseif ($this->isDeleteVendorFiles) {
85            foreach ($sourceFiles as $sourceFile) {
86                $relativeFilepath = $this->vendorDirectory . $sourceFile;
87
88                $absolutePath = $this->workingDir . $relativeFilepath;
89
90                if ($absolutePath !== realpath($absolutePath)) {
91                    continue;
92                }
93
94                $this->filesystem->delete($relativeFilepath);
95            }
96
97            $this->cleanupFilesAutoloader();
98        }
99
100        // Get the root folders of the moved files.
101        $rootSourceDirectories = [];
102        foreach ($sourceFiles as $sourceFile) {
103            $arr = explode("/", $sourceFile, 2);
104            $dir = $arr[0];
105            $rootSourceDirectories[ $dir ] = $dir;
106        }
107        $rootSourceDirectories = array_map(
108            function (string $path): string {
109                return $this->vendorDirectory . $path;
110            },
111            array_keys($rootSourceDirectories)
112        );
113
114        foreach ($rootSourceDirectories as $rootSourceDirectory) {
115            if (!is_dir($rootSourceDirectory) || is_link($rootSourceDirectory)) {
116                continue;
117            }
118
119            $it = new RecursiveIteratorIterator(
120                new RecursiveDirectoryIterator(
121                    $this->workingDir . $rootSourceDirectory,
122                    FilesystemIterator::SKIP_DOTS
123                ),
124                RecursiveIteratorIterator::CHILD_FIRST
125            );
126
127            foreach ($it as $file) {
128                if ($file->isDir() && $this->dirIsEmpty((string) $file)) {
129                    rmdir((string)$file);
130                }
131            }
132        }
133
134        $this->cleanupInstalledJson();
135    }
136
137    // TODO: Use Symfony or Flysystem functions.
138    protected function dirIsEmpty(string $dir): bool
139    {
140        $di = new RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
141        return iterator_count($di) === 0;
142    }
143
144    /**
145     * Composer creates a file `vendor/composer/installed.json` which is uses when running `composer dump-autoload`.
146     * When `delete-vendor-packages` or `delete-vendor-files` is true, files and directories which have been deleted
147     * must also be removed from `installed.json` or Composer will throw an error.
148     *
149     * TODO: {@see self::cleanupFilesAutoloader()} might be redundant if we run this function and then run `composer dump-autoload`.
150     */
151    public function cleanupInstalledJson(): void
152    {
153        $installedJsonFile = new JsonFile($this->workingDir . '/vendor/composer/installed.json');
154        if (!$installedJsonFile->exists()) {
155            return;
156        }
157        $installedJsonArray = $installedJsonFile->read();
158
159        foreach ($installedJsonArray['packages'] as $key => $package) {
160            if (!isset($package['autoload'])) {
161                continue;
162            }
163            $packageDir = $this->workingDir . $this->vendorDirectory . ltrim($package['install-path'], '.' . DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
164            if (!is_dir($packageDir)) {
165                // pcre, xdebug-handler.
166                continue;
167            }
168            $autoload_key = $package['autoload'];
169            foreach ($autoload_key as $type => $autoload) {
170                switch ($type) {
171                    case 'psr-4':
172                        foreach ($autoload_key[$type] as $namespace => $dirs) {
173                            if (is_array($dirs)) {
174                                $autoload_key[$type][$namespace] = array_filter($dirs, function ($dir) use ($packageDir) {
175                                    $dir = $packageDir . $dir;
176                                    return is_readable($dir);
177                                });
178                            } else {
179                                $dir = $packageDir . $dirs;
180                                if (! is_readable($dir)) {
181                                    unset($autoload_key[$type][$namespace]);
182                                }
183                            }
184                        }
185                        break;
186                    default: // files, classmap
187                        $autoload_key[$type] = array_filter($autoload, function ($file) use ($packageDir) {
188                            $filename = $packageDir . $file;
189                            return file_exists($packageDir . $file);
190                        });
191                        break;
192                }
193            }
194            $installedJsonArray['packages'][$key]['autoload'] = array_filter($autoload_key);
195        }
196        $installedJsonFile->write($installedJsonArray);
197    }
198
199    /**
200     * After files are deleted, remove them from the Composer files autoloaders.
201     *
202     * @see https://github.com/BrianHenryIE/strauss/issues/34#issuecomment-922503813
203     */
204    protected function cleanupFilesAutoloader(): void
205    {
206        if (! file_exists($this->workingDir . 'vendor/composer/autoload_files.php')) {
207            return;
208        }
209
210        $files = include $this->workingDir . 'vendor/composer/autoload_files.php';
211
212        $missingFiles = array();
213
214        foreach ($files as $file) {
215            if (! file_exists($file)) {
216                $missingFiles[] = str_replace([ $this->workingDir, 'vendor/composer/../', 'vendor/' ], '', $file);
217                // When `composer install --no-dev` is run, it creates an index of files autoload files which
218                // references the non-existent files. This causes a fatal error when the autoloader is included.
219                // TODO: if delete_vendor_packages is true, do not create this file.
220                $this->filesystem->write(
221                    str_replace($this->workingDir, '', $file),
222                    '<?php // This file was deleted by {@see https://github.com/BrianHenryIE/strauss}.'
223                );
224            }
225        }
226
227        if (empty($missingFiles)) {
228            return;
229        }
230
231        $targetDirectory = $this->targetDirectory;
232
233        foreach (array('autoload_static.php', 'autoload_files.php') as $autoloadFile) {
234            $autoloadStaticPhp = $this->filesystem->read('vendor/composer/'.$autoloadFile);
235
236            $autoloadStaticPhpAsArray = explode(PHP_EOL, $autoloadStaticPhp);
237
238            $newAutoloadStaticPhpAsArray = array_map(
239                function (string $line) use ($missingFiles, $targetDirectory): string {
240                    $containsFile = array_reduce(
241                        $missingFiles,
242                        function (bool $carry, string $filepath) use ($line): bool {
243                            return $carry || false !== strpos($line, $filepath);
244                        },
245                        false
246                    );
247
248                    if (!$containsFile) {
249                        return $line;
250                    }
251
252                    // TODO: Check the file does exist at the new location. It definitely should be.
253                    // TODO: If the Strauss autoloader is being created, just return an empty string here.
254
255                    return str_replace([
256                        "=> __DIR__ . '/..' . '/",
257                        "=> \$vendorDir . '/"
258                    ], [
259                        "=> __DIR__ . '/../../$targetDirectory' . '/",
260                        "=> \$baseDir . '/$targetDirectory"
261                    ], $line);
262                },
263                $autoloadStaticPhpAsArray
264            );
265
266            $newAutoloadStaticPhp = implode(PHP_EOL, $newAutoloadStaticPhpAsArray);
267
268            $this->filesystem->write('vendor/composer/'.$autoloadFile, $newAutoloadStaticPhp);
269        }
270    }
271}