Passed
Pull Request — master (#39)
by Sergei
02:09
created

FileHelper::findFiles()   B

Complexity

Conditions 10
Paths 7

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