Test Failed
Pull Request — master (#41)
by Alexander
02:00
created

FileHelper::ensureDirectory()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 12
nc 3
nop 2
dl 0
loc 24
ccs 7
cts 7
cp 1
crap 4
rs 9.8666
c 0
b 0
f 0
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 LogicException;
11
use RecursiveDirectoryIterator;
12
use RecursiveIteratorIterator;
13
use RuntimeException;
14
use Yiisoft\Files\PathMatcher\PathMatcherInterface;
15
use function array_key_exists;
16
use function get_class;
17
use function is_object;
18
19
/**
20
 * FileHelper provides useful methods to manage files and directories.
21
 */
22
class FileHelper
23
{
24
    /**
25
     * Opens a file or URL.
26
     *
27
     * This method is similar to the PHP {@see fopen()} function, except that it suppresses the {@see E_WARNING}
28
     * level error and throws the {@see \RuntimeException} exception if it can't open the file.
29
     *
30
     * @param string $filename The file or URL.
31
     * @param string $mode The type of access.
32
     * @param bool $useIncludePath Whether to search for a file in the include path.
33
     * @param resource|null $context The stream context or `null`.
34
     *
35
     * @throws RuntimeException If the file could not be opened.
36
     *
37 3
     * @return resource The file pointer resource.
38
     *
39 3
     * @psalm-suppress PossiblyNullArgument
40
     */
41 3
    public static function openFile(string $filename, string $mode, bool $useIncludePath = false, $context = null)
42 1
    {
43
        $filePointer = @fopen($filename, $mode, $useIncludePath, $context);
44
45 2
        if ($filePointer === false) {
46
            throw new RuntimeException("The file \"{$filename}\" could not be opened.");
47
        }
48
49
        return $filePointer;
50
    }
51
52
    /**
53
     * Ensures directory exists and has specific permissions.
54
     *
55
     * This method is similar to the PHP {@see mkdir()} function with some differences:
56
     *
57
     * - It does not fail if directory already exists.
58
     * - It uses {@see chmod()} to set the permission of the created directory in order to avoid the impact
59 53
     *   of the `umask` setting.
60
     * - It throws exceptions instead of returning false and emitting {@see E_WARNING}.
61 53
     *
62
     * @param string $path Path of the directory to be created.
63
     * @param int $mode The permission to be set for the created directory.
64 53
     */
65 53
    public static function ensureDirectory(string $path, int $mode = 0775): void
66
    {
67 2
        $path = static::normalizePath($path);
68 2
69 1
        if (!is_dir($path)) {
70 1
            set_error_handler(static function (int $errorNumber, string $errorString) use ($path) {
71 1
                throw new RuntimeException(
72
                    sprintf('Failed to create directory "%s". ', $path) . $errorString,
73
                    $errorNumber,
74
                    null
75
                );
76
            });
77 53
78
            // See https://github.com/kalessil/phpinspectionsea/blob/master/docs/probable-bugs.md#mkdir-race-condition
79
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
80
                throw new RuntimeException(
81
                    sprintf('Failed to create directory "%s".', $path),
82
                );
83
            }
84
85
            restore_error_handler();
86
        }
87
88
        chmod($path, $mode);
89
    }
90
91
    /**
92
     * Normalizes a file/directory path.
93
     *
94 53
     * The normalization does the following work:
95
     *
96 53
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
97
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
98 53
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
99 1
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
100
     *
101
     * @param string $path The file/directory path to be normalized.
102 53
     *
103
     * @return string The normalized file/directory path.
104 53
     */
105 53
    public static function normalizePath(string $path): string
106
    {
107
        $isWindowsShare = strpos($path, '\\\\') === 0;
108 1
109
        if ($isWindowsShare) {
110 1
            $path = substr($path, 2);
111 1
        }
112 1
113 1
        $path = rtrim(strtr($path, '/\\', '//'), '/');
114 1
115
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
116
            return $isWindowsShare ? "\\\\$path" : $path;
117
        }
118 1
119
        $parts = [];
120 1
121 1
        foreach (explode('/', $path) as $part) {
122
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
123
                array_pop($parts);
124 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
125
                $parts[] = $part;
126
            }
127
        }
128
129
        $path = implode('/', $parts);
130
131
        if ($isWindowsShare) {
132
            $path = '\\\\' . $path;
133 53
        }
134
135
        return $path === '' ? '.' : $path;
136 53
    }
137 1
138 1
    /**
139
     * Removes a directory (and all its content) recursively.
140
     *
141 53
     * @param string $directory The directory to be deleted recursively.
142 2
     * @param array $options Options for directory remove ({@see clearDirectory()}).
143
     */
144 53
    public static function removeDirectory(string $directory, array $options = []): void
145
    {
146 53
        try {
147
            static::clearDirectory($directory, $options);
148
        } catch (InvalidArgumentException $e) {
149
            return;
150
        }
151
152
        if (is_link($directory)) {
153
            self::unlink($directory);
154
        } else {
155
            rmdir($directory);
156
        }
157
    }
158
159
    /**
160 53
     * Clear all directory content.
161
     *
162 53
     * @param string $directory The directory to be cleared.
163 53
     * @param array $options Options for directory clear . Valid options are:
164 53
     *
165 53
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
166 53
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
167
     *   Only symlink would be removed in that default case.
168 38
     *
169 38
     * @throws InvalidArgumentException if unable to open directory.
170 37
     */
171
    public static function clearDirectory(string $directory, array $options = []): void
172 28
    {
173
        $handle = static::openDirectory($directory);
174
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
175 53
            while (($file = readdir($handle)) !== false) {
176
                if ($file === '.' || $file === '..') {
177 53
                    continue;
178
                }
179
                $path = $directory . '/' . $file;
180
                if (is_dir($path)) {
181
                    self::removeDirectory($path, $options);
182
                } else {
183
                    self::unlink($path);
184 32
                }
185
            }
186 32
            closedir($handle);
187
        }
188 32
    }
189 32
190 31
    /**
191
     * Removes a file or symlink in a cross-platform way.
192
     *
193
     * @param string $path Path to unlink.
194
     */
195
    public static function unlink(string $path): void
196
    {
197
        $isWindows = DIRECTORY_SEPARATOR === '\\';
198
199
        if (!$isWindows) {
200
            unlink($path);
201
            return;
202
        }
203
204
        if (is_link($path)) {
205
            if (false === @unlink($path)) {
206
                rmdir($path);
207
            }
208
            return;
209
        }
210
211
        if (file_exists($path) && !is_writable($path)) {
212
            chmod($path, 0777);
213 1
        }
214
        unlink($path);
215 1
    }
216 1
217
    /**
218
     * Tells whether the path is a empty directory.
219 1
     *
220
     * @param string $path Path to check for being an empty directory.
221
     *
222
     * @return bool
223
     */
224
    public static function isEmptyDirectory(string $path): bool
225
    {
226
        if (!is_dir($path)) {
227
            return false;
228
        }
229
230
        return !(new FilesystemIterator($path))->valid();
231
    }
232
233
    /**
234
     * Copies a whole directory as another one.
235
     *
236
     * The files and sub-directories will also be copied over.
237
     *
238
     * @param string $source The source directory.
239
     * @param string $destination The destination directory.
240
     * @param array $options Options for directory copy. Valid options are:
241
     *
242
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
243
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
244
     *   setting.
245
     * - filter: a filter to apply while copying files. It should be an instance of {@see PathMatcherInterface}.
246
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
247
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
248
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
249
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
250
     *   while `$to` is the copy target.
251
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
252
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
253
     *   copied from, while `$to` is the copy target.
254
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
255
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
256
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
257
     *   `except`. Defaults to true.
258
     *
259
     * @throws InvalidArgumentException if unable to open directory
260
     * @throws Exception
261 12
     *
262
     * @psalm-param array{
263 12
     *   dirMode?: int,
264 12
     *   fileMode?: int,
265
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
266 12
     *   recursive?: bool,
267
     *   beforeCopy?: callable,
268 10
     *   afterCopy?: callable,
269
     *   copyEmptyDirectories?: bool,
270 10
     * } $options
271 10
     */
272
    public static function copyDirectory(string $source, string $destination, array $options = []): void
273 6
    {
274 6
        $source = static::normalizePath($source);
275
        $destination = static::normalizePath($destination);
276
277 10
        static::assertNotSelfDirectory($source, $destination);
278
279 9
        $destinationExists = is_dir($destination);
280 9
        if (
281
            !$destinationExists &&
282
            (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])
283 9
        ) {
284 9
            static::ensureDirectory($destination, $options['dirMode'] ?? 0775);
285 9
            $destinationExists = true;
286
        }
287
288 7
        $handle = static::openDirectory($source);
289 7
290
        if (!isset($options['basePath'])) {
291 7
            $options['basePath'] = realpath($source);
292 7
        }
293 7
294 2
        while (($file = readdir($handle)) !== false) {
295 2
            if ($file === '.' || $file === '..') {
296
                continue;
297 7
            }
298 7
299 7
            $from = $source . '/' . $file;
300
            $to = $destination . '/' . $file;
301 6
302 5
            if (!isset($options['filter']) || $options['filter']->match($from)) {
303
                if (is_file($from)) {
304
                    if (!$destinationExists) {
305
                        static::ensureDirectory($destination, $options['dirMode'] ?? 0775);
306
                        $destinationExists = true;
307 9
                    }
308 9
                    copy($from, $to);
309
                    if (isset($options['fileMode'])) {
310
                        chmod($to, $options['fileMode']);
311
                    }
312
                } elseif (!isset($options['recursive']) || $options['recursive']) {
313
                    static::copyDirectory($from, $to, $options);
314
                }
315
            }
316
        }
317
318 12
        closedir($handle);
319
    }
320 12
321 2
    /**
322
     * Assert that destination is not within the source directory.
323 10
     *
324
     * @param string $source Path to source.
325
     * @param string $destination Path to destination.
326
     *
327
     * @throws InvalidArgumentException
328
     */
329
    private static function assertNotSelfDirectory(string $source, string $destination): void
330
    {
331
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
332
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
333
        }
334 53
    }
335
336 53
    /**
337
     * Open directory handle.
338 53
     *
339 3
     * @param string $directory Path to directory.
340
     *
341
     * @throws InvalidArgumentException
342 53
     *
343
     * @return resource
344
     */
345
    private static function openDirectory(string $directory)
346
    {
347
        $handle = @opendir($directory);
348
349
        if ($handle === false) {
350
            throw new InvalidArgumentException("Unable to open directory: $directory.");
351
        }
352
353
        return $handle;
354
    }
355
356 2
    /**
357
     * Returns the last modification time for the given paths.
358 2
     *
359 1
     * If the path is a directory, any nested files/directories will be checked as well.
360
     *
361
     * @param string ...$paths The directories to be checked.
362 1
     *
363
     * @throws LogicException If path is not set.
364 1
     *
365 1
     * @return int Unix timestamp representing the last modification time.
366
     */
367 1
    public static function lastModifiedTime(string ...$paths): int
368 1
    {
369
        if (empty($paths)) {
370
            throw new LogicException('Path is required.');
371
        }
372 1
373 1
        $times = [];
374 1
375
        foreach ($paths as $path) {
376
            $times[] = static::modifiedTime($path);
377 1
378 1
            if (is_file($path)) {
379
                continue;
380
            }
381
382
            /** @var iterable<string, string> $iterator */
383 1
            $iterator = new RecursiveIteratorIterator(
384
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
385
                RecursiveIteratorIterator::SELF_FIRST
386 1
            );
387
388 1
            foreach ($iterator as $p => $info) {
389
                $times[] = static::modifiedTime($p);
390
            }
391
        }
392
393
        /** @psalm-suppress ArgumentTypeCoercion */
394
        return max($times);
395
    }
396
397
    private static function modifiedTime(string $path): int
398
    {
399
        return (int)filemtime($path);
400
    }
401
402
    /**
403
     * Returns the directories found under the specified directory and subdirectories.
404
     *
405
     * @param string $directory The directory under which the files will be looked for.
406
     * @param array $options Options for directory searching. Valid options are:
407
     *
408
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
409
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
410 5
     *
411
     * @psalm-param array{
412 5
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
413 1
     *   recursive?: bool,
414
     * } $options
415 4
     *
416
     * @throws InvalidArgumentException If the directory is invalid.
417 4
     *
418
     * @return string[] Directories found under the directory specified, in no particular order.
419 4
     * Ordering depends on the file system used.
420 4
     */
421 4
    public static function findDirectories(string $directory, array $options = []): array
422 4
    {
423
        if (!is_dir($directory)) {
424
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
425 4
        }
426 4
427 3
        if (array_key_exists('filter', $options) && !$options['filter'] instanceof PathMatcherInterface) {
428
            $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
429
            throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
430 4
        }
431 4
432
        $directory = static::normalizePath($directory);
433
434 4
        $result = [];
435 3
436
        $handle = static::openDirectory($directory);
437
        while (false !== $file = readdir($handle)) {
438 4
            if ($file === '.' || $file === '..') {
439
                continue;
440 4
            }
441
442
            $path = $directory . '/' . $file;
443
            if (is_file($path)) {
444
                continue;
445
            }
446
447
            if (!isset($options['filter']) || $options['filter']->match($path)) {
448
                $result[] = $path;
449
            }
450
451
            if (!isset($options['recursive']) || $options['recursive']) {
452
                $result = array_merge($result, static::findDirectories($path, $options));
453
            }
454
        }
455
        closedir($handle);
456
457
        return $result;
458
    }
459
460
    /**
461
     * Returns the files found under the specified directory and subdirectories.
462 5
     *
463
     * @param string $directory The directory under which the files will be looked for.
464 5
     * @param array $options Options for file searching. Valid options are:
465 1
     *
466
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
467 4
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
468
     *
469 4
     * @psalm-param array{
470
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
471 4
     *   recursive?: bool,
472 4
     * } $options
473 4
     *
474 4
     * @throws InvalidArgumentException If the directory is invalid.
475
     *
476
     * @return array Files found under the directory specified, in no particular order.
477 4
     * Ordering depends on the files system used.
478
     */
479 4
    public static function findFiles(string $directory, array $options = []): array
480 4
    {
481 4
        if (!is_dir($directory)) {
482
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
483 4
        }
484
485
        if (array_key_exists('filter', $options) && !$options['filter'] instanceof PathMatcherInterface) {
486 4
            $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
487 3
            throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
488
        }
489
490 4
        $directory = static::normalizePath($directory);
491
492 4
        $result = [];
493
494
        $handle = static::openDirectory($directory);
495
        while (false !== $file = readdir($handle)) {
496
            if ($file === '.' || $file === '..') {
497
                continue;
498
            }
499
500
            $path = $directory . '/' . $file;
501
502
            if (is_file($path)) {
503
                if (!isset($options['filter']) || $options['filter']->match($path)) {
504
                    $result[] = $path;
505
                }
506
                continue;
507
            }
508
509
            if (!isset($options['recursive']) || $options['recursive']) {
510
                $result = array_merge($result, static::findFiles($path, $options));
511
            }
512
        }
513
        closedir($handle);
514
515
        return $result;
516
    }
517
}
518