Passed
Push — master ( 066310...c62d82 )
by Alexander
02:02
created

FileHelper::openFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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