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 the size of a file
428: *
429: * @phpstan-impure
430: */
431: public static function size(string $filename): int
432: {
433: return self::check(@filesize($filename), 'filesize', $filename);
434: }
435:
436: /**
437: * Get the type of a file
438: *
439: * @return ("fifo"|"char"|"dir"|"block"|"link"|"file"|"socket"|"unknown")
440: */
441: public static function type(string $filename): string
442: {
443: /** @var ("fifo"|"char"|"dir"|"block"|"link"|"file"|"socket"|"unknown") */
444: return self::check(@filetype($filename), 'filetype', $filename);
445: }
446:
447: /**
448: * Write data to a file
449: *
450: * @param resource|array<int|float|string|bool|Stringable|null>|string $data
451: * @param int-mask-of<\FILE_USE_INCLUDE_PATH|\FILE_APPEND|\LOCK_EX> $flags
452: */
453: public static function writeContents(string $filename, $data, int $flags = 0): int
454: {
455: return self::check(@file_put_contents($filename, $data, $flags), 'file_put_contents', $filename);
456: }
457:
458: /**
459: * Check for errors after fgets(), fgetcsv(), etc. return false
460: *
461: * @param resource $stream
462: * @param Stringable|string|null $uri
463: */
464: public static function checkEof($stream, $uri = null): void
465: {
466: $error = error_get_last();
467: if (@feof($stream)) {
468: return;
469: }
470: if ($error) {
471: throw new FilesystemErrorException($error['message']);
472: }
473: throw new FilesystemErrorException(sprintf(
474: 'Error reading from %s',
475: self::getStreamName($uri, $stream),
476: ));
477: }
478:
479: /**
480: * Close an open stream
481: *
482: * @param resource $stream
483: * @param Stringable|string|null $uri
484: */
485: public static function close($stream, $uri = null): void
486: {
487: $uri = self::getStreamName($uri, $stream);
488: self::check(@fclose($stream), 'fclose', $uri);
489: }
490:
491: /**
492: * Close a pipe to a process and return its exit status
493: *
494: * @param resource $pipe
495: */
496: public static function closePipe($pipe, ?string $command = null): int
497: {
498: $result = @pclose($pipe);
499: if ($result === -1) {
500: self::check(false, 'pclose', $command ?? '<pipe>');
501: }
502: return $result;
503: }
504:
505: /**
506: * If a stream is not seekable, copy it to a temporary stream that is and
507: * close it
508: *
509: * @param resource $stream
510: * @param Stringable|string|null $uri
511: * @return resource
512: */
513: public static function getSeekableStream($stream, $uri = null)
514: {
515: if (self::isSeekableStream($stream)) {
516: return $stream;
517: }
518: $seekable = self::open('php://temp', 'r+');
519: self::copy($stream, $seekable, $uri);
520: self::close($stream, $uri);
521: self::rewind($seekable);
522: return $seekable;
523: }
524:
525: /**
526: * Get the URI associated with a stream
527: *
528: * @param resource $stream
529: * @return string|null `null` if `$stream` is closed or does not have a URI.
530: */
531: public static function getStreamUri($stream): ?string
532: {
533: if (self::isStream($stream)) {
534: return stream_get_meta_data($stream)['uri'] ?? null;
535: }
536: return null;
537: }
538:
539: /**
540: * Check if a value is a seekable stream resource
541: *
542: * @param mixed $value
543: * @phpstan-assert-if-true resource $value
544: */
545: public static function isSeekableStream($value): bool
546: {
547: return self::isStream($value)
548: // @phpstan-ignore-next-line
549: && (stream_get_meta_data($value)['seekable'] ?? false);
550: }
551:
552: /**
553: * Check if a value is a stream resource
554: *
555: * @param mixed $value
556: * @phpstan-assert-if-true resource $value
557: */
558: public static function isStream($value): bool
559: {
560: return is_resource($value) && get_resource_type($value) === 'stream';
561: }
562:
563: /**
564: * Open a file or URI
565: *
566: * @return resource
567: */
568: public static function open(string $filename, string $mode)
569: {
570: return self::check(@fopen($filename, $mode), 'fopen', $filename);
571: }
572:
573: /**
574: * Open a resource if it is not already open
575: *
576: * @template TResource of Stringable|string|resource
577: * @template TUri of Stringable|string|null
578: *
579: * @param TResource $resource
580: * @param TUri $uri
581: * @param-out bool $close
582: * @param-out (TUri is null ? string|null : (TResource is resource ? TUri : string)) $uri
583: * @return resource
584: */
585: public static function maybeOpen($resource, string $mode, ?bool &$close, &$uri)
586: {
587: $close = false;
588: if (is_resource($resource)) {
589: /** @phpstan-var resource $resource */
590: self::assertResourceIsStream($resource);
591: $uri ??= self::getStreamUri($resource);
592: return $resource;
593: }
594: self::assertResourceIsStringable($resource);
595: $uri = (string) $resource;
596: $close = true;
597: return self::open($uri, $mode);
598: }
599:
600: /**
601: * Open a pipe to a process
602: *
603: * @return resource
604: */
605: public static function openPipe(string $command, string $mode)
606: {
607: return self::check(@popen($command, $mode), 'popen', $command);
608: }
609:
610: /**
611: * Read from an open stream
612: *
613: * @param resource $stream
614: * @param int<1,max> $length
615: * @param Stringable|string|null $uri
616: */
617: public static function read($stream, int $length, $uri = null): string
618: {
619: return self::check(@fread($stream, $length), 'fread', $uri, $stream);
620: }
621:
622: /**
623: * Read from an open stream until data of the expected length is read
624: *
625: * @param resource $stream
626: * @param int<0,max> $length
627: * @param Stringable|string|null $uri
628: * @throws UnreadDataException when fewer bytes are read than expected and
629: * the stream is at end-of-file.
630: */
631: public static function readAll($stream, int $length, $uri = null): string
632: {
633: if ($length === 0) {
634: return '';
635: }
636: $data = '';
637: $dataLength = 0;
638: do {
639: assert($length - $dataLength > 0);
640: $result = self::read($stream, $length - $dataLength, $uri);
641: if ($result === '') {
642: if (@feof($stream)) {
643: break;
644: }
645: usleep(10000);
646: continue;
647: }
648: $data .= $result;
649: $dataLength += strlen($result);
650: if ($dataLength === $length) {
651: return $data;
652: }
653: // Minimise CPU usage, e.g. when reading from non-blocking streams
654: usleep(10000);
655: } while (true);
656:
657: throw new UnreadDataException(Inflect::format(
658: $length - $dataLength,
659: 'Error reading from stream: expected {{#}} more {{#:byte}} from %s',
660: self::getStreamName($uri, $stream),
661: ));
662: }
663:
664: /**
665: * Read a line from an open stream
666: *
667: * @param resource $stream
668: * @param Stringable|string|null $uri
669: */
670: public static function readLine($stream, $uri = null): string
671: {
672: $line = @fgets($stream);
673: if ($line !== false) {
674: return $line;
675: }
676: self::checkEof($stream, $uri);
677: return '';
678: }
679:
680: /**
681: * Rewind to the beginning of a stream
682: *
683: * @param resource $stream
684: * @param Stringable|string|null $uri
685: */
686: public static function rewind($stream, $uri = null): void
687: {
688: self::check(rewind($stream), 'rewind', $uri, $stream);
689: }
690:
691: /**
692: * Rewind to the beginning of a stream if it is seekable
693: *
694: * @param resource $stream
695: * @param Stringable|string|null $uri
696: */
697: public static function maybeRewind($stream, $uri = null): void
698: {
699: if (self::isSeekableStream($stream)) {
700: self::rewind($stream, $uri);
701: }
702: }
703:
704: /**
705: * Set the file position indicator for a stream
706: *
707: * @param resource $stream
708: * @param \SEEK_SET|\SEEK_CUR|\SEEK_END $whence
709: * @param Stringable|string|null $uri
710: */
711: public static function seek($stream, int $offset, int $whence = \SEEK_SET, $uri = null): void
712: {
713: /** @disregard P1006 */
714: if (@fseek($stream, $offset, $whence) === -1) {
715: self::check(false, 'fseek', $uri, $stream);
716: }
717: }
718:
719: /**
720: * Set the file position indicator for a stream if it is seekable
721: *
722: * @param resource $stream
723: * @param \SEEK_SET|\SEEK_CUR|\SEEK_END $whence
724: * @param Stringable|string|null $uri
725: */
726: public static function maybeSeek($stream, int $offset, int $whence = \SEEK_SET, $uri = null): void
727: {
728: if (self::isSeekableStream($stream)) {
729: /** @disregard P1006 */
730: self::seek($stream, $offset, $whence, $uri);
731: }
732: }
733:
734: /**
735: * Get the file position indicator for a stream
736: *
737: * @param resource $stream
738: * @param Stringable|string|null $uri
739: *
740: * @phpstan-impure
741: */
742: public static function tell($stream, $uri = null): int
743: {
744: return self::check(@ftell($stream), 'ftell', $uri, $stream);
745: }
746:
747: /**
748: * Rewind to the beginning of a stream and truncate it
749: *
750: * @param resource $stream
751: * @param int<0,max> $size
752: * @param Stringable|string|null $uri
753: */
754: public static function truncate($stream, int $size = 0, $uri = null): void
755: {
756: self::seek($stream, 0, \SEEK_SET, $uri);
757: self::check(@ftruncate($stream, $size), 'ftruncate', $uri, $stream);
758: }
759:
760: /**
761: * Write to an open stream
762: *
763: * @param resource $stream
764: * @param int<0,max>|null $length
765: * @param Stringable|string|null $uri
766: * @throws UnwrittenDataException when fewer bytes are written than
767: * expected.
768: */
769: public static function write($stream, string $data, ?int $length = null, $uri = null): int
770: {
771: $result = self::doWrite($stream, $data, $length, $unwritten, $uri);
772: if ($unwritten > 0) {
773: throw new UnwrittenDataException(Inflect::format(
774: $unwritten,
775: 'Error writing to stream: {{#}} {{#:byte}} not written to %s',
776: self::getStreamName($uri, $stream),
777: ));
778: }
779: return $result;
780: }
781:
782: /**
783: * Write to an open stream until there is no unwritten data
784: *
785: * @param resource $stream
786: * @param int<0,max>|null $length
787: * @param Stringable|string|null $uri
788: */
789: public static function writeAll($stream, string $data, ?int $length = null, $uri = null): int
790: {
791: if ($length !== null) {
792: $data = substr($data, 0, $length);
793: }
794: if ($data === '') {
795: return 0;
796: }
797: $result = 0;
798: do {
799: $result += self::maybeWrite($stream, $data, $data, null, $uri);
800: if ($data === '') {
801: return $result;
802: }
803: // Minimise CPU usage, e.g. when writing to non-blocking streams
804: // @codeCoverageIgnoreStart
805: usleep(10000);
806: // @codeCoverageIgnoreEnd
807: } while (true);
808: }
809:
810: /**
811: * Write to an open stream and apply any unwritten data to a buffer
812: *
813: * @param resource $stream
814: * @param-out string $buffer
815: * @param int<0,max>|null $length
816: * @param Stringable|string|null $uri
817: */
818: public static function maybeWrite($stream, string $data, ?string &$buffer, ?int $length = null, $uri = null): int
819: {
820: $result = self::doWrite($stream, $data, $length, $unwritten, $uri);
821: $buffer = substr($data, $result);
822: return $result;
823: }
824:
825: /**
826: * @param resource $stream
827: * @param int<0,max>|null $length
828: * @param Stringable|string|null $uri
829: */
830: private static function doWrite($stream, string $data, ?int $length, ?int &$unwritten, $uri): int
831: {
832: // $length can't be null in PHP 7.4
833: if ($length === null) {
834: $length = strlen($data);
835: $expected = $length;
836: } else {
837: $expected = min($length, strlen($data));
838: }
839: $result = @fwrite($stream, $data, $length);
840: self::check($result, 'fwrite', $uri, $stream);
841: assert($result <= $expected);
842: $unwritten = $expected - $result;
843: return $result;
844: }
845:
846: /**
847: * Write a line of comma-separated values to an open stream
848: *
849: * A shim for {@see fputcsv()} with `$eol` (added in PHP 8.1) and without
850: * `$escape` (which should be removed).
851: *
852: * @param resource $stream
853: * @param (int|float|string|bool|null)[] $fields
854: * @param Stringable|string|null $uri
855: */
856: public static function writeCsvLine(
857: $stream,
858: array $fields,
859: string $separator = ',',
860: string $enclosure = '"',
861: string $eol = "\n",
862: $uri = null
863: ): int {
864: $special = $separator . $enclosure . "\n\r\t ";
865: foreach ($fields as &$field) {
866: if (strpbrk((string) $field, $special) !== false) {
867: $field = $enclosure
868: . str_replace($enclosure, $enclosure . $enclosure, (string) $field)
869: . $enclosure;
870: }
871: }
872: return self::write(
873: $stream,
874: implode($separator, $fields) . $eol,
875: null,
876: $uri,
877: );
878: }
879:
880: /**
881: * Copy a file or stream to another file or stream
882: *
883: * @param Stringable|string|resource $from
884: * @param Stringable|string|resource $to
885: * @param Stringable|string|null $fromUri
886: * @param Stringable|string|null $toUri
887: */
888: public static function copy($from, $to, $fromUri = null, $toUri = null): void
889: {
890: $fromIsResource = is_resource($from);
891: $toIsResource = is_resource($to);
892: if (
893: ($fromIsResource xor $toIsResource)
894: || (Test::isStringable($from) xor Test::isStringable($to))
895: ) {
896: throw new InvalidArgumentException(
897: 'Argument #1 ($from) and argument #2 ($to) must both be Stringable|string or resource'
898: );
899: }
900:
901: if ($fromIsResource && $toIsResource) {
902: self::assertResourceIsStream($from);
903: self::assertResourceIsStream($to);
904: self::check(
905: @stream_copy_to_stream($from, $to),
906: 'stream_copy_to_stream',
907: null,
908: null,
909: self::getStreamName($fromUri, $from),
910: self::getStreamName($toUri, $to)
911: );
912: return;
913: }
914:
915: $from = (string) $from;
916: $to = (string) $to;
917: self::check(@copy($from, $to), 'copy', null, null, $from, $to);
918: }
919:
920: /**
921: * Get the contents of a file or stream
922: *
923: * @param Stringable|string|resource $resource
924: * @param Stringable|string|null $uri
925: */
926: public static function getContents($resource, ?int $offset = null, $uri = null): string
927: {
928: if (is_resource($resource)) {
929: self::assertResourceIsStream($resource);
930: return self::check(@stream_get_contents($resource, -1, $offset ?? -1), 'stream_get_contents', $uri, $resource);
931: }
932: self::assertResourceIsStringable($resource);
933: $resource = (string) $resource;
934: return self::check(@file_get_contents($resource, false, null, $offset ?? 0), 'file_get_contents', $resource);
935: }
936:
937: /**
938: * Get CSV-formatted data from a file or stream
939: *
940: * @todo Detect file encoding
941: *
942: * @param Stringable|string|resource $resource
943: * @param Stringable|string|null $uri
944: * @return list<array{null}|list<string>>
945: */
946: public static function getCsv($resource, $uri = null): array
947: {
948: $handle = self::maybeOpen($resource, 'rb', $close, $uri);
949: while (($row = @fgetcsv($handle, 0, ',', '"', '')) !== false) {
950: /** @var array{null}|list<string> $row */
951: $data[] = $row;
952: }
953: self::checkEof($handle, $uri);
954: if ($close) {
955: self::close($handle, $uri);
956: }
957: return $data ?? [];
958: }
959:
960: /**
961: * Get the end-of-line sequence used in a file or stream
962: *
963: * Recognised line endings are LF (`"\n"`), CRLF (`"\r\n"`) and CR (`"\r"`).
964: *
965: * @see Get::eol()
966: * @see Str::setEol()
967: *
968: * @param Stringable|string|resource $resource
969: * @param Stringable|string|null $uri
970: * @return string|null `null` if there are no recognised line breaks in the
971: * file.
972: */
973: public static function getEol($resource, $uri = null): ?string
974: {
975: $handle = self::maybeOpen($resource, 'r', $close, $uri);
976: $line = self::readLine($handle, $uri);
977: if ($close) {
978: self::close($handle, $uri);
979: }
980:
981: if ($line === '') {
982: return null;
983: }
984:
985: foreach (["\r\n", "\n", "\r"] as $eol) {
986: if (substr($line, -strlen($eol)) === $eol) {
987: return $eol;
988: }
989: }
990:
991: if (strpos($line, "\r") !== false) {
992: return "\r";
993: }
994:
995: return null;
996: }
997:
998: /**
999: * Get lines from a file or stream
1000: *
1001: * @param Stringable|string|resource $resource
1002: * @param Stringable|string|null $uri
1003: * @return string[]
1004: */
1005: public static function getLines($resource, $uri = null): array
1006: {
1007: if (is_resource($resource)) {
1008: self::assertResourceIsStream($resource);
1009: while (($line = @fgets($resource)) !== false) {
1010: $lines[] = $line;
1011: }
1012: self::checkEof($resource, $uri);
1013: return $lines ?? [];
1014: }
1015: self::assertResourceIsStringable($resource);
1016: $resource = (string) $resource;
1017: return self::check(@file($resource), 'file', $resource);
1018: }
1019:
1020: /**
1021: * Check if a file or stream appears to contain PHP code
1022: *
1023: * Returns `true` if `$resource` has a PHP open tag (`<?php`) at the start
1024: * of the first line that is not a shebang (`#!`).
1025: *
1026: * @param Stringable|string|resource $resource
1027: * @param Stringable|string|null $uri
1028: */
1029: public static function hasPhp($resource, $uri = null): bool
1030: {
1031: $handle = self::maybeOpen($resource, 'r', $close, $uri);
1032: $line = self::readLine($handle, $uri);
1033: if ($line !== '' && substr($line, 0, 2) === '#!') {
1034: $line = self::readLine($handle, $uri);
1035: }
1036: if ($close) {
1037: self::close($handle, $uri);
1038: }
1039:
1040: if ($line === '') {
1041: return false;
1042: }
1043:
1044: return (bool) Regex::match('/^<\?(php\s|(?!php|xml\s))/', $line);
1045: }
1046:
1047: /**
1048: * Get the status of a file or stream
1049: *
1050: * @param Stringable|string|resource $resource
1051: * @param Stringable|string|null $uri
1052: * @return int[]
1053: */
1054: public static function stat($resource, $uri = null): array
1055: {
1056: if (is_resource($resource)) {
1057: self::assertResourceIsStream($resource);
1058: return self::check(@fstat($resource), 'fstat', $uri, $resource);
1059: }
1060: self::assertResourceIsStringable($resource);
1061: $resource = (string) $resource;
1062: return self::check(@stat($resource), 'stat', $resource);
1063: }
1064:
1065: /**
1066: * Write CSV-formatted data to a file or stream
1067: *
1068: * For maximum interoperability with Excel across all platforms, output is
1069: * written in UTF-16LE with a BOM (byte order mark) by default.
1070: *
1071: * @template TValue
1072: *
1073: * @param Stringable|string|resource $resource
1074: * @param iterable<TValue> $data
1075: * @param bool $headerRow Write the first entry's keys before the first row
1076: * of data.
1077: * @param int|float|string|bool|null $nullValue Replace `null` values before
1078: * writing data.
1079: * @param (callable(TValue, int $index): mixed[])|null $callback Apply a
1080: * callback to each entry before it is written.
1081: * @param int|null $count Receives the number of entries written.
1082: * @param-out int $count
1083: * @param Stringable|string|null $uri
1084: */
1085: public static function writeCsv(
1086: $resource,
1087: iterable $data,
1088: bool $headerRow = true,
1089: $nullValue = null,
1090: ?callable $callback = null,
1091: ?int &$count = null,
1092: string $eol = "\r\n",
1093: bool $utf16le = true,
1094: bool $bom = true,
1095: $uri = null
1096: ): void {
1097: $handle = self::maybeOpen($resource, 'wb', $close, $uri);
1098:
1099: if ($utf16le) {
1100: if (!extension_loaded('iconv')) {
1101: // @codeCoverageIgnoreStart
1102: throw new InvalidRuntimeConfigurationException(
1103: "'iconv' extension required for UTF-16LE encoding"
1104: );
1105: // @codeCoverageIgnoreEnd
1106: }
1107: $filter = self::check(@stream_filter_append($handle, 'convert.iconv.UTF-8.UTF-16LE', \STREAM_FILTER_WRITE), 'stream_filter_append', $uri, $handle);
1108: }
1109:
1110: if ($bom) {
1111: self::write($handle, "\u{FEFF}", null, $uri);
1112: }
1113:
1114: $count = 0;
1115: foreach ($data as $entry) {
1116: if ($callback) {
1117: $entry = $callback($entry, $count);
1118: }
1119:
1120: /** @var (int|float|string|bool|mixed[]|object|null)[] $entry */
1121: $row = Arr::toScalars($entry, $nullValue);
1122:
1123: if (!$count && $headerRow) {
1124: self::writeCsvLine($handle, array_keys($row), ',', '"', $eol, $uri);
1125: }
1126:
1127: self::writeCsvLine($handle, $row, ',', '"', $eol, $uri);
1128: $count++;
1129: }
1130:
1131: if ($close) {
1132: self::close($handle, $uri);
1133: } elseif ($utf16le) {
1134: self::check(@stream_filter_remove($filter), 'stream_filter_remove', $uri, $handle);
1135: }
1136: }
1137:
1138: /**
1139: * @param resource $resource
1140: */
1141: private static function assertResourceIsStream($resource): void
1142: {
1143: $type = get_resource_type($resource);
1144: if ($type !== 'stream') {
1145: throw new InvalidArgumentException(
1146: sprintf('Invalid resource type: %s', $type)
1147: );
1148: }
1149: }
1150:
1151: /**
1152: * @param mixed $resource
1153: * @phpstan-assert Stringable|string $resource
1154: */
1155: private static function assertResourceIsStringable($resource): void
1156: {
1157: if (!Test::isStringable($resource)) {
1158: throw new InvalidArgumentTypeException(1, 'resource', 'Stringable|string|resource', $resource);
1159: }
1160: }
1161:
1162: /**
1163: * @template T
1164: *
1165: * @param T $result
1166: * @param Stringable|string|null $uri
1167: * @param resource|null $stream
1168: * @return (T is false ? never : T)
1169: * @phpstan-param T|false $result
1170: * @phpstan-return ($result is false ? never : T)
1171: */
1172: private static function check(
1173: $result,
1174: string $function,
1175: $uri = null,
1176: $stream = null,
1177: string ...$args
1178: ) {
1179: if ($result !== false) {
1180: return $result;
1181: }
1182: $error = error_get_last();
1183: if ($error) {
1184: throw new FilesystemErrorException($error['message']);
1185: }
1186: if (func_num_args() < 3) {
1187: throw new FilesystemErrorException(sprintf(
1188: 'Error calling %s()',
1189: $function,
1190: ));
1191: }
1192: throw new FilesystemErrorException(sprintf(
1193: 'Error calling %s() with %s',
1194: $function,
1195: $args
1196: ? implode(', ', $args)
1197: : self::getStreamName($uri, $stream),
1198: ));
1199: }
1200:
1201: /**
1202: * @param Stringable|string|null $uri
1203: * @param resource|null $stream
1204: */
1205: private static function getStreamName($uri, $stream): string
1206: {
1207: if ($uri !== null) {
1208: return (string) $uri;
1209: }
1210: if ($stream !== null) {
1211: $uri = self::getStreamUri($stream);
1212: }
1213: return $uri ?? '<stream>';
1214: }
1215: }
1216: