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