Passed
Pull Request — master (#22)
by Sergei
25:20 queued 10:23
created

FileHelper::normalizeOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use Exception;
8
use FilesystemIterator;
9
use InvalidArgumentException;
10
use RecursiveIteratorIterator;
11
use RecursiveDirectoryIterator;
12
use RuntimeException;
13
14
/**
15
 * FileHelper provides useful methods to manage files and directories
16
 */
17
class FileHelper
18
{
19
    /**
20
     * Creates a new directory.
21
     *
22
     * This method is similar to the PHP `mkdir()` function except that it uses `chmod()` to set the permission of the
23
     * created directory in order to avoid the impact of the `umask` setting.
24
     *
25
     * @param string $path path of the directory to be created.
26
     * @param int $mode the permission to be set for the created directory.
27
     *
28
     * @return bool whether the directory is created successfully.
29
     */
30
    public static function createDirectory(string $path, int $mode = 0775): bool
31
    {
32
        $path = static::normalizePath($path);
33
34
        try {
35
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
36
                return false;
37
            }
38
        } catch (Exception $e) {
39
            if (!is_dir($path)) {
40
                throw new RuntimeException(
41
                    "Failed to create directory \"$path\": " . $e->getMessage(),
42
                    $e->getCode(),
43
                    $e
44
                );
45
            }
46
        }
47
48
        return static::chmod($path, $mode);
49
    }
50
51
    /**
52
     * Set permissions directory.
53
     *
54
     * @param string $path
55
     * @param integer $mode
56 36
     *
57
     * @return boolean|null
58 36
     *
59
     * @throws RuntimeException
60
     */
61 36
    private static function chmod(string $path, int $mode): ?bool
62 36
    {
63
        try {
64 2
            return chmod($path, $mode);
65 2
        } catch (Exception $e) {
66 1
            throw new RuntimeException(
67 1
                "Failed to change permissions for directory \"$path\": " . $e->getMessage(),
68 1
                $e->getCode(),
69
                $e
70
            );
71
        }
72
    }
73
74 36
    /**
75
     * Normalizes a file/directory path.
76
     *
77
     * The normalization does the following work:
78
     *
79
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
80
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
81
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
82
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
83
     *
84
     * @param string $path the file/directory path to be normalized
85
     *
86
     * @return string the normalized file/directory path
87 36
     */
88
    public static function normalizePath(string $path): string
89
    {
90 36
        $isWindowsShare = strpos($path, '\\\\') === 0;
91
92
        if ($isWindowsShare) {
93
            $path = substr($path, 2);
94
        }
95
96
        $path = rtrim(strtr($path, '/\\', '//'), '/');
97
98
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
99
            return $isWindowsShare ? "\\\\$path" : $path;
100
        }
101
102
        $parts = [];
103
104
        foreach (explode('/', $path) as $part) {
105
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
106
                array_pop($parts);
107
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
108
                $parts[] = $part;
109
            }
110
        }
111
112
        $path = implode('/', $parts);
113
114 36
        if ($isWindowsShare) {
115
            $path = '\\\\' . $path;
116 36
        }
117
118 36
        return $path === '' ? '.' : $path;
119 1
    }
120
121
    /**
122 36
     * Removes a directory (and all its content) recursively.
123
     *
124 36
     * @param string $directory the directory to be deleted recursively.
125 36
     * @param array $options options for directory remove ({@see clearDirectory()}).
126
     *
127
     * @return void
128 1
     */
129
    public static function removeDirectory(string $directory, array $options = []): void
130 1
    {
131 1
        try {
132 1
            static::clearDirectory($directory, $options);
133 1
        } catch (InvalidArgumentException $e) {
134 1
            return;
135
        }
136
137
        if (is_link($directory)) {
138 1
            self::unlink($directory);
139
        } else {
140 1
            rmdir($directory);
141 1
        }
142
    }
143
144 1
    /**
145
     * Clear all directory content.
146
     *
147
     * @param string $directory the directory to be cleared.
148
     * @param array $options options for directory clear . Valid options are:
149
     *
150
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
151
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
152
     *   Only symlink would be removed in that default case.
153
     *
154
     * @return void
155 36
     *
156
     * @throws InvalidArgumentException if unable to open directory
157
     */
158 36
    public static function clearDirectory(string $directory, array $options = []): void
159 1
    {
160 1
        $handle = static::openDirectory($directory);
161
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
162
            while (($file = readdir($handle)) !== false) {
163 36
                if ($file === '.' || $file === '..') {
164 2
                    continue;
165
                }
166 36
                $path = $directory . '/' . $file;
167
                if (is_dir($path)) {
168 36
                    self::removeDirectory($path, $options);
169
                } else {
170
                    self::unlink($path);
171
                }
172
            }
173
            closedir($handle);
174
        }
175
    }
176
177
    /**
178
     * Removes a file or symlink in a cross-platform way.
179
     * @param string $path
180
     * @return bool
181
     */
182
    public static function unlink(string $path): bool
183
    {
184 36
        $isWindows = DIRECTORY_SEPARATOR === '\\';
185
186 36
        if (!$isWindows) {
187 36
            return unlink($path);
188 36
        }
189 36
190 36
        if (is_link($path) && is_dir($path)) {
191
            return rmdir($path);
192 24
        }
193 24
194 24
        if (!is_writable($path)) {
195
            chmod($path, 0777);
196 16
        }
197
198
        return unlink($path);
199 36
    }
200
201 36
    /**
202
     * Tells whether the path is a empty directory
203
     * @param string $path
204
     * @return bool
205
     */
206
    public static function isEmptyDirectory(string $path): bool
207
    {
208 17
        if (!is_dir($path)) {
209
            return false;
210 17
        }
211
212 17
        return !(new FilesystemIterator($path))->valid();
213 17
    }
214
215
    /**
216
     * Copies a whole directory as another one.
217
     *
218
     * The files and sub-directories will also be copied over.
219
     *
220
     * @param string $source the source directory.
221
     * @param string $destination the destination directory.
222
     * @param array $options options for directory copy. Valid options are:
223
     *
224
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
225
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
226
     *   setting.
227
     * - filter: PathMatcherInterface
228
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
229
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
230
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
231
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
232 1
     *   while `$to` is the copy target.
233
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
234 1
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
235 1
     *   copied from, while `$to` is the copy target.
236
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
237
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
238 1
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
239
     *   `except`. Defaults to true.
240
     *
241
     * @return void
242
     * @throws Exception
243
     *
244
     * @throws InvalidArgumentException if unable to open directory
245
     */
246
    public static function copyDirectory(string $source, string $destination, array $options = []): void
247
    {
248
        $source = static::normalizePath($source);
249
        $destination = static::normalizePath($destination);
250
251
        static::assertNotSelfDirectory($source, $destination);
252
253
        $destinationExists = static::setDestination($destination, $options);
254
255
        $handle = static::openDirectory($source);
256
257
        $options = static::setBasePath($source, $options);
258
259
        while (($file = readdir($handle)) !== false) {
260
            if ($file === '.' || $file === '..') {
261
                continue;
262
            }
263
264
            $from = $source . '/' . $file;
265
            $to = $destination . '/' . $file;
266
267
            if (!isset($options['filter']) || $options['filter']->match($from)) {
268
                if (is_file($from)) {
269
                    if (!$destinationExists) {
270
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
271
                        $destinationExists = true;
272
                    }
273
                    copy($from, $to);
274
                    if (isset($options['fileMode'])) {
275
                        static::chmod($to, $options['fileMode']);
276
                    }
277
                } elseif (!isset($options['recursive']) || $options['recursive']) {
278
                    static::copyDirectory($from, $to, $options);
279
                }
280
            }
281
        }
282
283
        closedir($handle);
284
    }
285
286
    /**
287
     * Check copy it self directory.
288
     *
289
     * @param string $source
290
     * @param string $destination
291 12
     *
292
     * @throws InvalidArgumentException
293 12
     */
294 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
295
    {
296 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
297
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
298 10
        }
299
    }
300 10
301
    /**
302 9
     * Open directory handle.
303
     *
304 9
     * @param string $directory
305 9
     *
306 9
     * @return resource
307
     *
308
     * @throws InvalidArgumentException
309 7
     */
310 7
    private static function openDirectory(string $directory)
311
    {
312 7
        $handle = @opendir($directory);
313 7
314 7
        if ($handle === false) {
315 2
            throw new InvalidArgumentException("Unable to open directory: $directory");
316 2
        }
317
318 7
        return $handle;
319 7
    }
320 7
321
    /**
322 6
     * Set base path directory.
323 5
     *
324
     * @param string $source
325
     * @param array $options
326
     *
327
     * @return array
328 9
     */
329 9
    private static function setBasePath(string $source, array $options): array
330
    {
331
        if (!isset($options['basePath'])) {
332
            // this should be done only once
333
            $options['basePath'] = realpath($source);
334
        }
335
336
        return $options;
337
    }
338
339 12
    /**
340
     * Set destination directory.
341 12
     *
342 2
     * @param string $destination
343
     * @param array $options
344 10
     *
345
     * @return bool
346
     */
347
    private static function setDestination(string $destination, array $options): bool
348
    {
349
        $destinationExists = is_dir($destination);
350
351
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
352
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
353
            $destinationExists = true;
354 36
        }
355
356 36
        return $destinationExists;
357
    }
358 36
359 3
    /**
360
     * Returns the last modification time for the given path.
361
     *
362 36
     * If the path is a directory, any nested files/directories will be checked as well.
363
     *
364
     * @param string $path the directory to be checked
365
     *
366
     * @return int Unix timestamp representing the last modification time
367
     */
368
    public static function lastModifiedTime(string $path): int
369
    {
370
        if (is_file($path)) {
371
            return filemtime($path);
372
        }
373 9
374
        $times = [filemtime($path)];
375 9
376
        $iterator = new RecursiveIteratorIterator(
377 9
            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
378 9
            RecursiveIteratorIterator::SELF_FIRST
379
        );
380
381 9
        foreach ($iterator as $p => $info) {
382
            $times[] = filemtime($p);
383
        }
384
385
        return max($times);
386
    }
387
}
388