Test Failed
Pull Request — master (#41)
by Alexander
02:00
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) {
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) use ($directory) {
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
        $source = static::normalizePath($source);
304
        $destination = static::normalizePath($destination);
305
306
        static::assertNotSelfDirectory($source, $destination);
307 9
308 9
        $destinationExists = is_dir($destination);
309
        if (
310
            !$destinationExists &&
311
            (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])
312
        ) {
313
            static::ensureDirectory($destination, $options['dirMode'] ?? 0775);
314
            $destinationExists = true;
315
        }
316
317
        $handle = static::openDirectory($source);
318 12
319
        if (!isset($options['basePath'])) {
320 12
            $options['basePath'] = realpath($source);
321 2
        }
322
323 10
        while (($file = readdir($handle)) !== false) {
324
            if ($file === '.' || $file === '..') {
325
                continue;
326
            }
327
328
            $from = $source . '/' . $file;
329
            $to = $destination . '/' . $file;
330
331
            if (!isset($options['filter']) || $options['filter']->match($from)) {
332
                if (is_file($from)) {
333
                    if (!$destinationExists) {
334 53
                        static::ensureDirectory($destination, $options['dirMode'] ?? 0775);
335
                        $destinationExists = true;
336 53
                    }
337
                    copy($from, $to);
338 53
                    if (isset($options['fileMode'])) {
339 3
                        chmod($to, $options['fileMode']);
340
                    }
341
                } elseif (!isset($options['recursive']) || $options['recursive']) {
342 53
                    static::copyDirectory($from, $to, $options);
343
                }
344
            }
345
        }
346
347
        closedir($handle);
348
    }
349
350
    /**
351
     * Assert that destination is not within the source directory.
352
     *
353
     * @param string $source Path to source.
354
     * @param string $destination Path to destination.
355
     *
356 2
     * @throws InvalidArgumentException
357
     */
358 2
    private static function assertNotSelfDirectory(string $source, string $destination): void
359 1
    {
360
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
361
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
362 1
        }
363
    }
364 1
365 1
    /**
366
     * Open directory handle.
367 1
     *
368 1
     * @param string $directory Path to directory.
369
     *
370
     * @throws RuntimeException
371
     *
372 1
     * @return resource
373 1
     */
374 1
    private static function openDirectory(string $directory)
375
    {
376
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory) {
377 1
            throw new RuntimeException(
378 1
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
379
                $errorNumber
380
            );
381
        });
382
383 1
        $handle = opendir($directory);
384
385
        if ($handle === false) {
386 1
            throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
387
        }
388 1
389
        restore_error_handler();
390
391
        return $handle;
392
    }
393
394
    /**
395
     * Returns the last modification time for the given paths.
396
     *
397
     * If the path is a directory, any nested files/directories will be checked as well.
398
     *
399
     * @param string ...$paths The directories to be checked.
400
     *
401
     * @throws LogicException If path is not set.
402
     *
403
     * @return int Unix timestamp representing the last modification time.
404
     */
405
    public static function lastModifiedTime(string ...$paths): int
406
    {
407
        if (empty($paths)) {
408
            throw new LogicException('Path is required.');
409
        }
410 5
411
        $times = [];
412 5
413 1
        foreach ($paths as $path) {
414
            $times[] = static::modifiedTime($path);
415 4
416
            if (is_file($path)) {
417 4
                continue;
418
            }
419 4
420 4
            /** @var iterable<string, string> $iterator */
421 4
            $iterator = new RecursiveIteratorIterator(
422 4
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
423
                RecursiveIteratorIterator::SELF_FIRST
424
            );
425 4
426 4
            foreach ($iterator as $p => $info) {
427 3
                $times[] = static::modifiedTime($p);
428
            }
429
        }
430 4
431 4
        /** @psalm-suppress ArgumentTypeCoercion */
432
        return max($times);
433
    }
434 4
435 3
    private static function modifiedTime(string $path): int
436
    {
437
        return (int)filemtime($path);
438 4
    }
439
440 4
    /**
441
     * Returns the directories found under the specified directory and subdirectories.
442
     *
443
     * @param string $directory The directory under which the files will be looked for.
444
     * @param array $options Options for directory searching. Valid options are:
445
     *
446
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
447
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
448
     *
449
     * @psalm-param array{
450
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
451
     *   recursive?: bool,
452
     * } $options
453
     *
454
     * @throws InvalidArgumentException If the directory is invalid.
455
     *
456
     * @return string[] Directories found under the directory specified, in no particular order.
457
     * Ordering depends on the file system used.
458
     */
459
    public static function findDirectories(string $directory, array $options = []): array
460
    {
461
        if (!is_dir($directory)) {
462 5
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
463
        }
464 5
465 1
        if (array_key_exists('filter', $options) && !$options['filter'] instanceof PathMatcherInterface) {
466
            $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
467 4
            throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
468
        }
469 4
470
        $directory = static::normalizePath($directory);
471 4
472 4
        $result = [];
473 4
474 4
        $handle = static::openDirectory($directory);
475
        while (false !== $file = readdir($handle)) {
476
            if ($file === '.' || $file === '..') {
477 4
                continue;
478
            }
479 4
480 4
            $path = $directory . '/' . $file;
481 4
            if (is_file($path)) {
482
                continue;
483 4
            }
484
485
            if (!isset($options['filter']) || $options['filter']->match($path)) {
486 4
                $result[] = $path;
487 3
            }
488
489
            if (!isset($options['recursive']) || $options['recursive']) {
490 4
                $result = array_merge($result, static::findDirectories($path, $options));
491
            }
492 4
        }
493
        closedir($handle);
494
495
        return $result;
496
    }
497
498
    /**
499
     * Returns the files found under the specified directory and subdirectories.
500
     *
501
     * @param string $directory The directory under which the files will be looked for.
502
     * @param array $options Options for file searching. Valid options are:
503
     *
504
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
505
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
506
     *
507
     * @psalm-param array{
508
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
509
     *   recursive?: bool,
510
     * } $options
511
     *
512
     * @throws InvalidArgumentException If the directory is invalid.
513
     *
514
     * @return array Files found under the directory specified, in no particular order.
515
     * Ordering depends on the files system used.
516
     */
517
    public static function findFiles(string $directory, array $options = []): array
518
    {
519
        if (!is_dir($directory)) {
520
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
521
        }
522
523
        if (array_key_exists('filter', $options) && !$options['filter'] instanceof PathMatcherInterface) {
524
            $type = is_object($options['filter']) ? get_class($options['filter']) : gettype($options['filter']);
525
            throw new InvalidArgumentException(sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type));
526
        }
527
528
        $directory = static::normalizePath($directory);
529
530
        $result = [];
531
532
        $handle = static::openDirectory($directory);
533
        while (false !== $file = readdir($handle)) {
534
            if ($file === '.' || $file === '..') {
535
                continue;
536
            }
537
538
            $path = $directory . '/' . $file;
539
540
            if (is_file($path)) {
541
                if (!isset($options['filter']) || $options['filter']->match($path)) {
542
                    $result[] = $path;
543
                }
544
                continue;
545
            }
546
547
            if (!isset($options['recursive']) || $options['recursive']) {
548
                $result = array_merge($result, static::findFiles($path, $options));
549
            }
550
        }
551
        closedir($handle);
552
553
        return $result;
554
    }
555
}
556