Passed
Pull Request — master (#41)
by Alexander
02:29
created

FileHelper::findDirectories()   C

Complexity

Conditions 13
Paths 10

Size

Total Lines 37
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 13.1458

Importance

Changes 0
Metric Value
eloc 20
c 0
b 0
f 0
dl 0
loc 37
ccs 19
cts 21
cp 0.9048
rs 6.6166
cc 13
nc 10
nop 2
crap 13.1458

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