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