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

FileHelper::ensureDirectory()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 4

Importance

Changes 0
Metric Value
eloc 11
c 0
b 0
f 0
dl 0
loc 23
ccs 2
cts 2
cp 1
rs 9.9
cc 4
nc 4
nop 2
crap 4
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 3
     *
38
     * @psalm-suppress PossiblyNullArgument
39 3
     */
40
    public static function openFile(string $filename, string $mode, bool $useIncludePath = false, $context = null)
41 3
    {
42 1
        set_error_handler(static function (int $errorNumber, string $errorString) use ($filename): ?bool {
43
            throw new RuntimeException(
44
                sprintf('Failed to open a file "%s". ', $filename) . $errorString,
45 2
                $errorNumber
46
            );
47
        });
48
49
        $filePointer = fopen($filename, $mode, $useIncludePath, $context);
50
51
        restore_error_handler();
52
53
        if ($filePointer === false) {
54
            throw new RuntimeException(sprintf('Failed to open a file "%s". ', $filename));
55
        }
56
57
        return $filePointer;
58
    }
59 53
60
    /**
61 53
     * Ensures directory exists and has specific permissions.
62
     *
63
     * This method is similar to the PHP {@see mkdir()} function with some differences:
64 53
     *
65 53
     * - 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 2
     *   of the `umask` setting.
68 2
     * - It throws exceptions instead of returning false and emitting {@see E_WARNING}.
69 1
     *
70 1
     * @param string $path Path of the directory to be created.
71 1
     * @param int $mode The permission to be set for the created directory.
72
     */
73
    public static function ensureDirectory(string $path, int $mode = 0775): void
74
    {
75
        $path = static::normalizePath($path);
76
77 53
        if (!is_dir($path)) {
78
            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
                if (!is_dir($path)) {
82
                    throw new RuntimeException(
83
                        sprintf('Failed to create directory "%s". ', $path) . $errorString,
84
                        $errorNumber
85
                    );
86
                }
87
            });
88
89
            mkdir($path, $mode, true);
90
91
            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 53
        }
97
    }
98 53
99 1
    /**
100
     * Normalizes a file/directory path.
101
     *
102 53
     * The normalization does the following work:
103
     *
104 53
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
105 53
     * - 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 1
     *
109
     * @param string $path The file/directory path to be normalized.
110 1
     *
111 1
     * @return string The normalized file/directory path.
112 1
     */
113 1
    public static function normalizePath(string $path): string
114 1
    {
115
        $isWindowsShare = strpos($path, '\\\\') === 0;
116
117
        if ($isWindowsShare) {
118 1
            $path = substr($path, 2);
119
        }
120 1
121 1
        $path = rtrim(strtr($path, '/\\', '//'), '/');
122
123
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
124 1
            return $isWindowsShare ? "\\\\$path" : $path;
125
        }
126
127
        $parts = [];
128
129
        foreach (explode('/', $path) as $part) {
130
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
131
                array_pop($parts);
132
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
133 53
                $parts[] = $part;
134
            }
135
        }
136 53
137 1
        $path = implode('/', $parts);
138 1
139
        if ($isWindowsShare) {
140
            $path = '\\\\' . $path;
141 53
        }
142 2
143
        return $path === '' ? '.' : $path;
144 53
    }
145
146 53
    /**
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
    public static function removeDirectory(string $directory, array $options = []): void
155
    {
156
        if (!file_exists($directory)) {
157
            return;
158
        }
159
160 53
        static::clearDirectory($directory, $options);
161
162 53
        if (is_link($directory)) {
163 53
            self::unlink($directory);
164 53
        } 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 53
                throw new RuntimeException(
167
                    sprintf('Failed to remove directory "%s". ', $directory) . $errorString,
168 38
                    $errorNumber
169 38
                );
170 37
            });
171
172 28
            rmdir($directory);
173
174
            restore_error_handler();
175 53
        }
176
    }
177 53
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 32
     * - 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 32
     *   Only symlink would be removed in that default case.
187
     *
188 32
     * @throws RuntimeException if unable to open directory.
189 32
     */
190 31
    public static function clearDirectory(string $directory, array $options = []): void
191
    {
192
        $handle = static::openDirectory($directory);
193
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
194
            while (($file = readdir($handle)) !== false) {
195
                if ($file === '.' || $file === '..') {
196
                    continue;
197
                }
198
                $path = $directory . '/' . $file;
199
                if (is_dir($path)) {
200
                    self::removeDirectory($path, $options);
201
                } else {
202
                    self::unlink($path);
203
                }
204
            }
205
            closedir($handle);
206
        }
207
    }
208
209
    /**
210
     * Removes a file or symlink in a cross-platform way.
211
     *
212
     * @param string $path Path to unlink.
213 1
     */
214
    public static function unlink(string $path): void
215 1
    {
216 1
        set_error_handler(static function (int $errorNumber, string $errorString) use ($path) {
217
            throw new RuntimeException(
218
                sprintf('Failed to unlink "%s". ', $path) . $errorString,
219 1
                $errorNumber
220
            );
221
        });
222
223
        $isWindows = DIRECTORY_SEPARATOR === '\\';
224
225
        if (!$isWindows) {
226
            unlink($path);
227
            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
    public static function isEmptyDirectory(string $path): bool
255
    {
256
        if (!is_dir($path)) {
257
            return false;
258
        }
259
260
        return !(new FilesystemIterator($path))->valid();
261 12
    }
262
263 12
    /**
264 12
     * Copies a whole directory as another one.
265
     *
266 12
     * The files and sub-directories will also be copied over.
267
     *
268 10
     * @param string $source The source directory.
269
     * @param string $destination The destination directory.
270 10
     * @param array $options Options for directory copy. Valid options are:
271 10
     *
272
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
273 6
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
274 6
     *   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 10
     * - 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 9
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
280 9
     *   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 9
     *   copied from, while `$to` is the copy target.
284 9
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
285 9
     *   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 7
     *
289 7
     * @throws RuntimeException if unable to open directory
290
     *
291 7
     * @psalm-param array{
292 7
     *   dirMode?: int,
293 7
     *   fileMode?: int,
294 2
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
295 2
     *   recursive?: bool,
296
     *   beforeCopy?: callable,
297 7
     *   afterCopy?: callable,
298 7
     *   copyEmptyDirectories?: bool,
299 7
     * } $options
300
     */
301 6
    public static function copyDirectory(string $source, string $destination, array $options = []): void
302 5
    {
303
        $filter = null;
304
        if (array_key_exists('filter', $options)) {
305
            if (!$options['filter'] instanceof PathMatcherInterface) {
306
                $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
307 9
                throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
308 9
            }
309
            $filter = $options['filter'];
310
        }
311
312
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
313
        $fileMode = $options['fileMode'] ?? null;
314
        $dirMode = $options['dirMode'] ?? 0775;
315
316
        $source = static::normalizePath($source);
317
        $destination = static::normalizePath($destination);
318 12
319
        static::assertNotSelfDirectory($source, $destination);
320 12
321 2
        $destinationExists = is_dir($destination);
322
        if (
323 10
            !$destinationExists &&
324
            (!array_key_exists('copyEmptyDirectories', $options) || $options['copyEmptyDirectories'])
325
        ) {
326
            static::ensureDirectory($destination, $dirMode);
327
            $destinationExists = true;
328
        }
329
330
        $handle = static::openDirectory($source);
331
332
        if (!array_key_exists('basePath', $options)) {
333
            $options['basePath'] = realpath($source);
334 53
        }
335
336 53
        while (($file = readdir($handle)) !== false) {
337
            if ($file === '.' || $file === '..') {
338 53
                continue;
339 3
            }
340
341
            $from = $source . '/' . $file;
342 53
            $to = $destination . '/' . $file;
343
344
            if ($filter === null || $filter->match($from)) {
345
                if (is_file($from)) {
346
                    if (!$destinationExists) {
347
                        static::ensureDirectory($destination, $dirMode);
348
                        $destinationExists = true;
349
                    }
350
                    copy($from, $to);
351
                    if ($fileMode !== null) {
352
                        chmod($to, $fileMode);
353
                    }
354
                } elseif ($recursive) {
355
                    static::copyDirectory($from, $to, $options);
356 2
                }
357
            }
358 2
        }
359 1
360
        closedir($handle);
361
    }
362 1
363
    /**
364 1
     * Assert that destination is not within the source directory.
365 1
     *
366
     * @param string $source Path to source.
367 1
     * @param string $destination Path to destination.
368 1
     *
369
     * @throws InvalidArgumentException
370
     */
371
    private static function assertNotSelfDirectory(string $source, string $destination): void
372 1
    {
373 1
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
374 1
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
375
        }
376
    }
377 1
378 1
    /**
379
     * Open directory handle.
380
     *
381
     * @param string $directory Path to directory.
382
     *
383 1
     * @throws RuntimeException
384
     *
385
     * @return resource
386 1
     */
387
    private static function openDirectory(string $directory)
388 1
    {
389
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory) {
390
            throw new RuntimeException(
391
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
392
                $errorNumber
393
            );
394
        });
395
396
        $handle = opendir($directory);
397
398
        if ($handle === false) {
399
            throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
400
        }
401
402
        restore_error_handler();
403
404
        return $handle;
405
    }
406
407
    /**
408
     * Returns the last modification time for the given paths.
409
     *
410 5
     * If the path is a directory, any nested files/directories will be checked as well.
411
     *
412 5
     * @param string ...$paths The directories to be checked.
413 1
     *
414
     * @throws LogicException If path is not set.
415 4
     *
416
     * @return int Unix timestamp representing the last modification time.
417 4
     */
418
    public static function lastModifiedTime(string ...$paths): int
419 4
    {
420 4
        if (empty($paths)) {
421 4
            throw new LogicException('Path is required.');
422 4
        }
423
424
        $times = [];
425 4
426 4
        foreach ($paths as $path) {
427 3
            $times[] = static::modifiedTime($path);
428
429
            if (is_file($path)) {
430 4
                continue;
431 4
            }
432
433
            /** @var iterable<string, string> $iterator */
434 4
            $iterator = new RecursiveIteratorIterator(
435 3
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
436
                RecursiveIteratorIterator::SELF_FIRST
437
            );
438 4
439
            foreach ($iterator as $p => $info) {
440 4
                $times[] = static::modifiedTime($p);
441
            }
442
        }
443
444
        /** @psalm-suppress ArgumentTypeCoercion */
445
        return max($times);
446
    }
447
448
    private static function modifiedTime(string $path): int
449
    {
450
        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 5
     * @psalm-param array{
463
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface|mixed,
464 5
     *   recursive?: bool,
465 1
     * } $options
466
     *
467 4
     * @throws InvalidArgumentException If the directory is invalid.
468
     *
469 4
     * @return string[] Directories found under the directory specified, in no particular order.
470
     * Ordering depends on the file system used.
471 4
     */
472 4
    public static function findDirectories(string $directory, array $options = []): array
473 4
    {
474 4
        if (!is_dir($directory)) {
475
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
476
        }
477 4
478
        $filter = null;
479 4
        if (array_key_exists('filter', $options)) {
480 4
            if (!$options['filter'] instanceof PathMatcherInterface) {
481 4
                $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 4
            }
484
            $filter = $options['filter'];
485
        }
486 4
487 3
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
488
489
        $directory = static::normalizePath($directory);
490 4
491
        $result = [];
492 4
493
        $handle = static::openDirectory($directory);
494
        while (false !== $file = readdir($handle)) {
495
            if ($file === '.' || $file === '..') {
496
                continue;
497
            }
498
499
            $path = $directory . '/' . $file;
500
            if (is_file($path)) {
501
                continue;
502
            }
503
504
            if ($filter === null || $filter->match($path)) {
505
                $result[] = $path;
506
            }
507
508
            if ($recursive) {
509
                $result = array_merge($result, static::findDirectories($path, $options));
510
            }
511
        }
512
        closedir($handle);
513
514
        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
    public static function findFiles(string $directory, array $options = []): array
537
    {
538
        if (!is_dir($directory)) {
539
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
540
        }
541
542
        $filter = null;
543
        if (array_key_exists('filter', $options)) {
544
            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
            $filter = $options['filter'];
549
        }
550
551
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
552
553
        $directory = static::normalizePath($directory);
554
555
        $result = [];
556
557
        $handle = static::openDirectory($directory);
558
        while (false !== $file = readdir($handle)) {
559
            if ($file === '.' || $file === '..') {
560
                continue;
561
            }
562
563
            $path = $directory . '/' . $file;
564
565
            if (is_file($path)) {
566
                if ($filter === null || $filter->match($path)) {
567
                    $result[] = $path;
568
                }
569
                continue;
570
            }
571
572
            if ($recursive) {
573
                $result = array_merge($result, static::findFiles($path, $options));
574
            }
575
        }
576
        closedir($handle);
577
578
        return $result;
579
    }
580
}
581