Passed
Pull Request — master (#22)
by Sergei
12:39
created

FileHelper::firstWildcardInPattern()   A

Complexity

Conditions 4
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 7
nc 1
nop 1
dl 0
loc 11
ccs 9
cts 9
cp 1
crap 4
rs 10
c 0
b 0
f 0
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 RuntimeException;
11
12
/**
13
 * FileHelper provides useful methods to manage files and directories
14
 */
15
class FileHelper
16
{
17
    /**
18
     * Creates a new directory.
19
     *
20
     * This method is similar to the PHP `mkdir()` function except that it uses `chmod()` to set the permission of the
21
     * created directory in order to avoid the impact of the `umask` setting.
22
     *
23
     * @param string $path path of the directory to be created.
24
     * @param int $mode the permission to be set for the created directory.
25
     *
26
     * @return bool whether the directory is created successfully.
27
     */
28
    public static function createDirectory(string $path, int $mode = 0775): bool
29
    {
30
        $path = static::normalizePath($path);
31
32
        try {
33
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
34
                return false;
35
            }
36
        } catch (Exception $e) {
37
            if (!is_dir($path)) {
38
                throw new RuntimeException(
39
                    "Failed to create directory \"$path\": " . $e->getMessage(),
40
                    $e->getCode(),
41
                    $e
42
                );
43
            }
44
        }
45
46
        return static::chmod($path, $mode);
47
    }
48
49
    /**
50
     * Set permissions directory.
51
     *
52
     * @param string $path
53
     * @param integer $mode
54 35
     *
55
     * @return boolean|null
56 35
     *
57
     * @throws RuntimeException
58
     */
59 35
    private static function chmod(string $path, int $mode): ?bool
60 35
    {
61
        try {
62 2
            return chmod($path, $mode);
63 2
        } catch (Exception $e) {
64 1
            throw new RuntimeException(
65 1
                "Failed to change permissions for directory \"$path\": " . $e->getMessage(),
66 1
                $e->getCode(),
67
                $e
68
            );
69
        }
70
    }
71
72 35
    /**
73
     * Normalizes a file/directory path.
74
     *
75
     * The normalization does the following work:
76
     *
77
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
78
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
79
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
80
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
81
     *
82
     * @param string $path the file/directory path to be normalized
83
     *
84
     * @return string the normalized file/directory path
85 35
     */
86
    public static function normalizePath(string $path): string
87
    {
88 35
        $isWindowsShare = strpos($path, '\\\\') === 0;
89
90
        if ($isWindowsShare) {
91
            $path = substr($path, 2);
92
        }
93
94
        $path = rtrim(strtr($path, '/\\', '//'), '/');
95
96
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
97
            return $isWindowsShare ? "\\\\$path" : $path;
98
        }
99
100
        $parts = [];
101
102
        foreach (explode('/', $path) as $part) {
103
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
104
                array_pop($parts);
105
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
106
                $parts[] = $part;
107
            }
108
        }
109
110
        $path = implode('/', $parts);
111
112 35
        if ($isWindowsShare) {
113
            $path = '\\\\' . $path;
114 35
        }
115
116 35
        return $path === '' ? '.' : $path;
117 1
    }
118
119
    /**
120 35
     * Removes a directory (and all its content) recursively.
121
     *
122 35
     * @param string $directory the directory to be deleted recursively.
123 35
     * @param array $options options for directory remove ({@see clearDirectory()}).
124
     *
125
     * @return void
126 1
     */
127
    public static function removeDirectory(string $directory, array $options = []): void
128 1
    {
129 1
        try {
130 1
            static::clearDirectory($directory, $options);
131 1
        } catch (InvalidArgumentException $e) {
132 1
            return;
133
        }
134
135
        if (is_link($directory)) {
136 1
            self::unlink($directory);
137
        } else {
138 1
            rmdir($directory);
139 1
        }
140
    }
141
142 1
    /**
143
     * Clear all directory content.
144
     *
145
     * @param string $directory the directory to be cleared.
146
     * @param array $options options for directory clear . Valid options are:
147
     *
148
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
149
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
150
     *   Only symlink would be removed in that default case.
151
     *
152
     * @return void
153 35
     *
154
     * @throws InvalidArgumentException if unable to open directory
155
     */
156 35
    public static function clearDirectory(string $directory, array $options = []): void
157 1
    {
158 1
        $handle = static::openDirectory($directory);
159
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
160
            while (($file = readdir($handle)) !== false) {
161 35
                if ($file === '.' || $file === '..') {
162 2
                    continue;
163
                }
164 35
                $path = $directory . '/' . $file;
165
                if (is_dir($path)) {
166 35
                    self::removeDirectory($path, $options);
167
                } else {
168
                    self::unlink($path);
169
                }
170
            }
171
            closedir($handle);
172
        }
173
    }
174
175
    /**
176
     * Removes a file or symlink in a cross-platform way.
177
     * @param string $path
178
     * @return bool
179
     */
180
    public static function unlink(string $path): bool
181
    {
182 35
        $isWindows = DIRECTORY_SEPARATOR === '\\';
183
184 35
        if (!$isWindows) {
185 35
            return unlink($path);
186 35
        }
187 35
188 35
        if (is_link($path) && is_dir($path)) {
189
            return rmdir($path);
190 23
        }
191 23
192 23
        if (!is_writable($path)) {
193
            chmod($path, 0777);
194 15
        }
195
196
        return unlink($path);
197 35
    }
198
199 35
    /**
200
     * Tells whether the path is a empty directory
201
     * @param string $path
202
     * @return bool
203
     */
204
    public static function isEmptyDirectory(string $path): bool
205
    {
206 16
        if (!is_dir($path)) {
207
            return false;
208 16
        }
209
210 16
        return !(new FilesystemIterator($path))->valid();
211 16
    }
212
213
    /**
214
     * Copies a whole directory as another one.
215
     *
216
     * The files and sub-directories will also be copied over.
217
     *
218
     * @param string $source the source directory.
219
     * @param string $destination the destination directory.
220
     * @param array $options options for directory copy. Valid options are:
221
     *
222
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
223
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
224
     *   setting.
225
     * - filter: PathMatcher
226
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
227
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
228
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
229
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
230 1
     *   while `$to` is the copy target.
231
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
232 1
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
233 1
     *   copied from, while `$to` is the copy target.
234
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
235
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
236 1
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
237
     *   `except`. Defaults to true.
238
     *
239
     * @return void
240
     * @throws Exception
241
     *
242
     * @throws InvalidArgumentException if unable to open directory
243
     */
244
    public static function copyDirectory(string $source, string $destination, array $options = []): void
245
    {
246
        $source = static::normalizePath($source);
247
        $destination = static::normalizePath($destination);
248
249
        static::assertNotSelfDirectory($source, $destination);
250
251
        $destinationExists = static::setDestination($destination, $options);
252
253
        $handle = static::openDirectory($source);
254
255
        $options = static::setBasePath($source, $options);
256
257
        while (($file = readdir($handle)) !== false) {
258
            if ($file === '.' || $file === '..') {
259
                continue;
260
            }
261
262
            $from = $source . '/' . $file;
263
            $to = $destination . '/' . $file;
264
265
            if (is_file($from)) {
266
                if (!isset($options['filter']) || $options['filter']->match($from)) {
267
                    if (!$destinationExists) {
268
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
269
                        $destinationExists = true;
270
                    }
271
                    copy($from, $to);
272
                    if (isset($options['fileMode'])) {
273
                        static::chmod($to, $options['fileMode']);
274
                    }
275
                }
276
            } elseif (!isset($options['recursive']) || $options['recursive']) {
277
                static::copyDirectory($from, $to, $options);
278
            }
279
        }
280
281
        closedir($handle);
282
    }
283
284
    /**
285
     * Check copy it self directory.
286
     *
287
     * @param string $source
288
     * @param string $destination
289 12
     *
290
     * @throws InvalidArgumentException
291 12
     */
292 12
    private static function assertNotSelfDirectory(string $source, string $destination): void
293
    {
294 12
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
295
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
296 10
        }
297
    }
298 10
299
    /**
300 9
     * Open directory handle.
301
     *
302 9
     * @param string $directory
303 9
     *
304 9
     * @return resource
305
     *
306
     * @throws InvalidArgumentException
307 7
     */
308 7
    private static function openDirectory(string $directory)
309
    {
310 7
        $handle = @opendir($directory);
311 7
312 7
        if ($handle === false) {
313 2
            throw new InvalidArgumentException("Unable to open directory: $directory");
314 2
        }
315
316 7
        return $handle;
317 7
    }
318 7
319
    /**
320 6
     * Set base path directory.
321 5
     *
322
     * @param string $source
323
     * @param array $options
324
     *
325
     * @return array
326 9
     */
327 9
    private static function setBasePath(string $source, array $options): array
328
    {
329
        if (!isset($options['basePath'])) {
330
            // this should be done only once
331
            $options['basePath'] = realpath($source);
332
        }
333
334
        return $options;
335
    }
336
337 12
    /**
338
     * Set destination directory.
339 12
     *
340 2
     * @param string $destination
341
     * @param array $options
342 10
     *
343
     * @return bool
344
     */
345
    private static function setDestination(string $destination, array $options): bool
346
    {
347
        $destinationExists = is_dir($destination);
348
349
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
350
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
351
            $destinationExists = true;
352 35
        }
353
354 35
        return $destinationExists;
355
    }
356
}
357