From 9842693f3e884a429496c129ace0725861068ef1 Mon Sep 17 00:00:00 2001 From: paranoiq Date: Mon, 12 Apr 2021 00:22:34 +0200 Subject: [PATCH] WIP --- build/PHPStan/phpstan.conf.php | 81 +- doc/Io.md | 25 + src/Enum/IntEnum.php | 7 + src/Enum/StringEnum.php | 7 + src/Http/HttpDownloadRequest.php | 6 +- src/Io/BinaryFile.php | 217 ++++ src/Io/ContentType/ContentTypeDetector.php | 147 ++- src/Io/DirectoryIterator.php | 69 -- src/Io/File.php | 449 +++----- src/Io/FileInfo.php | 248 +++-- src/Io/FileMode.php | 60 +- src/Io/FilePermissions.php | 47 + src/Io/FilePosition.php | 5 +- src/Io/FileType.php | 38 + src/Io/Ini/Ini.php | 168 +++ src/Io/Io.php | 997 ++++++++++++++++++ src/Io/LinkInfo.php | 81 ++ src/Io/LockType.php | 5 +- src/Io/Path.php | 28 + src/Io/Process.php | 243 +++++ src/Io/RecursiveDirectoryIterator.php | 70 -- .../FtpContext.php} | 11 +- src/Io/Stream/HttpContext.php | 34 + src/Io/Stream/HttpOptions.php | 30 + .../PharContext.php} | 6 +- src/Io/Stream/SocketContext.php | 17 + src/Io/Stream/SocketOptions.php | 27 + src/Io/Stream/SslOptions.php | 40 + src/Io/Stream/SslOptionsMixin.php | 38 + src/Io/Stream/StreamContext.php | 376 +++++++ src/Io/Stream/StreamEvent.php | 39 + src/Io/Stream/StreamEventSeverity.php | 25 + src/Io/Stream/StreamInfo.php | 106 ++ src/Io/Stream/Wrapper.php | 25 + .../ZipContext.php} | 6 +- src/Io/TextFile.php | 417 +++++++- ....php => ContentTypeDetectionException.php} | 22 +- src/Io/exceptions/FilesystemException.php | 103 ++ src/Language/Cp437.php | 59 ++ src/Language/Encoding.php | 68 +- src/Language/Locale/Locale.php | 16 +- src/System/Callstack.php | 59 ++ src/System/CallstackFrame.php | 210 ++++ src/System/Php.php | 21 +- .../CannotChangePriorityException.php} | 4 +- src/Time/DateTime.php | 16 + src/common/Check.php | 12 + src/common/Str.php | 7 + src/common/Text.php | 63 ++ src/common/consts/BitSize.php | 4 +- src/common/consts/IntBounds.php | 14 +- src/common/consts/LineEnding.php | 6 + tests/src/Enum/Enum.all.phpt | 6 - tests/src/Io/BinaryFile.phpt | 272 +++++ tests/src/Io/Io.dirs.phpt | 289 +++++ tests/src/Io/Io.files.phpt | 270 +++++ tests/src/Io/Io.misc.phpt | 110 ++ tests/src/Io/TextFile.phpt | 87 ++ tests/src/Io/textfile/foo.txt | Bin 0 -> 6 bytes tests/src/Math/IntCalc.phpt | 1 + tests/src/System/Callstack.phpt | 248 +++++ tests/src/common/Check.basic.phpt | 15 + 62 files changed, 5407 insertions(+), 770 deletions(-) create mode 100644 doc/Io.md create mode 100644 src/Io/BinaryFile.php delete mode 100644 src/Io/DirectoryIterator.php create mode 100644 src/Io/FilePermissions.php create mode 100644 src/Io/FileType.php create mode 100644 src/Io/Ini/Ini.php create mode 100644 src/Io/Io.php create mode 100644 src/Io/LinkInfo.php create mode 100644 src/Io/Path.php create mode 100644 src/Io/Process.php delete mode 100644 src/Io/RecursiveDirectoryIterator.php rename src/Io/{LineEndings.php => Stream/FtpContext.php} (62%) create mode 100644 src/Io/Stream/HttpContext.php create mode 100644 src/Io/Stream/HttpOptions.php rename src/Io/{exceptions/DirectoryException.php => Stream/PharContext.php} (74%) create mode 100644 src/Io/Stream/SocketContext.php create mode 100644 src/Io/Stream/SocketOptions.php create mode 100644 src/Io/Stream/SslOptions.php create mode 100644 src/Io/Stream/SslOptionsMixin.php create mode 100644 src/Io/Stream/StreamContext.php create mode 100644 src/Io/Stream/StreamEvent.php create mode 100644 src/Io/Stream/StreamEventSeverity.php create mode 100644 src/Io/Stream/StreamInfo.php create mode 100644 src/Io/Stream/Wrapper.php rename src/Io/{exceptions/StreamException.php => Stream/ZipContext.php} (77%) rename src/Io/exceptions/{FileException.php => ContentTypeDetectionException.php} (56%) create mode 100644 src/Io/exceptions/FilesystemException.php create mode 100644 src/Language/Cp437.php create mode 100644 src/System/Callstack.php create mode 100644 src/System/CallstackFrame.php rename src/{Io/ContentType/exceptions/ContentTypeDetectionException.php => System/exceptions/CannotChangePriorityException.php} (78%) create mode 100644 src/common/Text.php create mode 100644 tests/src/Io/BinaryFile.phpt create mode 100644 tests/src/Io/Io.dirs.phpt create mode 100644 tests/src/Io/Io.files.phpt create mode 100644 tests/src/Io/Io.misc.phpt create mode 100644 tests/src/Io/TextFile.phpt create mode 100644 tests/src/Io/textfile/foo.txt create mode 100644 tests/src/System/Callstack.phpt diff --git a/build/PHPStan/phpstan.conf.php b/build/PHPStan/phpstan.conf.php index 1b61b9ed..132bbbc1 100644 --- a/build/PHPStan/phpstan.conf.php +++ b/build/PHPStan/phpstan.conf.php @@ -1,33 +1,52 @@ -= 80000) { - $ignore[] = '~Parameter #1 \$objectOrClass of class ReflectionClass constructor expects class-string\|T of object, string given.~'; # in MethodTypeParser; temporary - $ignore[] = '~Strict comparison using === between CurlMultiHandle and false will always evaluate to false.~'; # in HttpChannelManager; probably a reflection bug -} -// 7.1 - 8.0 -if (PHP_VERSION_ID < 80000) { - $ignore[] = '~Parameter #1 \$argument of class ReflectionClass constructor expects class-string\|T of object, string given.~'; # you know nothing - $ignore[] = '~Method Dogma\\\\Arr::combine\(\) should return array but returns array\|false.~'; # in Arr - $ignore[] = '~Parameter #1 \$items of class Dogma\\\\ImmutableArray constructor expects array, array\|false given.~'; # in ImmutableArray - $ignore[] = '~has unknown class Curl(Multi)?Handle as its type.~'; # PHP 7 -> 8 - $ignore[] = '~has invalid return type Curl(Multi)?Handle~'; # PHP 7 -> 8 -} - -$excludePaths = [ - '*/tests/*/data/*', -]; -if (PHP_VERSION_ID < 70200) { - // interface changes allowed in later versions, non-fatal, but not able to ignore in phpstan - $excludePaths[] = '*/Time/DateTime.php'; -} - -return [ - 'parameters' => [ - 'ignoreErrors' => $ignore, - 'excludePaths' => [ - 'analyse' => $excludePaths, +$ignore = PHP_VERSION_ID < 80000 + ? [ + '~Parameter #1 \$argument of class ReflectionClass constructor expects class-string\|T of object, string given.~', # you know nothing + '~Method Dogma\\\\Arr::combine\(\) should return array but returns array\|false.~', # in Arr + '~Parameter #1 \$items of class Dogma\\\\ImmutableArray constructor expects array, array\|false given.~', # in ImmutableArray + '~Strict comparison using === between array and false will always evaluate to false.~', # in Cls + '~Strict comparison using === between DateInterval and false will always evaluate to false.~', # in Time + '~Strict comparison using === between static\(Dogma\\\\Time\\\\DateTime\) and false will always evaluate to false.~', # in DateTime + '~has unknown class Curl(Multi)?Handle as its type.~', # PHP 7 -> 8 + '~has invalid type Curl(Multi)?Handle.~', # PHP 7 -> 8 + [ + 'message' => '~Strict comparison using === between int and false will always evaluate to false.~', + 'path' => '../../src/Time/DateTime.php', + ], + [ + 'message' => '~Strict comparison using === between string and false will always evaluate to false.~', + 'path' => '../../src/Language/Locale/Locale.php', + ], + [ + 'message' => '~Strict comparison using === between (string|int) and false will always evaluate to false.~', + 'path' => '../../src/Language/Collator.php', ], - ], -]; + [ + 'message' => '~Strict comparison using === between resource and false will always evaluate to false.~', + 'path' => '../../src/Http/HttpRequest.php', + ], + [ + 'message' => '~Strict comparison using === between (PDOStatement|string) and false will always evaluate to false.~', + 'path' => '../../src/Database/SimplePdo.php', + ], + [ + 'message' => '~Strict comparison using === between array\|string\|false> and false will always evaluate to false.~', + 'path' => '../../src/Application/Configurator.php', + ] + ] + : [ + '~expects DateTimeZone(\|null)?, DateTimeZone\|false given~', # ignore DateTime::getTimeZone() returning false everywhere, because in that case, something is very wrong (probably php.ini) + '~should return DateTimeZone but returns DateTimeZone\|false.~', # -//- + '~\(DateTimeZone\) does not accept DateTimeZone\|false.~', # -//- + '~Cannot call method [a-zA-Z]+\(\) on DateTimeZone\|false.~', # -//- + '~Parameter #1 \$objectOrClass of class ReflectionClass constructor expects class-string\|T of object, string given.~', # in MethodTypeParser; temporary + '~Strict comparison using === between CurlMultiHandle and false will always evaluate to false.~', # in HttpChannelManager; probably a reflection bug + '~Method Dogma\\\\Language\\\\Locale\\\\Locale::matches\(\) should return bool but returns bool\|null.~', # not sure + [ + 'message' => '~Strict comparison using === between array and null will always evaluate to false.~', + 'path' => '../../src/Io/TextFile.php', + ] + ]; + +return ['parameters' => ['ignoreErrors' => $ignore]]; diff --git a/doc/Io.md b/doc/Io.md new file mode 100644 index 00000000..923cf36e --- /dev/null +++ b/doc/Io.md @@ -0,0 +1,25 @@ + +Dogma\Io +======== + +Main classes: +------------- + +- **Io** (static) - filesystem toolbox. basic file and directory operations +- **FileInfo** - file/directory path and operations on that + - **LinkInfo** - symbolic link path and operations on that +- **File** - open file in binary mode + - **TextFile** - open file in text mode (lines, CSV) + +Exception hierarchy: +-------------------- + +- **IoException** + - **FilesystemException** + - **FileAlreadyExistsException** + - **FileDoesNotExistException** + - **FilePermissionsException** + - **FileLockingException** + - **StreamException** + - **IniException** + - **ContentTypeDetectionException** \ No newline at end of file diff --git a/src/Enum/IntEnum.php b/src/Enum/IntEnum.php index 9ac6f988..0636c195 100644 --- a/src/Enum/IntEnum.php +++ b/src/Enum/IntEnum.php @@ -69,6 +69,13 @@ public function dump(): string ); } + final public static function checkValue(int $value): void + { + if (!self::isValid($value)) { + throw new InvalidValueException($value, static::class); + } + } + /** * Validates given value. Can also normalize the value, if needed. * diff --git a/src/Enum/StringEnum.php b/src/Enum/StringEnum.php index f01a72ad..8a7b77f8 100644 --- a/src/Enum/StringEnum.php +++ b/src/Enum/StringEnum.php @@ -69,6 +69,13 @@ public function dump(): string ); } + final public static function checkValue(string $value): void + { + if (!self::isValid($value)) { + throw new InvalidValueException($value, static::class); + } + } + /** * Validates given value. Can also normalize the value, if needed. * diff --git a/src/Http/HttpDownloadRequest.php b/src/Http/HttpDownloadRequest.php index 386b98a4..1d39f679 100644 --- a/src/Http/HttpDownloadRequest.php +++ b/src/Http/HttpDownloadRequest.php @@ -9,7 +9,7 @@ namespace Dogma\Http; -use Dogma\Io\File; +use Dogma\Io\BinaryFile; use const CURLOPT_BINARYTRANSFER; use const CURLOPT_FILE; @@ -19,7 +19,7 @@ class HttpDownloadRequest extends HttpRequest { - /** @var File */ + /** @var BinaryFile */ private $file; /** @@ -38,7 +38,7 @@ public function prepare(): void { parent::prepare(); - $this->file = File::createTemporaryFile(); + $this->file = BinaryFile::createTemporaryFile(); $this->setOption(CURLOPT_FILE, $this->file->getHandle()); $this->setOption(CURLOPT_BINARYTRANSFER, true); diff --git a/src/Io/BinaryFile.php b/src/Io/BinaryFile.php new file mode 100644 index 00000000..bc662c1e --- /dev/null +++ b/src/Io/BinaryFile.php @@ -0,0 +1,217 @@ +handle = $file; + $this->mode = $mode; + return; + } elseif (is_string($file)) { + $this->path = Io::normalizePath($file); + } elseif ($file instanceof Path) { + $this->path = $file->getPath(); + } else { + throw new InvalidArgumentException('Argument $file must be a file path or a stream resource.'); + } + + $this->mode = $mode; + $this->context = $context; + + if ($this->handle === null) { + $this->reopen(); + } + } + + /** + * @return static + */ + public static function createTemporaryFile(): self + { + error_clear_last(); + /** @var resource|false $handle */ + $handle = tmpfile(); + + if ($handle === false) { + throw FilesystemException::create("Cannot create a temporary file", null, null, error_get_last()); + } + + return new static($handle, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + } + + public static function createMemoryFile(?int $maxSize = null): self + { + if ($maxSize === null) { + return new static('php://memory', FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + } else { + return new static("php://temp/maxmemory:$maxSize", FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + } + } + + public function toTextFile(?string $encoding = null, ?string $lineEnding = null): TextFile + { + return new TextFile($this->getHandle(), $this->mode, $this->context, $encoding, $lineEnding); + } + + // content --------------------------------------------------------------------------------------------------------- + + public function getContents(): string + { + if ($this->getPosition()) { + $this->setPosition(0); + } + + $results = []; + while (!$this->endOfFileReached()) { + $results[] = $this->read(); + } + + return implode('', $results); + } + + public function read(?int $bytes = null): ?string + { + $bytes = $bytes ?? self::$defaultChunkSize; + + if (!FileMode::isReadable($this->mode)) { + throw new LogicException('Cannot read - file opened in write only mode.'); + } + + error_clear_last(); + $data = @fread($this->getHandle(), $bytes); + + if ($data === false) { + if ($this->endOfFileReached()) { + throw FilesystemException::create("Cannot read from file, end of file was reached", $this->path, $this->context, error_get_last()); + } else { + throw FilesystemException::create("Cannot read from file", $this->path, $this->context, error_get_last()); + } + } + + return $data === '' ? null : $data; + } + + public function write(string $data, ?int $bytes = null): void + { + error_clear_last(); + if ($bytes !== null) { + $result = @fwrite($this->getHandle(), $data, $bytes); + } else { + $result = @fwrite($this->getHandle(), $data); + } + + if ($result === false) { + throw FilesystemException::create("Cannot write to file", $this->path, $this->context, error_get_last()); + } + } + + /** + * Truncate file and move pointer at the end + * @param int $bytes + */ + public function truncate(int $bytes = 0): void + { + error_clear_last(); + $result = @ftruncate($this->getHandle(), $bytes); + + if ($result === false) { + throw FilesystemException::create("Cannot truncate file", $this->path, $this->context, error_get_last()); + } + + $this->setPosition($bytes); + } + + /** + * Copy range of data to another file (appending) or callback. Returns actual length of copied data. + * + * @param self|string|FileInfo|callable $destination + * @param int|null $start + * @param int $bytes + * @param int|null $chunkSize + * @return int + */ + public function copyData($destination, ?int $start = null, int $bytes = 0, ?int $chunkSize = null): int + { + $chunkSize = $chunkSize ?? self::$defaultChunkSize; + if ($start !== null) { + $this->setPosition($start); + } + + $close = false; + if (is_string($destination) && !is_callable($destination)) { + $destination = new FileInfo($destination); + } + if ($destination instanceof FileInfo) { + $destination = $destination->open(FileMode::CREATE_OR_APPEND_WRITE); + $close = true; + } + + $done = 0; + $chunk = $bytes ? min($bytes - $done, $chunkSize) : $chunkSize; + while (!$this->endOfFileReached() && (!$bytes || $done < $bytes)) { + $buffer = $this->read($chunk); + if ($buffer === null) { + return $done; + } + + $done += strlen($buffer); + + if ($destination instanceof self) { + $destination->write($buffer); + + } elseif (is_callable($destination)) { + $destination($buffer); + + } else { + throw new InvalidArgumentException('Destination must be File or callable!'); + } + } + + if ($close) { + $destination->close(); + } + + return $done; + } + +} diff --git a/src/Io/ContentType/ContentTypeDetector.php b/src/Io/ContentType/ContentTypeDetector.php index 2d18ff72..b473e640 100644 --- a/src/Io/ContentType/ContentTypeDetector.php +++ b/src/Io/ContentType/ContentTypeDetector.php @@ -9,23 +9,29 @@ namespace Dogma\Io\ContentType; +use Dogma\Io\ContentTypeDetectionException; use Dogma\Io\Path; use Dogma\Language\Encoding; use Dogma\StrictBehaviorMixin; use finfo; +use const FILEINFO_EXTENSION; +use const FILEINFO_MIME_ENCODING; +use const FILEINFO_MIME_TYPE; +use const FILEINFO_NONE; +use const FILEINFO_PRESERVE_ATIME; use function error_clear_last; use function error_get_last; +use function explode; use function finfo_buffer; use function finfo_file; use function finfo_open; -use const FILEINFO_MIME_ENCODING; -use const FILEINFO_MIME_TYPE; +use function finfo_set_flags; class ContentTypeDetector { use StrictBehaviorMixin; - /** @var string|null */ + /** @var string */ private $magicFile; /** @var resource|finfo|null */ @@ -34,31 +40,20 @@ class ContentTypeDetector /** @var resource|finfo|null */ private $encodingHandler; - public function __construct(?string $magicFile = null) + public function __construct(string $magicFile) { $this->magicFile = $magicFile; } - private function initTypeHandler(): void - { - error_clear_last(); - /** @var resource|false $typeHandler */ - $typeHandler = finfo_open(FILEINFO_MIME_TYPE, $this->magicFile); - if ($typeHandler === false) { - throw new ContentTypeDetectionException('Cannot initialize finfo extension: ' . error_get_last()['message']); - } - $this->typeHandler = $typeHandler; - } - - private function initEncodingHandler(): void + private function init(): void { error_clear_last(); - /** @var resource|false $encodingHandler */ - $encodingHandler = finfo_open(FILEINFO_MIME_ENCODING, $this->magicFile); - if ($encodingHandler === false) { - throw new ContentTypeDetectionException('Cannot initialize finfo extension. ' . error_get_last()['message']); + /** @var resource|false $handler */ + $handler = finfo_open(FILEINFO_NONE, $this->magicFile); + if ($handler === false) { + throw new ContentTypeDetectionException('Cannot initialize fileinfo extension.', error_get_last()); } - $this->encodingHandler = $encodingHandler; + $this->handler = $handler; } /** @@ -67,15 +62,20 @@ private function initEncodingHandler(): void */ public function detectFileContentType($file): ?ContentType { - if ($this->typeHandler === null) { - $this->initTypeHandler(); + if ($this->handler === null) { + $this->init(); } - error_clear_last(); $path = $file instanceof Path ? $file->getPath() : $file; - $type = finfo_file($this->typeHandler, $path); + + error_clear_last(); + $res = finfo_set_flags($this->handler, FILEINFO_MIME_TYPE | FILEINFO_PRESERVE_ATIME); + if ($res === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + $type = finfo_file($this->handler, $path); if ($type === false) { - throw new ContentTypeDetectionException('Cannot read file info. ' . error_get_last()['message']); + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); } return ContentType::get($type); @@ -83,52 +83,113 @@ public function detectFileContentType($file): ?ContentType public function detectStringContentType(string $string): ?ContentType { - if ($this->typeHandler === null) { - $this->initTypeHandler(); + if ($this->handler === null) { + $this->init(); } error_clear_last(); - $type = finfo_buffer($this->typeHandler, $string); + $res = finfo_set_flags($this->handler, FILEINFO_MIME_TYPE); + if ($res === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + $type = finfo_buffer($this->handler, $string); if ($type === false) { - throw new ContentTypeDetectionException('Cannot read file info. ' . error_get_last()['message']); + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); } return ContentType::get($type); } + /** + * @param string|Path $file + * @return string|null + */ + public function detectFileExtension($file): ?string + { + if ($this->handler === null) { + $this->init(); + } + + $path = $file instanceof Path ? $file->getPath() : $file; + + error_clear_last(); + $res = finfo_set_flags($this->handler, FILEINFO_EXTENSION | FILEINFO_PRESERVE_ATIME); + if ($res === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + $extensions = finfo_file($this->handler, $path); + if ($extensions === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + + $first = explode('/', $extensions)[0]; + + return $first === '???' ? null : $first; + } + + public function detectStringExtension(string $string): ?string + { + if ($this->handler === null) { + $this->init(); + } + + error_clear_last(); + $res = finfo_set_flags($this->handler, FILEINFO_EXTENSION); + if ($res === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + $extensions = finfo_buffer($this->handler, $string); + if ($extensions === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + + $first = explode('/', $extensions)[0]; + + return $first === '???' ? null : $first; + } + /** * @param string|Path $file * @return Encoding|null */ public function detectFileEncoding($file): ?Encoding { - if ($this->encodingHandler === null) { - $this->initEncodingHandler(); + if ($this->handler === null) { + $this->init(); } - error_clear_last(); $path = $file instanceof Path ? $file->getPath() : $file; - $type = finfo_file($this->encodingHandler, $path); - if ($type === false) { - throw new ContentTypeDetectionException('Cannot read file info. ' . error_get_last()['message']); + + error_clear_last(); + $res = finfo_set_flags($this->handler, FILEINFO_MIME_ENCODING | FILEINFO_PRESERVE_ATIME); + if ($res === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + $encoding = finfo_file($this->handler, $path); + if ($encoding === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); } - return Encoding::get($type); + return Encoding::get($encoding); } public function detectStringEncoding(string $string): ?Encoding { - if ($this->encodingHandler === null) { - $this->initEncodingHandler(); + if ($this->handler === null) { + $this->init(); } error_clear_last(); - $type = finfo_buffer($this->encodingHandler, $string); - if ($type === false) { - throw new ContentTypeDetectionException('Cannot read file info. ' . error_get_last()['message']); + $res = finfo_set_flags($this->handler, FILEINFO_MIME_ENCODING); + if ($res === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); + } + $encoding = finfo_buffer($this->handler, $string); + if ($encoding === false) { + throw new ContentTypeDetectionException('Cannot read file info.', error_get_last()); } - return Encoding::get($type); + return Encoding::get($encoding); } } diff --git a/src/Io/DirectoryIterator.php b/src/Io/DirectoryIterator.php deleted file mode 100644 index b00941cb..00000000 --- a/src/Io/DirectoryIterator.php +++ /dev/null @@ -1,69 +0,0 @@ -flags = $flags; - try { - if (!($flags & FilesystemIterator::CURRENT_AS_PATHNAME) && !($flags & FilesystemIterator::CURRENT_AS_SELF)) { - parent::__construct($path, $flags | FilesystemIterator::CURRENT_AS_PATHNAME); - } else { - parent::__construct($path, $flags); - } - } catch (UnexpectedValueException $e) { - throw new DirectoryException($e->getMessage(), $e); - } - } - - /** - * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint - * @param int|null $flags - */ - public function setFlags($flags = null): void - { - $this->flags = $flags; - if (!($flags & FilesystemIterator::CURRENT_AS_PATHNAME) && !($flags & FilesystemIterator::CURRENT_AS_SELF)) { - parent::setFlags($flags | FilesystemIterator::CURRENT_AS_PATHNAME); - } else { - parent::setFlags($flags); - } - } - - /** - * @return FileInfo|mixed - */ - #[ReturnTypeWillChange] - public function current() - { - if (!($this->flags & FilesystemIterator::CURRENT_AS_PATHNAME) && !($this->flags & FilesystemIterator::CURRENT_AS_SELF)) { - /** @var string $path */ - $path = parent::current(); - - return new FileInfo($path); - } - return parent::current(); - } - -} diff --git a/src/Io/File.php b/src/Io/File.php index a83deedf..c54099ea 100644 --- a/src/Io/File.php +++ b/src/Io/File.php @@ -7,19 +7,18 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ -// spell-check-ignore: maxmemory - namespace Dogma\Io; -use Dogma\InvalidArgumentException; -use Dogma\Io\Filesystem\FileInfo; +use Dogma\Check; +use Dogma\Io\Stream\StreamInfo; +use Dogma\LogicException; +use Dogma\Math\PowersOfTwo; use Dogma\NonCloneableMixin; use Dogma\NonSerializableMixin; -use Dogma\ResourceType; use Dogma\StrictBehaviorMixin; +use StreamContext; use const LOCK_UN; use function basename; -use function chmod; use function dirname; use function error_clear_last; use function error_get_last; @@ -28,117 +27,77 @@ use function fflush; use function flock; use function fopen; -use function fread; use function fseek; -use function fstat; use function ftell; -use function ftruncate; -use function fwrite; -use function get_resource_type; -use function is_callable; use function is_dir; use function is_resource; -use function is_string; -use function min; -use function rename; -use function str_replace; use function stream_get_meta_data; -use function strlen; -use function tmpfile; +use function stream_set_blocking; +use function stream_set_read_buffer; +use function stream_set_write_buffer; /** - * Binary file reader/writer + * Common base for BinaryFile and TextFile */ -class File implements Path +abstract class File implements Path { use StrictBehaviorMixin; use NonCloneableMixin; use NonSerializableMixin; /** @var positive-int */ - public static $defaultChunkSize = 8192; + public static $defaultChunkSize = PowersOfTwo::_64K; - /** @var string */ + /** @var string|null */ protected $path; /** @var string */ protected $mode; - /** @var resource|null */ - protected $streamContext; + /** @var StreamContext|null */ + protected $context; /** @var resource|null */ protected $handle; - /** @var FileMetaData|null */ - private $metaData; + /** @var int|null */ + protected $lock; + + /** @var FileInfo|null */ + protected $fileInfo; /** * @param string|resource|FilePath|FileInfo $file * @param resource|null $streamContext */ - final public function __construct($file, string $mode = FileMode::OPEN_READ, $streamContext = null) - { - if (is_resource($file) && get_resource_type($file) === ResourceType::STREAM) { - $this->handle = $file; - $this->mode = $mode; - return; - } elseif (is_string($file)) { - $this->path = $file; - } elseif ($file instanceof FilePath || $file instanceof FileInfo) { - $this->path = $file->getPath(); - } else { - throw new InvalidArgumentException('Argument $file must be a file path or a stream resource.'); - } - - $this->mode = $mode; - $this->streamContext = $streamContext; + abstract public function __construct($file, string $mode = FileMode::OPEN_READ, ?StreamContext $context = null); - if ($this->handle === null) { - $this->open(); - } - } - - public function __destruct() - { - if ($this->handle !== null) { - $this->close(); - } - } - - public static function createTemporaryFile(): self + /** + * @return resource + */ + public function getHandle() { - error_clear_last(); - /** @var resource|false $handle */ - $handle = tmpfile(); - - if ($handle === false) { - throw new FileException('Cannot create a temporary file.', error_get_last()); + if (!is_resource($this->handle)) { + throw FilesystemException::create("File is already closed", $this->path, $this->context); } - return new static($handle, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + return $this->handle; } - public static function createMemoryFile(?int $maxSize = null): self + // info ------------------------------------------------------------------------------------------------------------ + + public function getFileInfo(): FileInfo { - if ($maxSize === null) { - return new static('php://memory', FileMode::CREATE_OR_TRUNCATE_READ_WRITE); - } else { - return new static("php://temp/maxmemory:$maxSize", FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + if (!$this->fileInfo) { + $this->fileInfo = new FileInfo($this); } - } - public function toTextFile(): TextFile - { - return new TextFile($this->handle, $this->mode, $this->streamContext); + return $this->fileInfo; } - /** - * @return resource|null - */ - public function getHandle() + public function getStreamInfo(): StreamInfo { - return $this->handle; + return new StreamInfo(stream_get_meta_data($this->getHandle())); } public function getMode(): string @@ -148,9 +107,9 @@ public function getMode(): string public function getPath(): string { - if (empty($this->path)) { - $meta = $this->getStreamMetaData(); - $this->path = str_replace('\\', '/', $meta['uri']); + if ($this->path === null) { + $info = $this->getStreamInfo(); + $this->path = Io::normalizePath($info->uri); } return $this->path; @@ -161,332 +120,224 @@ public function getName(): string return basename($this->getPath()); } - public function move(string $path): self + public function isOpen(): bool + { + return is_resource($this->handle); + } + + // actions --------------------------------------------------------------------------------------------------------- + + /** + * Move file to a new destination + * Closes and reopens file in the process, tries to lock it if the file was locked before + * Creates path when RECURSIVE is set + * + * @param string $destination + * @param int $flags + */ + public function rename(string $destination, int $flags = 0): void { - $destination = dirname($path); - if (!is_dir($destination)) { - throw new FileException("Directory $destination is not writable."); + Check::flags($flags, Io::RECURSIVE); + + $dir = dirname($destination); + if (!is_dir($dir)) { + throw FilesystemException::create("Path is not a directory", $destination); } - if (!rename($this->getPath(), $destination)) { - throw new FileException("Cannot move file '{$this->getPath()}' to '$path'."); + + $wasOpen = false; + $lock = null; + if ($this->isOpen()) { + $wasOpen = true; + $lock = $this->lock; + $this->close(); } - chmod($destination, 0666); - $this->path = $destination; + // todo: does not work for previously opened file. must be created + Io::rename($this->getPath(), $destination, $flags, $this->context); - return $this; + $this->path = $destination; + if ($wasOpen) { + $this->reopen(); + } + if ($lock !== null) { + $this->lock($lock); + } } - public function isOpen(): bool + public function reopen(): void { - return (bool) $this->handle; - } + if ($this->isOpen()) { + throw new LogicException('The file is not closed.'); + } + $this->mode = FileMode::getReopenMode($this->mode); - public function open(): void - { error_clear_last(); - if ($this->streamContext !== null) { - $handle = fopen($this->path, $this->mode, false, $this->streamContext); + if ($this->context !== null) { + $handle = @fopen($this->getPath(), $this->mode, false, $this->context->getResource()); } else { - $handle = fopen($this->path, $this->mode, false); + $handle = @fopen($this->getPath(), $this->mode, false); } if ($handle === false) { - throw new FileException("Cannot open file in mode '$this->mode'.", error_get_last()); + throw FilesystemException::create("Cannot open file in mode '$this->mode'", $this->path, $this->context, error_get_last()); } $this->handle = $handle; } public function close(): void { - $this->checkOpened(); + if (!is_resource($this->handle)) { + $this->fileInfo = null; + $this->handle = null; + $this->lock = null; + return; + } error_clear_last(); - $result = fclose($this->handle); + $result = @fclose($this->handle); if ($result === false) { - throw new FileException('Cannot close file.', error_get_last()); + throw FilesystemException::create("Cannot close file", $this->path, $this->context, error_get_last()); } - $this->metaData = null; + + $this->fileInfo = null; $this->handle = null; + $this->lock = null; } public function endOfFileReached(): bool { - $this->checkOpened(); - error_clear_last(); - $feof = feof($this->handle); + $end = @feof($this->getHandle()); - if ($feof === true) { + if ($end === true) { $error = error_get_last(); if ($error !== null) { - throw new FileException('File interrupted.', $error); + throw FilesystemException::create("File reading interrupted", $this->path, $this->context, $error); } } - return $feof; + return $end; } - /** - * @param positive-int|null $length - */ - public function read(?int $length = null): string - { - $this->checkOpened(); - - if ($length === null) { - $length = self::$defaultChunkSize; - } - - error_clear_last(); - $data = fread($this->handle, $length); - - if ($data === false) { - if ($this->endOfFileReached()) { - throw new FileException('Cannot read data from file. End of file was reached.', error_get_last()); - } else { - throw new FileException('Cannot read data from file.', error_get_last()); - } - } - - return $data; - } - - /** - * Copy range of data to another File or callback. Returns actual length of copied data. - * @param File|callable $destination - * @return int - */ - public function copyData($destination, ?int $start = null, int $length = 0, ?int $chunkSize = null): int + public function setPosition(int $position, ?int $from = null): void { - if ($chunkSize === null) { - $chunkSize = self::$defaultChunkSize; + if ($position < 0) { + $position *= -1; + $from = FilePosition::END; } - if ($start !== null) { - $this->setPosition($start); + if ($from === null) { + $from = FilePosition::BEGINNING; } - $done = 0; - /** @var positive-int $chunk */ - $chunk = $length ? min($length - $done, $chunkSize) : $chunkSize; - while (!$this->endOfFileReached() && (!$length || $done < $length)) { - $buff = $this->read($chunk); - $done += strlen($buff); - - if ($destination instanceof self) { - $destination->write($buff); - - } elseif (is_callable($destination)) { - $destination($buff); + error_clear_last(); + $result = @fseek($this->getHandle(), $position, $from); - } else { - throw new InvalidArgumentException('Destination must be File or callable!'); - } + if ($result !== 0) { + throw FilesystemException::create("Cannot set file pointer position", $this->path, $this->context, error_get_last()); } - - return $done; } - public function getContent(): string + public function getPosition(): int { - if ($this->getPosition()) { - $this->setPosition(0); - } + error_clear_last(); + $position = @ftell($this->getHandle()); - $result = ''; - while (!$this->endOfFileReached()) { - $result .= $this->read(); + if ($position === false) { + throw FilesystemException::create("Cannot get file pointer position", $this->path, $this->context, error_get_last()); } - return $result; + return $position; } - public function write(string $data): void - { - $this->checkOpened(); - - error_clear_last(); - $result = fwrite($this->handle, $data); + // concurrency, buffering, flushing -------------------------------------------------------------------------------- - if ($result === false) { - throw new FileException('Cannot write data to file.', error_get_last()); - } - } - - /** - * Truncate file and move pointer at the end - * @param int<0, max> $size - */ - public function truncate(int $size = 0): void + public function setBlocking(): void { - $this->checkOpened(); - error_clear_last(); - $result = ftruncate($this->handle, $size); + $result = @stream_set_blocking($this->handle, true); if ($result === false) { - throw new FileException('Cannot truncate file.', error_get_last()); + throw FilesystemException::create("Cannot set file to blocking mode", $this->path, $this->context, error_get_last()); } - - $this->setPosition($size); } - /** - * Flush the file output buffer (fsync) - */ - public function flush(): void + public function setNonBlocking(): void { - $this->checkOpened(); - error_clear_last(); - $result = fflush($this->handle); + $result = @stream_set_blocking($this->handle, false); if ($result === false) { - throw new FileException('Cannot flush file cache.', error_get_last()); + throw FilesystemException::create("Cannot set file to non-blocking mode", $this->path, $this->context, error_get_last()); } - $this->metaData = null; } - public function lock(?LockType $mode = null): void + public function lock(?int $mode = null): void { - $this->checkOpened(); - if ($mode === null) { - $mode = LockType::get(LockType::SHARED); + $mode = LockType::SHARED; + } else { + Check::enum($mode, LockType::SHARED, LockType::EXCLUSIVE, LockType::NON_BLOCKING); } error_clear_last(); $wouldBlock = null; - $result = flock($this->handle, $mode->getValue(), $wouldBlock); + $result = @flock($this->getHandle(), $mode, $wouldBlock); if ($result === false) { if ($wouldBlock) { - throw new FileException('Non-blocking lock cannot be acquired.', error_get_last()); + throw FilesystemException::create("Non-blocking lock cannot be acquired", $this->path, $this->context, error_get_last()); } else { - throw new FileException('Cannot lock file.', error_get_last()); + throw FilesystemException::create("Cannot lock file", $this->path, $this->context, error_get_last()); } } + + $this->lock = $mode; } public function unlock(): void { - $this->checkOpened(); - error_clear_last(); - $result = flock($this->handle, LOCK_UN); + $result = flock($this->getHandle(), LOCK_UN); if ($result === false) { - throw new FileException('Cannot unlock file.', error_get_last()); + throw FilesystemException::create("Cannot unlock file", $this->path, $this->context, error_get_last()); } + + $this->lock = null; } - public function setPosition(int $position, ?FilePosition $from = null): void + public function setReadBuffer(int $size): void { - $this->checkOpened(); - - if ($position < 0) { - $position *= -1; - $from = FilePosition::get(FilePosition::END); - } - if ($from === null) { - $from = FilePosition::get(FilePosition::BEGINNING); - } - error_clear_last(); - $result = fseek($this->handle, $position, $from->getValue()); + $result = @stream_set_read_buffer($this->handle, $size); - if ($result !== 0) { - throw new FileException('Cannot set file pointer position.', error_get_last()); + if ($result === false) { + throw FilesystemException::create("Cannot set file read buffer size", $this->path, $this->context, error_get_last()); } } - public function getPosition(): int + public function setWriteBuffer(int $size): void { - $this->checkOpened(); - error_clear_last(); - $position = ftell($this->handle); - - if ($position === false) { - throw new FileException('Cannot get file pointer position.', error_get_last()); - } - - return $position; - } + $result = @stream_set_write_buffer($this->handle, $size); - public function getMetaData(): FileMetaData - { - if (!$this->metaData) { - if ($this->handle) { - error_clear_last(); - $stat = fstat($this->handle); - if (!$stat) { - throw new FileException('Cannot acquire file metadata.', error_get_last()); - } - $this->metaData = new FileMetaData($stat); - } else { - if (empty($this->path)) { - $this->getPath(); - } - $this->metaData = FileMetaData::get($this->path); - } + if ($result === false) { + throw FilesystemException::create("Cannot set file write buffer size", $this->path, $this->context, error_get_last()); } - - return $this->metaData; } /** - * Get stream meta data for files opened via HTTP, FTP… - * @return mixed[] - */ - public function getStreamMetaData(): array - { - return stream_get_meta_data($this->handle); - } - - /** - * Get stream wrapper headers (HTTP) - * @return mixed[] + * Flush the file output buffer (fsync) */ - public function getHeaders(): array + public function flush(): void { - $data = stream_get_meta_data($this->handle); - - return $data['wrapper_data']; - } + error_clear_last(); + $result = @fflush($this->getHandle()); - /* - [ - [wrapper_data] => [ - [0] => HTTP/1.1 200 OK - [1] => Server: Apache/2.2.3 (Red Hat) - [2] => Last-Modified: Tue, 15 Nov 2005 13:24:10 GMT - [3] => ETag: "b300b4-1b6-4059a80bfd280" - [4] => Accept-Ranges: bytes - [5] => Content-Type: text/html; charset=UTF-8 - [6] => Set-Cookie: FOO=BAR; expires=Fri, 21-Dec-2012 12:00:00 GMT; path=/; domain=.example.com - [6] => Connection: close - [7] => Date: Fri, 16 Oct 2009 12:00:00 GMT - [8] => Age: 1164 - [9] => Content-Length: 438 - ] - [wrapper_type] => http - [stream_type] => tcp_socket/ssl - [mode] => r - [unread_bytes] => 438 - [seekable] => - [uri] => http://www.example.com/ - [timed_out] => - [blocked] => 1 - [eof] => - ] - */ - - private function checkOpened(): void - { - if ($this->handle === null) { - throw new FileException('File is already closed.'); + if ($result === false) { + throw FilesystemException::create("Cannot flush file cache", $this->path, $this->context, error_get_last()); } + $this->fileInfo = null; } } diff --git a/src/Io/FileInfo.php b/src/Io/FileInfo.php index 893714a9..17fcacdc 100644 --- a/src/Io/FileInfo.php +++ b/src/Io/FileInfo.php @@ -14,11 +14,10 @@ use Dogma\Str; use Dogma\StrictBehaviorMixin; use Dogma\Time\DateTime; +use StreamContext; use function basename; -use function chgrp; -use function chmod; -use function chown; use function clearstatcache; +use function decoct; use function dirname; use function error_clear_last; use function error_get_last; @@ -30,112 +29,116 @@ use function is_writable; use function realpath; use function scandir; -use function sprintf; use function stat; use function str_replace; -use function touch; -class Info implements Path +/** + * Represents a file system path to a directory, file or a symbolic link target + * Does not validate existence of the path + * + * @see LinkInfo for symbolic link specific things + */ +class FileInfo implements Path { use StrictBehaviorMixin; /** @var string */ protected $path; - /** @var int[]|string[]|null */ - protected $stat; - - /** @var resource|null */ - private $handle; + /** @var File|null */ + private $file; /** - * @param string|Path $file - * @param resource|null $stat + * @param string|Path|File $file */ - public function __construct($file, $handle = null) + public function __construct($file) { - if ($file instanceof Path) { + if ($file instanceof File) { + $this->path = $file->getPath(); + $this->file = $file; + } elseif ($file instanceof Path) { $this->path = $file->getPath(); } else { - $this->path = str_replace('\\', '/', $file); + $this->path = Io::normalizePath($file); } - $this->handle = $handle; } public function clearCache(): void { - $this->stat = null; clearstatcache(true, $this->path); } - protected function init(): void + /** + * @return mixed[] + */ + protected function stat(): array { error_clear_last(); - if (is_resource($this->handle)) { - $stat = fstat($this->handle); + if ($this->file !== null && $this->file->isOpen()) { + $stat = fstat($this->file->getHandle()); } else { $stat = stat($this->path); } if ($stat === false) { - throw new FileException('Cannot acquire file metadata.', error_get_last()); + throw FilesystemException::create("Cannot acquire file metadata", $this->path, null, error_get_last()); } - $this->stat = $stat; + return $stat; } public function getLinkInfo(): LinkInfo { - if (!$this->isLink()) { - throw new FileException('File is not a link.'); - } - return new LinkInfo($this->path); } // actions --------------------------------------------------------------------------------------------------------- - public function open(): File + public function open(string $mode = FileMode::OPEN_READ, ?StreamContext $context = null): BinaryFile { - $path = $this->getRealPath(); + return new BinaryFile($this->path, $mode, $context); + } - return new File($path); + public function read(int $offset = 0, ?int $length = null, ?StreamContext $context = null): string + { + return Io::read($this->path, $offset, $length, $context); } - public function touch(): void + /** + * @param int $start + * @param int|null $count + * @param int $flags + * @param StreamContext|null $context + * @return string[] + */ + public function readLines(int $start = 0, ?int $count = null, int $flags = 0, ?StreamContext $context = null): array { - error_clear_last(); - $res = touch($this->path); - if ($res === false) { - throw new FileException('Cannot touch file.', error_get_last()); - } + return Io::readLines($this->path, $start, $count, $flags, $context); } - public function changePermissions(int $permissions): void + /** + * @param string $data + * @param int $flags (FILE_APPEND, LOCK_EX) + * @param StreamContext|null $context + * @return int + */ + public function write(string $data, int $flags = 0, ?StreamContext $context = null): int { - error_clear_last(); - $res = chmod($this->path, $permissions); - if ($res === false) { - throw new FileException('Cannot change permissions.', error_get_last()); - } + return Io::write($this->path, $data, $flags, $context); } - public function changeOwner(int $ownerId): void + /** + * @param int|DateTime|null $time + * @param int|DateTime|null $accessTime + */ + public function touch($time = null, $accessTime = null): void { - error_clear_last(); - $res = chown($this->path, $ownerId); - if ($res === false) { - throw new FileException('Cannot change file owner.', error_get_last()); - } + Io::touch($this->path, $time, $accessTime); } - public function changeGroup(int $groupId): void + public function updatePermissions(int $add, int $remove, ?int $owner = null, ?int $group = null): void { - error_clear_last(); - $res = chgrp($this->path, $groupId); - if ($res === false) { - throw new FileException('Cannot change file owner.', error_get_last()); - } + Io::updatePermissions($this->path, $add, $remove, $owner, $group, Io::FOLLOW_SYMLINKS); } // path ------------------------------------------------------------------------------------------------------------ @@ -161,7 +164,7 @@ public function getRealPath(): string { $path = realpath($this->path); if ($path === false) { - throw new FileException(sprintf('File %s does not exits.', $this->path)); + throw FilesystemException::create("Cannot get real path, file does not exits", $this->path); } return $path; @@ -176,11 +179,12 @@ public function getDirectory(): string public function getType(): int { - if ($this->stat === null) { - $this->init(); - } + return $this->stat()['mode'] & 0770000; + } - return $this->stat['mode'] & 0770000; + public function getTypeLetter(): string + { + return FileType::LETTERS[$this->getType()]; } public function isDirectory(): bool @@ -196,16 +200,24 @@ public function isDot(): bool public function isEmpty(): bool { if (!$this->isDirectory()) { - throw new FileException('Path is not a directory.'); + throw FilesystemException::create("Path is not a directory", $this->path); } error_clear_last(); $files = scandir($this->path); if ($files === false) { - throw new FileException('Cannot read directory.', error_get_last()); + throw FilesystemException::create("Cannot read directory", $this->path, null, error_get_last()); + } + foreach ($files as $name) { + if ($name === '.' || $name === '..') { + continue; + } + if (file_exists($this->path . '/' . $name)) { + return false; + } } - return count($files) === 2; + return true; } public function isFile(): bool @@ -262,121 +274,105 @@ public function isExecutable(): bool public function getPermissions(): int { - if ($this->stat === null) { - $this->init(); - } + return $this->stat()['mode'] & FilePermissions::ALL; + } - return $this->stat['mode'] & 0777; + public function getPermissionsOct(): string + { + return decoct($this->stat()['mode'] & FilePermissions::ALL); + } + + public function getPermissionsString(): string + { + $perms = $this->stat()['mode'] & FilePermissions::ALL; + + return $this->getTypeLetter() + . (($perms & FilePermissions::OWNER_READ) ? 'r' : '-') + . (($perms & FilePermissions::OWNER_WRITE) ? 'w' : '-') + . (($perms & FilePermissions::OWNER_EXECUTE) ? 'x' : '-') + . (($perms & FilePermissions::GROUP_READ) ? 'r' : '-') + . (($perms & FilePermissions::GROUP_WRITE) ? 'w' : '-') + . (($perms & FilePermissions::GROUP_EXECUTE) ? 'x' : '-') + . (($perms & FilePermissions::OTHER_READ) ? 'r' : '-') + . (($perms & FilePermissions::OTHER_WRITE) ? 'w' : '-') + . (($perms & FilePermissions::OTHER_EXECUTE) ? 'x' : '-'); } // stats ----------------------------------------------------------------------------------------------------------- public function getLinksCount(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['nlink']; + return $this->stat()['nlink']; } public function getOwner(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['uid']; + return $this->stat()['uid']; } public function getGroup(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['gid']; + return $this->stat()['gid']; } - public function getAccessTime(): DateTime + public function getAccessed(): int { - if ($this->stat === null) { - $this->init(); - } + return $this->stat()['atime']; + } - return DateTime::createFromTimestamp($this->stat['atime']); + public function getAccessedTime(): DateTime + { + return DateTime::createFromTimestamp($this->stat()['atime']); } - public function getModifyTime(): DateTime + public function getModified(): int { - if ($this->stat === null) { - $this->init(); - } + return $this->stat()['mtime']; + } - return DateTime::createFromTimestamp($this->stat['mtime']); + public function getModifiedTime(): DateTime + { + return DateTime::createFromTimestamp($this->stat()['mtime']); } - public function getInodeChangeTime(): DateTime + public function getChanged(): int { - if ($this->stat === null) { - $this->init(); - } + return $this->stat()['ctime']; + } - return DateTime::createFromTimestamp($this->stat['ctime']); + public function getChangedTime(): DateTime + { + return DateTime::createFromTimestamp($this->stat()['ctime']); } public function getInode(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['ino']; + return $this->stat()['ino']; } public function getDeviceId(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['dev']; + return $this->stat()['dev']; } public function getDeviceType(): string { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['rdev']; + return $this->stat()['rdev']; } public function getSize(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['size']; + return $this->stat()['size']; } public function getBlockSize(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['blksize']; + return $this->stat()['blksize']; } public function getBlocks(): int { - if ($this->stat === null) { - $this->init(); - } - - return $this->stat['blocks']; + return $this->stat()['blocks']; } } diff --git a/src/Io/FileMode.php b/src/Io/FileMode.php index 279a3355..8cc8aa07 100644 --- a/src/Io/FileMode.php +++ b/src/Io/FileMode.php @@ -7,34 +7,52 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ -// spell-check-ignore: xb - namespace Dogma\Io; use Dogma\StaticClassMixin; +use function in_array; class FileMode { use StaticClassMixin; - // if not found: ERROR; keep content - public const OPEN_READ = 'rb'; - public const OPEN_READ_WRITE = 'r+b'; - - // if found: ERROR; no content - public const CREATE_WRITE = 'xb'; - public const CREATE_READ_WRITE = 'x+b'; - - // if not found: create; keep content - public const CREATE_OR_OPEN_WRITE = 'cb'; - public const CREATE_OR_OPEN_READ_WRITE = 'c+b'; - - // if not found: create; truncate content - public const CREATE_OR_TRUNCATE_WRITE = 'wb'; - public const CREATE_OR_TRUNCATE_READ_WRITE = 'w+b'; - - // if not found: create; keep content, point to end of file, don't accept new position - public const CREATE_OR_APPEND_WRITE = 'ab'; - public const CREATE_OR_APPEND_READ_WRITE = 'a+b'; + // error if not found, keep content + public const OPEN_READ = 'r'; + public const OPEN_READ_WRITE = 'r+'; + + // error if found, no content + public const CREATE_WRITE = 'x'; + public const CREATE_READ_WRITE = 'x+'; + + // create if not found, keep content + public const CREATE_OR_OPEN_WRITE = 'c'; + public const CREATE_OR_OPEN_READ_WRITE = 'c+'; + + // create if not found, truncate content + public const CREATE_OR_TRUNCATE_WRITE = 'w'; + public const CREATE_OR_TRUNCATE_READ_WRITE = 'w+'; + + // create if not found, keep content, point to end of file, don't accept new position + public const CREATE_OR_APPEND_WRITE = 'a'; + public const CREATE_OR_APPEND_READ_WRITE = 'a+'; + + public static function getReopenMode(string $mode): string + { + if ($mode === self::CREATE_WRITE) { + return self::CREATE_OR_OPEN_WRITE; + } elseif ($mode === self::CREATE_READ_WRITE) { + return self::CREATE_OR_OPEN_READ_WRITE; + } else { + return $mode; + } + } + + public static function isReadable(string $mode): bool + { + return $mode !== self::CREATE_WRITE + && $mode !== self::CREATE_OR_OPEN_WRITE + && $mode !== self::CREATE_OR_TRUNCATE_WRITE + && $mode !== self::CREATE_OR_APPEND_WRITE; + } } diff --git a/src/Io/FilePermissions.php b/src/Io/FilePermissions.php new file mode 100644 index 00000000..e4aebf47 --- /dev/null +++ b/src/Io/FilePermissions.php @@ -0,0 +1,47 @@ + 'p', + self::CHAR_DEVICE => 'c', + self::DIRECTORY => 'd', + self::BLOCK_DEVICE => 'b', + self::FILE => '-', + self::LINK => 'l', + self::SOCKET => 's', + ]; + +} diff --git a/src/Io/Ini/Ini.php b/src/Io/Ini/Ini.php new file mode 100644 index 00000000..91980c3b --- /dev/null +++ b/src/Io/Ini/Ini.php @@ -0,0 +1,168 @@ +getPath(); + } + + error_clear_last(); + $values = @parse_ini_file($file, $sections, $mode ? INI_SCANNER_NORMAL : INI_SCANNER_TYPED); + if ($values === false) { + $error = error_get_last(); + if ($error === null) { + throw new IniException('Unknown error when parsing file ' . $file . '.'); + } else { + throw new IniException('Error when parsing ini file ' . $file . ': ' . $error['message']); + } + } + + return $mode === self::PARSE_OBJECTS + ? self::parseAll($values) + : $values; + } + + /** + * @param string $string + * @param bool $sections + * @param int $mode + * @return mixed[] + */ + public static function parseString(string $string, bool $sections = true, int $mode = self::PARSE_SCALARS): array + { + Check::enum($mode, [self::PARSE_NONE, self::PARSE_SCALARS, self::PARSE_OBJECTS]); + + error_clear_last(); + $values = @parse_ini_string($string, $sections, $mode ? INI_SCANNER_NORMAL : INI_SCANNER_TYPED); + if ($values === false) { + $error = error_get_last(); + if ($error === null) { + throw new IniException('Unknown error when parsing ini string.'); + } else { + throw new IniException('Error when parsing ini string: ' . $error['message']); + } + } + + return $mode === self::PARSE_OBJECTS + ? self::parseAll($values) + : $values; + } + + /** + * @param mixed[] $values + * @return mixed[] + */ + private static function parseAll(array $values): array + { + foreach ($values as $i => $value) { + if (is_array($value)) { + $values[$i] = self::parseAll($value); + } else { + $values[$i] = self::parse($value); + } + } + + return $values; + } + + /** + * When $parseObjects is set, in addition to scalars (bool, int, float) detects and returns these types: + * - Date + * - Time + * - DateTime + * - TimeZone + * + * @param string $value + * @return mixed + */ + private static function parse(string $value) + { + $lower = strtolower($value); + + if ($lower === 'true') { + return true; + } elseif ($lower === 'false') { + return false; + } elseif ($lower === 'null') { + return null; + } elseif (is_numeric($value)) { + $float = (float) $value; + + $negative = $float < 0; + $numbers = trim($value, " \t+-."); + if (ctype_digit($numbers)) { + $int = (int) $value; + if ($int !== PHP_INT_MAX) { + return $negative ? -$int : $int; + } + } + + if ($float === (float) (int) $float) { + return (int) $float; + } + + return $float; + } + + $trimmed = trim($value); + if (Str::match($trimmed, '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/')) { + return new Date($trimmed); + } elseif (Str::match($trimmed, '/^[0-2][0-9]:[0-5][0-9](:[0-5][0-9](.[0-9]+)?)?$/')) { + return new Time($trimmed); + } elseif (Str::match($trimmed, '/^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-2][0-9]:[0-5][0-9]/')) { + return new DateTime($trimmed); + } elseif (TimeZone::isValid($trimmed)) { + return new TimeZone($trimmed); + } + + return $value; + } + +} diff --git a/src/Io/Io.php b/src/Io/Io.php new file mode 100644 index 00000000..86734963 --- /dev/null +++ b/src/Io/Io.php @@ -0,0 +1,997 @@ + '/', // remove . dirs + '~(? '/', // remove duplicit /, but keep up to three / after uri scheme + '~([^/\.]+/(?R)*\.\./?)~' => '', // resolve .. dirs + ]; + do { + $previous = $path; + $path = Str::replace($path, $patterns); + } while ($path !== $previous); + + $path = Str::replace($path, [ + '~/+\.?$~' => '', // end /. + '~^\.$~' => '', // sole . + '~^/\.~' => '.', // /.. on start + ]); + + return $relative && $path[0] === '/' ? substr($path, 1) : $path; + } + + /** + * Normalizes path and dereferences symbolic links in the path + * Returns NULL if file/directory does not exist + * + * @param string $path + * @return string|null + */ + public static function canonicalizePath(string $path): ?string + { + return realpath($path) ?: null; + } + + /** + * Translate path from source directory to destination directory + * + * @param string $path + * @param string $sourcePrefix + * @param string $destinationPrefix + * @return string + */ + public static function translatePath(string $path, string $sourcePrefix, string $destinationPrefix): string + { + if (strpos($path, $sourcePrefix) === false) { + throw new InvalidArgumentException("Source path prefix not found in translated file path."); + } + + return str_replace($sourcePrefix, $destinationPrefix, $path); + } + + protected static function filterFiles(FileInfo $item): bool + { + return $item->isFile(); + } + + protected static function filterDirectories(FileInfo $item): bool + { + return $item->isDirectory(); + } + + // settings -------------------------------------------------------------------------------------------------------- + + public static function clearCache(): void + { + clearstatcache(true); + } + + public static function getPermissionMask(): int + { + return umask(); + } + + /** + * Set permission mask (umask) and return old value + * + * @param int $mask + * @return int + */ + public static function setPermissionMask(int $mask): int + { + Check::flags($mask, FilePermissions::ALL); + + return umask($mask); + } + + public static function getWorkingDirectory(): string + { + $path = getcwd(); + if ($path === false) { + throw new FilesystemException('Cannot get current working directory.', null); + } + + return self::normalizePath($path); + } + + /** + * @param string|Path $path + */ + public static function setWorkingDirectory($path): void + { + if ($path instanceof Path) { + $path = $path->getPath(); + } else { + $path = self::normalizePath($path); + } + + error_clear_last(); + $result = @chdir($path); + + if ($result === false) { + throw FilesystemException::create("Cannot set working directory", $path, null, error_get_last()); + } + } + + // storage --------------------------------------------------------------------------------------------------------- + + /** + * @param string|Path $path + * @return int + */ + public static function getStorageSize($path): int + { + if ($path instanceof Path) { + $path = $path->getPath(); + } + + error_clear_last(); + /** @var int|false $result */ + $result = @disk_total_space($path); + + if ($result === false) { + throw FilesystemException::create("Cannot get storage size", $path, null, error_get_last()); + } + + return (int) $result; + } + + /** + * @param string|Path $path + * @return int + */ + public static function getFreeSpace($path): int + { + if ($path instanceof Path) { + $path = $path->getPath(); + } + + error_clear_last(); + /** @var int|false $result */ + $result = @disk_free_space($path); + + if ($result === false) { + throw FilesystemException::create("Cannot get free space", $path, null, error_get_last()); + } + + return (int) $result; + } + + // files ----------------------------------------------------------------------------------------------------------- + + /** + * @param string|Path $file + * @return FileInfo + */ + public static function getInfo($file): FileInfo + { + return new FileInfo($file); + } + + /** + * @param string|Path $file + * @param string $mode + * @param StreamContext|null $context + * @return BinaryFile + */ + public static function open($file, string $mode = FileMode::OPEN_READ, ?StreamContext $context = null): BinaryFile + { + return new BinaryFile($file, $mode, $context); + } + + /** + * @param string|Path $file + * @return bool + */ + public static function exists($file): bool + { + if ($file instanceof Path) { + $file = $file->getPath(); + } + + return file_exists($file); + } + + /** + * Get path of first existing file + * + * @param string|Path ...$files + * @return string + */ + public static function existing(...$files): string + { + foreach ($files as $file) { + if ($file instanceof Path) { + $file = $file->getPath(); + } + + if (file_exists($file)) { + return $file; + } + } + + throw new IoException('None of given files exist.'); + } + + /** + * Replace strings in file line per line. Similar to unix utility `sed` in substitution mode + * Returns count of changed lines + * + * @param string|Path $file + * @param string|string[] $pattern + * @param string|callable|null $replacement + * @param int $offset + * @param int|null $length + * @param StreamContext|null $context + * @return int + */ + public static function edit($file, $pattern, $replacement = null, int $offset = 0, ?int $length = null, ?StreamContext $context = null): int + { + $changed = $inserted = $deleted = 0; + + $file = self::open($file, FileMode::OPEN_READ_WRITE, $context)->toTextFile(); + $file->lock(LOCK_EX); + + $lines = $file->readLines(); + $before = []; + $after = []; + if ($offset !== 0) { + $before = array_slice($lines, 0, $offset); + if ($length !== null) { + $lines = array_slice($lines, $offset, $length); + $after = array_slice($lines, $offset + $length); + } else { + $lines = array_slice($lines, $offset); + } + } + + $edited = []; + foreach ($lines as $line) { + $result = Str::replace($line, $pattern, $replacement); + if ($result !== $line) { + $changed++; + } + } + + $file->truncate(); + $file->writeLines($before); + $file->writeLines($edited); + $file->writeLines($after); + $file->unlock(); + $file->close(); + + return $changed; + } + + /** + * Read lines of a file; with regexp, callable or FILE_SKIP_EMPTY_LINES as filter + * + * @param string|Path $file + * @param string|callable|int $filter + * @param int $start + * @param int|null $count + * @param StreamContext|null $context + * @return string[] + */ + public static function readLines($file, $filter = null, int $start = 0, ?int $count = null, ?StreamContext $context = null): array + { + if ($file instanceof Path) { + $file = $file->getPath(); + } + + error_clear_last(); + $result = $context !== null + ? @file($file, FILE_IGNORE_NEW_LINES, $context->getResource()) + : @file($file, FILE_IGNORE_NEW_LINES); + + if ($result === false) { + throw FilesystemException::create("Cannot read file lines", $file, $context, error_get_last()); + } + + if ($start !== 0 || $count !== null) { + $result = array_slice($result, $start, (int) $count, false); + } + + if ($filter === FILE_SKIP_EMPTY_LINES) { + return array_values(array_filter($result, static function (string $line) { + return $line !== ''; + })); + } elseif (is_string($filter)) { + return array_values(array_filter($result, static function (string $line) use ($filter) { + return Str::match($line, $filter); + })); + } elseif (is_callable($filter)) { + return array_values(array_filter($result, static function (string $line) use ($filter) { + return $filter($line); + })); + } elseif ($filter !== null) { + throw new InvalidArgumentException('Filter should be regexp, callable or FILE_SKIP_EMPTY_LINES constant.'); + } + + return $result; + } + + /** + * Read contents of a file + * + * @param string|Path $file + * @param int $offset + * @param int|null $length + * @param StreamContext|null $context + * @return string + */ + public static function read($file, int $offset = 0, ?int $length = null, ?StreamContext $context = null): string + { + if ($file instanceof Path) { + $file = $file->getPath(); + } + + error_clear_last(); + $rawContext = $context !== null ? $context->getResource() : null; + // $length cannot be of null nor negative and 0 means zero length + if ($length !== null) { + $result = @file_get_contents($file, false, $rawContext, $offset, $length); + } else { + $result = @file_get_contents($file, false, $rawContext, $offset); + } + + if ($result === false) { + throw FilesystemException::create("Cannot read file", $file, $context, error_get_last()); + } + + return $result; + } + + /** + * Writes contents to a file + * Appends if FILE_APPEND is set + * Locks before writing if LOCK_EX is set + * Creates path if RECURSIVE is set + * + * @param string|Path $file + * @param string $data + * @param int $flags (FILE_APPEND, LOCK_EX) + * @param StreamContext|null $context + * @return int + */ + public static function write($file, string $data, int $flags = 0, ?StreamContext $context = null): int + { + Check::flags($flags, self::RECURSIVE | FILE_APPEND | LOCK_EX); + + if ($file instanceof Path) { + $file = $file->getPath(); + } + + $dir = dirname($file); + if (($flags & self::RECURSIVE) && !is_dir($dir)) { + self::createDirectory($dir, self::RECURSIVE, FilePermissions::ALL, $context); + } + + error_clear_last(); + $result = $context !== null + ? @file_put_contents($file, $data, ($flags & ~self::RECURSIVE), $context->getResource()) + : @file_put_contents($file, $data, ($flags & ~self::RECURSIVE)); + + if ($result === false) { + throw FilesystemException::create("Cannot write file", $file, $context, error_get_last()); + } + + return $result; + } + + /** + * Touch file (create empty file or modify timestamps of existing) + * Creates path if RECURSIVE is set + * + * @param string|Path $file + * @param int|DateTime|null $modified + * @param int|DateTime|null $accessed + * @param int $flags + */ + public static function touch($file, $modified = null, $accessed = null, int $flags = 0): void + { + Check::flags($flags, self::RECURSIVE); + + if ($file instanceof Path) { + $file = $file->getPath(); + } + if ($modified instanceof DateTime) { + $modified = $modified->getTimestamp(); + } + if ($accessed instanceof DateTime) { + $accessed = $accessed->getTimestamp(); + } + + $dir = dirname($file); + if (($flags & self::RECURSIVE) && !is_dir($dir)) { + self::createDirectory($dir, self::RECURSIVE, FilePermissions::ALL); + } + + error_clear_last(); + // time params are not nullable and 0 means 1970-01-01 + if ($modified !== null && $accessed !== null) { + $result = @touch($file, $modified, $accessed); + } elseif ($modified !== null) { + $result = @touch($file, $modified); + } elseif ($accessed !== null) { + throw new InvalidArgumentException('Parameter $modified must be not null when $accessed is not null.'); + } else { + $result = @touch($file); + } + + if ($result === false) { + throw FilesystemException::create("Cannot touch file", $file, null, error_get_last()); + } + } + + /** + * Copy a file + * Overwrites destination file if it already exists + * Creates path if RECURSIVE is set + * + * @param string|Path $source + * @param string|Path $destination + * @param int $flags + * @param StreamContext|null $context + */ + public static function copy($source, $destination, int $flags = 0, ?StreamContext $context = null): void + { + Check::flags($flags, self::RECURSIVE); + + if ($source instanceof Path) { + $source = $source->getPath(); + } + if ($destination instanceof Path) { + $destination = $destination->getPath(); + } + + $dir = dirname($destination); + if (($flags & self::RECURSIVE) && !is_dir($dir)) { + self::createDirectory($dir, self::RECURSIVE, FilePermissions::ALL, $context); + } + + error_clear_last(); + $result = $context !== null + ? @copy($source, $destination, $context->getResource()) + : @copy($source, $destination); + + if ($result === false) { + throw FilesystemException::create("Cannot copy file to destination", $destination, $context, error_get_last()); + } + } + + /** + * Move file to a new location/name + * Creates path to destination if RECURSIVE is set + * + * @param string|Path $source + * @param string|Path $destination + * @param int $flags + * @param StreamContext|null $context + */ + public static function rename($source, $destination, int $flags = 0, ?StreamContext $context = null): void + { + Check::flags($flags, self::RECURSIVE); + + if ($source instanceof Path) { + $source = $source->getPath(); + } + if ($destination instanceof Path) { + $destination = $destination->getPath(); + } + + $dir = dirname($destination); + if (($flags & self::RECURSIVE) && !is_dir($dir)) { + self::createDirectory($dir, self::RECURSIVE, FilePermissions::ALL, $context); + } + + error_clear_last(); + $result = $context !== null + ? @rename($source, $destination, $context->getResource()) + : @rename($source, $destination); + + if ($result === false) { + throw FilesystemException::create("Cannot rename file to destination", $destination, $context, error_get_last()); + } + } + + /** + * Create a hardlink to file + * Creates path to destination if RECURSIVE is set + * Dereferences the path to source when FOLLOW_SYMLINKS is set + * + * @param string|Path $source + * @param string|Path $destination + * @param int $flags + */ + public static function link($source, $destination, int $flags = 0): void + { + Check::flags($flags, self::RECURSIVE); + + if ($source instanceof Path) { + $source = $source->getPath(); + } + if ($destination instanceof Path) { + $destination = $destination->getPath(); + } + + $dir = dirname($destination); + if (($flags & self::RECURSIVE) && !is_dir($dir)) { + self::createDirectory($dir, self::RECURSIVE, FilePermissions::ALL); + } + + // todo: follow symlinks + + error_clear_last(); + $result = @link($source, $destination); + + if ($result === false) { + throw FilesystemException::create("Cannot link file", $destination, null, error_get_last()); + } + } + + /** + * Create a symbolic link to file (softlink) + * Creates path to destination if RECURSIVE is set + * Dereferences the path to source when FOLLOW_SYMLINKS is set + * + * @param string|Path $source + * @param string|Path $destination + * @param int $flags + */ + public static function symlink($source, $destination, int $flags = 0): void + { + Check::flags($flags, self::RECURSIVE); + + if ($source instanceof Path) { + $source = $source->getPath(); + } + if ($destination instanceof Path) { + $destination = $destination->getPath(); + } + + // todo: follow symlinks + + $dir = dirname($destination); + if (($flags & self::RECURSIVE) && !is_dir($dir)) { + self::createDirectory($dir, self::RECURSIVE, FilePermissions::ALL); + } + + error_clear_last(); + $result = @symlink($source, $destination); + + if ($result === false) { + throw FilesystemException::create("Cannot symlink file", $destination, null, error_get_last()); + } + } + + /** + * Delete a file (remove link to its contents) + * Ignores error when IGNORE is set and file does not exist. + * + * @param string|Path $file + * @param int $flags + * @param StreamContext|null $context + */ + public static function unlink($file, int $flags = 0, ?StreamContext $context = null): void + { + Check::flags($flags, self::IGNORE); + + if ($file instanceof Path) { + $file = $file->getPath(); + } + + error_clear_last(); + $result = $context !== null + ? @unlink($file, $context->getResource()) + : @unlink($file); + + if ($result === false) { + if (!($flags & self::IGNORE) || file_exists($file)) { + throw FilesystemException::create("Cannot delete file", $file, $context, error_get_last()); + } + } + } + + /** + * Delete a file. Alias for unlink() + * + * @param string|Path $file + * @param int $flags + * @param StreamContext|null $context + */ + public static function delete($file, int $flags = 0, ?StreamContext $context = null): void + { + self::unlink($file, $flags, $context); + } + + // directory actions ----------------------------------------------------------------------------------------------- + + /** + * Create directory (or path with RECURSIVE) + * Ignores error when IGNORE is set and directory already exists + * + * @param string|Path $path + * @param int $flags + * @param int $permissions + * @param StreamContext|null $context + */ + public static function createDirectory($path, int $flags = 0, int $permissions = 0777, ?StreamContext $context = null): void + { + Check::flags($flags, self::IGNORE | self::RECURSIVE); + + if ($path instanceof Path) { + $path = $path->getPath(); + } + + error_clear_last(); + $result = $context !== null + ? @mkdir($path, $permissions, ($flags & self::RECURSIVE) !== 0, $context->getResource()) + : @mkdir($path, $permissions, ($flags & self::RECURSIVE) !== 0); + + if ($result === false && (!($flags & self::IGNORE) || !is_dir($path))) { + throw FilesystemException::create("Cannot create directory", $path, $context, error_get_last()); + } + } + + /** + * Delete directory (including its contents with RECURSIVE) + * Delete linked content when FOLLOW_SYMLINKS is set + * Ignores error when IGNORE is set and directory does not exist + * + * @param string|Path $path + * @param int $flags + * @param StreamContext|null $context + */ + public static function deleteDirectory($path, int $flags = 0, ?StreamContext $context = null): void + { + Check::flags($flags, self::IGNORE | self::RECURSIVE | self::FOLLOW_SYMLINKS); + + if ($path instanceof Path) { + $path = $path->getPath(); + } + + if (is_file($path)) { + throw FilesystemException::create("Expected directory path", $path, $context); + } + + if ($flags & self::RECURSIVE) { + $items = self::scanDirectory($path, $flags | self::CHILDREN_FIRST); + foreach ($items as $filePath => $fileInfo) { + if (is_dir($filePath)) { + self::removeDirectory($filePath, self::IGNORE, $context); + } else { + self::unlink($filePath, self::IGNORE, $context); + } + } + } + + self::removeDirectory($path, $flags, $context); + } + + /** + * Delete files (and directories with RECURSIVE) + * Delete linked content when FOLLOW_SYMLINKS is set + * + * @param string|Path $path + * @param int $flags + * @param callable|null $filter + * @param StreamContext|null $context + */ + public static function cleanDirectory($path, int $flags = 0, ?callable $filter = null, ?StreamContext $context = null): void + { + Check::flags($flags, self::RECURSIVE | self::FOLLOW_SYMLINKS); + + $items = self::scanDirectory($path, ($flags | self::CHILDREN_FIRST) & ~self::IGNORE); + foreach ($items as $filePath => $fileInfo) { + if ($filter !== null && !$filter($fileInfo)) { + continue; + } + + if (is_dir($filePath)) { + if ($filter === null || $fileInfo->isEmpty()) { + self::removeDirectory($filePath, self::IGNORE, $context); + } + } else { + self::unlink($filePath, self::IGNORE, $context); + } + } + } + + private static function removeDirectory(string $path, int $flags, ?StreamContext $context = null): void + { + error_clear_last(); + $result = $context !== null + ? @rmdir($path, $context->getResource()) + : @rmdir($path); + + if ($result === false && (!($flags & self::IGNORE) || is_dir($path))) { + throw FilesystemException::create("Cannot remove directory", $path, $context, error_get_last()); + } + } + + /** + * Copy files in directory (and subdirectories with RECURSIVE) + * Copy linked content instead of linking when FOLLOW_SYMLINKS is set + * Ignores error when IGNORE is set and target directory already exists (overwrites contents) + * + * @param string|Path $source + * @param string|Path $destination + * @param int $flags + * @param callable|null $filter + * @param StreamContext|null $context + */ + public static function copyDirectory($source, $destination, int $flags = 0, ?callable $filter = null, ?StreamContext $context = null): void + { + Check::flags($flags, self::IGNORE | self::RECURSIVE | self::FOLLOW_SYMLINKS); + + if ($source instanceof Path) { + $source = $source->getPath(); + } + if ($destination instanceof Path) { + $destination = $destination->getPath(); + } + $source = self::normalizePath($source); + $destination = self::normalizePath($destination); + + self::createDirectory($destination, $flags & self::IGNORE); + + $items = self::scanDirectory($source, $flags & ~self::IGNORE); + foreach ($items as $filePath => $fileInfo) { + if ($filter !== null && !$filter($fileInfo)) { + continue; + } + + $newPath = self::translatePath($filePath, $source, $destination); + if ($fileInfo->isDirectory()) { + self::createDirectory($newPath, self::IGNORE, FilePermissions::ALL, $context); + } else { + $dir = dirname($newPath); + if ($filter !== null && !is_dir($dir)) { + self::createDirectory($dir, self::IGNORE); + } + self::copy($fileInfo, $newPath, 0, $context); + } + } + } + + /** + * Returns iterator going through files in directory (and subdirectories with RECURSIVE) + * Scans linked files/directories when FOLLOW_SYMLINKS is set + * Returns contents before directory when CHILDREN_FIRST is set (useful when deleting stuff) + * + * @param string|Path $path + * @param int $flags + * @return Iterator|FileInfo[] + */ + public static function scanDirectory($path, int $flags = 0): Iterator + { + Check::flags($flags, self::RECURSIVE | self::FOLLOW_SYMLINKS | self::CHILDREN_FIRST); + + if ($path instanceof Path) { + $path = $path->getPath(); + } + + $iteratorFlags = FilesystemIterator::CURRENT_AS_PATHNAME | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + if ($flags & self::FOLLOW_SYMLINKS) { + $iteratorFlags |= FilesystemIterator::FOLLOW_SYMLINKS; + } + if ($flags & self::RECURSIVE) { + $iterator = new RecursiveDirectoryIterator($path, $iteratorFlags); + if ($flags & self::CHILDREN_FIRST) { + $iterator = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::CHILD_FIRST); + } else { + $iterator = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); + } + } else { + $iterator = new FilesystemIterator($path, $iteratorFlags); + } + + return new CallbackIterator($iterator, static function (string $path): FileInfo { + return new FileInfo($path); + }); + } + + // permissions ----------------------------------------------------------------------------------------------------- + + /** + * Update permissions of file/directory (and directory contents with RECURSIVE) + * Including linked content when FOLLOW_SYMLINKS is set + * + * @param string|Path $path + * @param int $add + * @param int $remove + * @param int|null $owner + * @param int|null $group + * @param int $flags + * @param callable|null $filter + */ + public static function updatePermissions( + $path, + int $add, + int $remove, + ?int $owner = null, + ?int $group = null, + int $flags = 0, + ?callable $filter = null + ): void + { + Check::flags($flags, self::RECURSIVE | self::FOLLOW_SYMLINKS); + Check::flags($add, FilePermissions::ALL); + Check::flags($remove, FilePermissions::ALL); + + if (!$path instanceof FileInfo) { + $path = new FileInfo($path); + } + self::setPermissions($path->getPath(), $path->getPermissions(), $add, $remove, $owner, $group); + + if (($flags & self::RECURSIVE) === 0) { + return; + } + + // todo: symlinks + //$updateLinks = !($flags & self::FOLLOW_SYMLINKS); + + $items = self::scanDirectory($path, $flags); + foreach ($items as $filePath => $fileInfo) { + if ($filter !== null && !$filter($fileInfo)) { + continue; + } + + self::setPermissions($filePath, $fileInfo->getPermissions(), $add, $remove, $owner, $group); + } + } + + private static function setPermissions(string $path, int $permissions, int $add, int $remove, ?int $owner, ?int $group): void + { + if ($add !== 0 || $remove !== 0) { + $permissions |= $add; + $permissions &= ~$remove; + + error_clear_last(); + $result = @chmod($path, $permissions); + if ($result === false) { + throw FilesystemException::create("Cannot update permissions", $path, null, error_get_last()); + } + } + + if ($owner !== null) { + error_clear_last(); + $result = @chown($path, $owner); + + if ($result === false) { + throw FilesystemException::create("Cannot change owner", $path, null, error_get_last()); + } + + /* + if ($updateLinks && $fileInfo->isLink()) { + error_clear_last(); + $result = @lchown($path, $owner); + + if ($result === false) { + throw FilesystemException::create("Cannot change link owner", $path, null, error_get_last()); + } + } + */ + } + + if ($group !== null) { + error_clear_last(); + $result = @chgrp($path, $group); + + if ($result === false) { + throw FilesystemException::create("Cannot change group", $path, null, error_get_last()); + } + + /* + if ($updateLinks && $fileInfo->isLink()) { + error_clear_last(); + $result = @lchgrp($path, $group); + + if ($result === false) { + throw FilesystemException::create("Cannot change link group", $path, null, error_get_last()); + } + } + */ + } + } + +} diff --git a/src/Io/LinkInfo.php b/src/Io/LinkInfo.php new file mode 100644 index 00000000..4e4d8736 --- /dev/null +++ b/src/Io/LinkInfo.php @@ -0,0 +1,81 @@ +path); + + if ($stat === false) { + throw FilesystemException::create("Cannot acquire file metadata", $this->path, null, error_get_last()); + } + + return $stat; + } + + public function updateLinkOwner(int $owner, ?int $group = null): void + { + if (!$this->isLink()) { + throw FilesystemException::create("Path is not a link", $this->path); + } + + error_clear_last(); + $result = lchown($this->path, $owner); + + if ($result === false) { + throw FilesystemException::create("Cannot change link owner", $this->path, null, error_get_last()); + } + + if ($group === null) { + return; + } + + error_clear_last(); + $result = lchgrp($this->path, $group); + + if ($result === false) { + throw FilesystemException::create("Cannot change link group", $this->path, null, error_get_last()); + } + } + + public function getLinkTarget(): FileInfo + { + if (!$this->isLink()) { + throw FilesystemException::create("Path is not a link", $this->path); + } + + error_clear_last(); + $target = readlink($this->path); + if ($target === false) { + throw FilesystemException::create("Cannot read link target", $this->path, null, error_get_last()); + } + + return new FileInfo($target); + } + +} diff --git a/src/Io/LockType.php b/src/Io/LockType.php index 2172e54b..b5800240 100644 --- a/src/Io/LockType.php +++ b/src/Io/LockType.php @@ -9,13 +9,14 @@ namespace Dogma\Io; -use Dogma\Enum\IntEnum; +use Dogma\StaticClassMixin; use const LOCK_EX; use const LOCK_NB; use const LOCK_SH; -class LockType extends IntEnum +class LockType { + use StaticClassMixin; public const SHARED = LOCK_SH; public const EXCLUSIVE = LOCK_EX; diff --git a/src/Io/Path.php b/src/Io/Path.php new file mode 100644 index 00000000..223c9e3a --- /dev/null +++ b/src/Io/Path.php @@ -0,0 +1,28 @@ +close(); + } + + /** + * Run command and return result code and output + * + * @param string $command + * @return int[]|string[] (int $code, string $output) + */ + public static function run(string $command): array + { + $output = []; + exec($command, $output, $resultCode); + + return [$resultCode, implode("\n", $output)]; + } + + /** + * @param string $command + * @param string[][]|resource[] $descriptors + * @param string|Path|null $cwd + * @param mixed[] $env + * @param bool[] $options + * @return self + */ + public static function open(string $command, array $descriptors = [], $cwd = null, array $env = [], array $options = []): self + { + $descriptors += [ + self::STDIN => ['pipe', FileMode::CREATE_OR_TRUNCATE_WRITE], + self::STDOUT => ['pipe', FileMode::OPEN_READ], + self::STDERR => ['pipe', FileMode::OPEN_READ], + ]; + if ($cwd instanceof Path) { + $cwd = $cwd->getPath(); + } + + error_clear_last(); + $process = @proc_open($command, $descriptors, $pipes, $cwd, $env, $options); + if ($process === false) { + throw new IoException('Cannot open process: ' . error_get_last()['message']); + } + stream_set_blocking($pipes[self::STDERR], false); + + $self = new self(); + $self->descriptors = $descriptors; + $self->process = $process; + $self->pipes = $pipes; + + return $self; + } + + public function isClosed(): bool + { + return $this->process === null; + } + + public function close(): int + { + if ($this->isClosed()) { + throw new ShouldNotHappenException('Process is already closed.'); + } + + foreach ($this->handlers as $handler) { + $handler->close(); + } + $result = @proc_close($this->process); + $this->process = null; + + return $result; + } + + public function terminate(int $signal = 15 /* SIGTERM */): bool + { + if (Os::isWindows()) { + return !$this->close(); + } + if ($this->isClosed()) { + throw new ShouldNotHappenException('Process is already closed.'); + } + + foreach ($this->handlers as $handler) { + $handler->close(); + } + $result = @proc_terminate($this->process, $signal); + $this->process = null; + + return $result; + } + + /** + * Call before calling isRunning() if you do not care about output data, because unread data in full buffers + * may prevent the process from terminating until it writes its outputs. + */ + public function cleanOutputBuffers(): void + { + if ($this->isClosed()) { + throw new ShouldNotHappenException('Process is already closed.'); + } + + foreach ($this->descriptors as $descriptor => [$type, $mode]) { + if ($type !== 'pipe' || $mode !== FileMode::OPEN_READ) { + continue; + } + @stream_get_contents($this->pipes[$descriptor]); + } + } + + /** + * @return string[]|int[]|bool[] + */ + public function getInfo(): array + { + if ($this->isClosed()) { + throw new ShouldNotHappenException('Process is already closed.'); + } + error_clear_last(); + $info = @proc_get_status($this->process); + if ($info === false) { + throw new IoException('Cannot get process info: ' . error_get_last()['message']); + } + + return $info; + } + + public function getCommand(): string + { + return $this->getInfo()['command']; + } + + public function getPid(): int + { + return $this->getInfo()['pid']; + } + + public function isRunning(): bool + { + return $this->getInfo()['running']; + } + + public function wasTerminated(): bool + { + return $this->getInfo()['signaled']; + } + + public function getTerminateSignal(): int + { + return $this->getInfo()['termsig']; + } + + public function wasStopped(): bool + { + return $this->getInfo()['stopped']; + } + + public function getStopSignal(): int + { + return $this->getInfo()['stopsig']; + } + + public function getExitCode(): int + { + return $this->getInfo()['exitcode']; + } + + public function getInput(bool $textMode = false, ?string $encoding = null, ?string $lineEndings = null): File + { + return $this->getHandler(self::STDIN, $textMode, $encoding, $lineEndings); + } + + public function getOutput(bool $textMode = false, ?string $encoding = null, ?string $lineEndings = null): File + { + return $this->getHandler(self::STDOUT, $textMode, $encoding, $lineEndings); + } + + public function getErrorOutput(bool $textMode = false, ?string $encoding = null, ?string $lineEndings = null): File + { + return $this->getHandler(self::STDERR, $textMode, $encoding, $lineEndings); + } + + public function getHandler(int $descriptor, bool $textMode = false, ?string $encoding = null, ?string $lineEndings = null): File + { + if (!isset($this->handlers[$descriptor])) { + $this->handlers[$descriptor] = $textMode + ? new TextFile($this->pipes[$descriptor], $this->descriptors[$descriptor][1], null, $encoding, $lineEndings) + : new BinaryFile($this->pipes[$descriptor], $this->descriptors[$descriptor][1]); + } + + return $this->handlers[$descriptor]; + } + +} diff --git a/src/Io/RecursiveDirectoryIterator.php b/src/Io/RecursiveDirectoryIterator.php deleted file mode 100644 index e5dd3430..00000000 --- a/src/Io/RecursiveDirectoryIterator.php +++ /dev/null @@ -1,70 +0,0 @@ -flags = $flags; - try { - if (!($flags & FilesystemIterator::CURRENT_AS_PATHNAME) && !($flags & FilesystemIterator::CURRENT_AS_SELF)) { - parent::__construct($path, $flags | FilesystemIterator::CURRENT_AS_PATHNAME); - } else { - parent::__construct($path, $flags); - } - } catch (UnexpectedValueException $e) { - throw new DirectoryException($e->getMessage(), $e); - } - } - - /** - * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint - * @param int|null $flags - */ - public function setFlags($flags = null): void - { - $this->flags = $flags; - if (!($flags & FilesystemIterator::CURRENT_AS_PATHNAME) && !($flags & FilesystemIterator::CURRENT_AS_SELF)) { - parent::setFlags($flags | FilesystemIterator::CURRENT_AS_PATHNAME); - } else { - parent::setFlags($flags); - } - } - - /** - * @return FileInfo|mixed - */ - #[ReturnTypeWillChange] - public function current() - { - if (!($this->flags & FilesystemIterator::CURRENT_AS_PATHNAME) && !($this->flags & FilesystemIterator::CURRENT_AS_SELF)) { - /** @var string $path */ - $path = parent::current(); - - return new FileInfo($path); - } - return parent::current(); - } - -} diff --git a/src/Io/LineEndings.php b/src/Io/Stream/FtpContext.php similarity index 62% rename from src/Io/LineEndings.php rename to src/Io/Stream/FtpContext.php index 98fcc76c..f8334c95 100644 --- a/src/Io/LineEndings.php +++ b/src/Io/Stream/FtpContext.php @@ -7,15 +7,12 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ -namespace Dogma\Io; +namespace Dogma\Io\Stream; -use Dogma\Enum\StringEnum; +use StreamContext; -class LineEndings extends StringEnum +class FtpContext extends StreamContext { - - public const UNIX = "\n"; - public const WINDOWS = "\r\n"; - public const MAC = "\r"; + use SslOptionsMixin; } diff --git a/src/Io/Stream/HttpContext.php b/src/Io/Stream/HttpContext.php new file mode 100644 index 00000000..c589050e --- /dev/null +++ b/src/Io/Stream/HttpContext.php @@ -0,0 +1,34 @@ +setOption('http', HttpOptions::USER_AGENT, $agent); + } + + public function followLocation(bool $enabled = true, int $maxRedirects = 20): self + { + return $this->setOption('http', HttpOptions::FOLLOW_LOCATION, $enabled) + ->setOption('http', HttpOptions::MAX_REDIRECTS, $maxRedirects); + } + + public function setTimeout(int $seconds = 60): self + { + return $this->setOption('http', HttpOptions::TIMEOUT, $seconds); + } + +} diff --git a/src/Io/Stream/HttpOptions.php b/src/Io/Stream/HttpOptions.php new file mode 100644 index 00000000..ea82b3a9 --- /dev/null +++ b/src/Io/Stream/HttpOptions.php @@ -0,0 +1,30 @@ +context, ['ssl' => $options]); + if ($res === false) { + throw new IoException("Cannot set stream SSL/TLS options on wrapper: " . error_get_last()['message']); + } + + return $this; + } + +} diff --git a/src/Io/Stream/StreamContext.php b/src/Io/Stream/StreamContext.php new file mode 100644 index 00000000..89900d0e --- /dev/null +++ b/src/Io/Stream/StreamContext.php @@ -0,0 +1,376 @@ +context = $context; + } + + /** + * @internal + * @return resource(stream-context) + */ + public function getResource() + { + return $this->context; + } + + /** + * @param mixed[] $options + * @return self + */ + public function create(array $options): self + { + $context = stream_context_create($options); + + return new self($context); + } + + /** + * @param resource $context + * @return self + */ + public function createFromResource($context): self + { + Check::resource($context, ResourceType::STREAM_CONTEXT); + + return new self($context); + } + + /** + * @param string $method + * @param mixed[] $headers + * @param string|null $data + * @return HttpContext + */ + public static function createHttp(string $method = HttpMethod::GET, array $headers = [], ?string $data = null): HttpContext + { + $options = [ + 'http' => [ + 'method' => strtoupper($method), + ], + ]; + if ($headers !== []) { + $options['http']['header'] = HttpHeaderParser::formatHeaders($headers); + } + if ($data !== null) { + $options['http']['content'] = $data; + } + + $context = stream_context_create($options); + + return new HttpContext($context); + } + + public static function createFtp(bool $overwrite = false, ?int $resumeAt = null, ?string $proxy = null): FtpContext + { + $options = [ + 'ftp' => [ + 'overwrite' => $overwrite, + ], + ]; + if ((int) $resumeAt !== 0) { + $options['ftp']['resume_pos'] = $resumeAt; + } + if ($proxy !== null) { + $options['ftp']['proxy'] = $proxy; + } + + $context = stream_context_create($options); + + return new FtpContext($context); + } + + /** + * @param int|null $compression + * @param string|null $bootstrap + * @param mixed[]|null $metadata + * @return PharContext + */ + public static function createPhar(?int $compression = null, ?string $bootstrap = null, ?array $metadata = null): PharContext + { + $options = [ + 'phar' => [], + ]; + + if ($compression !== null) { + $options['phar']['compress'] = $compression; + } + + if ($metadata !== null && $bootstrap !== null) { + $metadata['bootstrap'] = $bootstrap; + } elseif ($bootstrap !== null) { + $metadata = ['bootstrap' => $bootstrap]; + } + + if ($metadata !== null) { + $options['metadata'] = $metadata; + } + + return new PharContext(stream_context_create($options)); + } + + public static function createZip(string $password): ZipContext + { + $options = [ + 'zip' => [ + 'password' => $password, + ], + ]; + + return new ZipContext(stream_context_create($options)); + } + + public static function createSocket(string $address, ?int $port = null, bool $noDelay = false): SocketContext + { + if (strpos($address, ':') !== false && $port !== null) { + $address = "[$address]:$port"; + } elseif ($port !== null) { + $address = "$address:$port"; + } + + $options = [ + 'socket' => [ + 'bindto' => $address, + 'tcp_nodelay' => $noDelay, + ], + ]; + + return new SocketContext(stream_context_create($options)); + } + + // options --------------------------------------------------------------------------------------------------------- + + /** + * @param string $wrapper + * @param string $option + * @param string|int $value + * @return self + */ + public function setOption(string $wrapper, string $option, $value): self + { + error_clear_last(); + $res = @stream_context_set_option($this->context, $wrapper, $option, $value); + if ($res === false) { + $value = ExceptionValueFormatter::format($value); + throw new IoException("Cannot set stream option $option with value $value on wrapper $wrapper: " . error_get_last()['message']); + } + + return $this; + } + + // events ---------------------------------------------------------------------------------------------------------- + + /** + * Callback params: (StreamContext $this, int $event, int $severity, string $message, int $messageCode, int $bytesTransferred, int $bytesMax): void + * + * @param callable $callback + * @return self + */ + public function setCallback(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[null] = $callback; + + return $this; + } + + public function onResolved(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::RESOLVED] = $callback; + + return $this; + } + + public function onConnected(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::CONNECTED] = $callback; + + return $this; + } + + public function onAuthRequired(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::AUTH_REQUIRED] = $callback; + + return $this; + } + + public function onAuthResult(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::AUTH_RESULT] = $callback; + + return $this; + } + + public function onRedirect(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::REDIRECTED] = $callback; + + return $this; + } + + public function onMimeType(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::MIME_TYPE] = $callback; + + return $this; + } + + public function onFileSize(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::FILE_SIZE] = $callback; + + return $this; + } + + public function onProgress(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::PROGRESS] = $callback; + + return $this; + } + + public function onCompleted(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::COMPLETED] = $callback; + + return $this; + } + + public function onFailure(callable $callback): self + { + if ($this->callbacks === []) { + $this->initCallbacks(); + } + + $this->callbacks[StreamEvent::FAILURE] = $callback; + + return $this; + } + + private function initCallbacks(): void + { + stream_context_set_params($this->context, [ + 'notification' => function (int $event, int $severity, ?string $message, int $messageCode, int $bytesTransferred, int $bytesMax): void { + $callback = $this->callbacks[null] ?? null; + if ($callback !== null) { + $callback($this, $event, $severity, $message, $messageCode, $bytesTransferred, $bytesMax); + } + + $callback = $this->callbacks[$event] ?? null; + if ($callback === null) { + return; + } + switch ($event) { + case StreamEvent::RESOLVED: + $callback($this, $message, $messageCode); + break; + case StreamEvent::CONNECTED: + $callback($this, $message, $messageCode); + break; + case StreamEvent::AUTH_REQUIRED: + $callback($this, $message, $messageCode); + break; + case StreamEvent::AUTH_RESULT: + $callback($this, $message, $messageCode); + break; + case StreamEvent::REDIRECTED: + $callback($this, $message, $messageCode); + break; + case StreamEvent::MIME_TYPE: + $callback($this, $message, $messageCode); + break; + case StreamEvent::FAILURE: + $callback($this, $message, $messageCode); + break; + case StreamEvent::FILE_SIZE: + $callback($this, $bytesTransferred, $bytesMax); + break; + case StreamEvent::PROGRESS: + $callback($this, $bytesTransferred, $bytesMax); + break; + case StreamEvent::COMPLETED: + $callback($this, $bytesTransferred, $bytesMax); + break; + } + }, + ]); + } + +} diff --git a/src/Io/Stream/StreamEvent.php b/src/Io/Stream/StreamEvent.php new file mode 100644 index 00000000..1f8b3e60 --- /dev/null +++ b/src/Io/Stream/StreamEvent.php @@ -0,0 +1,39 @@ +uri = $data['uri']; + $this->wrapper = $data['wrapper_type']; + $this->streamType = $data['stream_type']; + $this->mode = $data['mode']; + $this->filters = $data['filters'] ?? []; + $this->unreadBytes = (int) $data['unread_bytes']; + $this->seekable = (bool) $data['seekable']; + $this->timedOut = (bool) $data['timed_out']; + $this->blocked = (bool) $data['blocked']; + $this->eof = (bool) $data['eof']; + } + + /** + * @return mixed[] + */ + public function getHttpHeaders(): array + { + return $this->wrapper === 'http' + ? HttpHeaderParser::parseHeaders($this->wrapperData['wrapper_data']) + : []; + } + + /* + [ + [wrapper_data] => [ + [0] => HTTP/1.1 200 OK + [1] => Server: Apache/2.2.3 (Red Hat) + [2] => Last-Modified: Tue, 15 Nov 2005 13:24:10 GMT + [3] => ETag: "b300b4-1b6-4059a80bfd280" + [4] => Accept-Ranges: bytes + [5] => Content-Type: text/html; charset=UTF-8 + [6] => Set-Cookie: FOO=BAR; expires=Fri, 21-Dec-2012 12:00:00 GMT; path=/; domain=.example.com + [6] => Connection: close + [7] => Date: Fri, 16 Oct 2009 12:00:00 GMT + [8] => Age: 1164 + [9] => Content-Length: 438 + ] + [wrapper_type] => http + [stream_type] => tcp_socket/ssl + [mode] => r + [unread_bytes] => 438 + [seekable] => + [uri] => http://www.example.com/ + [timed_out] => + [blocked] => 1 + [eof] => + ] + */ + +} diff --git a/src/Io/Stream/Wrapper.php b/src/Io/Stream/Wrapper.php new file mode 100644 index 00000000..e84f9eaf --- /dev/null +++ b/src/Io/Stream/Wrapper.php @@ -0,0 +1,25 @@ +handle = $file; + $this->mode = $mode; + return; + } elseif (is_string($file)) { + $this->path = Io::normalizePath($file); + } elseif ($file instanceof Path) { + $this->path = $file->getPath(); + } else { + throw new InvalidArgumentException('Argument $file must be a file path or a stream resource.'); + } + + $this->mode = $mode; + $this->context = $context; + + if ($this->handle === null) { + $this->reopen(); + } if ($encoding !== null) { - $self->setEncoding($encoding); + Encoding::checkValue($encoding); + + $this->encoding = $encoding; } + if ($lineEndings !== null) { - $self->setLineEndings($lineEndings); + $this->setLineEndings($lineEndings); + } + } + + /** + * @return static + */ + public static function createTemporaryFile(): self + { + error_clear_last(); + /** @var resource|false $handle */ + $handle = tmpfile(); + + if ($handle === false) { + throw FilesystemException::create("Cannot create a temporary file", null, null, error_get_last()); } - return $self; + return new static($handle, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); } - public function setEncoding(Encoding $encoding): void + public static function createMemoryFile(?int $maxSize = null): self { - $this->encoding = $encoding->getValue(); + if ($maxSize === null) { + return new static('php://memory', FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + } else { + return new static("php://temp/maxmemory:$maxSize", FileMode::CREATE_OR_TRUNCATE_READ_WRITE); + } + } + + public function toBinaryFile(): BinaryFile + { + return new BinaryFile($this->getHandle(), $this->mode, $this->context); + } + + // encoding and format --------------------------------------------------------------------------------------------- + + public function setEncoding(string $encoding): void + { + Encoding::checkValue($encoding); + + $this->encoding = $encoding; } - public function setInternalEncoding(Encoding $internalEncoding): void + public function getEncoding(): string { - $this->internalEncoding = $internalEncoding->getValue(); + return $this->encoding; } - public function setLineEndings(LineEndings $nl): void + public function convertEncoding(string $encoding): void { - $this->nl = $nl->getValue(); + Encoding::checkValue($encoding); + + $text = $this->getContents(); + + $text = Str::convertEncoding($text, $this->encoding, $encoding); + + $this->truncate(); + $this->writeBinary($text); + $this->encoding = $encoding; + } + + public function setLineEndings(string $lineEndings): void + { + LineEnding::checkValue($lineEndings); + + $this->eol = $lineEndings; + $this->eolEncoded = Encoding::isSupersetOfAscii($this->encoding) + ? $lineEndings + : $this->encode($lineEndings); + } + + public function getLineEndings(): string + { + return $this->eol; + } + + public function convertLineEndings(string $lineEndings): void + { + LineEnding::checkValue($lineEndings); + + $text = $this->getContents(); + + // to cope with multibyte encodings and endian + if ($this->encoding !== Encoding::UTF_8) { + $text = Str::convertEncoding($text, $this->encoding, Encoding::UTF_8); + } + + $text = str_replace($this->eol, $lineEndings, $text); + + if ($this->encoding !== Encoding::UTF_8) { + $text = Str::convertEncoding($text, Encoding::UTF_8, $this->encoding); + } + + $this->truncate(); + $this->writeBinary($text); + $this->setLineEndings($lineEndings); + } + + // read/write ------------------------------------------------------------------------------------------------------ + + public function getContents(): string + { + if ($this->getPosition()) { + $this->setPosition(0); + } + + $results = []; + while (!$this->endOfFileReached()) { + $results[] = $this->readBinary(); + } + + return implode('', $results); + } + + public function read(?int $characters = null): string + { + $characters = $characters ?? self::$defaultChunkSize; + $multiplier = Encoding::minLength($this->encoding); + + $bufferLength = mb_strlen($this->buffer, $this->encoding); + $readChars = $characters - $bufferLength; + if ($readChars > 0) { + $readBytes = $readChars * $multiplier * 2; + do { + $chunk = $this->readBinary($readBytes); + $this->buffer .= $chunk; + $bufferLength = mb_strlen($this->buffer); + if (strlen($chunk) < $readBytes) { + // eof + break; + } + $readChars = $characters - $bufferLength; + $readBytes = $readChars * $multiplier * 2; + } while ($bufferLength < $characters); + } + + if ($bufferLength < $characters) { + $this->buffer = ''; + + return $this->buffer; + } else { + $return = mb_substr($this->buffer, 0, $characters, $this->encoding); + $this->buffer = mb_substr($this->buffer, $characters, PHP_INT_MAX, $this->encoding); + + return $return; + } + } + + public function write(string $data, ?int $characters = null): void + { + if ($characters !== null) { + $data = mb_substr($data, 0, $characters, Encoding::UTF_8); + } + + if ($this->encoding !== Encoding::UTF_8) { + $data = $this->encode($data); + } + + $this->writeBinary($data); } public function readLine(): ?string { + if (!FileMode::isReadable($this->mode)) { + throw new LogicException('Cannot read - file opened in write only mode.'); + } + error_clear_last(); - $line = fgets($this->handle); + // todo: stream_get_line() + $line = @stream_get_line($this->getHandle(), 0, $this->eolEncoded); + //$line = @fgets($this->getHandle()); if ($line === false) { if ($this->endOfFileReached()) { return null; } else { - throw new FileException('Cannot read data from file.', error_get_last()); + throw FilesystemException::create('Cannot read data from file', $this->path, $this->context, error_get_last()); } } - if ($this->encoding !== $this->internalEncoding) { + + if ($this->encoding !== Encoding::UTF_8) { $line = $this->decode($line); } + return $line; } public function writeLine(string $line): void { - if ($this->encoding !== $this->internalEncoding) { - $line = $this->encode($line); + if ($this->encoding !== Encoding::UTF_8) { + $line = $this->encode($line . $this->eol); + } else { + $line .= $this->eolEncoded; } - $this->write($line . $this->nl); + + $this->writeBinary($line); } /** * @return string[] */ - public function readCsvRow(string $delimiter, string $quoteChar, string $escapeChar): array + public function readLines(?int $count = null): array + { + if (!FileMode::isReadable($this->mode)) { + throw new LogicException('Cannot read - file opened in write only mode.'); + } + + $count = $count ?? PHP_INT_MAX; + $lines = []; + $n = 0; + do { + $line = $this->readLine(); + if ($line === null) { + // eof + break; + } + $lines[] = $line; + $n++; + } while ($n < $count); + + return $lines; + } + + /** + * @param string[] $lines + */ + public function writeLines(array $lines): void + { + foreach ($lines as $line) { + $this->writeLine($line); + } + } + + /** + * Truncate file and move pointer at the end + * @param int $characters + */ + public function truncate(int $characters = 0): void + { + if ($characters === 0) { + $this->truncateBinary(0); + } + + $text = $this->read($characters); + $this->truncateBinary(strlen($text)); + } + + /** + * Truncate file and move pointer at the end + * @param int $lines + */ + public function truncateLines(int $lines = 0): void { + if ($lines === 0) { + $this->truncateBinary(0); + } + + $text = implode($this->eolEncoded, $this->readLines($lines)); + $this->truncateBinary(strlen($text)); + } + + private function readBinary(?int $bytes = null): ?string + { + $bytes = $bytes ?? self::$defaultChunkSize; + + if (!FileMode::isReadable($this->mode)) { + throw new LogicException('Cannot read - file opened in write only mode.'); + } + error_clear_last(); - $row = fgetcsv($this->handle, 0, $delimiter, $quoteChar, $escapeChar); + $data = @fread($this->getHandle(), $bytes); - if ($row === false) { + if ($data === false) { + if ($this->endOfFileReached()) { + throw FilesystemException::create("Cannot read from file, end of file was reached", $this->path, $this->context, error_get_last()); + } else { + throw FilesystemException::create("Cannot read from file", $this->path, $this->context, error_get_last()); + } + } elseif ($data === '') { + + } + + return $data === '' ? null : $data; + } + + private function writeBinary(string $data, ?int $bytes = null): void + { + error_clear_last(); + if ($bytes !== null) { + $result = @fwrite($this->getHandle(), $data, $bytes); + } else { + $result = @fwrite($this->getHandle(), $data); + } + + if ($result === false) { + throw FilesystemException::create("Cannot write to file", $this->path, $this->context, error_get_last()); + } + } + + /** + * Truncate file and move pointer at the end + * @param int $size + */ + private function truncateBinary(int $size = 0): void + { + error_clear_last(); + $result = @ftruncate($this->getHandle(), $size); + + if ($result === false) { + throw FilesystemException::create("Cannot truncate file", $this->path, $this->context, error_get_last()); + } + + $this->setPosition($size); + } + + // CSV https://tools.ietf.org/html/rfc4180 ------------------------------------------------------------------------- + + /** + * @param string $delimiter + * @param string $quoteChar + * @param string $escapeChar + * @return string[]|null[] +>>>>>>> 00d7609 (WIP) + */ + public function readCsvRow(string $delimiter = ',', string $quoteChar = '"', string $escapeChar = '"'): array + { + if ($this->encoding !== Encoding::UTF_8) { + $delimiter = $this->encode($delimiter); + $quoteChar = $this->encode($quoteChar); + $escapeChar = $this->encode($escapeChar); + } + + error_clear_last(); + $row = @fgetcsv($this->getHandle(), 0, $delimiter, $quoteChar, $escapeChar); + + if ($row === false || $row === null) { if ($this->endOfFileReached()) { return []; } else { - throw new FileException('Cannot read data from file.', error_get_last()); + throw FilesystemException::create('Cannot read data from file.', $this->path, $this->context, error_get_last()); } } - if ($this->encoding !== $this->internalEncoding) { + if ($this->encoding !== Encoding::UTF_8) { foreach ($row as &$item) { $item = $this->decode($item); } @@ -124,34 +448,41 @@ public function readCsvRow(string $delimiter, string $quoteChar, string $escapeC /** * @param string[] $row - * @return int */ - public function writeCsvRow(array $row, string $delimiter, string $quoteChar): int + public function writeCsvRow(array $row, string $delimiter = ',', string $quoteChar = '"', string $escapeChar = '"'): int { - if ($this->encoding !== $this->internalEncoding) { - foreach ($row as &$item) { - $item = $this->encode($item); + if ($this->encoding !== Encoding::UTF_8) { + $delimiter = $this->encode($delimiter); + $quoteChar = $this->encode($quoteChar); + $escapeChar = $this->encode($escapeChar); + } + + if ($this->encoding !== Encoding::UTF_8) { + foreach ($row as $i => $item) { + $row[$i] = $this->encode((string) $item); } } error_clear_last(); - $written = fputcsv($this->handle, $row, $delimiter, $quoteChar); + $written = @fputcsv($this->getHandle(), $row, $delimiter, $quoteChar, $escapeChar); if ($written === false) { - throw new FileException('Cannot write CSV row', error_get_last()); + throw FilesystemException::create('Cannot write CSV row', $this->path, $this->context, error_get_last()); } return $written; } + // helpers --------------------------------------------------------------------------------------------------------- + private function encode(string $string): string { - return Str::convertEncoding($string, $this->encoding, $this->internalEncoding); + return Str::convertEncoding($string, Encoding::UTF_8, $this->encoding); } private function decode(string $string): string { - return Str::convertEncoding($string, $this->internalEncoding, $this->encoding); + return Str::convertEncoding($string, $this->encoding, Encoding::UTF_8); } } diff --git a/src/Io/exceptions/FileException.php b/src/Io/exceptions/ContentTypeDetectionException.php similarity index 56% rename from src/Io/exceptions/FileException.php rename to src/Io/exceptions/ContentTypeDetectionException.php index 54b79ce9..09f6d04a 100644 --- a/src/Io/exceptions/FileException.php +++ b/src/Io/exceptions/ContentTypeDetectionException.php @@ -9,30 +9,22 @@ namespace Dogma\Io; +use Dogma\Exception; use Throwable; -class FileException extends IoException +class ContentTypeDetectionException extends Exception { - /** @var mixed[]|null */ - private $error; - /** * @param mixed[]|null $error */ - public function __construct(string $message, ?array $error = null, ?Throwable $previous = null) + public function __construct(string $message, ?array $error, ?Throwable $previous = null) { - parent::__construct($message, $previous); - - $this->error = $error; - } + if ($error !== null) { + $message .= ' ' . $error['message']; + } - /** - * @return mixed[]|null - */ - public function getError(): ?array - { - return $this->error; + parent::__construct($message, $previous); } } diff --git a/src/Io/exceptions/FilesystemException.php b/src/Io/exceptions/FilesystemException.php new file mode 100644 index 00000000..7be51be8 --- /dev/null +++ b/src/Io/exceptions/FilesystemException.php @@ -0,0 +1,103 @@ +path = $path; + $this->context = $context; + $this->error = $error; + } + + /** + * @param string $message + * @param string|null $path + * @param StreamContext|null $context + * @param mixed[]|null $error + * @param Throwable|null $previous + * @return self + */ + public static function create( + string $message, + ?string $path, + ?StreamContext $context = null, + ?array $error = null, + ?Throwable $previous = null + ): self + { + // todo permissions, locking + + return new self($message, $path, $context, $error, $previous); + } + + public function getPath(): ?string + { + return $this->path; + } + + public function getContext(): ?StreamContext + { + return $this->context; + } + + /** + * @return mixed[]|null + */ + public function getError(): ?array + { + return $this->error; + } + +} diff --git a/src/Language/Cp437.php b/src/Language/Cp437.php new file mode 100644 index 00000000..fa7ec317 --- /dev/null +++ b/src/Language/Cp437.php @@ -0,0 +1,59 @@ + "~^(?!x)x~", // match nothing ]; + public static function isSupersetOfAscii(string $encoding): bool + { + static $asciiLike = [ + self::BINARY, self::ASCII, self::UTF_8, + self::ISO_8859_1, self::ISO_8859_2, self::ISO_8859_3, self::ISO_8859_4, self::ISO_8859_5, + self::ISO_8859_6, self::ISO_8859_7, self::ISO_8859_8, self::ISO_8859_9, self::ISO_8859_10, + self::ISO_8859_11, self::ISO_8859_13, self::ISO_8859_14, self::ISO_8859_15, self::ISO_8859_16, + self::WINDOWS_1250, self::WINDOWS_1251, self::WINDOWS_1252, self::WINDOWS_1253, self::WINDOWS_1254, + self::WINDOWS_1255, self::WINDOWS_1256, self::WINDOWS_1257, self::WINDOWS_1258, + self::ARMSCII_8, + ]; + + return in_array($encoding, $asciiLike, true); + } + + public static function isVariableLength(string $encoding): bool + { + static $variable = [ + self::UTF_8, self::UTF_16, self::UTF_16LE, self::UTF_32, self::UTF_32LE, + self::GB18030, + ]; + + return in_array($encoding, $variable, true); + } + + public static function minLength(string $encoding): ?int + { + static $lengths = [ + self::UTF_16 => 2, self::UTF_16LE => 2, + self::UTF_32 => 4, self::UTF_32LE => 4, + self::BIG_5 => 2, + self::UHC => 2, + ]; + + return $lengths[$encoding] ?? 1; + } + + public static function getBom(string $encoding): ?string + { + static $bom = [ + self::UTF_8 => "\xEF\xBB\xBF", + self::UTF_16 => "\xFE\xFF", + self::UTF_16LE => "\xFF\xFE", + self::UTF_32 => "\x00\x00\xFE\xFF", + self::UTF_32LE => "\xFF\xFE\x00\x00", + ]; + + return $bom[$encoding] ?? null; + } + public static function validateValue(string &$value): bool { $value = self::normalize($value); diff --git a/src/Language/Locale/Locale.php b/src/Language/Locale/Locale.php index fadf537e..54a0e7d3 100644 --- a/src/Language/Locale/Locale.php +++ b/src/Language/Locale/Locale.php @@ -53,12 +53,15 @@ final private function __construct(string $value, array $components) public static function get(string $value): self { + /** @var string $value */ $value = PhpLocale::canonicalize($value); if (isset(self::$instances[$value])) { return self::$instances[$value]; } else { + /** @var string[] $components */ $components = PhpLocale::parseLocale($value); + /** @var string[] $keywords */ $keywords = PhpLocale::getKeywords($value); if ($keywords) { $components['keywords'] = $keywords; @@ -111,6 +114,7 @@ public static function create( return $key . '=' . $value; })); } + /** @var string $value */ $value = PhpLocale::canonicalize($value); if (isset(self::$instances[$value])) { @@ -123,6 +127,11 @@ public static function create( } } + public function getValue(): string + { + return $this->value; + } + public function getCollator(): Collator { return new Collator($this); @@ -175,11 +184,6 @@ public function findBestMatch(array $locales, $default = null): ?self return $match ? self::get($match) : null; } - public function getValue(): string - { - return $this->value; - } - public function getLanguage(): Language { /** @var string $language */ @@ -215,7 +219,7 @@ public function getCountry(): ?Country */ public function getVariants(): array { - return PhpLocale::getAllVariants($this->value); + return PhpLocale::getAllVariants($this->value) ?? []; } public function getVariant(int $n): ?string diff --git a/src/System/Callstack.php b/src/System/Callstack.php new file mode 100644 index 00000000..b749ee3d --- /dev/null +++ b/src/System/Callstack.php @@ -0,0 +1,59 @@ +frames = $frames; + } + + public static function get(): self + { + $backtrace = debug_backtrace(); + $frames = []; + foreach ($backtrace as $i => $item) { + if ($i === 0) { + continue; + } + $frames[] = new CallstackFrame($item); + } + + return new self($frames); + } + + public static function last(): CallstackFrame + { + $item = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + + return new CallstackFrame($item); + } + + public static function previous(): CallstackFrame + { + $item = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 3)[2]; + + return new CallstackFrame($item); + } + +} diff --git a/src/System/CallstackFrame.php b/src/System/CallstackFrame.php new file mode 100644 index 00000000..d17ef9f1 --- /dev/null +++ b/src/System/CallstackFrame.php @@ -0,0 +1,210 @@ +'; + public const STATIC = '::'; + + /** @var string|null */ + public $file; + + /** @var int|null */ + public $line; + + /** @var string|null */ + public $class; + + /** @var object|null */ + public $object; + + /** @var string */ + public $function; + + /** @var mixed[] */ + public $args; + + /** @var string self::INSTANCE | self::STATIC */ + public $type; + + /** + * @param mixed[] $data + */ + public function __construct(array $data) + { + $this->file = isset($data['file']) ? Io::normalizePath($data['file']) : null; + $this->line = $data['line'] ?? null; + $this->class = $data['class'] ?? null; + $this->object = $data['object'] ?? null; + $this->function = $data['function'] ?? null; + $this->args = $data['args'] ?? null; + $this->type = $data['type'] ?? null; + } + + public function getFullName(): string + { + return $this->class ? $this->class . $this->type . $this->function : $this->function; + } + + public function isClosure(): bool + { + return $this->class === null && Str::endsWith($this->function, '{closure}'); + } + + public function isFunction(): bool + { + return $this->class === null && !Str::endsWith($this->function, '{closure}'); + } + + public function isMethod(): bool + { + return $this->class !== null; + } + + public function isStatic(): bool + { + return $this->type === self::STATIC; + } + + public function isAnonymous(): bool + { + return $this->class !== null && Str::startsWith($this->class, 'class@anonymous'); + } + + // reflection ------------------------------------------------------------------------------------------------------ + + public function getFunctionReflection(): ReflectionFunction + { + if (!$this->isFunction()) { + throw new LogicException($this->getFullName() . ' is not a function.'); + } + + return new ReflectionFunction($this->function); + } + + public function getMethodReflection(): ReflectionMethod + { + if (!$this->isMethod()) { + throw new LogicException($this->getFullName() . ' is not a method.'); + } + /** @var string $object */ + $object = $this->object ?? $this->class; + + return new ReflectionMethod($object, $this->function); + } + + public function getObjectReflection(): ReflectionObject + { + if ($this->object === null) { + throw new LogicException($this->getFullName() . ' is not an instance method.'); + } + + return new ReflectionObject($this->object); + } + + public function getClassReflection(): ReflectionClass + { + if ($this->class === null) { + throw new LogicException($this->getFullName() . ' is not a class method.'); + } + + return new ReflectionClass($this->class); + } + + // code ------------------------------------------------------------------------------------------------------------ + + public function getLineCode(): string + { + if ($this->file === null) { + throw new LogicException($this->getFullName() . ' does not have a file.'); + } + + return Io::readLines($this->file, null, $this->line - 1, 1)[0]; + } + + public function getCode(): string + { + if ($this->file === null) { + throw new LogicException($this->getFullName() . ' does not have a file.'); + } + + if ($this->isFunction()) { + $reflection = $this->getFunctionReflection(); + } elseif ($this->isMethod()) { + $reflection = $this->getMethodReflection(); + } else { + throw new LogicException('Cannot get code of a closure.'); + } + $start = $reflection->getStartLine(); + $end = $reflection->getEndLine(); + + return implode("\n", Io::readLines($this->file, null, $start - 1, $end - $start + 1)); + } + + public function getClassCode(): string + { + if ($this->file === null) { + throw new LogicException($this->getFullName() . ' does not have a file.'); + } + + if ($this->isMethod()) { + $reflection = $this->getClassReflection(); + } else { + throw new LogicException($this->getFullName() . ' is not a class method.'); + } + $start = $reflection->getStartLine(); + $end = $reflection->getEndLine(); + + return implode("\n", Io::readLines($this->file, null, $start - 1, $end - $start + 1)); + } + + public function getFileCode(): string + { + if ($this->file === null) { + throw new LogicException($this->getFullName() . ' does not have a file.'); + } + + return Io::read($this->file); + } + + public function getFile(): BinaryFile + { + if ($this->file === null) { + throw new LogicException($this->getFullName() . ' does not have a file.'); + } + + return new BinaryFile($this->file); + } + + public function getFileInfo(): FileInfo + { + if ($this->file === null) { + throw new LogicException($this->getFullName() . ' does not have a file.'); + } + + return new FileInfo($this->file); + } + +} diff --git a/src/System/Php.php b/src/System/Php.php index ab4c4df1..ba0765fe 100644 --- a/src/System/Php.php +++ b/src/System/Php.php @@ -10,14 +10,16 @@ namespace Dogma\System; use Dogma\Re; +use Dogma\Io\Output; use Dogma\StaticClassMixin; use const INFO_GENERAL; use const PHP_INT_SIZE; use const PHP_SAPI; +use function error_clear_last; +use function error_get_last; use function extension_loaded; -use function ob_get_clean; -use function ob_start; use function phpinfo; +use function proc_nice; class Php { @@ -52,13 +54,22 @@ public static function isThreadSafe(): bool { static $threadSafe; if ($threadSafe === null) { - ob_start(); - phpinfo(INFO_GENERAL); - $info = (string) ob_get_clean(); + $info = Output::capture(function () { + phpinfo(INFO_GENERAL); + }); $threadSafe = (bool) Re::match($info, '~Thread Safety\s*\s*]*>\s*enabled~'); } return $threadSafe; } + public static function setPriority(int $priority): void + { + error_clear_last(); + $result = @proc_nice($priority); + if ($result !== true) { + throw new CannotChangePriorityException('Cannot change system priority: ' . error_get_last()['message']); + } + } + } diff --git a/src/Io/ContentType/exceptions/ContentTypeDetectionException.php b/src/System/exceptions/CannotChangePriorityException.php similarity index 78% rename from src/Io/ContentType/exceptions/ContentTypeDetectionException.php rename to src/System/exceptions/CannotChangePriorityException.php index 8fc49e4d..5edd8e32 100644 --- a/src/Io/ContentType/exceptions/ContentTypeDetectionException.php +++ b/src/System/exceptions/CannotChangePriorityException.php @@ -7,11 +7,11 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ -namespace Dogma\Io\ContentType; +namespace Dogma\System; use Dogma\Exception; -class ContentTypeDetectionException extends Exception +class CannotChangePriorityException extends Exception { } diff --git a/src/Time/DateTime.php b/src/Time/DateTime.php index cd01c304..06c5ff65 100644 --- a/src/Time/DateTime.php +++ b/src/Time/DateTime.php @@ -27,6 +27,7 @@ use Dogma\Time\Provider\TimeProvider; use Dogma\Time\Span\DateOrTimeSpan; use Dogma\Time\Span\DateTimeSpan; +use Throwable; use const DATE_RFC2822; use function array_keys; use function array_values; @@ -139,6 +140,21 @@ public static function createFromAnyFormat(array $formats, string $timeString, ? } } + /** + * @param string[] $formats + * @param string $timeString + * @param DateTimeZone|null $timeZone + * @return static|null + */ + public static function tryCreateFromAnyFormat(array $formats, string $timeString, ?DateTimeZone $timeZone = null): ?self + { + try { + return self::createFromAnyFormat($formats, $timeString, $timeZone); + } catch (Throwable $e) { + return null; + } + } + public static function createFromTimestamp(int $timestamp, ?DateTimeZone $timeZone = null): self { $dateTime = static::createFromFormat('U', (string) $timestamp, TimeZone::getUtc()); diff --git a/src/common/Check.php b/src/common/Check.php index 83a37a52..39884993 100644 --- a/src/common/Check.php +++ b/src/common/Check.php @@ -11,6 +11,7 @@ namespace Dogma; +use Dogma\System\Callstack; use stdClass; use const INF; use function array_keys; @@ -1218,6 +1219,17 @@ public static function enum($value, ...$allowedValues): void } } + public static function flags(int $value, int $allowedValues): void + { + if (($value & $allowedValues) !== $value) { + $line = Callstack::last()->getLineCode(); + /** @var string[] $allowed */ + $allowed = Str::match($line, '/::flags\\([^,]+,\\s*([^)]+)\\)/'); + + throw new InvalidValueException($value, $allowed[1]); + } + } + /** * @param mixed $value * @return bool diff --git a/src/common/Str.php b/src/common/Str.php index b986e01c..daa4155d 100644 --- a/src/common/Str.php +++ b/src/common/Str.php @@ -915,6 +915,13 @@ public static function matchAll(string $string, string $pattern, int $flags = 0, return Re::matchAll($string, $pattern, $flags, $offset); } + public static function matchSingle(string $string, string $pattern): ?string + { + $match = self::match($string, $pattern); + + return $match === null ? null : $match[1]; + } + /** * @deprecated use Re::replace() instead * @param string|string[] $pattern diff --git a/src/common/Text.php b/src/common/Text.php new file mode 100644 index 00000000..63139d12 --- /dev/null +++ b/src/common/Text.php @@ -0,0 +1,63 @@ + [0, self::UINT24_MAX], BitSize::BITS_32 => [0, self::UINT32_MAX], BitSize::BITS_48 => [0, self::UINT48_MAX], + BitSize::BITS_53 => [0, self::UINT53_MAX], BitSize::BITS_64 => [0, self::UINT64_MAX], ], Sign::SIGNED => [ @@ -56,6 +57,7 @@ public static function getRange(int $size, string $sign = Sign::SIGNED): array BitSize::BITS_24 => [self::INT24_MIN, self::INT24_MAX], BitSize::BITS_32 => [self::INT32_MIN, self::INT32_MAX], BitSize::BITS_48 => [self::INT48_MIN, self::INT48_MAX], + BitSize::BITS_53 => [self::INT48_MIN, self::INT53_MAX], BitSize::BITS_64 => [self::INT64_MIN, self::INT64_MAX], ], ]; diff --git a/src/common/consts/LineEnding.php b/src/common/consts/LineEnding.php index 832a8ca4..a75e2513 100644 --- a/src/common/consts/LineEnding.php +++ b/src/common/consts/LineEnding.php @@ -17,8 +17,14 @@ class LineEnding public const CRLF = "\r\n"; public const CR = "\r"; + public const UNIX = self::LF; public const LINUX = self::LF; public const WINDOWS = self::CRLF; public const MAC = self::CR; + public static function checkValue(string $value): void + { + Check::enum($value, [self::LF, self::CRLF, self::CR]); + } + } diff --git a/tests/src/Enum/Enum.all.phpt b/tests/src/Enum/Enum.all.phpt index 05feca3b..ab374cd4 100644 --- a/tests/src/Enum/Enum.all.phpt +++ b/tests/src/Enum/Enum.all.phpt @@ -15,9 +15,6 @@ use Dogma\Http\HttpOrCurlStatus; use Dogma\Http\HttpResponseStatus; use Dogma\Io\ContentType\BaseContentType; use Dogma\Io\ContentType\ContentType; -use Dogma\Io\FilePosition; -use Dogma\Io\LineEndings; -use Dogma\Io\LockType; use Dogma\Language\Encoding; use Dogma\Language\Language; use Dogma\Language\Locale\LocaleCalendar; @@ -64,9 +61,6 @@ $enums = [ HttpResponseStatus::class, BaseContentType::class, ContentType::class, - FilePosition::class, - LineEndings::class, - LockType::class, Encoding::class, Language::class, Script::class, diff --git a/tests/src/Io/BinaryFile.phpt b/tests/src/Io/BinaryFile.phpt new file mode 100644 index 00000000..1c849856 --- /dev/null +++ b/tests/src/Io/BinaryFile.phpt @@ -0,0 +1,272 @@ +isEmpty()); +}; +$cleanup(); + + +crateTemporaryFile: +$file = BinaryFile::createTemporaryFile(); +Assert::true($file->isOpen()); + + +createMemoryFile: +$file = BinaryFile::createMemoryFile(); +Assert::true($file->isOpen()); + +$file = BinaryFile::createMemoryFile(1024); +Assert::true($file->isOpen()); + + +__construct: +// does not exist +Assert::exception(static function () use ($testFile): void { + new BinaryFile($testFile, FileMode::OPEN_READ); +}, FilesystemException::class); + +// does not exist +Assert::exception(static function () use ($testFile): void { + new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +}, FilesystemException::class); + +// create +$file = new BinaryFile($testFile, FileMode::CREATE_WRITE); +Assert::true($file->isOpen()); +$file->write('foo'); + +// cannot read in write only mode +Assert::exception(static function () use ($file): void { + $file->read(); +}, LogicException::class); +$file->close(); + +// cannot create when already created +Assert::exception(static function () use ($testFile): void { + $file = new BinaryFile(FileMode::CREATE_READ_WRITE); + try { + new BinaryFile($testFile, FileMode::CREATE_READ_WRITE); + } finally { + $file->close(); + } +}, FilesystemException::class); + +// read +$file = new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +Assert::same($file->read(), 'foo'); +$file->close(); + + +toTextFile: +$file = new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +$file = $file->toTextFile(); +Assert::type($file, TextFile::class); +$file->close(); + + +getFileInfo: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$info = $file->getFileInfo(); +Assert::same(get_class($info), FileInfo::class); +$file->close(); + + +getStreamMetaData: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$metaData = $file->getStreamInfo(); +Assert::same(get_class($metaData), StreamInfo::class); + + +getMode: +$file = new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +Assert::same($file->getMode(), FileMode::OPEN_READ_WRITE); +$file->close(); + + +getPath: +$file = new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +Assert::same($file->getPath(), $testFile); +$file->close(); + + +getName: +$file = new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +Assert::same($file->getName(), basename($testFile)); +//$file->close(); + + +rename: +$file = new BinaryFile($testFile, FileMode::OPEN_READ_WRITE); +$file->rename($testDir . '/rename1.txt'); +Assert::false(file_exists($testFile)); +Assert::true(file_exists($testDir . '/rename1.txt')); +$file->close(); + + +close: +isOpen: +reopen: +$file = new BinaryFile($testFile, FileMode::CREATE_WRITE); +Assert::true($file->isOpen()); +$file->close(); +Assert::false($file->isOpen()); +$file->reopen(); +Assert::true($file->isOpen()); + +Assert::exception(static function () use ($file): void { + $file->reopen(); +}, LogicException::class); +$file->close(); + + +read: +write: +endOfFileReached: +$file = new BinaryFile($testFile, FileMode::CREATE_READ_WRITE); +$file->write('1234567890'); +$file->setPosition(0); +$str = $file->read(5); +Assert::same($str, '12345'); +Assert::false($file->endOfFileReached()); +$str = $file->read(5); +Assert::same($str, '67890'); +Assert::false($file->endOfFileReached()); +$str = $file->read(1); +Assert::same($str, null); +Assert::true($file->endOfFileReached()); +$file->close(); + + +copyData: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$file->write('0123456789'); + +// callback +$result = $file->copyData(static function (string $data): void { + Assert::same($data, '234'); +}, 2, 3); +Assert::same($result, 3); + +$result = $file->copyData(static function (): void { + Assert::fail('Callback should not have been called.'); +}, 20, 3); +Assert::same($result, 0); + +// File instance +$destinationFile = new BinaryFile($testDir . '/dest.txt', FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$result = $file->copyData($destinationFile, 2, 3); +Assert::same($result, 3); +Assert::same(Io::read($destinationFile), '234'); + +$destinationFile->truncate(); +$result = $file->copyData($destinationFile, 2, 6, 3); +Assert::same($result, 6); +Assert::same(Io::read($destinationFile), '234567'); + +$destinationFile->truncate(); +$result = $file->copyData($destinationFile, 2); +Assert::same($result, 8); +Assert::same(Io::read($destinationFile), '23456789'); + +$destinationFile->close(); + +// FileInfo +Io::delete($destinationFile); +$result = $file->copyData($destinationFile->getFileInfo(), 2); +Assert::same($result, 8); +Assert::same(Io::read($destinationFile), '23456789'); + + +// file path +Io::delete($destinationFile); +$result = $file->copyData($destinationFile->getPath(), 2); +Assert::same($result, 8); +Assert::same(Io::read($destinationFile), '23456789'); + +$file->close(); + + +getContents: +truncate: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$file->write('0123456789'); +Assert::same($file->getContents(), '0123456789'); + +$file->truncate(); +Assert::same($file->getContents(), ''); + + +setPosition: +getPosition: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$file->write('0123456789'); +$file->setPosition(5); +Assert::same($file->getPosition(), 5); +Assert::same($file->read(), '56789'); +Assert::same($file->getPosition(), 10); +$file->setPosition(0); +Assert::same($file->getPosition(), 0); +Assert::same($file->read(5), '01234'); +Assert::same($file->getPosition(), 5); + + +flush: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$file->flush(); + + +lock: +unlock: +$file = new BinaryFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$file->lock(); // shared + +$file->unlock(); +$file->lock(LockType::EXCLUSIVE); +$file->lock(LockType::SHARED); +$file->unlock(); + +$file->lock(LockType::SHARED); +Assert::exception(static function () use ($file): void { + $file->lock(LockType::NON_BLOCKING); +}, IoException::class); + +$file->lock(LockType::EXCLUSIVE); +Assert::exception(static function () use ($file): void { + $file->lock(LockType::NON_BLOCKING); +}, IoException::class); + +$file->unlock(); +if (!Os::isWindows()) { + // Windows does not support non-blocking locks + $file->lock(LockType::NON_BLOCKING); +} + + +$file->close(); +$cleanup(); diff --git a/tests/src/Io/Io.dirs.phpt b/tests/src/Io/Io.dirs.phpt new file mode 100644 index 00000000..c380bc2c --- /dev/null +++ b/tests/src/Io/Io.dirs.phpt @@ -0,0 +1,289 @@ +isEmpty()); +}; +$cleanup(); + + +createDirectory: +$targetDir = $testDir . '/foo'; +Io::createDirectory($targetDir); +Assert::true(is_dir($targetDir)); + +// fail without ignore +Assert::exception(static function () use ($targetDir): void { + Io::createDirectory($targetDir); +}, FilesystemException::class); + +// pass with ignore +Io::createDirectory($targetDir, Io::IGNORE); + +// fail without recursive +$targetDir = $testDir . '/bar/baz'; +Assert::exception(static function () use ($targetDir): void { + Io::createDirectory($targetDir); +}, FilesystemException::class); + +// pass with recursive +Io::createDirectory($targetDir, Io::RECURSIVE); +Assert::true(is_dir($targetDir)); + +$cleanup(); + + +deleteDirectory: +$targetDir = $testDir . '/foo'; +Io::createDirectory($targetDir); +Assert::true(is_dir($targetDir)); +Io::deleteDirectory($targetDir); +Assert::false(file_exists($targetDir)); + +// fail without ignore +Assert::exception(static function () use ($targetDir): void { + Io::deleteDirectory($targetDir); +}, FilesystemException::class); + +// pass with ignore +Io::deleteDirectory($targetDir, Io::IGNORE); + +// fail without recursive +$innerDir = $testDir . '/bar/baz'; +$targetDir = $testDir . '/bar'; +Io::createDirectory($innerDir, Io::RECURSIVE); +Assert::true(is_dir($innerDir)); +Assert::exception(static function () use ($targetDir): void { + Io::deleteDirectory($targetDir); +}, FilesystemException::class); + +// pass with recursive +Io::deleteDirectory($targetDir, Io::RECURSIVE); +Assert::false(file_exists($targetDir)); + +$cleanup(); + + +cleanDirectory: +Io::createDirectory($testDir . '/foo'); +Io::touch($testDir . '/foo.txt'); +Io::cleanDirectory($testDir); +Assert::true($testDirInfo->isEmpty()); + +// fail without recursive +Io::createDirectory($testDir . '/foo/bar', Io::RECURSIVE); +Io::touch($testDir . '/foo/bar.txt'); +Assert::exception(static function () use ($testDir): void { + Io::cleanDirectory($testDir); +}, FilesystemException::class); + +// pass with recursive +Io::cleanDirectory($testDir, Io::RECURSIVE); +Assert::true($testDirInfo->isEmpty()); + +// filters - files only +Io::createDirectory($testDir . '/foo/bar', Io::RECURSIVE); +Io::touch($testDir . '/foo.txt'); +Io::touch($testDir . '/foo/foo.txt'); +Io::touch($testDir . '/bar.txt'); +Io::touch($testDir . '/foo/bar.txt'); +Io::cleanDirectory($testDir, Io::RECURSIVE, Io::FILES_ONLY); +Assert::false(file_exists($testDir . '/bar.txt')); +Assert::false(file_exists($testDir . '/foo/bar.txt')); +Assert::false(file_exists($testDir . '/foo.txt')); +Assert::false(file_exists($testDir . '/foo/foo.txt')); +Assert::true(is_dir($testDir . '/foo/bar')); + +// filters - specific files +Io::touch($testDir . '/foo.txt'); +Io::touch($testDir . '/foo/foo.txt'); +Io::touch($testDir . '/bar.txt'); +Io::touch($testDir . '/foo/bar.txt'); +Io::cleanDirectory($testDir, Io::RECURSIVE, static function (FileInfo $item): bool { + return $item->isFile() && Str::startsWith($item->getName(), 'foo'); +}); +Assert::true(file_exists($testDir . '/bar.txt')); +Assert::true(file_exists($testDir . '/foo/bar.txt')); +Assert::false(file_exists($testDir . '/foo.txt')); +Assert::false(file_exists($testDir . '/foo/foo.txt')); +Assert::true(is_dir($testDir . '/foo/bar')); + +// filters - non-empty dirs are kept +Io::touch($testDir . '/foo.txt'); +Io::touch($testDir . '/foo/foo.txt'); +Io::touch($testDir . '/bar.txt'); +Io::touch($testDir . '/foo/bar.txt'); +Io::cleanDirectory($testDir, Io::RECURSIVE, static function (FileInfo $item): bool { + return Str::startsWith($item->getName(), 'foo'); +}); +Assert::true(file_exists($testDir . '/bar.txt')); +Assert::true(file_exists($testDir . '/foo/bar.txt')); +Assert::false(file_exists($testDir . '/foo.txt')); +Assert::false(file_exists($testDir . '/foo/foo.txt')); +Assert::true(is_dir($testDir . '/foo/bar')); + +$cleanup(); + + +copyDirectory: +$sourceDir = $testDir . '/src'; +$destDir = $testDir . '/dest'; +Io::createDirectory($sourceDir); +Io::touch($sourceDir . '/foo.txt'); +Io::copyDirectory($sourceDir, $destDir); +Assert::true(file_exists($destDir . '/foo.txt')); + +// fail so rewrite without ignore +Assert::exception(static function () use ($sourceDir, $destDir): void { + Io::copyDirectory($sourceDir, $destDir); +}, FilesystemException::class); + +// pass with ignore +Io::copyDirectory($sourceDir, $destDir, Io::IGNORE); + +// copy only direct descendants without recursive +$cleanup(); +Io::createDirectory($sourceDir . '/foo/bar', Io::RECURSIVE); +Io::touch($sourceDir . '/foo.txt'); +Io::touch($sourceDir . '/foo/bar.txt'); +Io::copyDirectory($sourceDir, $destDir); +Assert::true(file_exists($destDir . '/foo.txt')); +Assert::false(file_exists($destDir . '/foo/bar.txt')); +Assert::false(is_dir($destDir . '/foo/bar')); + +// copy everything with recursive +$cleanup(); +Io::createDirectory($sourceDir . '/foo/bar', Io::RECURSIVE); +Io::touch($sourceDir . '/foo.txt'); +Io::touch($sourceDir . '/foo/bar.txt'); +Io::copyDirectory($sourceDir, $destDir, Io::RECURSIVE); +Assert::true(file_exists($destDir . '/foo.txt')); +Assert::true(file_exists($destDir . '/foo/bar.txt')); +Assert::true(is_dir($destDir . '/foo/bar')); + +// filters - directories only +$cleanup(); +Io::createDirectory($sourceDir . '/foo/bar', Io::RECURSIVE); +Io::touch($sourceDir . '/foo.txt'); +Io::touch($sourceDir . '/foo/foo.txt'); +Io::touch($sourceDir . '/bar.txt'); +Io::touch($sourceDir . '/foo/bar.txt'); +Io::copyDirectory($sourceDir, $destDir, Io::RECURSIVE, Io::DIRECTORIES_ONLY); +Assert::false(file_exists($destDir . '/bar.txt')); +Assert::false(file_exists($destDir . '/foo/bar.txt')); +Assert::false(file_exists($destDir . '/foo.txt')); +Assert::false(file_exists($destDir . '/foo/foo.txt')); +Assert::true(is_dir($destDir . '/foo/bar')); + +// filters - specific files, create paths +$cleanup(); +Io::createDirectory($sourceDir . '/foo/bar', Io::RECURSIVE); +Io::touch($sourceDir . '/foo.txt'); +Io::touch($sourceDir . '/foo/foo.txt'); +Io::touch($sourceDir . '/bar.txt'); +Io::touch($sourceDir . '/foo/bar.txt'); +Io::copyDirectory($sourceDir, $destDir, Io::RECURSIVE, static function (FileInfo $item) { + return $item->isFile() && Str::startsWith($item->getName(), 'foo'); +}); +Assert::false(file_exists($destDir . '/bar.txt')); +Assert::false(file_exists($destDir . '/foo/bar.txt')); +Assert::true(file_exists($destDir . '/foo.txt')); +Assert::true(file_exists($destDir . '/foo/foo.txt')); +Assert::false(is_dir($destDir . '/foo/bar')); + +$cleanup(); + + +scanDirectory: +Io::touch($testDir . '/foo/bar/foo.txt', null, null, Io::RECURSIVE); +Io::touch($testDir . '/foo/bar.txt'); +Io::touch($testDir . '/foo.txt'); +$results = Io::scanDirectory($testDir); +Assert::same(array_keys(iterator_to_array($results)), [ + $testDir . '/foo', + $testDir . '/foo.txt', +]); + +$results = Io::scanDirectory($testDir, Io::RECURSIVE); +Assert::same(array_keys(iterator_to_array($results)), [ + $testDir . '/foo', + $testDir . '/foo/bar', + $testDir . '/foo/bar/foo.txt', + $testDir . '/foo/bar.txt', + $testDir . '/foo.txt', +]); + +$results = Io::scanDirectory($testDir, Io::RECURSIVE | Io::CHILDREN_FIRST); +Assert::same(array_keys(iterator_to_array($results)), [ + $testDir . '/foo/bar/foo.txt', + $testDir . '/foo/bar', + $testDir . '/foo/bar.txt', + $testDir . '/foo', + $testDir . '/foo.txt', +]); + +$cleanup(); + + +if (Os::isWindows()) { + Environment::skip('stat() always returns mode 777/666 on Windows.'); +} + +updatePermissions: +Io::setPermissionMask(0); +Io::createDirectory($testDir . '/foo'); +Io::touch($testDir . '/foo/bar.txt'); +Io::touch($testDir . '/foo.txt'); +$fooDir = new FileInfo($testDir . '/foo'); +$fooBar = new FileInfo($testDir . '/foo/bar.txt'); +$foo = new FileInfo($testDir . '/foo.txt'); +Assert::same($fooDir->getPermissionsOct(), '777'); +Assert::same($fooBar->getPermissionsOct(), '666'); +Assert::same($foo->getPermissionsOct(), '666'); + +// remove +Io::updatePermissions($foo, 0, FilePermissions::OTHER_ALL | FilePermissions::GROUP_WRITE); +Assert::same($fooDir->getPermissionsOct(), '777'); +Assert::same($fooBar->getPermissionsOct(), '666'); +Assert::same($foo->getPermissionsOct(), '640'); + +// add +Io::updatePermissions($foo, FilePermissions::OTHER_ALL | FilePermissions::GROUP_WRITE, 0); +Assert::same($fooDir->getPermissionsOct(), '777'); +Assert::same($fooBar->getPermissionsOct(), '666'); +Assert::same($foo->getPermissionsOct(), '666'); + +// recursive remove +Io::updatePermissions($testDir, 0, FilePermissions::OTHER_ALL | FilePermissions::GROUP_WRITE, Io::RECURSIVE); +Assert::same($fooDir->getPermissionsOct(), '750'); +Assert::same($fooBar->getPermissionsOct(), '640'); +Assert::same($foo->getPermissionsOct(), '640'); + +// recursive add +Io::updatePermissions($testDir, 0, FilePermissions::OTHER_ALL | FilePermissions::GROUP_WRITE, Io::RECURSIVE); +Assert::same($fooDir->getPermissionsOct(), '777'); +Assert::same($fooBar->getPermissionsOct(), '666'); +Assert::same($foo->getPermissionsOct(), '666'); + +$cleanup(); diff --git a/tests/src/Io/Io.files.phpt b/tests/src/Io/Io.files.phpt new file mode 100644 index 00000000..b25962cd --- /dev/null +++ b/tests/src/Io/Io.files.phpt @@ -0,0 +1,270 @@ +isEmpty()); +}; +$cleanup(); + + +$firstLine = 'clearCache(); +Assert::true($info->getModifiedTime()->equals($info->getAccessedTime())); + +// times +Io::touch($targetFile, new DateTime('-10 seconds'), new DateTime('-20 seconds')); +$info->clearCache(); +Assert::false($info->getModifiedTime()->equals($info->getAccessedTime())); + +// fail without path +Assert::exception(static function () use ($testDir): void { + Io::touch($testDir . '/foo/bar/touch2.php'); +}, FilesystemException::class); + +// create path +$targetFile = $testDir . '/foo/bar/touch2.php'; +Io::touch($targetFile, null, null, Io::RECURSIVE); +Assert::true(file_exists($targetFile)); + +$cleanup(); + + +copy: +$targetFile = $testDir . '/copy1.php'; +Io::copy(__FILE__, $targetFile); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +// fail without path +Assert::exception(static function () use ($testDir): void { + Io::copy(__FILE__, $testDir . '/foo/bar/copy2.php'); +}, FilesystemException::class); + +// create path +$targetFile = $testDir . '/foo/bar/copy2.php'; +Io::copy(__FILE__, $targetFile, Io::RECURSIVE); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +$cleanup(); + + +rename: +$originalFile = $testDir . '/renameOrig.php'; +$targetFile = $testDir . '/rename1.php'; +Io::copy(__FILE__, $originalFile); +Assert::true(file_exists($originalFile)); + +Io::rename($originalFile, $targetFile); +Assert::false(file_exists($originalFile)); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +Io::copy(__FILE__, $originalFile); +Assert::true(file_exists($originalFile)); + +// fail without path +Assert::exception(static function () use ($originalFile, $testDir): void { + Io::rename($originalFile, $testDir . '/foo/bar/rename2.php'); +}, FilesystemException::class); + +// create path +Io::rename($originalFile, $testDir . '/foo/bar/rename2.php', Io::RECURSIVE); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +$cleanup(); + + +link: +$originalFile = $testDir . '/linkOrig.php'; +$targetFile = $testDir . '/link1.php'; +Io::copy(__FILE__, $originalFile); +Assert::true(file_exists($originalFile)); + +Io::link($originalFile, $targetFile); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +// fail without path +Assert::exception(static function () use ($originalFile, $testDir): void { + Io::link($originalFile, $testDir . '/foo/bar/link2.php'); +}, FilesystemException::class); + +// create path +Io::link($originalFile, $testDir . '/foo/bar/link2.php', Io::RECURSIVE); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +// does not rewrite destination +Io::write($testDir . '/link3.php', 'foobar'); +Assert::exception(static function () use ($originalFile, $testDir): void { + Io::link($originalFile, $testDir . '/link3.php'); +}, IoException::class); + +$cleanup(); + + +unlink: +$originalFile = $testDir . '/unlink.php'; +Io::copy(__FILE__, $originalFile); +Assert::true(file_exists($originalFile)); + +Io::unlink($originalFile); +Assert::false(file_exists($targetFile)); + +// fail without ignore +Assert::exception(static function () use ($originalFile): void { + Io::unlink($originalFile); +}, FilesystemException::class); + +// pass with ignore +Io::unlink($originalFile, Io::IGNORE); + +$cleanup(); + + +// why the fuck? +if (Os::isWindows()) { + Environment::skip('Io::symlink() needs admin access on Windows.'); +} + +symlink: +$originalFile = $testDir . '/symlinkOrig.php'; +$targetFile = $testDir . '/symlink1.php'; +Io::copy(__FILE__, $originalFile); +Assert::true(file_exists($originalFile)); + +Io::symlink($originalFile, $targetFile); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +// fail without path +Assert::exception(static function () use ($originalFile, $testDir): void { + Io::symlink($originalFile, $testDir . '/foo/bar/symlink2.php'); +}, FilesystemException::class); + +// create path +Io::symlink($originalFile, $testDir . '/foo/bar/symlink2.php', Io::RECURSIVE); +Assert::true(file_exists($targetFile)); +Assert::same(Io::read($targetFile), Io::read(__FILE__)); + +$cleanup(); diff --git a/tests/src/Io/Io.misc.phpt b/tests/src/Io/Io.misc.phpt new file mode 100644 index 00000000..e20bb154 --- /dev/null +++ b/tests/src/Io/Io.misc.phpt @@ -0,0 +1,110 @@ + 0); +Assert::exception(static function (): void { + if (Os::isWindows()) { + Io::getStorageSize('z:/foo/bar'); + } else { + Io::getStorageSize('/foo/bar'); + } +}, FilesystemException::class); + + +getFreeSpace: +Assert::true(Io::getFreeSpace(__DIR__) > 0); +Assert::exception(static function (): void { + if (Os::isWindows()) { + Io::getFreeSpace('z:/foo/bar'); + } else { + Io::getFreeSpace('/foo/bar'); + } +}, FilesystemException::class); diff --git a/tests/src/Io/TextFile.phpt b/tests/src/Io/TextFile.phpt new file mode 100644 index 00000000..acf84b65 --- /dev/null +++ b/tests/src/Io/TextFile.phpt @@ -0,0 +1,87 @@ +isEmpty()); +}; +$cleanup(); + + +$testFile = $testDir . '/foo.txt'; + +toBinaryFile: +$file = new TextFile($testFile, FileMode::CREATE_OR_TRUNCATE_READ_WRITE); +$file = $file->toBinaryFile(); +Assert::type($file, File::class); +$file->write("\x00f\x00o\x00o"); +$file->setPosition(0); +$file = $file->toTextFile(); +Assert::type($file, TextFile::class); + + +setEncoding: +getEncoding: +$file->setEncoding(Encoding::UTF_8); +Assert::same($file->getEncoding(), Encoding::UTF_8); +Assert::same($file->readLine(), "\x00f\x00o\x00o"); +$file->setPosition(0); +$file->setEncoding(Encoding::UTF_16); +Assert::same($file->getEncoding(), Encoding::UTF_16); +Assert::same($file->readLine(), 'foo'); + + +convertEncoding: + + +setLineEndings: +getLineEndings: + + +convertLineEndings: + + +getContents: + + +read: + + +write: + + +readLine: + + +writeLine: + + +readLines: + + +writeLines: + + +truncate: + + +truncateLines: + + +readCsvRow: +writeCsvRow: diff --git a/tests/src/Io/textfile/foo.txt b/tests/src/Io/textfile/foo.txt new file mode 100644 index 0000000000000000000000000000000000000000..849ecb39edf5d4a12b23c0c0299aa18c8486627e GIT binary patch literal 6 NcmZQbW5{R72LJ=W0Yv}+ literal 0 HcmV?d00001 diff --git a/tests/src/Math/IntCalc.phpt b/tests/src/Math/IntCalc.phpt index 47228a39..e7fd6423 100644 --- a/tests/src/Math/IntCalc.phpt +++ b/tests/src/Math/IntCalc.phpt @@ -74,6 +74,7 @@ Assert::same(IntCalc::greatestCommonDivider(2, 2), 2); Assert::same(IntCalc::greatestCommonDivider(2, 3), 1); Assert::same(IntCalc::greatestCommonDivider(4, 6), 2); Assert::same(IntCalc::greatestCommonDivider(84, 140), 28); +Assert::same(IntCalc::greatestCommonDivider(11322, 2098765413), 153); leastCommonMultiple: diff --git a/tests/src/System/Callstack.phpt b/tests/src/System/Callstack.phpt new file mode 100644 index 00000000..360e98cb --- /dev/null +++ b/tests/src/System/Callstack.phpt @@ -0,0 +1,248 @@ +frames, 1); +$frame = $callstack->frames[0]; +Assert::same($frame->getLineCode(), '$callstack = get();'); + + +// CallstackFrame::getFile() +Assert::same($frame->getFile()->getPath(), Io::normalizePath(__FILE__)); + + +// CallstackFrame::getFileInfo() +Assert::same($frame->getFileInfo()->getPath(), Io::normalizePath(__FILE__)); + + +// CallstackFrame::getFileCode() +Assert::same($frame->getFileCode(), Io::read(__FILE__)); + + +// in function +$frame = last(); +Assert::same($frame->getFullName(), __NAMESPACE__ . '\\last'); +Assert::true($frame->isFunction()); +Assert::false($frame->isMethod()); +Assert::false($frame->isClosure()); +Assert::false($frame->isStatic()); +Assert::false($frame->isAnonymous()); + +Assert::type($frame->getFunctionReflection(), ReflectionFunction::class); +Assert::exception(static function () use ($frame): void { + $frame->getMethodReflection(); +}, LogicException::class); +Assert::exception(static function () use ($frame): void { + $frame->getClassReflection(); +}, LogicException::class); +Assert::exception(static function () use ($frame): void { + $frame->getObjectReflection(); +}, LogicException::class); + +Assert::same($frame->getLineCode(), '$frame = last();'); +Assert::same($frame->getCode(), 'function last(): CallstackFrame +{ + return Callstack::last(); +}'); +Assert::exception(static function () use ($frame): void { + $frame->getClassCode(); +}, LogicException::class); + + +// in closure +$frame = $last(); +Assert::same($frame->getFullName(), __NAMESPACE__ . '\\{closure}'); +Assert::false($frame->isFunction()); +Assert::false($frame->isMethod()); +Assert::true($frame->isClosure()); +Assert::false($frame->isStatic()); +Assert::false($frame->isAnonymous()); + +Assert::exception(static function () use ($frame): void { + $frame->getFunctionReflection(); +}, LogicException::class); +Assert::exception(static function () use ($frame): void { + $frame->getMethodReflection(); +}, LogicException::class); +Assert::exception(static function () use ($frame): void { + $frame->getClassReflection(); +}, LogicException::class); +Assert::exception(static function () use ($frame): void { + $frame->getObjectReflection(); +}, LogicException::class); + +Assert::same($frame->getLineCode(), '$frame = $last();'); +Assert::exception(static function () use ($frame): void { + $frame->getCode(); +}, LogicException::class); +Assert::exception(static function () use ($frame): void { + $frame->getClassCode(); +}, LogicException::class); + + +// in static method +$frame = Test::staticLast(); +Assert::same($frame->getFullName(), __NAMESPACE__ . '\\Test::staticLast'); +Assert::false($frame->isFunction()); +Assert::true($frame->isMethod()); +Assert::false($frame->isClosure()); +Assert::true($frame->isStatic()); +Assert::false($frame->isAnonymous()); + +Assert::exception(static function () use ($frame): void { + $frame->getFunctionReflection(); +}, LogicException::class); +Assert::type($frame->getMethodReflection(), ReflectionMethod::class); +Assert::type($frame->getClassReflection(), ReflectionClass::class); +Assert::exception(static function () use ($frame): void { + $frame->getObjectReflection(); +}, LogicException::class); + +Assert::same($frame->getLineCode(), '$frame = Test::staticLast();'); +Assert::same($frame->getCode(), ' public static function staticLast(): CallstackFrame + { + return Callstack::last(); + }'); +Assert::same($frame->getClassCode(), 'class Test +{ + + public static function staticLast(): CallstackFrame + { + return Callstack::last(); + } + + public function last(): CallstackFrame + { + return Callstack::last(); + } + +}'); + + +// in object method +$object = new Test(); +$frame = $object->last(); +Assert::same($frame->getFullName(), __NAMESPACE__ . '\\Test->last'); +Assert::false($frame->isFunction()); +Assert::true($frame->isMethod()); +Assert::false($frame->isClosure()); +Assert::false($frame->isStatic()); +Assert::false($frame->isAnonymous()); + +Assert::exception(static function () use ($frame): void { + $frame->getFunctionReflection(); +}, LogicException::class); +Assert::type($frame->getMethodReflection(), ReflectionMethod::class); +Assert::type($frame->getClassReflection(), ReflectionClass::class); +Assert::type($frame->getObjectReflection(), ReflectionObject::class); + +Assert::same($frame->getLineCode(), '$frame = $object->last();'); +Assert::same($frame->getCode(), ' public function last(): CallstackFrame + { + return Callstack::last(); + }'); +Assert::same($frame->getClassCode(), 'class Test +{ + + public static function staticLast(): CallstackFrame + { + return Callstack::last(); + } + + public function last(): CallstackFrame + { + return Callstack::last(); + } + +}'); + + +// in anonymous class method +$frame = $class->last(); +Assert::false($frame->isFunction()); +Assert::true($frame->isMethod()); +Assert::false($frame->isClosure()); +Assert::false($frame->isStatic()); +Assert::true($frame->isAnonymous()); + +Assert::exception(static function () use ($frame): void { + $frame->getFunctionReflection(); +}, LogicException::class); +Assert::type($frame->getMethodReflection(), ReflectionMethod::class); +Assert::type($frame->getClassReflection(), ReflectionClass::class); +Assert::type($frame->getObjectReflection(), ReflectionObject::class); + +Assert::same($frame->getLineCode(), '$frame = $class->last();'); +Assert::same($frame->getCode(), ' public function last(): CallstackFrame + { + return Callstack::last(); + }'); +Assert::same($frame->getClassCode(), '$class = new class { + + public function last(): CallstackFrame + { + return Callstack::last(); + } + +};'); diff --git a/tests/src/common/Check.basic.phpt b/tests/src/common/Check.basic.phpt index 80548e71..1e5de090 100644 --- a/tests/src/common/Check.basic.phpt +++ b/tests/src/common/Check.basic.phpt @@ -167,6 +167,21 @@ Assert::exception(static function () use ($short): void { Check::oneOf($short, $short); }, ValueOutOfRangeException::class); + +enum: +Check::enum(1, 1, 2, 3); +Assert::exception(static function (): void { + Check::enum(11, 1, 2, 3); +}, InvalidValueException::class); + + +flags: +Check::flags(7, 1 | 2 | 4); +Assert::exception(static function (): void { + Check::flags(11, 1 | 2 | 3); +}, InvalidValueException::class); + + class TestTraversable implements IteratorAggregate {