1<?php
2
3/**
4 * This file is part of the Nette Framework (https://nette.org)
5 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6 */
7
8declare(strict_types=1);
9
10namespace Nette\Utils;
11
12use Nette;
13
14
15/**
16 * File system tool.
17 */
18final class FileSystem
19{
20	use Nette\StaticClass;
21
22	/**
23	 * Creates a directory if it does not exist, including parent directories.
24	 * @throws Nette\IOException  on error occurred
25	 */
26	public static function createDir(string $dir, int $mode = 0777): void
27	{
28		if (!is_dir($dir) && !@mkdir($dir, $mode, true) && !is_dir($dir)) { // @ - dir may already exist
29			throw new Nette\IOException(sprintf(
30				"Unable to create directory '%s' with mode %s. %s",
31				self::normalizePath($dir),
32				decoct($mode),
33				Helpers::getLastError(),
34			));
35		}
36	}
37
38
39	/**
40	 * Copies a file or an entire directory. Overwrites existing files and directories by default.
41	 * @throws Nette\IOException  on error occurred
42	 * @throws Nette\InvalidStateException  if $overwrite is set to false and destination already exists
43	 */
44	public static function copy(string $origin, string $target, bool $overwrite = true): void
45	{
46		if (stream_is_local($origin) && !file_exists($origin)) {
47			throw new Nette\IOException(sprintf("File or directory '%s' not found.", self::normalizePath($origin)));
48
49		} elseif (!$overwrite && file_exists($target)) {
50			throw new Nette\InvalidStateException(sprintf("File or directory '%s' already exists.", self::normalizePath($target)));
51
52		} elseif (is_dir($origin)) {
53			static::createDir($target);
54			foreach (new \FilesystemIterator($target) as $item) {
55				static::delete($item->getPathname());
56			}
57
58			foreach ($iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($origin, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST) as $item) {
59				if ($item->isDir()) {
60					static::createDir($target . '/' . $iterator->getSubPathName());
61				} else {
62					static::copy($item->getPathname(), $target . '/' . $iterator->getSubPathName());
63				}
64			}
65		} else {
66			static::createDir(dirname($target));
67			if (@stream_copy_to_stream(static::open($origin, 'rb'), static::open($target, 'wb')) === false) { // @ is escalated to exception
68				throw new Nette\IOException(sprintf(
69					"Unable to copy file '%s' to '%s'. %s",
70					self::normalizePath($origin),
71					self::normalizePath($target),
72					Helpers::getLastError(),
73				));
74			}
75		}
76	}
77
78
79	/**
80	 * Opens file and returns resource.
81	 * @return resource
82	 * @throws Nette\IOException  on error occurred
83	 */
84	public static function open(string $path, string $mode)
85	{
86		$f = @fopen($path, $mode); // @ is escalated to exception
87		if (!$f) {
88			throw new Nette\IOException(sprintf(
89				"Unable to open file '%s'. %s",
90				self::normalizePath($path),
91				Helpers::getLastError(),
92			));
93		}
94		return $f;
95	}
96
97
98	/**
99	 * Deletes a file or an entire directory if exists. If the directory is not empty, it deletes its contents first.
100	 * @throws Nette\IOException  on error occurred
101	 */
102	public static function delete(string $path): void
103	{
104		if (is_file($path) || is_link($path)) {
105			$func = DIRECTORY_SEPARATOR === '\\' && is_dir($path) ? 'rmdir' : 'unlink';
106			if (!@$func($path)) { // @ is escalated to exception
107				throw new Nette\IOException(sprintf(
108					"Unable to delete '%s'. %s",
109					self::normalizePath($path),
110					Helpers::getLastError(),
111				));
112			}
113		} elseif (is_dir($path)) {
114			foreach (new \FilesystemIterator($path) as $item) {
115				static::delete($item->getPathname());
116			}
117
118			if (!@rmdir($path)) { // @ is escalated to exception
119				throw new Nette\IOException(sprintf(
120					"Unable to delete directory '%s'. %s",
121					self::normalizePath($path),
122					Helpers::getLastError(),
123				));
124			}
125		}
126	}
127
128
129	/**
130	 * Renames or moves a file or a directory. Overwrites existing files and directories by default.
131	 * @throws Nette\IOException  on error occurred
132	 * @throws Nette\InvalidStateException  if $overwrite is set to false and destination already exists
133	 */
134	public static function rename(string $origin, string $target, bool $overwrite = true): void
135	{
136		if (!$overwrite && file_exists($target)) {
137			throw new Nette\InvalidStateException(sprintf("File or directory '%s' already exists.", self::normalizePath($target)));
138
139		} elseif (!file_exists($origin)) {
140			throw new Nette\IOException(sprintf("File or directory '%s' not found.", self::normalizePath($origin)));
141
142		} else {
143			static::createDir(dirname($target));
144			if (realpath($origin) !== realpath($target)) {
145				static::delete($target);
146			}
147
148			if (!@rename($origin, $target)) { // @ is escalated to exception
149				throw new Nette\IOException(sprintf(
150					"Unable to rename file or directory '%s' to '%s'. %s",
151					self::normalizePath($origin),
152					self::normalizePath($target),
153					Helpers::getLastError(),
154				));
155			}
156		}
157	}
158
159
160	/**
161	 * Reads the content of a file.
162	 * @throws Nette\IOException  on error occurred
163	 */
164	public static function read(string $file): string
165	{
166		$content = @file_get_contents($file); // @ is escalated to exception
167		if ($content === false) {
168			throw new Nette\IOException(sprintf(
169				"Unable to read file '%s'. %s",
170				self::normalizePath($file),
171				Helpers::getLastError(),
172			));
173		}
174
175		return $content;
176	}
177
178
179	/**
180	 * Reads the file content line by line. Because it reads continuously as we iterate over the lines,
181	 * it is possible to read files larger than the available memory.
182	 * @return \Generator<int, string>
183	 * @throws Nette\IOException  on error occurred
184	 */
185	public static function readLines(string $file, bool $stripNewLines = true): \Generator
186	{
187		return (function ($f) use ($file, $stripNewLines) {
188			$counter = 0;
189			do {
190				$line = Callback::invokeSafe('fgets', [$f], fn($error) => throw new Nette\IOException(sprintf(
191					"Unable to read file '%s'. %s",
192					self::normalizePath($file),
193					$error,
194				)));
195				if ($line === false) {
196					fclose($f);
197					break;
198				}
199				if ($stripNewLines) {
200					$line = rtrim($line, "\r\n");
201				}
202
203				yield $counter++ => $line;
204
205			} while (true);
206		})(static::open($file, 'r'));
207	}
208
209
210	/**
211	 * Writes the string to a file.
212	 * @throws Nette\IOException  on error occurred
213	 */
214	public static function write(string $file, string $content, ?int $mode = 0666): void
215	{
216		static::createDir(dirname($file));
217		if (@file_put_contents($file, $content) === false) { // @ is escalated to exception
218			throw new Nette\IOException(sprintf(
219				"Unable to write file '%s'. %s",
220				self::normalizePath($file),
221				Helpers::getLastError(),
222			));
223		}
224
225		if ($mode !== null && !@chmod($file, $mode)) { // @ is escalated to exception
226			throw new Nette\IOException(sprintf(
227				"Unable to chmod file '%s' to mode %s. %s",
228				self::normalizePath($file),
229				decoct($mode),
230				Helpers::getLastError(),
231			));
232		}
233	}
234
235
236	/**
237	 * Sets file permissions to `$fileMode` or directory permissions to `$dirMode`.
238	 * Recursively traverses and sets permissions on the entire contents of the directory as well.
239	 * @throws Nette\IOException  on error occurred
240	 */
241	public static function makeWritable(string $path, int $dirMode = 0777, int $fileMode = 0666): void
242	{
243		if (is_file($path)) {
244			if (!@chmod($path, $fileMode)) { // @ is escalated to exception
245				throw new Nette\IOException(sprintf(
246					"Unable to chmod file '%s' to mode %s. %s",
247					self::normalizePath($path),
248					decoct($fileMode),
249					Helpers::getLastError(),
250				));
251			}
252		} elseif (is_dir($path)) {
253			foreach (new \FilesystemIterator($path) as $item) {
254				static::makeWritable($item->getPathname(), $dirMode, $fileMode);
255			}
256
257			if (!@chmod($path, $dirMode)) { // @ is escalated to exception
258				throw new Nette\IOException(sprintf(
259					"Unable to chmod directory '%s' to mode %s. %s",
260					self::normalizePath($path),
261					decoct($dirMode),
262					Helpers::getLastError(),
263				));
264			}
265		} else {
266			throw new Nette\IOException(sprintf("File or directory '%s' not found.", self::normalizePath($path)));
267		}
268	}
269
270
271	/**
272	 * Determines if the path is absolute.
273	 */
274	public static function isAbsolute(string $path): bool
275	{
276		return (bool) preg_match('#([a-z]:)?[/\\\\]|[a-z][a-z0-9+.-]*://#Ai', $path);
277	}
278
279
280	/**
281	 * Normalizes `..` and `.` and directory separators in path.
282	 */
283	public static function normalizePath(string $path): string
284	{
285		$parts = $path === '' ? [] : preg_split('~[/\\\\]+~', $path);
286		$res = [];
287		foreach ($parts as $part) {
288			if ($part === '..' && $res && end($res) !== '..' && end($res) !== '') {
289				array_pop($res);
290			} elseif ($part !== '.') {
291				$res[] = $part;
292			}
293		}
294
295		return $res === ['']
296			? DIRECTORY_SEPARATOR
297			: implode(DIRECTORY_SEPARATOR, $res);
298	}
299
300
301	/**
302	 * Joins all segments of the path and normalizes the result.
303	 */
304	public static function joinPaths(string ...$paths): string
305	{
306		return self::normalizePath(implode('/', $paths));
307	}
308
309
310	/**
311	 * Converts backslashes to slashes.
312	 */
313	public static function unixSlashes(string $path): string
314	{
315		return strtr($path, '\\', '/');
316	}
317
318
319	/**
320	 * Converts slashes to platform-specific directory separators.
321	 */
322	public static function platformSlashes(string $path): string
323	{
324		return DIRECTORY_SEPARATOR === '/'
325			? strtr($path, '\\', '/')
326			: str_replace(':\\\\', '://', strtr($path, '/', '\\')); // protocol://
327	}
328}
329