Filesystem   A
last analyzed

Complexity

Total Complexity 41

Size/Duplication

Total Lines 376
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
eloc 85
dl 0
loc 376
ccs 89
cts 89
cp 1
rs 9.1199
c 0
b 0
f 0
wmc 41

13 Methods

Rating   Name   Duplication   Size   Complexity  
A isFile() 0 3 2
A buildIgnore() 0 7 2
A getIterator() 0 11 2
A directorySize() 0 24 5
A isReallyWritable() 0 28 4
A checkIgnore() 0 3 2
A normalizeFilePath() 0 34 4
A isDirectory() 0 3 2
A fileRead() 0 8 2
B lineCounter() 0 43 7
A fileWrite() 0 14 3
A checkExtensions() 0 3 2
A directoryList() 0 30 4

How to fix   Complexity   

Complex Class

Complex classes like Filesystem often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Filesystem, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of Esi\Utility.
7
 *
8
 * (c) 2017 - 2025 Eric Sizemore <[email protected]>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE.md file that was distributed with this source code.
12
 */
13
14
namespace Esi\Utility;
15
16
use FilesystemIterator;
17
use InvalidArgumentException;
18
use Random\RandomException;
19
use RecursiveDirectoryIterator;
20
use RecursiveIteratorIterator;
21
use RuntimeException;
22
use SplFileInfo;
23
use SplFileObject;
24
25
use function array_filter;
26
use function array_pop;
27
use function array_reduce;
28
use function clearstatcache;
29
use function explode;
30
use function file_exists;
31
use function file_get_contents;
32
use function file_put_contents;
33
use function implode;
34
use function is_dir;
35
use function is_readable;
36
use function is_writable;
37
use function iterator_count;
38
use function natsort;
39
use function preg_match;
40
use function preg_quote;
41
use function rtrim;
42
use function unlink;
43
44
use const DIRECTORY_SEPARATOR;
45
use const FILE_APPEND;
46
use const PHP_OS_FAMILY;
47
48
/**
49
 * File system utilities.
50
 *
51
 * @see Tests\FilesystemTest
52
 */
53
abstract class Filesystem
54
{
55
    /**
56
     * directoryList().
57
     *
58
     * Retrieves contents of a directory.
59
     *
60
     * @param string        $directory Directory to parse.
61
     * @param array<string> $ignore    Subdirectories of $directory you wish to not include.
62
     *
63
     * @throws InvalidArgumentException
64
     *
65
     * @return array<string>
66
     */
67 1
    public static function directoryList(string $directory, array $ignore = []): array
68
    {
69
        // Sanity checks
70 1
        if (!Filesystem::isDirectory($directory)) {
71 1
            throw new InvalidArgumentException('Invalid $directory specified');
72
        }
73
74
        /** @var array<string> $contents */
75 1
        $contents = [];
76
77
        // Directories to ignore, if any
78 1
        $ignore = Filesystem::buildIgnore($ignore);
79
80 1
        $iterator = Filesystem::getIterator($directory, true);
81
82
        // Build the actual contents of the directory
83
        /** @var SplFileInfo $pathInfo */
84 1
        foreach ($iterator as $pathInfo) {
85 1
            $path = $pathInfo->getPathname();
86
87 1
            if (Filesystem::checkIgnore($path, $ignore)) {
88 1
                continue;
89
            }
90
91 1
            $contents[] = $path;
92
        }
93
94 1
        natsort($contents);
95
96 1
        return $contents;
97
    }
98
99
    /**
100
     * directorySize().
101
     *
102
     * Retrieves size of a directory (in bytes).
103
     *
104
     * @param string        $directory Directory to parse.
105
     * @param array<string> $ignore    Subdirectories of $directory you wish to not include.
106
     *
107
     * @throws InvalidArgumentException
108
     */
109 1
    public static function directorySize(string $directory, array $ignore = []): int
110
    {
111
        // Sanity checks
112 1
        if (!Filesystem::isDirectory($directory)) {
113 1
            throw new InvalidArgumentException('Invalid $directory specified');
114
        }
115
116
        // Initialize
117 1
        $size = 0;
118
119
        // Determine directory size by checking file sizes
120
        /** @var RecursiveDirectoryIterator $fileInfo */
121 1
        foreach (Filesystem::getIterator($directory) as $fileInfo) {
122
            // Directories we wish to ignore, if any
123 1
            if (Filesystem::checkIgnore($fileInfo->getPath(), Filesystem::buildIgnore($ignore))) {
124 1
                continue;
125
            }
126
127 1
            if ($fileInfo->isFile()) {
128 1
                $size += $fileInfo->getSize();
129
            }
130
        }
131
132 1
        return $size;
133
    }
134
135
    /**
136
     * fileRead().
137
     *
138
     * Perform a read operation on a pre-existing file.
139
     *
140
     * @param string $file Filename.
141
     *
142
     * @throws InvalidArgumentException
143
     *
144
     * @return false|string
145
     */
146 1
    public static function fileRead(string $file): false|string
147
    {
148
        // Sanity check
149 1
        if (!Filesystem::isFile($file)) {
150 1
            throw new InvalidArgumentException(\sprintf("File '%s' does not exist or is not readable.", $file));
151
        }
152
153 1
        return file_get_contents($file);
154
    }
155
156
    /**
157
     * fileWrite().
158
     *
159
     * Perform a write operation on a pre-existing file.
160
     *
161
     * @param string $file  Filename.
162
     * @param string $data  If writing to the file, the data to write.
163
     * @param int    $flags Bitwise OR'ed set of flags for file_put_contents. One or
164
     *                      more of FILE_USE_INCLUDE_PATH, FILE_APPEND, LOCK_EX.
165
     *                      {@link http://php.net/file_put_contents}
166
     *
167
     * @throws InvalidArgumentException|RandomException
168
     *
169
     * @return false|int<0, max>
170
     */
171 5
    public static function fileWrite(string $file, string $data = '', int $flags = 0): false|int
172
    {
173
        // Sanity checks
174 5
        if (!Filesystem::isFile($file)) {
175 1
            throw new InvalidArgumentException(\sprintf("File '%s' does not exist or is not readable.", $file));
176
        }
177
178
        // @codeCoverageIgnoreStart
179
        if (!Filesystem::isReallyWritable($file)) {
180
            throw new InvalidArgumentException(\sprintf("File '%s' is not writable.", $file));
181
        }
182
183
        // @codeCoverageIgnoreEnd
184 5
        return file_put_contents($file, $data, $flags);
185
    }
186
187
    /**
188
     * isDirectory().
189
     *
190
     * Determines if the given $directory is both a directory and readable.
191
     *
192
     * @since 2.0.0
193
     *
194
     * @param string $directory Directory to check.
195
     */
196 3
    public static function isDirectory(string $directory): bool
197
    {
198 3
        return (is_dir($directory) && is_readable($directory));
199
    }
200
201
    /**
202
     * isFile().
203
     *
204
     * Determines if the given $file is both a file and readable.
205
     *
206
     * @since 2.0.0
207
     *
208
     * @param string $file Directory to check.
209
     */
210 20
    public static function isFile(string $file): bool
211
    {
212 20
        return (is_file($file) && is_readable($file));
213
    }
214
215
    /**
216
     * isReallyWritable().
217
     *
218
     * Checks to see if a file or directory is really writable.
219
     *
220
     * @param string $file File or directory to check.
221
     *
222
     * @throws RandomException  If unable to generate random string for the temp file.
223
     * @throws RuntimeException If the file or directory does not exist.
224
     */
225 6
    public static function isReallyWritable(string $file): bool
226
    {
227 6
        clearstatcache(true, $file);
228
229 6
        if (!file_exists($file)) {
230 1
            throw new RuntimeException('Invalid file or directory specified');
231
        }
232
233
        // If we are on Unix/Linux just run is_writable()
234
        // @codeCoverageIgnoreStart
235
        if (PHP_OS_FAMILY !== 'Windows') {
236
            return is_writable($file);
237
        }
238
239
        // We ignore code coverage due to differences in local and remote testing environments
240
241
        // Otherwise, if on Windows...
242
        // Attempt to write to the file or directory
243
        if (is_dir($file)) {
244
            $tmpFile = rtrim($file, '\\/') . DIRECTORY_SEPARATOR . Strings::randomString() . '.txt';
245
            $data    = file_put_contents($tmpFile, 'tmpData', FILE_APPEND);
246
247
            unlink($tmpFile);
248
        } else {
249
            $data = file_get_contents($file);
250
        }
251
252
        return ($data !== false);
253
        // @codeCoverageIgnoreEnd
254
    }
255
256
    /**
257
     * lineCounter().
258
     *
259
     * Parse a given directory's files for an approximate line count. Could be used for
260
     * a project directory, for example, to determine the line count for a project's codebase.
261
     *
262
     * NOTE: It does not count empty lines.
263
     *
264
     * @param string        $directory     Directory to parse.
265
     * @param array<string> $ignore        Subdirectories of $directory you wish
266
     *                                     to not include in the line count.
267
     * @param array<string> $extensions    An array of file types/extensions of
268
     *                                     files you want included in the line count.
269
     * @param bool          $onlyLineCount If set to true, only returns an array
270
     *                                     of line counts without directory/filenames.
271
     *
272
     * @throws InvalidArgumentException
273
     *
274
     * @return ($onlyLineCount is true ? int[] : array<string, array<string, int>>)
275
     */
276 1
    public static function lineCounter(string $directory, array $ignore = [], array $extensions = [], bool $onlyLineCount = false): array
277
    {
278
        // Sanity check
279 1
        if (!Filesystem::isDirectory($directory)) {
280 1
            throw new InvalidArgumentException('Invalid $directory specified');
281
        }
282
283
        // Initialize
284 1
        $lines = [];
285
286
        // Build the actual contents of the directory
287
        /** @var RecursiveDirectoryIterator $fileInfo */
288 1
        foreach (Filesystem::getIterator($directory) as $fileInfo) {
289
            // Directory names or extensions we wish to ignore
290 1
            if (!$fileInfo->isFile()) {
291
                //@codeCoverageIgnoreStart
292
                continue;
293
                //@codeCoverageIgnoreEnd
294
            }
295
296 1
            if (Filesystem::checkIgnore($fileInfo->getPath(), Filesystem::buildIgnore($ignore))) {
297 1
                continue;
298
            }
299
300 1
            if (Filesystem::checkExtensions($fileInfo->getExtension(), $extensions)) {
301 1
                continue;
302
            }
303
304 1
            $file = new SplFileObject($fileInfo->getPathname());
305 1
            $file->setFlags(SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
306
307 1
            $lineCount = iterator_count($file);
308
309 1
            if (!$onlyLineCount) {
310
                /** @var array<string, array<string, int>> $lines */
311 1
                $lines[$file->getPath()][$file->getFilename()] = $lineCount;
312
            } else {
313
                /** @var int[] $lines */
314 1
                $lines[] = $lineCount;
315
            }
316
        }
317
318 1
        return $lines;
319
    }
320
321
    /**
322
     * normalizeFilePath().
323
     *
324
     * Normalizes a file or directory path.
325
     *
326
     * @param string $path The file or directory path.
327
     *
328
     * @return string The normalized file or directory path.
329
     */
330 1
    public static function normalizeFilePath(string $path): string
331
    {
332
        // Path will be based on current directory separator as determined by PHP
333 1
        $separator = DIRECTORY_SEPARATOR;
334
335
        // Clean up the path a bit first
336 1
        $path = rtrim(str_replace(['/', '\\'], $separator, $path), $separator);
337
338
        /** @var array<string> $parts */
339 1
        $parts = array_filter(
340 1
            explode($separator, $path),
341 1
            static fn (string $string): bool => Strings::length($string) > 0
342 1
        );
343
344 1
        $filtered = array_reduce(
345 1
            $parts,
346
            /**
347
             * @param array<string> $tmp
348
             *
349
             * @return array<string>
350
             */
351 1
            static function (array $tmp, string $item): array {
352 1
                if ($item === '..') {
353 1
                    array_pop($tmp);
354 1
                } elseif ($item !== '.') {
355 1
                    $tmp[] = $item;
356
                }
357
358 1
                return $tmp;
359 1
            },
360 1
            []
361 1
        );
362
363 1
        return ($separator !== '\\' ? $separator : '') . implode($separator, $filtered);
364
    }
365
366
    /**
367
     * Builds the ignore list for lineCounter(), directorySize(), and directoryList().
368
     *
369
     * @since 2.0.0
370
     *
371
     * @param array<string> $ignore Array of file/folder names to ignore.
372
     */
373 3
    private static function buildIgnore(array $ignore): string
374
    {
375 3
        if ($ignore !== []) {
376 3
            return preg_quote(implode('|', $ignore), '#');
377
        }
378
379 3
        return '';
380
    }
381
382
    /**
383
     * Checks the extension ignore list for lineCounter(), directorySize(), and directoryList().
384
     *
385
     * @since 2.0.0
386
     *
387
     * @param string        $extension  File extension to check.
388
     * @param array<string> $extensions Array of file extensions to ignore.
389
     */
390 1
    private static function checkExtensions(string $extension, array $extensions): bool
391
    {
392 1
        return $extensions !== [] && !Arrays::valueExists($extensions, $extension);
393
    }
394
395
    /**
396
     * Checks the ignore list for lineCounter(), directorySize(), and directoryList().
397
     *
398
     * @since 2.0.0
399
     *
400
     * @param string $path   The file path to check against ignore list.
401
     * @param string $ignore The ignore list pattern.
402
     */
403 3
    private static function checkIgnore(string $path, string $ignore): bool
404
    {
405 3
        return $ignore !== '' && preg_match(\sprintf('#(%s)#i', $ignore), $path) === 1;
406
    }
407
408
    /**
409
     * Builds the Iterator for lineCounter(), directorySize(), and directoryList().
410
     *
411
     * @since 2.0.0
412
     *
413
     * @param string $forDirectory The directory to create an iterator for.
414
     * @param bool   $keyAsPath    Whether to use the key as pathname.
415
     *
416
     * @return RecursiveIteratorIterator<RecursiveDirectoryIterator>
417
     */
418 3
    private static function getIterator(string $forDirectory, bool $keyAsPath = false): RecursiveIteratorIterator
419
    {
420
        /** @var FilesystemIterator::CURRENT_AS_FILEINFO|FilesystemIterator::KEY_AS_PATHNAME|FilesystemIterator::SKIP_DOTS $flags */
421 3
        $flags = FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS;
422
423 3
        if ($keyAsPath) {
424 1
            $flags |= FilesystemIterator::KEY_AS_PATHNAME;
425
        }
426
427 3
        return new RecursiveIteratorIterator(
428 3
            new RecursiveDirectoryIterator($forDirectory, $flags)
429 3
        );
430
    }
431
}
432