Passed
Push — master ( 1570c1...f3a509 )
by Alexander
02:29
created

FileHelper::unlink()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 9.0146

Importance

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