Passed
Push — master ( 3e3561...2dcc8a )
by Alexander
03:09 queued 01:06
created

FileHelper::lastModifiedTime()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

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