Passed
Push — master ( 38b573...cee49b )
by Alexander
02:38 queued 25s
created

FileHelper::findDirectories()   B

Complexity

Conditions 10
Paths 8

Size

Total Lines 31
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 10

Importance

Changes 0
Metric Value
eloc 17
c 0
b 0
f 0
dl 0
loc 31
ccs 18
cts 18
cp 1
rs 7.6666
cc 10
nc 8
nop 2
crap 10

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use Exception;
8
use FilesystemIterator;
9
use InvalidArgumentException;
10
use LogicException;
11
use RecursiveDirectoryIterator;
12
use RecursiveIteratorIterator;
13
use RuntimeException;
14
15
/**
16
 * FileHelper provides useful methods to manage files and directories
17
 */
18
class FileHelper
19
{
20
    /**
21
     * Opens a file or URL.
22
     *
23
     * This method is similar to the PHP `fopen()` function, except that it suppresses the `E_WARNING`
24
     * level error and throws the `\RuntimeException` exception if it can't open the file.
25
     *
26
     * @param string $filename The file or URL.
27
     * @param string $mode The type of access.
28
     * @param bool $useIncludePath Whether to search for a file in the include path.
29
     * @param resource|null $context The stream context or `null`.
30
     *
31
     * @throws RuntimeException If the file could not be opened.
32
     *
33
     * @return resource The file pointer resource.
34
     *
35
     * @psalm-suppress PossiblyNullArgument
36
     */
37 3
    public static function openFile(string $filename, string $mode, bool $useIncludePath = false, $context = null)
38
    {
39 3
        $filePointer = @fopen($filename, $mode, $useIncludePath, $context);
40
41 3
        if ($filePointer === false) {
42 1
            throw new RuntimeException("The file \"{$filename}\" could not be opened.");
43
        }
44
45 2
        return $filePointer;
46
    }
47
48
    /**
49
     * Creates a new directory.
50
     *
51
     * This method is similar to the PHP `mkdir()` function except that it uses `chmod()` to set the permission of the
52
     * created directory in order to avoid the impact of the `umask` setting.
53
     *
54
     * @param string $path path of the directory to be created.
55
     * @param int $mode the permission to be set for the created directory.
56
     *
57
     * @return bool whether the directory is created successfully.
58
     */
59 53
    public static function createDirectory(string $path, int $mode = 0775): bool
60
    {
61 53
        $path = static::normalizePath($path);
62
63
        try {
64 53
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
65 53
                return false;
66
            }
67 2
        } catch (Exception $e) {
68 2
            if (!is_dir($path)) {
69 1
                throw new RuntimeException(
70 1
                    'Failed to create directory "' . $path . '": ' . $e->getMessage(),
71 1
                    (int)$e->getCode(),
72
                    $e
73
                );
74
            }
75
        }
76
77 53
        return chmod($path, $mode);
78
    }
79
80
    /**
81
     * Normalizes a file/directory path.
82
     *
83
     * The normalization does the following work:
84
     *
85
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
86
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
87
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
88
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
89
     *
90
     * @param string $path the file/directory path to be normalized
91
     *
92
     * @return string the normalized file/directory path
93
     */
94 53
    public static function normalizePath(string $path): string
95
    {
96 53
        $isWindowsShare = strpos($path, '\\\\') === 0;
97
98 53
        if ($isWindowsShare) {
99 1
            $path = substr($path, 2);
100
        }
101
102 53
        $path = rtrim(strtr($path, '/\\', '//'), '/');
103
104 53
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
105 53
            return $isWindowsShare ? "\\\\$path" : $path;
106
        }
107
108 1
        $parts = [];
109
110 1
        foreach (explode('/', $path) as $part) {
111 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
112 1
                array_pop($parts);
113 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
114 1
                $parts[] = $part;
115
            }
116
        }
117
118 1
        $path = implode('/', $parts);
119
120 1
        if ($isWindowsShare) {
121 1
            $path = '\\\\' . $path;
122
        }
123
124 1
        return $path === '' ? '.' : $path;
125
    }
126
127
    /**
128
     * Removes a directory (and all its content) recursively.
129
     *
130
     * @param string $directory the directory to be deleted recursively.
131
     * @param array $options options for directory remove ({@see clearDirectory()}).
132
     */
133 53
    public static function removeDirectory(string $directory, array $options = []): void
134
    {
135
        try {
136 53
            static::clearDirectory($directory, $options);
137 1
        } catch (InvalidArgumentException $e) {
138 1
            return;
139
        }
140
141 53
        if (is_link($directory)) {
142 2
            self::unlink($directory);
143
        } else {
144 53
            rmdir($directory);
145
        }
146 53
    }
147
148
    /**
149
     * Clear all directory content.
150
     *
151
     * @param string $directory the directory to be cleared.
152
     * @param array $options options for directory clear . Valid options are:
153
     *
154
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
155
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
156
     *   Only symlink would be removed in that default case.
157
     *
158
     * @throws InvalidArgumentException if unable to open directory
159
     */
160 53
    public static function clearDirectory(string $directory, array $options = []): void
161
    {
162 53
        $handle = static::openDirectory($directory);
163 53
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
164 53
            while (($file = readdir($handle)) !== false) {
165 53
                if ($file === '.' || $file === '..') {
166 53
                    continue;
167
                }
168 38
                $path = $directory . '/' . $file;
169 38
                if (is_dir($path)) {
170 37
                    self::removeDirectory($path, $options);
171
                } else {
172 28
                    self::unlink($path);
173
                }
174
            }
175 53
            closedir($handle);
176
        }
177 53
    }
178
179
    /**
180
     * Removes a file or symlink in a cross-platform way.
181
     *
182
     * @param string $path
183
     */
184 32
    public static function unlink(string $path): void
185
    {
186 32
        $isWindows = DIRECTORY_SEPARATOR === '\\';
187
188 32
        if (!$isWindows) {
189 32
            unlink($path);
190 31
            return;
191
        }
192
193
        if (is_link($path)) {
194
            if (false === @unlink($path)) {
195
                rmdir($path);
196
            }
197
            return;
198
        }
199
200
        if (file_exists($path) && !is_writable($path)) {
201
            chmod($path, 0777);
202
        }
203
        unlink($path);
204
    }
205
206
    /**
207
     * Tells whether the path is a empty directory
208
     *
209
     * @param string $path
210
     *
211
     * @return bool
212
     */
213 1
    public static function isEmptyDirectory(string $path): bool
214
    {
215 1
        if (!is_dir($path)) {
216 1
            return false;
217
        }
218
219 1
        return !(new FilesystemIterator($path))->valid();
220
    }
221
222
    /**
223
     * Copies a whole directory as another one.
224
     *
225
     * The files and sub-directories will also be copied over.
226
     *
227
     * @param string $source the source directory.
228
     * @param string $destination the destination directory.
229
     * @param array $options options for directory copy. Valid options are:
230
     *
231
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
232
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
233
     *   setting.
234
     * - filter: a filter to apply while copying files. It should be an instance of {@see PathMatcherInterface}.
235
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
236
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
237
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
238
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
239
     *   while `$to` is the copy target.
240
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
241
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
242
     *   copied from, while `$to` is the copy target.
243
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
244
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
245
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
246
     *   `except`. Defaults to true.
247
     *
248
     * @throws InvalidArgumentException if unable to open directory
249
     * @throws Exception
250
     *
251
     * @psalm-param array{
252
     *   dirMode?: int,
253
     *   fileMode?: int,
254
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
255
     *   recursive?: bool,
256
     *   beforeCopy?: callable,
257
     *   afterCopy?: callable,
258
     *   copyEmptyDirectories?: bool,
259
     * } $options
260
     */
261 12
    public static function copyDirectory(string $source, string $destination, array $options = []): void
262
    {
263 12
        $source = static::normalizePath($source);
264 12
        $destination = static::normalizePath($destination);
265
266 12
        static::assertNotSelfDirectory($source, $destination);
267
268 10
        $destinationExists = is_dir($destination);
269
        if (
270 10
            !$destinationExists &&
271 10
            (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])
272
        ) {
273 6
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
274 6
            $destinationExists = true;
275
        }
276
277 10
        $handle = static::openDirectory($source);
278
279 9
        if (!isset($options['basePath'])) {
280 9
            $options['basePath'] = realpath($source);
281
        }
282
283 9
        while (($file = readdir($handle)) !== false) {
284 9
            if ($file === '.' || $file === '..') {
285 9
                continue;
286
            }
287
288 7
            $from = $source . '/' . $file;
289 7
            $to = $destination . '/' . $file;
290
291 7
            if (!isset($options['filter']) || $options['filter']->match($from)) {
292 7
                if (is_file($from)) {
293 7
                    if (!$destinationExists) {
294 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
295 2
                        $destinationExists = true;
296
                    }
297 7
                    copy($from, $to);
298 7
                    if (isset($options['fileMode'])) {
299 7
                        chmod($to, $options['fileMode']);
300
                    }
301 6
                } elseif (!isset($options['recursive']) || $options['recursive']) {
302 5
                    static::copyDirectory($from, $to, $options);
303
                }
304
            }
305
        }
306
307 9
        closedir($handle);
308 9
    }
309
310
    /**
311
     * Check copy it self directory.
312
     *
313
     * @param string $source
314
     * @param string $destination
315
     *
316
     * @throws InvalidArgumentException
317
     */
318 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
319
    {
320 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
321 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
322
        }
323 10
    }
324
325
    /**
326
     * Open directory handle.
327
     *
328
     * @param string $directory
329
     *
330
     * @throws InvalidArgumentException
331
     *
332
     * @return resource
333
     */
334 53
    private static function openDirectory(string $directory)
335
    {
336 53
        $handle = @opendir($directory);
337
338 53
        if ($handle === false) {
339 3
            throw new InvalidArgumentException("Unable to open directory: $directory");
340
        }
341
342 53
        return $handle;
343
    }
344
345
    /**
346
     * Returns the last modification time for the given path.
347
     *
348
     * If the path is a directory, any nested files/directories will be checked as well.
349
     *
350
     * @param string ...$paths the directory to be checked
351
     *
352
     * @throws LogicException when path not set
353
     *
354
     * @return int Unix timestamp representing the last modification time
355
     */
356 2
    public static function lastModifiedTime(string ...$paths): int
357
    {
358 2
        if (empty($paths)) {
359 1
            throw new LogicException('Path is required.');
360
        }
361
362 1
        $times = [];
363
364 1
        foreach ($paths as $path) {
365 1
            $times[] = static::modifiedTime($path);
366
367 1
            if (is_file($path)) {
368 1
                continue;
369
            }
370
371
            /** @var iterable<string, string> $iterator */
372 1
            $iterator = new RecursiveIteratorIterator(
373 1
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
374 1
                RecursiveIteratorIterator::SELF_FIRST
375
            );
376
377 1
            foreach ($iterator as $p => $info) {
378 1
                $times[] = static::modifiedTime($p);
379
            }
380
        }
381
382
        /** @psalm-suppress ArgumentTypeCoercion */
383 1
        return max($times);
384
    }
385
386 1
    private static function modifiedTime(string $path): int
387
    {
388 1
        return (int)filemtime($path);
389
    }
390
391
    /**
392
     * Returns the directories found under the specified directory and subdirectories.
393
     *
394
     * @param string $directory the directory under which the files will be looked for.
395
     * @param array $options options for directory searching. Valid options are:
396
     *
397
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
398
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
399
     *
400
     * @psalm-param array{
401
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
402
     *   recursive?: bool,
403
     * } $options
404
     *
405
     * @throws InvalidArgumentException if the directory is invalid.
406
     *
407
     * @return string[] directories found under the directory specified, in no particular order.
408
     * Ordering depends on the files system used.
409
     */
410 5
    public static function findDirectories(string $directory, array $options = []): array
411
    {
412 5
        if (!is_dir($directory)) {
413 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
414
        }
415 4
        $directory = static::normalizePath($directory);
416
417 4
        $result = [];
418
419 4
        $handle = static::openDirectory($directory);
420 4
        while (false !== $file = readdir($handle)) {
421 4
            if ($file === '.' || $file === '..') {
422 4
                continue;
423
            }
424
425 4
            $path = $directory . '/' . $file;
426 4
            if (is_file($path)) {
427 3
                continue;
428
            }
429
430 4
            if (!isset($options['filter']) || $options['filter']->match($path)) {
431 4
                $result[] = $path;
432
            }
433
434 4
            if (!isset($options['recursive']) || $options['recursive']) {
435 3
                $result = array_merge($result, static::findDirectories($path, $options));
436
            }
437
        }
438 4
        closedir($handle);
439
440 4
        return $result;
441
    }
442
443
    /**
444
     * Returns the files found under the specified directory and subdirectories.
445
     *
446
     * @param string $directory the directory under which the files will be looked for.
447
     * @param array $options options for file searching. Valid options are:
448
     *
449
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
450
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
451
     *
452
     * @psalm-param array{
453
     *   filter?: \Yiisoft\Files\PathMatcher\PathMatcherInterface,
454
     *   recursive?: bool,
455
     * } $options
456
     *
457
     * @throws InvalidArgumentException if the dir is invalid.
458
     *
459
     * @return array files found under the directory specified, in no particular order.
460
     * Ordering depends on the files system used.
461
     */
462 5
    public static function findFiles(string $directory, array $options = []): array
463
    {
464 5
        if (!is_dir($directory)) {
465 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
466
        }
467 4
        $directory = static::normalizePath($directory);
468
469 4
        $result = [];
470
471 4
        $handle = static::openDirectory($directory);
472 4
        while (false !== $file = readdir($handle)) {
473 4
            if ($file === '.' || $file === '..') {
474 4
                continue;
475
            }
476
477 4
            $path = $directory . '/' . $file;
478
479 4
            if (is_file($path)) {
480 4
                if (!isset($options['filter']) || $options['filter']->match($path)) {
481 4
                    $result[] = $path;
482
                }
483 4
                continue;
484
            }
485
486 4
            if (!isset($options['recursive']) || $options['recursive']) {
487 3
                $result = array_merge($result, static::findFiles($path, $options));
488
            }
489
        }
490 4
        closedir($handle);
491
492 4
        return $result;
493
    }
494
}
495