1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Salient\Core\Process;
6: use Salient\Iterator\RecursiveFilesystemIterator;
7: use Salient\Utility\Exception\FilesystemErrorException;
8: use Salient\Utility\Exception\InvalidArgumentTypeException;
9: use Salient\Utility\Exception\InvalidRuntimeConfigurationException;
10: use Salient\Utility\Exception\UnreadDataException;
11: use Salient\Utility\Exception\UnwrittenDataException;
12: use InvalidArgumentException;
13: use Stringable;
14:
15: /**
16: * Work with files, directories, streams and paths
17: *
18: * Methods with an optional `$uri` parameter allow the resource URI reported on
19: * failure to be overridden.
20: *
21: * @api
22: */
23: final class File extends AbstractUtility
24: {
25: /**
26: * Check if a path is absolute without accessing the filesystem
27: *
28: * Returns `true` if `$path` starts with `/`, `\\`, `<letter>:\`,
29: * `<letter>:/` or a URI scheme with two or more characters.
30: */
31: public static function isAbsolute(string $path): bool
32: {
33: return (bool) Regex::match(
34: '@^(?:/|\\\\\\\\|[a-z]:[/\\\\]|[a-z][-a-z0-9+.]+:)@i',
35: $path,
36: );
37: }
38:
39: /**
40: * Resolve "/./" and "/../" segments in a path without accessing the
41: * filesystem
42: *
43: * If `$withEmptySegments` is `true`, a `"/../"` segment after two or more
44: * consecutive directory separators is resolved by removing one of the
45: * separators. If `false` (the default), it is resolved by treating
46: * consecutive separators as one separator.
47: *
48: * Example:
49: *
50: * ```php
51: * <?php
52: * echo File::resolvePath('/dir/subdir//../') . PHP_EOL;
53: * echo File::resolvePath('/dir/subdir//../', true) . PHP_EOL;
54: * ```
55: *
56: * Output:
57: *
58: * ```
59: * /dir/
60: * /dir/subdir/
61: * ```
62: */
63: public static function resolvePath(string $path, bool $withEmptySegments = false): string
64: {
65: $path = str_replace('\\', '/', $path);
66:
67: // Remove "/./" segments
68: $path = Regex::replace('@(?<=/|^)\.(?:/|$)@', '', $path);
69:
70: // Remove "/../" segments
71: $regex = $withEmptySegments ? '/' : '/+';
72: $regex = "@(?:^|(?<=^/)|(?<=/|^(?!/))(?!\.\.(?:/|\$))[^/]*{$regex})\.\.(?:/|\$)@";
73: do {
74: $path = Regex::replace($regex, '', $path, -1, $count);
75: } while ($count);
76:
77: return $path;
78: }
79:
80: /**
81: * Sanitise a path to a directory without accessing the filesystem
82: *
83: * Returns `"."` if `$directory` is an empty string, otherwise removes
84: * trailing directory separators.
85: */
86: public static function getCleanDir(string $directory): string
87: {
88: return $directory === ''
89: ? '.'
90: : Str::coalesce(
91: rtrim($directory, \DIRECTORY_SEPARATOR === '/' ? '/' : '\/'),
92: \DIRECTORY_SEPARATOR,
93: );
94: }
95:
96: /**
97: * Change the current directory
98: */
99: public static function chdir(string $directory): void
100: {
101: self::check(@chdir($directory), 'chdir', $directory);
102: }
103:
104: /**
105: * Create a directory
106: */
107: public static function mkdir(
108: string $directory,
109: int $permissions = 0777,
110: bool $recursive = false
111: ): void {
112: self::check(@mkdir($directory, $permissions, $recursive), 'mkdir', $directory);
113: }
114:
115: /**
116: * Change file permissions
117: */
118: public static function chmod(string $filename, int $permissions): void
119: {
120: self::check(@chmod($filename, $permissions), 'chmod', $filename);
121: }
122:
123: /**
124: * Set file access and modification times
125: */
126: public static function touch(
127: string $filename,
128: ?int $mtime = null,
129: ?int $atime = null
130: ): void {
131: $mtime ??= time();
132: $atime ??= $mtime;
133: self::check(@touch($filename, $mtime, $atime), 'touch', $filename);
134: }
135:
136: /**
137: * Get a path or its closest parent that exists
138: *
139: * Returns `null` if the leftmost segment of `$path` doesn't exist, or if
140: * the closest parent that exists is not a directory.
141: */
142: public static function getClosestPath(string $path): ?string
143: {
144: $pathIsParent = false;
145: while (!file_exists($path)) {
146: $parent = dirname($path);
147: if ($parent === $path) {
148: // @codeCoverageIgnoreStart
149: return null;
150: // @codeCoverageIgnoreEnd
151: }
152: $path = $parent;
153: $pathIsParent = true;
154: }
155:
156: if ($pathIsParent && !is_dir($path)) {
157: return null;
158: }
159:
160: return $path;
161: }
162:
163: /**
164: * Create a file if it doesn't exist
165: *
166: * @param int $permissions Applied when creating `$filename`.
167: * @param int $dirPermissions Applied when creating `$filename`'s directory.
168: */
169: public static function create(
170: string $filename,
171: int $permissions = 0777,
172: int $dirPermissions = 0777
173: ): void {
174: if (is_file($filename)) {
175: return;
176: }
177: self::createDir(dirname($filename), $dirPermissions);
178: self::touch($filename);
179: self::chmod($filename, $permissions);
180: }
181:
182: /**
183: * Create a directory if it doesn't exist
184: *
185: * @param int $permissions Used if `$directory` doesn't exist.
186: */
187: public static function createDir(
188: string $directory,
189: int $permissions = 0777
190: ): void {
191: if (is_dir($directory)) {
192: return;
193: }
194: $parent = dirname($directory);
195: if (!is_dir($parent)) {
196: self::mkdir($parent, 0777, true);
197: }
198: self::mkdir($directory, $permissions);
199: self::chmod($directory, $permissions);
200: }
201:
202: /**
203: * Create a temporary directory
204: */
205: public static function createTempDir(
206: ?string $directory = null,
207: ?string $prefix = null
208: ): string {
209: $directory = $directory === null
210: ? Sys::getTempDir()
211: : self::getCleanDir($directory);
212: $prefix ??= Sys::getProgramBasename();
213: do {
214: $dir = sprintf(
215: '%s/%s%s.tmp',
216: $directory,
217: $prefix,
218: Get::randomText(8),
219: );
220: } while (!@mkdir($dir, 0700));
221: self::chmod($dir, 0700);
222: return $dir;
223: }
224:
225: /**
226: * Delete a file if it exists
227: */
228: public static function delete(string $filename): void
229: {
230: if (!file_exists($filename)) {
231: return;
232: }
233: if (!is_file($filename)) {
234: throw new FilesystemErrorException(
235: sprintf('Not a file: %s', $filename),
236: );
237: }
238: self::check(@unlink($filename), 'unlink', $filename);
239: }
240:
241: /**
242: * Delete a directory if it exists
243: */
244: public static function deleteDir(string $directory): void
245: {
246: if (!file_exists($directory)) {
247: return;
248: }
249: if (!is_dir($directory)) {
250: throw new FilesystemErrorException(
251: sprintf('Not a directory: %s', $directory),
252: );
253: }
254: self::check(@rmdir($directory), 'rmdir', $directory);
255: }
256:
257: /**
258: * Iterate over files in one or more directories
259: *
260: * Syntactic sugar for `new RecursiveFilesystemIterator()`.
261: *
262: * @see RecursiveFilesystemIterator
263: */
264: public static function find(): RecursiveFilesystemIterator
265: {
266: return new RecursiveFilesystemIterator();
267: }
268:
269: /**
270: * Get the current working directory without resolving symbolic links
271: */
272: public static function getcwd(): string
273: {
274: $command = Sys::isWindows() ? 'cd' : 'pwd';
275: if (class_exists(Process::class)) {
276: $process = Process::withShellCommand($command);
277: if ($process->run() === 0) {
278: return $process->getText();
279: }
280: } else {
281: // @codeCoverageIgnoreStart
282: $pipe = self::openPipe($command, 'r');
283: $dir = self::getContents($pipe);
284: if (self::closePipe($pipe, $command) === 0) {
285: return Str::trimNativeEol($dir);
286: }
287: // @codeCoverageIgnoreEnd
288: }
289: error_clear_last();
290: return self::check(@getcwd(), 'getcwd');
291: }
292:
293: /**
294: * Check if a path exists and is writable, or doesn't exist but descends
295: * from a writable directory
296: */
297: public static function isCreatable(string $path): bool
298: {
299: $path = self::getClosestPath($path);
300: return $path !== null && is_writable($path);
301: }
302:
303: /**
304: * Recursively delete the contents of a directory before optionally deleting
305: * the directory itself
306: *
307: * If `$setPermissions` is `true`, file modes in `$directory` are changed if
308: * necessary for deletion to succeed.
309: */
310: public static function pruneDir(string $directory, bool $delete = false, bool $setPermissions = false): void
311: {
312: $files = (new RecursiveFilesystemIterator())
313: ->in($directory)
314: ->dirs();
315:
316: $windows = false;
317: if ($setPermissions) {
318: $windows = Sys::isWindows();
319: clearstatcache();
320: // With exceptions `chmod()` can't address:
321: // - On *nix, filesystem entries can be deleted if their parent
322: // directory is writable
323: // - On Windows, they can be deleted if they are writable, whether
324: // their parent directory is writable or not
325: if (!$windows) {
326: foreach ($files->noFiles() as $dir) {
327: if (
328: $dir->isReadable()
329: && $dir->isWritable()
330: && $dir->isExecutable()
331: ) {
332: continue;
333: }
334: $perms = @$dir->getPerms();
335: if ($perms === false) {
336: // @codeCoverageIgnoreStart
337: $perms = 0;
338: // @codeCoverageIgnoreEnd
339: }
340: self::chmod((string) $dir, $perms | 0700);
341: }
342: }
343: }
344:
345: foreach ($files->dirsLast() as $file) {
346: $filename = (string) $file;
347: if ($windows && !$file->isWritable()) {
348: self::chmod($filename, $file->isDir() ? 0700 : 0600);
349: }
350: if ($file->isDir()) {
351: self::check(@rmdir($filename), 'rmdir', $filename);
352: } else {
353: self::check(@unlink($filename), 'unlink', $filename);
354: }
355: }
356:
357: if ($delete) {
358: self::check(@rmdir($directory), 'rmdir', $directory);
359: }
360: }
361:
362: /**
363: * Resolve symbolic links and relative references in a path or Phar URI
364: *
365: * An exception is thrown if `$path` does not exist.
366: */
367: public static function realpath(string $path): string
368: {
369: if (Str::lower(substr($path, 0, 7)) === 'phar://' && file_exists($path)) {
370: return self::resolvePath($path, true);
371: }
372: error_clear_last();
373: return self::check(@realpath($path), 'realpath', $path);
374: }
375:
376: /**
377: * Get a path relative to a parent directory
378: *
379: * Returns `$default` if `$path` does not belong to `$parentDirectory`.
380: *
381: * An exception is thrown if `$path` or `$parentDirectory` do not exist.
382: *
383: * @template TDefault of string|null
384: *
385: * @param TDefault $default
386: * @return string|TDefault
387: */
388: public static function getRelativePath(
389: string $path,
390: string $parentDirectory,
391: ?string $default = null
392: ): ?string {
393: $path = self::realpath($path);
394: $basePath = self::realpath($parentDirectory);
395: if (strpos($path, $basePath) === 0) {
396: return substr($path, strlen($basePath) + 1);
397: }
398: return $default;
399: }
400:
401: /**
402: * Check if two paths refer to the same filesystem entry
403: */
404: public static function same(string $filename1, string $filename2): bool
405: {
406: if (!file_exists($filename1)) {
407: return false;
408: }
409:
410: if ($filename1 === $filename2) {
411: return true;
412: }
413:
414: if (!file_exists($filename2)) {
415: return false;
416: }
417:
418: $stat1 = self::stat($filename1);
419: $stat2 = self::stat($filename2);
420:
421: return
422: $stat1['dev'] === $stat2['dev']
423: && $stat1['ino'] === $stat2['ino'];
424: }
425:
426: /**
427: * Get a unique identifier for a file from its device and inode numbers
428: */
429: public static function getIdentifier(string $filename): string
430: {
431: $stat = self::stat($filename);
432: return sprintf('%d:%d', $stat['dev'], $stat['ino']);
433: }
434:
435: /**
436: * Get the size of a file
437: *
438: * @phpstan-impure
439: */
440: public static function size(string $filename): int
441: {
442: return self::check(@filesize($filename), 'filesize', $filename);
443: }
444:
445: /**
446: * Get the type of a file
447: *
448: * @return ("fifo"|"char"|"dir"|"block"|"link"|"file"|"socket"|"unknown")
449: */
450: public static function type(string $filename): string
451: {
452: /** @var ("fifo"|"char"|"dir"|"block"|"link"|"file"|"socket"|"unknown") */
453: return self::check(@filetype($filename), 'filetype', $filename);
454: }
455:
456: /**
457: * Write data to a file
458: *
459: * @param resource|array<int|float|string|bool|Stringable|null>|string $data
460: * @param int-mask-of<\FILE_USE_INCLUDE_PATH|\FILE_APPEND|\LOCK_EX> $flags
461: */
462: public static function writeContents(string $filename, $data, int $flags = 0): int
463: {
464: return self::check(@file_put_contents($filename, $data, $flags), 'file_put_contents', $filename);
465: }
466:
467: /**
468: * Check for errors after fgets(), fgetcsv(), etc. return false
469: *
470: * @param resource $stream
471: * @param Stringable|string|null $uri
472: */
473: public static function checkEof($stream, $uri = null): void
474: {
475: $error = error_get_last();
476: if (@feof($stream)) {
477: return;
478: }
479: if ($error) {
480: throw new FilesystemErrorException($error['message']);
481: }
482: throw new FilesystemErrorException(sprintf(
483: 'Error reading from %s',
484: self::getStreamName($uri, $stream),
485: ));
486: }
487:
488: /**
489: * Close an open stream
490: *
491: * @param resource $stream
492: * @param Stringable|string|null $uri
493: */
494: public static function close($stream, $uri = null): void
495: {
496: $uri = self::getStreamName($uri, $stream);
497: self::check(@fclose($stream), 'fclose', $uri);
498: }
499:
500: /**
501: * Close a pipe to a process and return its exit status
502: *
503: * @param resource $pipe
504: */
505: public static function closePipe($pipe, ?string $command = null): int
506: {
507: $result = @pclose($pipe);
508: if ($result === -1) {
509: self::check(false, 'pclose', $command ?? '<pipe>');
510: }
511: return $result;
512: }
513:
514: /**
515: * If a stream is not seekable, copy it to a temporary stream that is and
516: * close it
517: *
518: * @param resource $stream
519: * @param Stringable|string|null $uri
520: * @return resource
521: */
522: public static function getSeekableStream($stream, $uri = null)
523: {
524: if (self::isSeekableStream($stream)) {
525: return $stream;
526: }
527: $seekable = self::open('php://temp', 'r+');
528: self::copy($stream, $seekable, $uri);
529: self::close($stream, $uri);
530: self::rewind($seekable);
531: return $seekable;
532: }
533:
534: /**
535: * Get the URI associated with a stream
536: *
537: * @param resource $stream
538: * @return string|null `null` if `$stream` is closed or does not have a URI.
539: */
540: public static function getStreamUri($stream): ?string
541: {
542: if (self::isStream($stream)) {
543: return stream_get_meta_data($stream)['uri'] ?? null;
544: }
545: return null;
546: }
547:
548: /**
549: * Check if a value is a seekable stream resource
550: *
551: * @param mixed $value
552: * @phpstan-assert-if-true resource $value
553: */
554: public static function isSeekableStream($value): bool
555: {
556: return self::isStream($value)
557: // @phpstan-ignore-next-line
558: && (stream_get_meta_data($value)['seekable'] ?? false);
559: }
560:
561: /**
562: * Check if a value is a stream resource
563: *
564: * @param mixed $value
565: * @phpstan-assert-if-true resource $value
566: */
567: public static function isStream($value): bool
568: {
569: return is_resource($value) && get_resource_type($value) === 'stream';
570: }
571:
572: /**
573: * Open a file or URI
574: *
575: * @return resource
576: */
577: public static function open(string $filename, string $mode)
578: {
579: return self::check(@fopen($filename, $mode), 'fopen', $filename);
580: }
581:
582: /**
583: * Open a resource if it is not already open
584: *
585: * @template TResource of Stringable|string|resource
586: * @template TUri of Stringable|string|null
587: *
588: * @param TResource $resource
589: * @param TUri $uri
590: * @param-out bool $close
591: * @param-out (TUri is null ? string|null : (TResource is resource ? TUri : string)) $uri
592: * @return resource
593: */
594: public static function maybeOpen($resource, string $mode, ?bool &$close, &$uri)
595: {
596: $close = false;
597: if (is_resource($resource)) {
598: /** @phpstan-var resource $resource */
599: self::assertResourceIsStream($resource);
600: $uri ??= self::getStreamUri($resource);
601: return $resource;
602: }
603: self::assertResourceIsStringable($resource);
604: $uri = (string) $resource;
605: $close = true;
606: return self::open($uri, $mode);
607: }
608:
609: /**
610: * Open a pipe to a process
611: *
612: * @return resource
613: */
614: public static function openPipe(string $command, string $mode)
615: {
616: return self::check(@popen($command, $mode), 'popen', $command);
617: }
618:
619: /**
620: * Read from an open stream
621: *
622: * @param resource $stream
623: * @param int<1,max> $length
624: * @param Stringable|string|null $uri
625: */
626: public static function read($stream, int $length, $uri = null): string
627: {
628: return self::check(@fread($stream, $length), 'fread', $uri, $stream);
629: }
630:
631: /**
632: * Read from an open stream until data of the expected length is read
633: *
634: * @param resource $stream
635: * @param int<0,max> $length
636: * @param Stringable|string|null $uri
637: * @throws UnreadDataException when fewer bytes are read than expected and
638: * the stream is at end-of-file.
639: */
640: public static function readAll($stream, int $length, $uri = null): string
641: {
642: if ($length === 0) {
643: return '';
644: }
645: $data = '';
646: $dataLength = 0;
647: do {
648: assert($length - $dataLength > 0);
649: $result = self::read($stream, $length - $dataLength, $uri);
650: if ($result === '') {
651: if (@feof($stream)) {
652: break;
653: }
654: usleep(10000);
655: continue;
656: }
657: $data .= $result;
658: $dataLength += strlen($result);
659: if ($dataLength === $length) {
660: return $data;
661: }
662: // Minimise CPU usage, e.g. when reading from non-blocking streams
663: usleep(10000);
664: } while (true);
665:
666: throw new UnreadDataException(Inflect::format(
667: $length - $dataLength,
668: 'Error reading from stream: expected {{#}} more {{#:byte}} from %s',
669: self::getStreamName($uri, $stream),
670: ));
671: }
672:
673: /**
674: * Read a line from an open stream
675: *
676: * @param resource $stream
677: * @param Stringable|string|null $uri
678: */
679: public static function readLine($stream, $uri = null): string
680: {
681: $line = @fgets($stream);
682: if ($line !== false) {
683: return $line;
684: }
685: self::checkEof($stream, $uri);
686: return '';
687: }
688:
689: /**
690: * Rewind to the beginning of a stream
691: *
692: * @param resource $stream
693: * @param Stringable|string|null $uri
694: */
695: public static function rewind($stream, $uri = null): void
696: {
697: self::check(rewind($stream), 'rewind', $uri, $stream);
698: }
699:
700: /**
701: * Rewind to the beginning of a stream if it is seekable
702: *
703: * @param resource $stream
704: * @param Stringable|string|null $uri
705: */
706: public static function maybeRewind($stream, $uri = null): void
707: {
708: if (self::isSeekableStream($stream)) {
709: self::rewind($stream, $uri);
710: }
711: }
712:
713: /**
714: * Set the file position indicator for a stream
715: *
716: * @param resource $stream
717: * @param \SEEK_SET|\SEEK_CUR|\SEEK_END $whence
718: * @param Stringable|string|null $uri
719: */
720: public static function seek($stream, int $offset, int $whence = \SEEK_SET, $uri = null): void
721: {
722: /** @disregard P1006 */
723: if (@fseek($stream, $offset, $whence) === -1) {
724: self::check(false, 'fseek', $uri, $stream);
725: }
726: }
727:
728: /**
729: * Set the file position indicator for a stream if it is seekable
730: *
731: * @param resource $stream
732: * @param \SEEK_SET|\SEEK_CUR|\SEEK_END $whence
733: * @param Stringable|string|null $uri
734: */
735: public static function maybeSeek($stream, int $offset, int $whence = \SEEK_SET, $uri = null): void
736: {
737: if (self::isSeekableStream($stream)) {
738: /** @disregard P1006 */
739: self::seek($stream, $offset, $whence, $uri);
740: }
741: }
742:
743: /**
744: * Get the file position indicator for a stream
745: *
746: * @param resource $stream
747: * @param Stringable|string|null $uri
748: *
749: * @phpstan-impure
750: */
751: public static function tell($stream, $uri = null): int
752: {
753: return self::check(@ftell($stream), 'ftell', $uri, $stream);
754: }
755:
756: /**
757: * Rewind to the beginning of a stream and truncate it
758: *
759: * @param resource $stream
760: * @param int<0,max> $size
761: * @param Stringable|string|null $uri
762: */
763: public static function truncate($stream, int $size = 0, $uri = null): void
764: {
765: self::seek($stream, 0, \SEEK_SET, $uri);
766: self::check(@ftruncate($stream, $size), 'ftruncate', $uri, $stream);
767: }
768:
769: /**
770: * Write to an open stream
771: *
772: * @param resource $stream
773: * @param int<0,max>|null $length
774: * @param Stringable|string|null $uri
775: * @throws UnwrittenDataException when fewer bytes are written than
776: * expected.
777: */
778: public static function write($stream, string $data, ?int $length = null, $uri = null): int
779: {
780: $result = self::doWrite($stream, $data, $length, $unwritten, $uri);
781: if ($unwritten > 0) {
782: throw new UnwrittenDataException(Inflect::format(
783: $unwritten,
784: 'Error writing to stream: {{#}} {{#:byte}} not written to %s',
785: self::getStreamName($uri, $stream),
786: ));
787: }
788: return $result;
789: }
790:
791: /**
792: * Write to an open stream until there is no unwritten data
793: *
794: * @param resource $stream
795: * @param int<0,max>|null $length
796: * @param Stringable|string|null $uri
797: */
798: public static function writeAll($stream, string $data, ?int $length = null, $uri = null): int
799: {
800: if ($length !== null) {
801: $data = substr($data, 0, $length);
802: }
803: if ($data === '') {
804: return 0;
805: }
806: $result = 0;
807: do {
808: $result += self::maybeWrite($stream, $data, $data, null, $uri);
809: if ($data === '') {
810: return $result;
811: }
812: // Minimise CPU usage, e.g. when writing to non-blocking streams
813: // @codeCoverageIgnoreStart
814: usleep(10000);
815: // @codeCoverageIgnoreEnd
816: } while (true);
817: }
818:
819: /**
820: * Write to an open stream and apply any unwritten data to a buffer
821: *
822: * @param resource $stream
823: * @param int<0,max>|null $length
824: * @param Stringable|string|null $uri
825: * @param-out string $buffer
826: */
827: public static function maybeWrite($stream, string $data, ?string &$buffer, ?int $length = null, $uri = null): int
828: {
829: $result = self::doWrite($stream, $data, $length, $unwritten, $uri);
830: $buffer = substr($data, $result);
831: return $result;
832: }
833:
834: /**
835: * @param resource $stream
836: * @param int<0,max>|null $length
837: * @param Stringable|string|null $uri
838: */
839: private static function doWrite($stream, string $data, ?int $length, ?int &$unwritten, $uri): int
840: {
841: // $length can't be null in PHP 7.4
842: if ($length === null) {
843: $length = strlen($data);
844: $expected = $length;
845: } else {
846: $expected = min($length, strlen($data));
847: }
848: $result = @fwrite($stream, $data, $length);
849: self::check($result, 'fwrite', $uri, $stream);
850: assert($result <= $expected);
851: $unwritten = $expected - $result;
852: return $result;
853: }
854:
855: /**
856: * Write a line of comma-separated values to an open stream
857: *
858: * A shim for {@see fputcsv()} with `$eol` (added in PHP 8.1) and without
859: * `$escape` (which should be removed).
860: *
861: * @param resource $stream
862: * @param (int|float|string|bool|null)[] $fields
863: * @param Stringable|string|null $uri
864: */
865: public static function writeCsvLine(
866: $stream,
867: array $fields,
868: string $separator = ',',
869: string $enclosure = '"',
870: string $eol = "\n",
871: $uri = null
872: ): int {
873: $special = $separator . $enclosure . "\n\r\t ";
874: foreach ($fields as &$field) {
875: if (strpbrk((string) $field, $special) !== false) {
876: $field = $enclosure
877: . str_replace($enclosure, $enclosure . $enclosure, (string) $field)
878: . $enclosure;
879: }
880: }
881: return self::write(
882: $stream,
883: implode($separator, $fields) . $eol,
884: null,
885: $uri,
886: );
887: }
888:
889: /**
890: * Copy a file or stream to another file or stream
891: *
892: * @param Stringable|string|resource $from
893: * @param Stringable|string|resource $to
894: * @param Stringable|string|null $fromUri
895: * @param Stringable|string|null $toUri
896: */
897: public static function copy($from, $to, $fromUri = null, $toUri = null): void
898: {
899: $fromIsResource = is_resource($from);
900: $toIsResource = is_resource($to);
901: if (
902: ($fromIsResource xor $toIsResource)
903: || (Test::isStringable($from) xor Test::isStringable($to))
904: ) {
905: throw new InvalidArgumentException(
906: 'Argument #1 ($from) and argument #2 ($to) must both be Stringable|string or resource'
907: );
908: }
909:
910: if ($fromIsResource && $toIsResource) {
911: self::assertResourceIsStream($from);
912: self::assertResourceIsStream($to);
913: self::check(
914: @stream_copy_to_stream($from, $to),
915: 'stream_copy_to_stream',
916: null,
917: null,
918: self::getStreamName($fromUri, $from),
919: self::getStreamName($toUri, $to)
920: );
921: return;
922: }
923:
924: $from = (string) $from;
925: $to = (string) $to;
926: self::check(@copy($from, $to), 'copy', null, null, $from, $to);
927: }
928:
929: /**
930: * Get the contents of a file or stream
931: *
932: * @param Stringable|string|resource $resource
933: * @param Stringable|string|null $uri
934: */
935: public static function getContents($resource, ?int $offset = null, $uri = null): string
936: {
937: if (is_resource($resource)) {
938: self::assertResourceIsStream($resource);
939: return self::check(@stream_get_contents($resource, -1, $offset ?? -1), 'stream_get_contents', $uri, $resource);
940: }
941: self::assertResourceIsStringable($resource);
942: $resource = (string) $resource;
943: return self::check(@file_get_contents($resource, false, null, $offset ?? 0), 'file_get_contents', $resource);
944: }
945:
946: /**
947: * Get CSV-formatted data from a file or stream
948: *
949: * @todo Detect file encoding
950: *
951: * @param Stringable|string|resource $resource
952: * @param Stringable|string|null $uri
953: * @return list<array{null}|list<string>>
954: */
955: public static function getCsv($resource, $uri = null): array
956: {
957: $handle = self::maybeOpen($resource, 'rb', $close, $uri);
958: while (($row = @fgetcsv($handle, 0, ',', '"', '')) !== false) {
959: /** @var array{null}|list<string> $row */
960: $data[] = $row;
961: }
962: self::checkEof($handle, $uri);
963: if ($close) {
964: self::close($handle, $uri);
965: }
966: return $data ?? [];
967: }
968:
969: /**
970: * Get the end-of-line sequence used in a file or stream
971: *
972: * Recognised line endings are LF (`"\n"`), CRLF (`"\r\n"`) and CR (`"\r"`).
973: *
974: * @see Get::eol()
975: * @see Str::setEol()
976: *
977: * @param Stringable|string|resource $resource
978: * @param Stringable|string|null $uri
979: * @return string|null `null` if there are no recognised line breaks in the
980: * file.
981: */
982: public static function getEol($resource, $uri = null): ?string
983: {
984: $handle = self::maybeOpen($resource, 'r', $close, $uri);
985: $line = self::readLine($handle, $uri);
986: if ($close) {
987: self::close($handle, $uri);
988: }
989:
990: if ($line === '') {
991: return null;
992: }
993:
994: foreach (["\r\n", "\n", "\r"] as $eol) {
995: if (substr($line, -strlen($eol)) === $eol) {
996: return $eol;
997: }
998: }
999:
1000: if (strpos($line, "\r") !== false) {
1001: return "\r";
1002: }
1003:
1004: return null;
1005: }
1006:
1007: /**
1008: * Get lines from a file or stream
1009: *
1010: * @param Stringable|string|resource $resource
1011: * @param Stringable|string|null $uri
1012: * @return string[]
1013: */
1014: public static function getLines($resource, $uri = null): array
1015: {
1016: if (is_resource($resource)) {
1017: self::assertResourceIsStream($resource);
1018: while (($line = @fgets($resource)) !== false) {
1019: $lines[] = $line;
1020: }
1021: self::checkEof($resource, $uri);
1022: return $lines ?? [];
1023: }
1024: self::assertResourceIsStringable($resource);
1025: $resource = (string) $resource;
1026: return self::check(@file($resource), 'file', $resource);
1027: }
1028:
1029: /**
1030: * Check if a file or stream appears to contain PHP code
1031: *
1032: * Returns `true` if `$resource` has a PHP open tag (`<?php`) at the start
1033: * of the first line that is not a shebang (`#!`).
1034: *
1035: * @param Stringable|string|resource $resource
1036: * @param Stringable|string|null $uri
1037: */
1038: public static function hasPhp($resource, $uri = null): bool
1039: {
1040: $handle = self::maybeOpen($resource, 'r', $close, $uri);
1041: $line = self::readLine($handle, $uri);
1042: if ($line !== '' && substr($line, 0, 2) === '#!') {
1043: $line = self::readLine($handle, $uri);
1044: }
1045: if ($close) {
1046: self::close($handle, $uri);
1047: }
1048:
1049: if ($line === '') {
1050: return false;
1051: }
1052:
1053: return (bool) Regex::match('/^<\?(php\s|(?!php|xml\s))/', $line);
1054: }
1055:
1056: /**
1057: * Get the status of a file or stream
1058: *
1059: * @param Stringable|string|resource $resource
1060: * @param Stringable|string|null $uri
1061: * @return int[]
1062: */
1063: public static function stat($resource, $uri = null): array
1064: {
1065: if (is_resource($resource)) {
1066: self::assertResourceIsStream($resource);
1067: return self::check(@fstat($resource), 'fstat', $uri, $resource);
1068: }
1069: self::assertResourceIsStringable($resource);
1070: $resource = (string) $resource;
1071: return self::check(@stat($resource), 'stat', $resource);
1072: }
1073:
1074: /**
1075: * Write CSV-formatted data to a file or stream
1076: *
1077: * For maximum interoperability with Excel across all platforms, output is
1078: * written in UTF-16LE with a BOM (byte order mark) by default.
1079: *
1080: * @template TValue
1081: *
1082: * @param Stringable|string|resource $resource
1083: * @param iterable<TValue> $data
1084: * @param bool $headerRow Write the first entry's keys before the first row
1085: * of data.
1086: * @param int|float|string|bool|null $nullValue Replace `null` values before
1087: * writing data.
1088: * @param (callable(TValue, int $index): mixed[])|null $callback Apply a
1089: * callback to each entry before it is written.
1090: * @param int|null $count Receives the number of entries written.
1091: * @param Stringable|string|null $uri
1092: * @param-out int $count
1093: */
1094: public static function writeCsv(
1095: $resource,
1096: iterable $data,
1097: bool $headerRow = true,
1098: $nullValue = null,
1099: ?callable $callback = null,
1100: ?int &$count = null,
1101: string $eol = "\r\n",
1102: bool $utf16le = true,
1103: bool $bom = true,
1104: $uri = null
1105: ): void {
1106: $handle = self::maybeOpen($resource, 'wb', $close, $uri);
1107:
1108: if ($utf16le) {
1109: if (!extension_loaded('iconv')) {
1110: // @codeCoverageIgnoreStart
1111: throw new InvalidRuntimeConfigurationException(
1112: "'iconv' extension required for UTF-16LE encoding"
1113: );
1114: // @codeCoverageIgnoreEnd
1115: }
1116: $filter = self::check(@stream_filter_append($handle, 'convert.iconv.UTF-8.UTF-16LE', \STREAM_FILTER_WRITE), 'stream_filter_append', $uri, $handle);
1117: }
1118:
1119: if ($bom) {
1120: self::write($handle, "\u{FEFF}", null, $uri);
1121: }
1122:
1123: $count = 0;
1124: foreach ($data as $entry) {
1125: if ($callback) {
1126: $entry = $callback($entry, $count);
1127: }
1128:
1129: /** @var (int|float|string|bool|mixed[]|object|null)[] $entry */
1130: $row = Arr::toScalars($entry, $nullValue);
1131:
1132: if (!$count && $headerRow) {
1133: self::writeCsvLine($handle, array_keys($row), ',', '"', $eol, $uri);
1134: }
1135:
1136: self::writeCsvLine($handle, $row, ',', '"', $eol, $uri);
1137: $count++;
1138: }
1139:
1140: if ($close) {
1141: self::close($handle, $uri);
1142: } elseif ($utf16le) {
1143: self::check(@stream_filter_remove($filter), 'stream_filter_remove', $uri, $handle);
1144: }
1145: }
1146:
1147: /**
1148: * @param resource $resource
1149: */
1150: private static function assertResourceIsStream($resource): void
1151: {
1152: $type = get_resource_type($resource);
1153: if ($type !== 'stream') {
1154: throw new InvalidArgumentException(
1155: sprintf('Invalid resource type: %s', $type)
1156: );
1157: }
1158: }
1159:
1160: /**
1161: * @param mixed $resource
1162: * @phpstan-assert Stringable|string $resource
1163: */
1164: private static function assertResourceIsStringable($resource): void
1165: {
1166: if (!Test::isStringable($resource)) {
1167: throw new InvalidArgumentTypeException(1, 'resource', 'Stringable|string|resource', $resource);
1168: }
1169: }
1170:
1171: /**
1172: * @template T
1173: *
1174: * @param T $result
1175: * @param Stringable|string|null $uri
1176: * @param resource|null $stream
1177: * @return (T is false ? never : T)
1178: * @phpstan-param T|false $result
1179: * @phpstan-return ($result is false ? never : T)
1180: */
1181: private static function check(
1182: $result,
1183: string $function,
1184: $uri = null,
1185: $stream = null,
1186: string ...$args
1187: ) {
1188: if ($result !== false) {
1189: return $result;
1190: }
1191: $error = error_get_last();
1192: if ($error) {
1193: throw new FilesystemErrorException($error['message']);
1194: }
1195: if (func_num_args() < 3) {
1196: throw new FilesystemErrorException(sprintf(
1197: 'Error calling %s()',
1198: $function,
1199: ));
1200: }
1201: throw new FilesystemErrorException(sprintf(
1202: 'Error calling %s() with %s',
1203: $function,
1204: $args
1205: ? implode(', ', $args)
1206: : self::getStreamName($uri, $stream),
1207: ));
1208: }
1209:
1210: /**
1211: * @param Stringable|string|null $uri
1212: * @param resource|null $stream
1213: */
1214: private static function getStreamName($uri, $stream): string
1215: {
1216: if ($uri !== null) {
1217: return (string) $uri;
1218: }
1219: if ($stream !== null) {
1220: $uri = self::getStreamUri($stream);
1221: }
1222: return $uri ?? '<stream>';
1223: }
1224: }
1225: