Passed
Pull Request — master (#59)
by
unknown
02:12
created

FileHelper::unlink()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 9.6594

Importance

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