Completed
Push — master ( 7a38c7...a3c5ae )
by Dmitry
11:27
created

BaseFileHelper::removeDirectory()   D

Complexity

Conditions 10
Paths 6

Size

Total Lines 28
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 10.017

Importance

Changes 0
Metric Value
dl 0
loc 28
ccs 17
cts 18
cp 0.9444
rs 4.8196
c 0
b 0
f 0
cc 10
eloc 19
nc 6
nop 2
crap 10.017

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
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\helpers;
9
10
use Yii;
11
use yii\base\ErrorException;
12
use yii\base\InvalidArgumentException;
13
use yii\base\InvalidConfigException;
14
15
/**
16
 * BaseFileHelper provides concrete implementation for [[FileHelper]].
17
 *
18
 * Do not use BaseFileHelper. Use [[FileHelper]] instead.
19
 *
20
 * @author Qiang Xue <[email protected]>
21
 * @author Alex Makarov <[email protected]>
22
 * @since 2.0
23
 */
24
class BaseFileHelper
25
{
26
    const PATTERN_NODIR = 1;
27
    const PATTERN_ENDSWITH = 4;
28
    const PATTERN_MUSTBEDIR = 8;
29
    const PATTERN_NEGATIVE = 16;
30
    const PATTERN_CASE_INSENSITIVE = 32;
31
32
    /**
33
     * @var string the path (or alias) of a PHP file containing MIME type information.
34
     */
35
    public static $mimeMagicFile = '@yii/helpers/mimeTypes.php';
36
    /**
37
     * @var string the path (or alias) of a PHP file containing MIME aliases.
38
     * @since 2.0.14
39
     */
40
    public static $mimeAliasesFile = '@yii/helpers/mimeAliases.php';
41
42
43
    /**
44
     * Normalizes a file/directory path.
45
     *
46
     * The normalization does the following work:
47
     *
48
     * - Convert all directory separators into `DIRECTORY_SEPARATOR` (e.g. "\a/b\c" becomes "/a/b/c")
49
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
50
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
51
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
52
     *
53
     * @param string $path the file/directory path to be normalized
54
     * @param string $ds the directory separator to be used in the normalized result. Defaults to `DIRECTORY_SEPARATOR`.
55
     * @return string the normalized file/directory path
56
     */
57 34
    public static function normalizePath($path, $ds = DIRECTORY_SEPARATOR)
58
    {
59 34
        $path = rtrim(strtr($path, '/\\', $ds . $ds), $ds);
60 34
        if (strpos($ds . $path, "{$ds}.") === false && strpos($path, "{$ds}{$ds}") === false) {
61 33
            return $path;
62
        }
63
        // the path may contain ".", ".." or double slashes, need to clean them up
64 1
        if (strpos($path, "{$ds}{$ds}") === 0 && $ds == '\\') {
65 1
            $parts = [$ds];
66
        } else {
67 1
            $parts = [];
68
        }
69 1
        foreach (explode($ds, $path) as $part) {
70 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
71 1
                array_pop($parts);
72 1
            } elseif ($part === '.' || $part === '' && !empty($parts)) {
73 1
                continue;
74
            } else {
75 1
                $parts[] = $part;
76
            }
77
        }
78 1
        $path = implode($ds, $parts);
79 1
        return $path === '' ? '.' : $path;
80
    }
81
82
    /**
83
     * Returns the localized version of a specified file.
84
     *
85
     * The searching is based on the specified language code. In particular,
86
     * a file with the same name will be looked for under the subdirectory
87
     * whose name is the same as the language code. For example, given the file "path/to/view.php"
88
     * and language code "zh-CN", the localized file will be looked for as
89
     * "path/to/zh-CN/view.php". If the file is not found, it will try a fallback with just a language code that is
90
     * "zh" i.e. "path/to/zh/view.php". If it is not found as well the original file will be returned.
91
     *
92
     * If the target and the source language codes are the same,
93
     * the original file will be returned.
94
     *
95
     * @param string $file the original file
96
     * @param string $language the target language that the file should be localized to.
97
     * If not set, the value of [[\yii\base\Application::language]] will be used.
98
     * @param string $sourceLanguage the language that the original file is in.
99
     * If not set, the value of [[\yii\base\Application::sourceLanguage]] will be used.
100
     * @return string the matching localized file, or the original file if the localized version is not found.
101
     * If the target and the source language codes are the same, the original file will be returned.
102
     */
103 75
    public static function localize($file, $language = null, $sourceLanguage = null)
104
    {
105 75
        if ($language === null) {
106 74
            $language = Yii::$app->language;
107
        }
108 75
        if ($sourceLanguage === null) {
109 74
            $sourceLanguage = Yii::$app->sourceLanguage;
110
        }
111 75
        if ($language === $sourceLanguage) {
112 75
            return $file;
113
        }
114 1
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
115 1
        if (is_file($desiredFile)) {
116 1
            return $desiredFile;
117
        }
118
119
        $language = substr($language, 0, 2);
120
        if ($language === $sourceLanguage) {
121
            return $file;
122
        }
123
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
124
125
        return is_file($desiredFile) ? $desiredFile : $file;
126
    }
127
128
    /**
129
     * Determines the MIME type of the specified file.
130
     * This method will first try to determine the MIME type based on
131
     * [finfo_open](http://php.net/manual/en/function.finfo-open.php). If the `fileinfo` extension is not installed,
132
     * it will fall back to [[getMimeTypeByExtension()]] when `$checkExtension` is true.
133
     * @param string $file the file name.
134
     * @param string $magicFile name of the optional magic database file (or alias), usually something like `/path/to/magic.mime`.
135
     * This will be passed as the second parameter to [finfo_open()](http://php.net/manual/en/function.finfo-open.php)
136
     * when the `fileinfo` extension is installed. If the MIME type is being determined based via [[getMimeTypeByExtension()]]
137
     * and this is null, it will use the file specified by [[mimeMagicFile]].
138
     * @param bool $checkExtension whether to use the file extension to determine the MIME type in case
139
     * `finfo_open()` cannot determine it.
140
     * @return string the MIME type (e.g. `text/plain`). Null is returned if the MIME type cannot be determined.
141
     * @throws InvalidConfigException when the `fileinfo` PHP extension is not installed and `$checkExtension` is `false`.
142
     */
143 25
    public static function getMimeType($file, $magicFile = null, $checkExtension = true)
144
    {
145 25
        if ($magicFile !== null) {
146
            $magicFile = Yii::getAlias($magicFile);
147
        }
148 25
        if (!extension_loaded('fileinfo')) {
149
            if ($checkExtension) {
150
                return static::getMimeTypeByExtension($file, $magicFile);
0 ignored issues
show
Bug introduced by
It seems like $magicFile defined by \Yii::getAlias($magicFile) on line 146 can also be of type boolean; however, yii\helpers\BaseFileHelp...etMimeTypeByExtension() does only seem to accept string|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
151
            }
152
153
            throw new InvalidConfigException('The fileinfo PHP extension is not installed.');
154
        }
155 25
        $info = finfo_open(FILEINFO_MIME_TYPE, $magicFile);
156
157 25
        if ($info) {
158 25
            $result = finfo_file($info, $file);
159 25
            finfo_close($info);
160
161 25
            if ($result !== false) {
162 25
                return $result;
163
            }
164
        }
165
166
        return $checkExtension ? static::getMimeTypeByExtension($file, $magicFile) : null;
0 ignored issues
show
Bug introduced by
It seems like $magicFile defined by \Yii::getAlias($magicFile) on line 146 can also be of type boolean; however, yii\helpers\BaseFileHelp...etMimeTypeByExtension() does only seem to accept string|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
167
    }
168
169
    /**
170
     * Determines the MIME type based on the extension name of the specified file.
171
     * This method will use a local map between extension names and MIME types.
172
     * @param string $file the file name.
173
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
174
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
175
     * @return string|null the MIME type. Null is returned if the MIME type cannot be determined.
176
     */
177 8
    public static function getMimeTypeByExtension($file, $magicFile = null)
178
    {
179 8
        $mimeTypes = static::loadMimeTypes($magicFile);
180
181 8
        if (($ext = pathinfo($file, PATHINFO_EXTENSION)) !== '') {
182 8
            $ext = strtolower($ext);
183 8
            if (isset($mimeTypes[$ext])) {
184 8
                return $mimeTypes[$ext];
185
            }
186
        }
187
188 1
        return null;
189
    }
190
191
    /**
192
     * Determines the extensions by given MIME type.
193
     * This method will use a local map between extension names and MIME types.
194
     * @param string $mimeType file MIME type.
195
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
196
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
197
     * @return array the extensions corresponding to the specified MIME type
198
     */
199 12
    public static function getExtensionsByMimeType($mimeType, $magicFile = null)
200
    {
201 12
        $aliases = static::loadMimeAliases(static::$mimeAliasesFile);
202 12
        if (isset($aliases[$mimeType])) {
203
            $mimeType = $aliases[$mimeType];
204
        }
205
206 12
        $mimeTypes = static::loadMimeTypes($magicFile);
207 12
        return array_keys($mimeTypes, mb_strtolower($mimeType, 'UTF-8'), true);
208
    }
209
210
    private static $_mimeTypes = [];
211
212
    /**
213
     * Loads MIME types from the specified file.
214
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
215
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
216
     * @return array the mapping from file extensions to MIME types
217
     */
218 20
    protected static function loadMimeTypes($magicFile)
219
    {
220 20
        if ($magicFile === null) {
221 20
            $magicFile = static::$mimeMagicFile;
222
        }
223 20
        $magicFile = Yii::getAlias($magicFile);
224 20
        if (!isset(self::$_mimeTypes[$magicFile])) {
225 1
            self::$_mimeTypes[$magicFile] = require $magicFile;
226
        }
227
228 20
        return self::$_mimeTypes[$magicFile];
229
    }
230
231
    private static $_mimeAliases = [];
232
233
    /**
234
     * Loads MIME aliases from the specified file.
235
     * @param string $aliasesFile the path (or alias) of the file that contains MIME type aliases.
236
     * If this is not set, the file specified by [[mimeAliasesFile]] will be used.
237
     * @return array the mapping from file extensions to MIME types
238
     * @since 2.0.14
239
     */
240 12
    protected static function loadMimeAliases($aliasesFile)
241
    {
242 12
        if ($aliasesFile === null) {
243
            $aliasesFile = static::$mimeAliasesFile;
244
        }
245 12
        $aliasesFile = Yii::getAlias($aliasesFile);
246 12
        if (!isset(self::$_mimeAliases[$aliasesFile])) {
247 1
            self::$_mimeAliases[$aliasesFile] = require $aliasesFile;
248
        }
249
250 12
        return self::$_mimeAliases[$aliasesFile];
251
    }
252
253
    /**
254
     * Copies a whole directory as another one.
255
     * The files and sub-directories will also be copied over.
256
     * @param string $src the source directory
257
     * @param string $dst the destination directory
258
     * @param array $options options for directory copy. Valid options are:
259
     *
260
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
261
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment setting.
262
     * - filter: callback, a PHP callback that is called for each directory or file.
263
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
264
     *   The callback can return one of the following values:
265
     *
266
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored)
267
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored)
268
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied
269
     *
270
     * - only: array, list of patterns that the file paths should match if they want to be copied.
271
     *   A path matches a pattern if it contains the pattern string at its end.
272
     *   For example, '.php' matches all file paths ending with '.php'.
273
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths.
274
     *   If a file path matches a pattern in both "only" and "except", it will NOT be copied.
275
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from being copied.
276
     *   A path matches a pattern if it contains the pattern string at its end.
277
     *   Patterns ending with '/' apply to directory paths only, and patterns not ending with '/'
278
     *   apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b';
279
     *   and '.svn/' matches directory paths ending with '.svn'. Note, the '/' characters in a pattern matches
280
     *   both '/' and '\' in the paths.
281
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to true.
282
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
283
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file.
284
     *   If the callback returns false, the copy operation for the sub-directory or file will be cancelled.
285
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or
286
     *   file to be copied from, while `$to` is the copy target.
287
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
288
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or
289
     *   file copied from, while `$to` is the copy target.
290
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating directories
291
     *   that do not contain files. This affects directories that do not contain files initially as well as directories that
292
     *   do not contain files at the target destination because files have been filtered via `only` or `except`.
293
     *   Defaults to true. This option is available since version 2.0.12. Before 2.0.12 empty directories are always copied.
294
     * @throws InvalidArgumentException if unable to open directory
295
     */
296 17
    public static function copyDirectory($src, $dst, $options = [])
297
    {
298 17
        $src = static::normalizePath($src);
299 17
        $dst = static::normalizePath($dst);
300
301 17
        if ($src === $dst || strpos($dst, $src . DIRECTORY_SEPARATOR) === 0) {
302 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
303
        }
304 15
        $dstExists = is_dir($dst);
305 15
        if (!$dstExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
306 6
            static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true);
307 6
            $dstExists = true;
308
        }
309
310 15
        $handle = opendir($src);
311 15
        if ($handle === false) {
312
            throw new InvalidArgumentException("Unable to open directory: $src");
313
        }
314 15
        if (!isset($options['basePath'])) {
315
            // this should be done only once
316 15
            $options['basePath'] = realpath($src);
317 15
            $options = static::normalizeOptions($options);
318
        }
319 15
        while (($file = readdir($handle)) !== false) {
320 15
            if ($file === '.' || $file === '..') {
321 15
                continue;
322
            }
323 13
            $from = $src . DIRECTORY_SEPARATOR . $file;
324 13
            $to = $dst . DIRECTORY_SEPARATOR . $file;
325 13
            if (static::filterPath($from, $options)) {
326 13
                if (isset($options['beforeCopy']) && !call_user_func($options['beforeCopy'], $from, $to)) {
327 2
                    continue;
328
                }
329 11
                if (is_file($from)) {
330 11
                    if (!$dstExists) {
331
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
332 5
                        static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true);
333 5
                        $dstExists = true;
334
                    }
335 11
                    copy($from, $to);
336 11
                    if (isset($options['fileMode'])) {
337 11
                        @chmod($to, $options['fileMode']);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
338
                    }
339
                } else {
340
                    // recursive copy, defaults to true
341 8
                    if (!isset($options['recursive']) || $options['recursive']) {
342 7
                        static::copyDirectory($from, $to, $options);
343
                    }
344
                }
345 11
                if (isset($options['afterCopy'])) {
346
                    call_user_func($options['afterCopy'], $from, $to);
347
                }
348
            }
349
        }
350 15
        closedir($handle);
351 15
    }
352
353
    /**
354
     * Removes a directory (and all its content) recursively.
355
     *
356
     * @param string $dir the directory to be deleted recursively.
357
     * @param array $options options for directory remove. Valid options are:
358
     *
359
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
360
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
361
     *   Only symlink would be removed in that default case.
362
     *
363
     * @throws ErrorException in case of failure
364
     */
365 122
    public static function removeDirectory($dir, $options = [])
366
    {
367 122
        if (!is_dir($dir)) {
368 31
            return;
369
        }
370 120
        if (!empty($options['traverseSymlinks']) || !is_link($dir)) {
371 120
            if (!($handle = opendir($dir))) {
372
                return;
373
            }
374 120
            while (($file = readdir($handle)) !== false) {
375 120
                if ($file === '.' || $file === '..') {
376 120
                    continue;
377
                }
378 102
                $path = $dir . DIRECTORY_SEPARATOR . $file;
379 102
                if (is_dir($path)) {
380 57
                    static::removeDirectory($path, $options);
381
                } else {
382 83
                    static::unlink($path);
383
                }
384
            }
385 120
            closedir($handle);
386
        }
387 120
        if (is_link($dir)) {
388 2
            static::unlink($dir);
389
        } else {
390 120
            rmdir($dir);
391
        }
392 120
    }
393
394
    /**
395
     * Removes a file or symlink in a cross-platform way
396
     *
397
     * @param string $path
398
     * @return bool
399
     *
400
     * @since 2.0.14
401
     */
402 84
    public static function unlink($path)
403
    {
404 84
        $isWindows = DIRECTORY_SEPARATOR === '\\';
405
406 84
        if (!$isWindows) {
407 84
            return unlink($path);
408
        }
409
410
        if (is_link($path) && is_dir($path)) {
411
            return rmdir($path);
412
        }
413
414
        try {
415
            return unlink($path);
416
        } catch (ErrorException $e) {
417
            // last resort measure for Windows
418
            $lines = [];
419
            exec('DEL /F/Q ' . escapeshellarg($path), $lines, $deleteError);
420
            return $deleteError !== 0;
421
        }
422
    }
423
424
    /**
425
     * Returns the files found under the specified directory and subdirectories.
426
     * @param string $dir the directory under which the files will be looked for.
427
     * @param array $options options for file searching. Valid options are:
428
     *
429
     * - `filter`: callback, a PHP callback that is called for each directory or file.
430
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
431
     *   The callback can return one of the following values:
432
     *
433
     *   * `true`: the directory or file will be returned (the `only` and `except` options will be ignored)
434
     *   * `false`: the directory or file will NOT be returned (the `only` and `except` options will be ignored)
435
     *   * `null`: the `only` and `except` options will determine whether the directory or file should be returned
436
     *
437
     * - `except`: array, list of patterns excluding from the results matching file or directory paths.
438
     *   Patterns ending with slash ('/') apply to directory paths only, and patterns not ending with '/'
439
     *   apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b';
440
     *   and `.svn/` matches directory paths ending with `.svn`.
441
     *   If the pattern does not contain a slash (`/`), it is treated as a shell glob pattern
442
     *   and checked for a match against the pathname relative to `$dir`.
443
     *   Otherwise, the pattern is treated as a shell glob suitable for consumption by `fnmatch(3)`
444
     *   with the `FNM_PATHNAME` flag: wildcards in the pattern will not match a `/` in the pathname.
445
     *   For example, `views/*.php` matches `views/index.php` but not `views/controller/index.php`.
446
     *   A leading slash matches the beginning of the pathname. For example, `/*.php` matches `index.php` but not `views/start/index.php`.
447
     *   An optional prefix `!` which negates the pattern; any matching file excluded by a previous pattern will become included again.
448
     *   If a negated pattern matches, this will override lower precedence patterns sources. Put a backslash (`\`) in front of the first `!`
449
     *   for patterns that begin with a literal `!`, for example, `\!important!.txt`.
450
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths.
451
     * - `only`: array, list of patterns that the file paths should match if they are to be returned. Directory paths
452
     *   are not checked against them. Same pattern matching rules as in the `except` option are used.
453
     *   If a file path matches a pattern in both `only` and `except`, it will NOT be returned.
454
     * - `caseSensitive`: boolean, whether patterns specified at `only` or `except` should be case sensitive. Defaults to `true`.
455
     * - `recursive`: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
456
     * @return array files found under the directory, in no particular order. Ordering depends on the files system used.
457
     * @throws InvalidArgumentException if the dir is invalid.
458
     */
459 71
    public static function findFiles($dir, $options = [])
460
    {
461 71
        $dir = self::clearDir($dir);
462 71
        $options = self::setBasePath($dir, $options);
463 71
        $list = [];
464 71
        $handle = self::openDir($dir);
465 71
        while (($file = readdir($handle)) !== false) {
466 71
            if ($file === '.' || $file === '..') {
467 71
                continue;
468
            }
469 71
            $path = $dir . DIRECTORY_SEPARATOR . $file;
470 71
            if (static::filterPath($path, $options)) {
471 71
                if (is_file($path)) {
472 69
                    $list[] = $path;
473 20
                } elseif (is_dir($path) && (!isset($options['recursive']) || $options['recursive'])) {
474 19
                    $list = array_merge($list, static::findFiles($path, $options));
475
                }
476
            }
477
        }
478 71
        closedir($handle);
479
480 71
        return $list;
481
    }
482
483
    /**
484
     * Returns the directories found under the specified directory and subdirectories.
485
     * @param string $dir the directory under which the files will be looked for.
486
     * @param array $options options for directory searching. Valid options are:
487
     *
488
     * - `filter`: callback, a PHP callback that is called for each directory or file.
489
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
490
     *   The callback can return one of the following values:
491
     *
492
     *   * `true`: the directory will be returned
493
     *   * `false`: the directory will NOT be returned
494
     *
495
     * - `recursive`: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
496
     * @return array directories found under the directory, in no particular order. Ordering depends on the files system used.
497
     * @throws InvalidArgumentException if the dir is invalid.
498
     * @since 2.0.14
499
     */
500 1
    public static function findDirectories($dir, $options = [])
501
    {
502 1
        $dir = self::clearDir($dir);
503 1
        $options = self::setBasePath($dir, $options);
504 1
        $list = [];
505 1
        $handle = self::openDir($dir);
506 1
        while (($file = readdir($handle)) !== false) {
507 1
            if ($file === '.' || $file === '..') {
508 1
                continue;
509
            }
510 1
            $path = $dir . DIRECTORY_SEPARATOR . $file;
511 1
            if (is_dir($path) && static::filterPath($path, $options)) {
512 1
                $list[] = $path;
513 1
                if (!isset($options['recursive']) || $options['recursive']) {
514 1
                    $list = array_merge($list, static::findDirectories($path, $options));
515
                }
516
            }
517
        }
518 1
        closedir($handle);
519
520 1
        return $list;
521
    }
522
523
    /*
524
     * @param string $dir
525
     */
526 72
    private static function setBasePath($dir, $options)
527
    {
528 72
        if (!isset($options['basePath'])) {
529
            // this should be done only once
530 72
            $options['basePath'] = realpath($dir);
531 72
            $options = static::normalizeOptions($options);
532
        }
533
534 72
        return $options;
535
    }
536
537
    /*
538
     * @param string $dir
539
     */
540 72
    private static function openDir($dir)
541
    {
542 72
        $handle = opendir($dir);
543 72
        if ($handle === false) {
544
            throw new InvalidArgumentException("Unable to open directory: $dir");
545
        }
546 72
        return $handle;
547
    }
548
549
    /*
550
     * @param string $dir
551
     */
552 72
    private static function clearDir($dir)
553
    {
554 72
        if (!is_dir($dir)) {
555
            throw new InvalidArgumentException("The dir argument must be a directory: $dir");
556
        }
557 72
        return rtrim($dir, DIRECTORY_SEPARATOR);
558
    }
559
560
    /**
561
     * Checks if the given file path satisfies the filtering options.
562
     * @param string $path the path of the file or directory to be checked
563
     * @param array $options the filtering options. See [[findFiles()]] for explanations of
564
     * the supported options.
565
     * @return bool whether the file or directory satisfies the filtering options.
566
     */
567 81
    public static function filterPath($path, $options)
568
    {
569 81
        if (isset($options['filter'])) {
570 2
            $result = call_user_func($options['filter'], $path);
571 2
            if (is_bool($result)) {
572 2
                return $result;
573
            }
574
        }
575
576 80
        if (empty($options['except']) && empty($options['only'])) {
577 24
            return true;
578
        }
579
580 61
        $path = str_replace('\\', '/', $path);
581
582 61
        if (!empty($options['except'])) {
583 42
            if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except'])) !== null) {
584 3
                return $except['flags'] & self::PATTERN_NEGATIVE;
585
            }
586
        }
587
588 61
        if (!empty($options['only']) && !is_dir($path)) {
589 59
            if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only'])) !== null) {
590
                // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
591 57
                return true;
592
            }
593
594 19
            return false;
595
        }
596
597 19
        return true;
598
    }
599
600
    /**
601
     * Creates a new directory.
602
     *
603
     * This method is similar to the PHP `mkdir()` function except that
604
     * it uses `chmod()` to set the permission of the created directory
605
     * in order to avoid the impact of the `umask` setting.
606
     *
607
     * @param string $path path of the directory to be created.
608
     * @param int $mode the permission to be set for the created directory.
609
     * @param bool $recursive whether to create parent directories if they do not exist.
610
     * @return bool whether the directory is created successfully
611
     * @throws \yii\base\Exception if the directory could not be created (i.e. php error due to parallel changes)
612
     */
613 169
    public static function createDirectory($path, $mode = 0775, $recursive = true)
614
    {
615 169
        if (is_dir($path)) {
616 62
            return true;
617
        }
618 149
        $parentDir = dirname($path);
619
        // recurse if parent dir does not exist and we are not at the root of the file system.
620 149
        if ($recursive && !is_dir($parentDir) && $parentDir !== $path) {
621 5
            static::createDirectory($parentDir, $mode, true);
622
        }
623
        try {
624 149
            if (!mkdir($path, $mode)) {
625 149
                return false;
626
            }
627
        } catch (\Exception $e) {
628
            if (!is_dir($path)) {// https://github.com/yiisoft/yii2/issues/9288
629
                throw new \yii\base\Exception("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
630
            }
631
        }
632
        try {
633 149
            return chmod($path, $mode);
634
        } catch (\Exception $e) {
635
            throw new \yii\base\Exception("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
636
        }
637
    }
638
639
    /**
640
     * Performs a simple comparison of file or directory names.
641
     *
642
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
643
     *
644
     * @param string $baseName file or directory name to compare with the pattern
645
     * @param string $pattern the pattern that $baseName will be compared against
646
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
647
     * @param int $flags pattern flags
648
     * @return bool whether the name matches against pattern
649
     */
650 60
    private static function matchBasename($baseName, $pattern, $firstWildcard, $flags)
651
    {
652 60
        if ($firstWildcard === false) {
653 50
            if ($pattern === $baseName) {
654 50
                return true;
655
            }
656 49
        } elseif ($flags & self::PATTERN_ENDSWITH) {
657
            /* "*literal" matching against "fooliteral" */
658 48
            $n = StringHelper::byteLength($pattern);
659 48
            if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) {
660
                return true;
661
            }
662
        }
663
664 60
        $matchOptions = [];
665 60
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
666 1
            $matchOptions['caseSensitive'] = false;
667
        }
668
669 60
        return StringHelper::matchWildcard($pattern, $baseName, $matchOptions);
670
    }
671
672
    /**
673
     * Compares a path part against a pattern with optional wildcards.
674
     *
675
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
676
     *
677
     * @param string $path full path to compare
678
     * @param string $basePath base of path that will not be compared
679
     * @param string $pattern the pattern that path part will be compared against
680
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
681
     * @param int $flags pattern flags
682
     * @return bool whether the path part matches against pattern
683
     */
684 43
    private static function matchPathname($path, $basePath, $pattern, $firstWildcard, $flags)
685
    {
686
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
687 43
        if (isset($pattern[0]) && $pattern[0] === '/') {
688 40
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
689 40
            if ($firstWildcard !== false && $firstWildcard !== 0) {
690
                $firstWildcard--;
691
            }
692
        }
693
694 43
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
695 43
        $name = StringHelper::byteSubstr($path, -$namelen, $namelen);
696
697 43
        if ($firstWildcard !== 0) {
698 43
            if ($firstWildcard === false) {
699 42
                $firstWildcard = StringHelper::byteLength($pattern);
700
            }
701
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
702 43
            if ($firstWildcard > $namelen) {
703 2
                return false;
704
            }
705
706 43
            if (strncmp($pattern, $name, $firstWildcard)) {
707 43
                return false;
708
            }
709 4
            $pattern = StringHelper::byteSubstr($pattern, $firstWildcard, StringHelper::byteLength($pattern));
710 4
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
711
712
            // If the whole pattern did not have a wildcard, then our prefix match is all we need; we do not need to call fnmatch at all.
713 4
            if (empty($pattern) && empty($name)) {
714 3
                return true;
715
            }
716
        }
717
718
        $matchOptions = [
719 2
            'filePath' => true
720
        ];
721 2
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
722
            $matchOptions['caseSensitive'] = false;
723
        }
724
725 2
        return StringHelper::matchWildcard($pattern, $name, $matchOptions);
726
    }
727
728
    /**
729
     * Scan the given exclude list in reverse to see whether pathname
730
     * should be ignored.  The first match (i.e. the last on the list), if
731
     * any, determines the fate.  Returns the element which
732
     * matched, or null for undecided.
733
     *
734
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
735
     *
736
     * @param string $basePath
737
     * @param string $path
738
     * @param array $excludes list of patterns to match $path against
739
     * @return array|null null or one of $excludes item as an array with keys: 'pattern', 'flags'
740
     * @throws InvalidArgumentException if any of the exclude patterns is not a string or an array with keys: pattern, flags, firstWildcard.
741
     */
742 61
    private static function lastExcludeMatchingFromList($basePath, $path, $excludes)
743
    {
744 61
        foreach (array_reverse($excludes) as $exclude) {
745 61
            if (is_string($exclude)) {
746
                $exclude = self::parseExcludePattern($exclude, false);
747
            }
748 61
            if (!isset($exclude['pattern']) || !isset($exclude['flags']) || !isset($exclude['firstWildcard'])) {
749
                throw new InvalidArgumentException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.');
750
            }
751 61
            if ($exclude['flags'] & self::PATTERN_MUSTBEDIR && !is_dir($path)) {
752
                continue;
753
            }
754
755 61
            if ($exclude['flags'] & self::PATTERN_NODIR) {
756 60
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
757 58
                    return $exclude;
758
                }
759 59
                continue;
760
            }
761
762 43
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
763 43
                return $exclude;
764
            }
765
        }
766
767 60
        return null;
768
    }
769
770
    /**
771
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
772
     * @param string $pattern
773
     * @param bool $caseSensitive
774
     * @throws InvalidArgumentException
775
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
776
     */
777 61
    private static function parseExcludePattern($pattern, $caseSensitive)
778
    {
779 61
        if (!is_string($pattern)) {
780
            throw new InvalidArgumentException('Exclude/include pattern must be a string.');
781
        }
782
783
        $result = [
784 61
            'pattern' => $pattern,
785 61
            'flags' => 0,
786
            'firstWildcard' => false,
787
        ];
788
789 61
        if (!$caseSensitive) {
790 1
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
791
        }
792
793 61
        if (!isset($pattern[0])) {
794
            return $result;
795
        }
796
797 61
        if ($pattern[0] === '!') {
798
            $result['flags'] |= self::PATTERN_NEGATIVE;
799
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
800
        }
801 61
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
802
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
803
            $result['flags'] |= self::PATTERN_MUSTBEDIR;
804
        }
805 61
        if (strpos($pattern, '/') === false) {
806 60
            $result['flags'] |= self::PATTERN_NODIR;
807
        }
808 61
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
809 61
        if ($pattern[0] === '*' && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
810 48
            $result['flags'] |= self::PATTERN_ENDSWITH;
811
        }
812 61
        $result['pattern'] = $pattern;
813
814 61
        return $result;
815
    }
816
817
    /**
818
     * Searches for the first wildcard character in the pattern.
819
     * @param string $pattern the pattern to search in
820
     * @return int|bool position of first wildcard character or false if not found
821
     */
822 61
    private static function firstWildcardInPattern($pattern)
823
    {
824 61
        $wildcards = ['*', '?', '[', '\\'];
825 61
        $wildcardSearch = function ($r, $c) use ($pattern) {
826 61
            $p = strpos($pattern, $c);
827
828 61
            return $r === false ? $p : ($p === false ? $r : min($r, $p));
829 61
        };
830
831 61
        return array_reduce($wildcards, $wildcardSearch, false);
832
    }
833
834
    /**
835
     * @param array $options raw options
836
     * @return array normalized options
837
     * @since 2.0.12
838
     */
839 83
    protected static function normalizeOptions(array $options)
840
    {
841 83
        if (!array_key_exists('caseSensitive', $options)) {
842 82
            $options['caseSensitive'] = true;
843
        }
844 83
        if (isset($options['except'])) {
845 42
            foreach ($options['except'] as $key => $value) {
846 42
                if (is_string($value)) {
847 42
                    $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
848
                }
849
            }
850
        }
851 83
        if (isset($options['only'])) {
852 59
            foreach ($options['only'] as $key => $value) {
853 59
                if (is_string($value)) {
854 59
                    $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
855
                }
856
            }
857
        }
858
859 83
        return $options;
860
    }
861
}
862