Passed
Push — master ( e6d102...7dc300 )
by Eric
12:39
created

Filesystem::lineCounter()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 42
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 18
nc 7
nop 4
dl 0
loc 42
rs 8.8333
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Utility - Collection of various PHP utility functions.
7
 *
8
 * @author    Eric Sizemore <[email protected]>
9
 * @version   2.0.0
10
 * @copyright (C) 2017 - 2024 Eric Sizemore
11
 * @license   The MIT License (MIT)
12
 *
13
 * Copyright (C) 2017 - 2024 Eric Sizemore <https://www.secondversion.com>.
14
 *
15
 * Permission is hereby granted, free of charge, to any person obtaining a copy
16
 * of this software and associated documentation files (the "Software"), to
17
 * deal in the Software without restriction, including without limitation the
18
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
19
 * sell copies of the Software, and to permit persons to whom the Software is
20
 * furnished to do so, subject to the following conditions:
21
 *
22
 * The above copyright notice and this permission notice shall be included in
23
 * all copies or substantial portions of the Software.
24
 *
25
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
31
 * THE SOFTWARE.
32
 */
33
34
namespace Esi\Utility;
35
36
// Exceptions
37
use InvalidArgumentException;
38
use RuntimeException;
39
use Random\RandomException;
1 ignored issue
show
Bug introduced by
The type Random\RandomException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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