Lines 48.59% 52 / 107
Methods 33.33% 4 / 12
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 rootPath 100.00% 1 / 1 100.00% 1 / 1 1
 overrideRootPath 0.00% 0 / 1 0.00% 0 / 1 2
 isAbsolute 83.33% 5 / 6 0.00% 0 / 1 4.07
 resolvePath 100.00% 1 / 1 100.00% 1 / 1 1
 absolutePath 100.00% 3 / 3 100.00% 1 / 1 2
 normalizePath 100.00% 25 / 25 100.00% 1 / 1 13
 isWithinRoot 89.47% 17 / 19 0.00% 0 / 1 6.04
 getFiles 0.00% 0 / 22 0.00% 0 / 1 90
 get 0.00% 0 / 9 0.00% 0 / 1 20
 create 0.00% 0 / 13 0.00% 0 / 1 42
 formatFileSize 0.00% 0 / 6 0.00% 0 / 1 6
 root 0.00% 0 / 1 0.00% 0 / 1 2
20class FileSystem
21{
22    /**
23     * The root path used for resolving relative paths
24     *
25     * @var string
26     */
27    private static string $root_path;
28
29    /**
30     * Get the current root path. The trailing `/` will be removed before this function is called.
31     *
32     * @return string The current root path
33     */
34    public static function rootPath(): string
35    {
36        return self::$root_path;
37    }
38
39    /**
40     * Override the default root path
41     *
42     * @param string $path The new root path
43     * @return void
44     */
45    public static function overrideRootPath(string $path): void
46    {
47        self::$root_path = rtrim($path, DIRECTORY_SEPARATOR);
48    }
49
50    /**
51     * Determine whether a path is absolute.
52     *
53     * Recognises Unix-style paths (leading "/") and Windows-style paths
54     * (leading drive letter such as "C:\" or "C:/").
55     *
56     * @param string $path The path to test
57     * @return bool True if the path is absolute, false otherwise
58     */
59    public static function isAbsolute(string $path): bool
60    {
61        if ($path === '') {
62            return false;
63        }
64
65        // Unix-style absolute path.
66        if (str_starts_with($path, '/')) {
67            return true;
68        }
69
70        // Windows-style absolute path (e.g. C:\ or C:/).
71        return DIRECTORY_SEPARATOR === '\\'
72            && preg_match('/^[A-Z]:[\\\\\/]/i', $path) === 1;
73    }
74
75    /**
76     * Resolve a path against the configured root.
77     *
78     * The path is always treated as relative to the root: a leading
79     * separator is stripped and the root path is prepended. This matches
80     * the convention used by {@see File} and {@see Folder}, where a leading
81     * `/` means "relative to root" rather than an absolute filesystem path.
82     * The result is normalized but not checked against the containment
83     * guard â€” callers that construct {@see File} or {@see Folder} objects
84     * get that check from the constructor.
85     *
86     * @param string $path The path to resolve (relative to root)
87     * @return string The resolved absolute path
88     */
89    public static function resolvePath(string $path): string
90    {
91        return self::rootPath() . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
92    }
93
94    /**
95     * Resolve a path to an absolute filesystem path.
96     *
97     * Absolute paths are returned unchanged; relative paths are resolved
98     * against the root via {@see resolvePath()}. Unlike {@see resolvePath()},
99     * a leading separator is treated as a filesystem-absolute path rather
100     * than root-relative.
101     *
102     * @param string $path The path to resolve (relative or absolute)
103     * @return string The resolved absolute path
104     */
105    public static function absolutePath(string $path): string
106    {
107        return self::isAbsolute($path)
108            ? $path
109            : self::resolvePath($path);
110    }
111
112    /**
113     * Normalize a path lexically, resolving `.` and `..` segments.
114     *
115     * This is a pure string operation â€” it does not touch the filesystem, so
116     * it works for paths that do not exist yet. It preserves the leading
117     * separator for absolute paths, Windows drive-letter prefixes, and keeps
118     * leading `..` segments for relative paths.
119     *
120     * @param string $path The path to normalize
121     * @return string The normalized path
122     */
123    public static function normalizePath(string $path): string
124    {
125        if ($path === '') {
126            return '';
127        }
128
129        // Preserve a Windows drive-letter prefix (e.g. "C:").
130        $prefix = '';
131        if (preg_match('/^[A-Za-z]:/', $path)) {
132            $prefix = substr($path, 0, 2);
133            $path = substr($path, 2);
134        }
135
136        // Preserve whether the path is absolute.
137        $isAbsolute = str_starts_with($path, '/') || str_starts_with($path, '\\');
138        if ($isAbsolute) {
139            $path = ltrim($path, '/\\');
140        }
141
142        $segments = preg_split('/[\/\\\\]+/', $path);
143        $stack = [];
144
145        foreach ($segments as $segment) {
146            if ($segment === '' || $segment === '.') {
147                continue;
148            }
149
150            if ($segment === '..') {
151                if (!empty($stack) && end($stack) !== '..') {
152                    array_pop($stack);
153                } elseif (!$isAbsolute) {
154                    // Preserve leading ".." for relative paths.
155                    $stack[] = '..';
156                }
157                continue;
158            }
159
160            $stack[] = $segment;
161        }
162
163        $result = implode(DIRECTORY_SEPARATOR, $stack);
164
165        if ($isAbsolute) {
166            $result = DIRECTORY_SEPARATOR . $result;
167        }
168
169        return $prefix . $result;
170    }
171
172    /**
173     * Determine whether a path is contained within the configured root path.
174     *
175     * Resolves the path (handling `..` segments and symlinks) and checks it
176     * does not escape the root. Works for both existing and non-existent
177     * paths â€” the latter are resolved via their parent directory.
178     *
179     * @param string $path The absolute path to check
180     * @return bool True if the path is within the root, false otherwise
181     */
182    public static function isWithinRoot(string $path): bool
183    {
184        $path = self::normalizePath($path);
185
186        $root = realpath(self::$root_path);
187        if ($root === false) {
188            // Root doesn't exist yet; fall back to a lexical comparison.
189            $root = rtrim(self::$root_path, DIRECTORY_SEPARATOR);
190        }
191        $root = rtrim($root, DIRECTORY_SEPARATOR);
192
193        // Resolve the deepest existing ancestor of $path, then re-append the
194        // remaining (possibly non-existent) segments lexically. This handles
195        // paths whose intermediate directories don't exist yet (e.g. a File
196        // created before its parent Folder).
197        $resolved = $path;
198        $suffix = '';
199        while (true) {
200            $real = realpath($resolved);
201            if ($real !== false) {
202                $resolved = $real . $suffix;
203                break;
204            }
205            $parent = dirname($resolved);
206            if ($parent === $resolved) {
207                // Reached the filesystem root without finding an existing ancestor.
208                return false;
209            }
210            $suffix = DIRECTORY_SEPARATOR . basename($resolved) . $suffix;
211            $resolved = $parent;
212        }
213
214        return $resolved === $root
215            || str_starts_with($resolved, $root . DIRECTORY_SEPARATOR);
216    }
217
218    /**
219     * Get all files in a directory recursively with optional extension filtering
220     *
221     * @param string|null $directory The directory to scan (relative to root path), or null for root path
222     * @param string|array|null $extensions Optional extensions to filter by (e.g., 'php' or ['php', 'js'])
223     * @param bool $recursive Whether to search recursively in subdirectories
224     * @return array Array of File objects representing files in the directory
225     * @throws Exception
226     */
227    public static function getFiles(?string $directory = null, string|array|null $extensions = null, bool $recursive = true): array
228    {
229        // Determine the directory path
230        if ($directory == null) {
231            $directoryPath = self::rootPath();
232        } else {
233            $cleanDir = ltrim($directory, DIRECTORY_SEPARATOR);
234            $directoryPath = self::normalizePath(self::$root_path . DIRECTORY_SEPARATOR . $cleanDir);
235        }
236
237        // Normalize extensions to array and lowercase if provided
238        if ($extensions !== null) {
239            $extensions = is_array($extensions) ? $extensions : [$extensions];
240            $extensions = array_map('strtolower', $extensions);
241        }
242
243        $items = [];
244
245        // Set up the appropriate iterator based on a recursive flag
246        if ($recursive) {
247            $iterator = new RecursiveIteratorIterator(
248                new RecursiveDirectoryIterator($directoryPath, FilesystemIterator::SKIP_DOTS),
249                RecursiveIteratorIterator::SELF_FIRST
250            );
251        } else {
252            $iterator = new \DirectoryIterator($directoryPath);
253        }
254
255        foreach ($iterator as $fileInfo) {
256            if ($fileInfo->isFile()) {
257                // If extension filter is provided, check if file matches
258                if ($extensions !== null) {
259                    $extension = strtolower(pathinfo($fileInfo->getFilename(), PATHINFO_EXTENSION));
260                    if (!in_array($extension, $extensions)) {
261                        continue; // Skip files that don't match the extensions
262                    }
263                }
264
265                // Create a file object with absolute path (true)
266                $items[] = new File($fileInfo->getRealPath(), true);
267            }
268        }
269
270        return $items;
271    }
272
273    /**
274     * Get a file instance if it exists, or null if it doesn't
275     *
276     * @param string $path Path to the file (relative to root path)
277     * @return File|null File instance or null if file doesn't exist
278     */
279    public static function get(string $path): ?File
280    {
281        try {
282            // Clean the path
283            $cleanPath = ltrim($path, DIRECTORY_SEPARATOR);
284            $fullPath = self::normalizePath(self::$root_path . DIRECTORY_SEPARATOR . $cleanPath);
285
286            // Containment guard: reject paths that escape the root.
287            if (!self::isWithinRoot($fullPath)) {
288                return null;
289            }
290
291            // Contract: return null if the file doesn't exist.
292            if (!file_exists($fullPath)) {
293                return null;
294            }
295
296            return new File($fullPath, true);
297        } catch (Exception $e) {
298            return null;
299        }
300    }
301
302    /**
303     * Create a new file with optional content
304     *
305     * This method creates the directory structure if it doesn't exist
306     * and initializes the file with the provided content if any.
307     *
308     * @param string $path Path to the file (relative to root path)
309     * @param string $content Optional initial content for the file
310     * @return File|null The file instance or null on failure
311     */
312    public static function create(string $path, string $content = ''): ?File
313    {
314        // Clean the path
315        $cleanPath = ltrim($path, DIRECTORY_SEPARATOR);
316        $fullPath = self::normalizePath(self::$root_path . DIRECTORY_SEPARATOR . $cleanPath);
317
318        // Containment guard: reject paths that escape the root.
319        if (!self::isWithinRoot($fullPath)) {
320            return null;
321        }
322
323        // Create directory if it doesn't exist
324        $directory = dirname($fullPath);
325        if (!is_dir($directory)) {
326            if (!mkdir($directory, 0755, true)) {
327                return null;
328            }
329        }
330
331        // Create the file with initial content
332        if (file_put_contents($fullPath, $content) === false) {
333            return null;
334        }
335
336        try {
337            // Return a new File instance with absolute path
338            return new File($fullPath, true);
339        } catch (Exception $e) {
340            return null;
341        }
342    }
343
344    /**
345     * Format file size in a human-readable format
346     *
347     * @param int $bytes File size in bytes
348     * @return string Formatted file size
349     */
350    public static function formatFileSize(int $bytes): string
351    {
352        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
353
354        $bytes = max($bytes, 0);
355        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
356        $pow = min($pow, count($units) - 1);
357
358        $bytes /= pow(1024, $pow);
359
360        return round($bytes, 2) . ' ' . $units[$pow];
361    }
362
363    public static function root(): Folder
364    {
365        return new Folder(self::$root_path, true);
366    }
367}