Passed
Push — master ( 7237d3...00026e )
by Alexander
14:56 queued 12:27
created

FileHelper::processCallback()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

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