Passed
Push — master ( 272916...3e3561 )
by Alexander
01:44
created

FileHelper::isPatternEndsWith()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 3
c 0
b 0
f 0
nc 2
nop 2
dl 0
loc 7
ccs 4
cts 4
cp 1
crap 3
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 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 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 int $mode
56
     *
57
     * @throws RuntimeException
58
     *
59
     * @return bool|null
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 36
    public static function removeDirectory(string $directory, array $options = []): void
128
    {
129
        try {
130 36
            static::clearDirectory($directory, $options);
131 1
        } catch (InvalidArgumentException $e) {
132 1
            return;
133
        }
134
135 36
        if (is_link($directory)) {
136 2
            self::unlink($directory);
137
        } else {
138 36
            rmdir($directory);
139
        }
140 36
    }
141
142
    /**
143
     * Clear all directory content.
144
     *
145
     * @param string $directory the directory to be cleared.
146
     * @param array $options options for directory clear . Valid options are:
147
     *
148
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
149
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
150
     *   Only symlink would be removed in that default case.
151
     *
152
     * @throws InvalidArgumentException if unable to open directory
153
     */
154 36
    public static function clearDirectory(string $directory, array $options = []): void
155
    {
156 36
        $handle = static::openDirectory($directory);
157 36
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
158 36
            while (($file = readdir($handle)) !== false) {
159 36
                if ($file === '.' || $file === '..') {
160 36
                    continue;
161
                }
162 25
                $path = $directory . '/' . $file;
163 25
                if (is_dir($path)) {
164 25
                    self::removeDirectory($path, $options);
165
                } else {
166 17
                    self::unlink($path);
167
                }
168
            }
169 36
            closedir($handle);
170
        }
171 36
    }
172
173
    /**
174
     * Removes a file or symlink in a cross-platform way.
175
     *
176
     * @param string $path
177
     *
178
     * @return bool
179
     */
180 18
    public static function unlink(string $path): bool
181
    {
182 18
        $isWindows = DIRECTORY_SEPARATOR === '\\';
183
184 18
        if (!$isWindows) {
185 18
            return unlink($path);
186
        }
187
188
        if (is_link($path) && is_dir($path)) {
189
            return rmdir($path);
190
        }
191
192
        if (!is_writable($path)) {
193
            chmod($path, 0777);
194
        }
195
196
        return unlink($path);
197
    }
198
199
    /**
200
     * Tells whether the path is a empty directory
201
     *
202
     * @param string $path
203
     *
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: a filter to apply while copying files. It should be an instance of {@see 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
     * @throws InvalidArgumentException if unable to open directory
242
     * @throws Exception
243
     */
244 12
    public static function copyDirectory(string $source, string $destination, array $options = []): void
245
    {
246 12
        $source = static::normalizePath($source);
247 12
        $destination = static::normalizePath($destination);
248
249 12
        static::assertNotSelfDirectory($source, $destination);
250
251 10
        $destinationExists = static::setDestination($destination, $options);
252
253 10
        $handle = static::openDirectory($source);
254
255 9
        $options = static::setBasePath($source, $options);
256
257 9
        while (($file = readdir($handle)) !== false) {
258 9
            if ($file === '.' || $file === '..') {
259 9
                continue;
260
            }
261
262 7
            $from = $source . '/' . $file;
263 7
            $to = $destination . '/' . $file;
264
265 7
            if (!isset($options['filter']) || $options['filter']->match($from)) {
266 7
                if (is_file($from)) {
267 7
                    if (!$destinationExists) {
268 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
269 2
                        $destinationExists = true;
270
                    }
271 7
                    copy($from, $to);
272 7
                    if (isset($options['fileMode'])) {
273 7
                        static::chmod($to, $options['fileMode']);
274
                    }
275 6
                } elseif (!isset($options['recursive']) || $options['recursive']) {
276 5
                    static::copyDirectory($from, $to, $options);
277
                }
278
            }
279
        }
280
281 9
        closedir($handle);
282 9
    }
283
284
    /**
285
     * Check copy it self directory.
286
     *
287
     * @param string $source
288
     * @param string $destination
289
     *
290
     * @throws InvalidArgumentException
291
     */
292 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
293
    {
294 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
295 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
296
        }
297 10
    }
298
299
    /**
300
     * Open directory handle.
301
     *
302
     * @param string $directory
303
     *
304
     * @throws InvalidArgumentException
305
     *
306
     * @return resource
307
     */
308 36
    private static function openDirectory(string $directory)
309
    {
310 36
        $handle = @opendir($directory);
311
312 36
        if ($handle === false) {
313 3
            throw new InvalidArgumentException("Unable to open directory: $directory");
314
        }
315
316 36
        return $handle;
317
    }
318
319
    /**
320
     * Set base path directory.
321
     *
322
     * @param string $source
323
     * @param array $options
324
     *
325
     * @return array
326
     */
327 9
    private static function setBasePath(string $source, array $options): array
328
    {
329 9
        if (!isset($options['basePath'])) {
330
            // this should be done only once
331 9
            $options['basePath'] = realpath($source);
332
        }
333
334 9
        return $options;
335
    }
336
337
    /**
338
     * Set destination directory.
339
     *
340
     * @param string $destination
341
     * @param array $options
342
     *
343
     * @return bool
344
     */
345 10
    private static function setDestination(string $destination, array $options): bool
346
    {
347 10
        $destinationExists = is_dir($destination);
348
349 10
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
350 6
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
351 6
            $destinationExists = true;
352
        }
353
354 10
        return $destinationExists;
355
    }
356
357
    /**
358
     * Returns the last modification time for the given path.
359
     *
360
     * If the path is a directory, any nested files/directories will be checked as well.
361
     *
362
     * @param string $path the directory to be checked
363
     *
364
     * @return int Unix timestamp representing the last modification time
365
     */
366 1
    public static function lastModifiedTime(string $path): int
367
    {
368 1
        if (is_file($path)) {
369
            return filemtime($path);
370
        }
371
372 1
        $times = [filemtime($path)];
373
374 1
        $iterator = new RecursiveIteratorIterator(
375 1
            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
376 1
            RecursiveIteratorIterator::SELF_FIRST
377
        );
378
379 1
        foreach ($iterator as $p => $info) {
380 1
            $times[] = filemtime($p);
381
        }
382
383 1
        return max($times);
384
    }
385
}
386