Passed
Pull Request — master (#22)
by Sergei
03:02
created

FileHelper::setOnly()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 5
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 11
ccs 6
cts 6
cp 1
crap 4
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 36
    public static function createDirectory(string $path, int $mode = 0775): bool
31
    {
32 36
        $path = static::normalizePath($path);
33
34
        try {
35 36
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
36 36
                return false;
37
            }
38 2
        } catch (Exception $e) {
39 2
            if (!is_dir($path)) {
40 1
                throw new RuntimeException(
41 1
                    "Failed to create directory \"$path\": " . $e->getMessage(),
42 1
                    $e->getCode(),
43
                    $e
44
                );
45
            }
46
        }
47
48 36
        return static::chmod($path, $mode);
49
    }
50
51
    /**
52
     * Set permissions directory.
53
     *
54
     * @param string $path
55
     * @param integer $mode
56
     *
57
     * @return boolean|null
58
     *
59
     * @throws RuntimeException
60
     */
61 36
    private static function chmod(string $path, int $mode): ?bool
62
    {
63
        try {
64 36
            return chmod($path, $mode);
65
        } catch (Exception $e) {
66
            throw new RuntimeException(
67
                "Failed to change permissions for directory \"$path\": " . $e->getMessage(),
68
                $e->getCode(),
69
                $e
70
            );
71
        }
72
    }
73
74
    /**
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
     */
88 36
    public static function normalizePath(string $path): string
89
    {
90 36
        $isWindowsShare = strpos($path, '\\\\') === 0;
91
92 36
        if ($isWindowsShare) {
93 1
            $path = substr($path, 2);
94
        }
95
96 36
        $path = rtrim(strtr($path, '/\\', '//'), '/');
97
98 36
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
99 36
            return $isWindowsShare ? "\\\\$path" : $path;
100
        }
101
102 1
        $parts = [];
103
104 1
        foreach (explode('/', $path) as $part) {
105 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
106 1
                array_pop($parts);
107 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
108 1
                $parts[] = $part;
109
            }
110
        }
111
112 1
        $path = implode('/', $parts);
113
114 1
        if ($isWindowsShare) {
115 1
            $path = '\\\\' . $path;
116
        }
117
118 1
        return $path === '' ? '.' : $path;
119
    }
120
121
    /**
122
     * Removes a directory (and all its content) recursively.
123
     *
124
     * @param string $directory the directory to be deleted recursively.
125
     * @param array $options options for directory remove ({@see clearDirectory()}).
126
     *
127
     * @return void
128
     */
129 36
    public static function removeDirectory(string $directory, array $options = []): void
130
    {
131
        try {
132 36
            static::clearDirectory($directory, $options);
133 1
        } catch (InvalidArgumentException $e) {
134 1
            return;
135
        }
136
137 36
        if (is_link($directory)) {
138 2
            self::unlink($directory);
139
        } else {
140 36
            rmdir($directory);
141
        }
142 36
    }
143
144
    /**
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
     *
156
     * @throws InvalidArgumentException if unable to open directory
157
     */
158 36
    public static function clearDirectory(string $directory, array $options = []): void
159
    {
160 36
        $handle = static::openDirectory($directory);
161 36
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
162 36
            while (($file = readdir($handle)) !== false) {
163 36
                if ($file === '.' || $file === '..') {
164 36
                    continue;
165
                }
166 25
                $path = $directory . '/' . $file;
167 25
                if (is_dir($path)) {
168 25
                    self::removeDirectory($path, $options);
169
                } else {
170 17
                    self::unlink($path);
171
                }
172
            }
173 36
            closedir($handle);
174
        }
175 36
    }
176
177
    /**
178
     * Removes a file or symlink in a cross-platform way.
179
     * @param string $path
180
     * @return bool
181
     */
182 18
    public static function unlink(string $path): bool
183
    {
184 18
        $isWindows = DIRECTORY_SEPARATOR === '\\';
185
186 18
        if (!$isWindows) {
187 18
            return unlink($path);
188
        }
189
190
        if (is_link($path) && is_dir($path)) {
191
            return rmdir($path);
192
        }
193
194
        if (!is_writable($path)) {
195
            chmod($path, 0777);
196
        }
197
198
        return unlink($path);
199
    }
200
201
    /**
202
     * Tells whether the path is a empty directory
203
     * @param string $path
204
     * @return bool
205
     */
206 1
    public static function isEmptyDirectory(string $path): bool
207
    {
208 1
        if (!is_dir($path)) {
209 1
            return false;
210
        }
211
212 1
        return !(new FilesystemIterator($path))->valid();
213
    }
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
     *   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
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
235
     *   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
     *   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 12
    public static function copyDirectory(string $source, string $destination, array $options = []): void
247
    {
248 12
        $source = static::normalizePath($source);
249 12
        $destination = static::normalizePath($destination);
250
251 12
        static::assertNotSelfDirectory($source, $destination);
252
253 10
        $destinationExists = static::setDestination($destination, $options);
254
255 10
        $handle = static::openDirectory($source);
256
257 9
        $options = static::setBasePath($source, $options);
258
259 9
        while (($file = readdir($handle)) !== false) {
260 9
            if ($file === '.' || $file === '..') {
261 9
                continue;
262
            }
263
264 7
            $from = $source . '/' . $file;
265 7
            $to = $destination . '/' . $file;
266
267 7
            if (!isset($options['filter']) || $options['filter']->match($from)) {
268 7
                if (is_file($from)) {
269 7
                    if (!$destinationExists) {
270 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
271 2
                        $destinationExists = true;
272
                    }
273 7
                    copy($from, $to);
274 7
                    if (isset($options['fileMode'])) {
275 7
                        static::chmod($to, $options['fileMode']);
276
                    }
277 6
                } elseif (!isset($options['recursive']) || $options['recursive']) {
278 5
                    static::copyDirectory($from, $to, $options);
279
                }
280
            }
281
        }
282
283 9
        closedir($handle);
284 9
    }
285
286
    /**
287
     * Check copy it self directory.
288
     *
289
     * @param string $source
290
     * @param string $destination
291
     *
292
     * @throws InvalidArgumentException
293
     */
294 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
295
    {
296 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
297 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
298
        }
299 10
    }
300
301
    /**
302
     * Open directory handle.
303
     *
304
     * @param string $directory
305
     *
306
     * @return resource
307
     *
308
     * @throws InvalidArgumentException
309
     */
310 36
    private static function openDirectory(string $directory)
311
    {
312 36
        $handle = @opendir($directory);
313
314 36
        if ($handle === false) {
315 3
            throw new InvalidArgumentException("Unable to open directory: $directory");
316
        }
317
318 36
        return $handle;
319
    }
320
321
    /**
322
     * Set base path directory.
323
     *
324
     * @param string $source
325
     * @param array $options
326
     *
327
     * @return array
328
     */
329 9
    private static function setBasePath(string $source, array $options): array
330
    {
331 9
        if (!isset($options['basePath'])) {
332
            // this should be done only once
333 9
            $options['basePath'] = realpath($source);
334
        }
335
336 9
        return $options;
337
    }
338
339
    /**
340
     * Set destination directory.
341
     *
342
     * @param string $destination
343
     * @param array $options
344
     *
345
     * @return bool
346
     */
347 10
    private static function setDestination(string $destination, array $options): bool
348
    {
349 10
        $destinationExists = is_dir($destination);
350
351 10
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
352 6
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
353 6
            $destinationExists = true;
354
        }
355
356 10
        return $destinationExists;
357
    }
358
359
    /**
360
     * Returns the last modification time for the given path.
361
     *
362
     * 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 1
    public static function lastModifiedTime(string $path): int
369
    {
370 1
        if (is_file($path)) {
371
            return filemtime($path);
372
        }
373
374 1
        $times = [filemtime($path)];
375
376 1
        $iterator = new RecursiveIteratorIterator(
377 1
            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
378 1
            RecursiveIteratorIterator::SELF_FIRST
379
        );
380
381 1
        foreach ($iterator as $p => $info) {
382 1
            $times[] = filemtime($p);
383
        }
384
385 1
        return max($times);
386
    }
387
}
388