Passed
Pull Request — master (#41)
by Alexander
02:03
created

FileHelper::ensureDirectory()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4.0092

Importance

Changes 0
Metric Value
eloc 11
c 0
b 0
f 0
dl 0
loc 23
ccs 11
cts 12
cp 0.9167
rs 9.9
cc 4
nc 4
nop 2
crap 4.0092
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use FilesystemIterator;
8
use InvalidArgumentException;
9
use LogicException;
10
use RecursiveDirectoryIterator;
11
use RecursiveIteratorIterator;
12
use RuntimeException;
13
use Yiisoft\Files\PathMatcher\PathMatcherInterface;
14
use function array_key_exists;
15
use function get_class;
16
use function is_object;
17
18
/**
19
 * FileHelper provides useful methods to manage files and directories.
20
 */
21
class FileHelper
22
{
23
    /**
24
     * Opens a file or URL.
25
     *
26
     * This method is similar to the PHP {@see fopen()} function, except that it suppresses the {@see E_WARNING}
27
     * level error and throws the {@see \RuntimeException} exception if it can't open the file.
28
     *
29
     * @param string $filename The file or URL.
30
     * @param string $mode The type of access.
31
     * @param bool $useIncludePath Whether to search for a file in the include path.
32
     * @param resource|null $context The stream context or `null`.
33
     *
34
     * @throws RuntimeException If the file could not be opened.
35
     *
36
     * @return resource The file pointer resource.
37
     *
38
     * @psalm-suppress PossiblyNullArgument
39
     */
40 3
    public static function openFile(string $filename, string $mode, bool $useIncludePath = false, $context = null)
41
    {
42 3
        set_error_handler(static function (int $errorNumber, string $errorString) use ($filename): ?bool {
43 1
            throw new RuntimeException(
44 1
                sprintf('Failed to open a file "%s". ', $filename) . $errorString,
45
                $errorNumber
46
            );
47 3
        });
48
49 3
        $filePointer = fopen($filename, $mode, $useIncludePath, $context);
50
51 2
        restore_error_handler();
52
53 2
        if ($filePointer === false) {
54
            throw new RuntimeException(sprintf('Failed to open a file "%s". ', $filename));
55
        }
56
57 2
        return $filePointer;
58
    }
59
60
    /**
61
     * Ensures directory exists and has specific permissions.
62
     *
63
     * This method is similar to the PHP {@see mkdir()} function with some differences:
64
     *
65
     * - It does not fail if directory already exists.
66
     * - It uses {@see chmod()} to set the permission of the created directory in order to avoid the impact
67
     *   of the `umask` setting.
68
     * - It throws exceptions instead of returning false and emitting {@see E_WARNING}.
69
     *
70
     * @param string $path Path of the directory to be created.
71
     * @param int $mode The permission to be set for the created directory.
72
     */
73 53
    public static function ensureDirectory(string $path, int $mode = 0775): void
74
    {
75 53
        $path = static::normalizePath($path);
76
77 53
        if (!is_dir($path)) {
78 53
            set_error_handler(static function (int $errorNumber, string $errorString) use ($path) {
79
                // Handle race condition.
80
                // See https://github.com/kalessil/phpinspectionsea/blob/master/docs/probable-bugs.md#mkdir-race-condition
81 1
                if (!is_dir($path)) {
82 1
                    throw new RuntimeException(
83 1
                        sprintf('Failed to create directory "%s". ', $path) . $errorString,
84
                        $errorNumber
85
                    );
86
                }
87 53
            });
88
89 53
            mkdir($path, $mode, true);
90
91 53
            restore_error_handler();
92
        }
93
94 53
        if (!chmod($path, $mode)) {
95
            throw new RuntimeException(sprintf('Unable to set mode "%s" for "%s".', $mode, $path));
96
        }
97 53
    }
98
99
    /**
100
     * Normalizes a file/directory path.
101
     *
102
     * The normalization does the following work:
103
     *
104
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
105
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
106
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
107
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
108
     *
109
     * @param string $path The file/directory path to be normalized.
110
     *
111
     * @return string The normalized file/directory path.
112
     */
113 53
    public static function normalizePath(string $path): string
114
    {
115 53
        $isWindowsShare = strpos($path, '\\\\') === 0;
116
117 53
        if ($isWindowsShare) {
118 1
            $path = substr($path, 2);
119
        }
120
121 53
        $path = rtrim(strtr($path, '/\\', '//'), '/');
122
123 53
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
124 53
            return $isWindowsShare ? "\\\\$path" : $path;
125
        }
126
127 1
        $parts = [];
128
129 1
        foreach (explode('/', $path) as $part) {
130 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
131 1
                array_pop($parts);
132 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
133 1
                $parts[] = $part;
134
            }
135
        }
136
137 1
        $path = implode('/', $parts);
138
139 1
        if ($isWindowsShare) {
140 1
            $path = '\\\\' . $path;
141
        }
142
143 1
        return $path === '' ? '.' : $path;
144
    }
145
146
    /**
147
     * Removes a directory (and all its content) recursively. Does nothing if directory does not exists.
148
     *
149
     * @param string $directory The directory to be deleted recursively.
150
     * @param array $options Options for directory remove ({@see clearDirectory()}).
151
     *
152
     * @throw RuntimeException when unable to remove directory.
153
     */
154 53
    public static function removeDirectory(string $directory, array $options = []): void
155
    {
156 53
        if (!file_exists($directory)) {
157 1
            return;
158
        }
159
160 53
        static::clearDirectory($directory, $options);
161
162 53
        if (is_link($directory)) {
163 2
            self::unlink($directory);
164
        } else {
165 53
            set_error_handler(static function (int $errorNumber, string $errorString, string $errorFile, int $errorLine, array $context) use ($directory) {
0 ignored issues
show
Unused Code introduced by
The parameter $errorFile is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

165
            set_error_handler(static function (int $errorNumber, string $errorString, /** @scrutinizer ignore-unused */ string $errorFile, int $errorLine, array $context) use ($directory) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $context is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

165
            set_error_handler(static function (int $errorNumber, string $errorString, string $errorFile, int $errorLine, /** @scrutinizer ignore-unused */ array $context) use ($directory) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $errorLine is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

165
            set_error_handler(static function (int $errorNumber, string $errorString, string $errorFile, /** @scrutinizer ignore-unused */ int $errorLine, array $context) use ($directory) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
166
                throw new RuntimeException(
167
                    sprintf('Failed to remove directory "%s". ', $directory) . $errorString,
168
                    $errorNumber
169
                );
170 53
            });
171
172 53
            rmdir($directory);
173
174 53
            restore_error_handler();
175
        }
176 53
    }
177
178
    /**
179
     * Clear all directory content.
180
     *
181
     * @param string $directory The directory to be cleared.
182
     * @param array $options Options for directory clear . Valid options are:
183
     *
184
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
185
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
186
     *   Only symlink would be removed in that default case.
187
     *
188
     * @throws RuntimeException if unable to open directory.
189
     */
190 53
    public static function clearDirectory(string $directory, array $options = []): void
191
    {
192 53
        $handle = static::openDirectory($directory);
193 53
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
194 53
            while (($file = readdir($handle)) !== false) {
195 53
                if ($file === '.' || $file === '..') {
196 53
                    continue;
197
                }
198 38
                $path = $directory . '/' . $file;
199 38
                if (is_dir($path)) {
200 37
                    self::removeDirectory($path, $options);
201
                } else {
202 28
                    self::unlink($path);
203
                }
204
            }
205 53
            closedir($handle);
206
        }
207 53
    }
208
209
    /**
210
     * Removes a file or symlink in a cross-platform way.
211
     *
212
     * @param string $path Path to unlink.
213
     */
214 32
    public static function unlink(string $path): void
215
    {
216 32
        set_error_handler(static function (int $errorNumber, string $errorString) use ($path) {
217 1
            throw new RuntimeException(
218 1
                sprintf('Failed to unlink "%s". ', $path) . $errorString,
219
                $errorNumber
220
            );
221 32
        });
222
223 32
        $isWindows = DIRECTORY_SEPARATOR === '\\';
224
225 32
        if (!$isWindows) {
226 32
            unlink($path);
227 31
            return;
228
        }
229
230
        if (is_link($path)) {
231
            try {
232
                unlink($path);
233
            } catch (RuntimeException $e) {
234
                rmdir($path);
235
            }
236
            return;
237
        }
238
239
        if (file_exists($path) && !is_writable($path)) {
240
            chmod($path, 0777);
241
        }
242
        unlink($path);
243
244
        restore_error_handler();
245
    }
246
247
    /**
248
     * Tells whether the path is a empty directory.
249
     *
250
     * @param string $path Path to check for being an empty directory.
251
     *
252
     * @return bool
253
     */
254 1
    public static function isEmptyDirectory(string $path): bool
255
    {
256 1
        if (!is_dir($path)) {
257 1
            return false;
258
        }
259
260 1
        return !(new FilesystemIterator($path))->valid();
261
    }
262
263
    /**
264
     * Copies a whole directory as another one.
265
     *
266
     * The files and sub-directories will also be copied over.
267
     *
268
     * @param string $source The source directory.
269
     * @param string $destination The destination directory.
270
     * @param array $options Options for directory copy. Valid options are:
271
     *
272
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
273
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
274
     *   setting.
275
     * - filter: a filter to apply while copying files. It should be an instance of {@see PathMatcherInterface}.
276
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
277
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
278
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
279
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
280
     *   while `$to` is the copy target.
281
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
282
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
283
     *   copied from, while `$to` is the copy target.
284
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
285
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
286
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
287
     *   `except`. Defaults to true.
288
     *
289
     * @throws RuntimeException if unable to open directory
290
     *
291
     * @psalm-param array{
292
     *   dirMode?: int,
293
     *   fileMode?: int,
294
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
295
     *   recursive?: bool,
296
     *   beforeCopy?: callable,
297
     *   afterCopy?: callable,
298
     *   copyEmptyDirectories?: bool,
299
     * } $options
300
     */
301 12
    public static function copyDirectory(string $source, string $destination, array $options = []): void
302
    {
303 12
        $filter = null;
304 12
        if (array_key_exists('filter', $options)) {
305 3
            if (!$options['filter'] instanceof PathMatcherInterface) {
306
                $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
307
                throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
308
            }
309 3
            $filter = $options['filter'];
310
        }
311
312 12
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
313 12
        $fileMode = $options['fileMode'] ?? null;
314 12
        $dirMode = $options['dirMode'] ?? 0775;
315
316 12
        $source = static::normalizePath($source);
317 12
        $destination = static::normalizePath($destination);
318
319 12
        static::assertNotSelfDirectory($source, $destination);
320
321 10
        $destinationExists = is_dir($destination);
322
        if (
323 10
            !$destinationExists &&
324 10
            (!array_key_exists('copyEmptyDirectories', $options) || $options['copyEmptyDirectories'])
325
        ) {
326 6
            static::ensureDirectory($destination, $dirMode);
327 6
            $destinationExists = true;
328
        }
329
330 10
        $handle = static::openDirectory($source);
331
332 9
        if (!array_key_exists('basePath', $options)) {
333 9
            $options['basePath'] = realpath($source);
334
        }
335
336 9
        while (($file = readdir($handle)) !== false) {
337 9
            if ($file === '.' || $file === '..') {
338 9
                continue;
339
            }
340
341 7
            $from = $source . '/' . $file;
342 7
            $to = $destination . '/' . $file;
343
344 7
            if ($filter === null || $filter->match($from)) {
345 7
                if (is_file($from)) {
346 7
                    if (!$destinationExists) {
347 2
                        static::ensureDirectory($destination, $dirMode);
348 2
                        $destinationExists = true;
349
                    }
350 7
                    copy($from, $to);
351 7
                    if ($fileMode !== null) {
352 7
                        chmod($to, $fileMode);
353
                    }
354 6
                } elseif ($recursive) {
355 5
                    static::copyDirectory($from, $to, $options);
356
                }
357
            }
358
        }
359
360 9
        closedir($handle);
361 9
    }
362
363
    /**
364
     * Assert that destination is not within the source directory.
365
     *
366
     * @param string $source Path to source.
367
     * @param string $destination Path to destination.
368
     *
369
     * @throws InvalidArgumentException
370
     */
371 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
372
    {
373 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
374 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
375
        }
376 10
    }
377
378
    /**
379
     * Open directory handle.
380
     *
381
     * @param string $directory Path to directory.
382
     *
383
     * @throws RuntimeException
384
     *
385
     * @return resource
386
     */
387 53
    private static function openDirectory(string $directory)
388
    {
389 53
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory) {
390 2
            throw new RuntimeException(
391 2
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
392
                $errorNumber
393
            );
394 53
        });
395
396 53
        $handle = opendir($directory);
397
398 53
        if ($handle === false) {
399
            throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
400
        }
401
402 53
        restore_error_handler();
403
404 53
        return $handle;
405
    }
406
407
    /**
408
     * Returns the last modification time for the given paths.
409
     *
410
     * If the path is a directory, any nested files/directories will be checked as well.
411
     *
412
     * @param string ...$paths The directories to be checked.
413
     *
414
     * @throws LogicException If path is not set.
415
     *
416
     * @return int Unix timestamp representing the last modification time.
417
     */
418 2
    public static function lastModifiedTime(string ...$paths): int
419
    {
420 2
        if (empty($paths)) {
421 1
            throw new LogicException('Path is required.');
422
        }
423
424 1
        $times = [];
425
426 1
        foreach ($paths as $path) {
427 1
            $times[] = static::modifiedTime($path);
428
429 1
            if (is_file($path)) {
430 1
                continue;
431
            }
432
433
            /** @var iterable<string, string> $iterator */
434 1
            $iterator = new RecursiveIteratorIterator(
435 1
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
436 1
                RecursiveIteratorIterator::SELF_FIRST
437
            );
438
439 1
            foreach ($iterator as $p => $info) {
440 1
                $times[] = static::modifiedTime($p);
441
            }
442
        }
443
444
        /** @psalm-suppress ArgumentTypeCoercion */
445 1
        return max($times);
446
    }
447
448 1
    private static function modifiedTime(string $path): int
449
    {
450 1
        return (int)filemtime($path);
451
    }
452
453
    /**
454
     * Returns the directories found under the specified directory and subdirectories.
455
     *
456
     * @param string $directory The directory under which the files will be looked for.
457
     * @param array $options Options for directory searching. Valid options are:
458
     *
459
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
460
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
461
     *
462
     * @psalm-param array{
463
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
464
     *   recursive?: bool,
465
     * } $options
466
     *
467
     * @throws InvalidArgumentException If the directory is invalid.
468
     *
469
     * @return string[] Directories found under the directory specified, in no particular order.
470
     * Ordering depends on the file system used.
471
     */
472 5
    public static function findDirectories(string $directory, array $options = []): array
473
    {
474 5
        if (!is_dir($directory)) {
475 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
476
        }
477
478 4
        $filter = null;
479 4
        if (array_key_exists('filter', $options)) {
480 2
            if (!$options['filter'] instanceof PathMatcherInterface) {
481
                $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
482
                throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
483
            }
484 2
            $filter = $options['filter'];
485
        }
486
487 4
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
488
489 4
        $directory = static::normalizePath($directory);
490
491 4
        $result = [];
492
493 4
        $handle = static::openDirectory($directory);
494 4
        while (false !== $file = readdir($handle)) {
495 4
            if ($file === '.' || $file === '..') {
496 4
                continue;
497
            }
498
499 4
            $path = $directory . '/' . $file;
500 4
            if (is_file($path)) {
501 3
                continue;
502
            }
503
504 4
            if ($filter === null || $filter->match($path)) {
505 4
                $result[] = $path;
506
            }
507
508 4
            if ($recursive) {
509 3
                $result = array_merge($result, static::findDirectories($path, $options));
510
            }
511
        }
512 4
        closedir($handle);
513
514 4
        return $result;
515
    }
516
517
    /**
518
     * Returns the files found under the specified directory and subdirectories.
519
     *
520
     * @param string $directory The directory under which the files will be looked for.
521
     * @param array $options Options for file searching. Valid options are:
522
     *
523
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
524
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
525
     *
526
     * @psalm-param array{
527
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
528
     *   recursive?: bool,
529
     * } $options
530
     *
531
     * @throws InvalidArgumentException If the directory is invalid.
532
     *
533
     * @return array Files found under the directory specified, in no particular order.
534
     * Ordering depends on the files system used.
535
     */
536 5
    public static function findFiles(string $directory, array $options = []): array
537
    {
538 5
        if (!is_dir($directory)) {
539 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
540
        }
541
542 4
        $filter = null;
543 4
        if (array_key_exists('filter', $options)) {
544 1
            if (!$options['filter'] instanceof PathMatcherInterface) {
545
                $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
546
                throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
547
            }
548 1
            $filter = $options['filter'];
549
        }
550
551 4
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
552
553 4
        $directory = static::normalizePath($directory);
554
555 4
        $result = [];
556
557 4
        $handle = static::openDirectory($directory);
558 4
        while (false !== $file = readdir($handle)) {
559 4
            if ($file === '.' || $file === '..') {
560 4
                continue;
561
            }
562
563 4
            $path = $directory . '/' . $file;
564
565 4
            if (is_file($path)) {
566 4
                if ($filter === null || $filter->match($path)) {
567 4
                    $result[] = $path;
568
                }
569 4
                continue;
570
            }
571
572 4
            if ($recursive) {
573 3
                $result = array_merge($result, static::findFiles($path, $options));
574
            }
575
        }
576 4
        closedir($handle);
577
578 4
        return $result;
579
    }
580
}
581