diff --git a/.gitattributes b/.gitattributes index 18e14aa..c25dee4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ /tests export-ignore +/fixtures export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189105d..2f3eecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,5 +12,3 @@ jobs: uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main cs: uses: innmind/github-workflows/.github/workflows/cs.yml@main - with: - php-version: '8.2' diff --git a/.github/workflows/extensive.yml b/.github/workflows/extensive.yml new file mode 100644 index 0000000..257f139 --- /dev/null +++ b/.github/workflows/extensive.yml @@ -0,0 +1,12 @@ +name: Extensive CI + +on: + push: + tags: + - '*' + paths: + - '.github/workflows/extensive.yml' + +jobs: + blackbox: + uses: innmind/github-workflows/.github/workflows/extensive.yml@main diff --git a/CHANGELOG.md b/CHANGELOG.md index f538bb7..c460478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added + +- `Innmind\IO\Files::require()` +- `Innmind\IO\Files::access()` +- `Innmind\IO\Files::create()` +- `Innmind\IO\Files::exists()` + +### Changed + +- Requires PHP `8.4` +- Requires `innmind/immutable:~6.0` +- Requires `innmind/time:~1.0` + ## 3.5.1 - 2025-08-18 ### Fixed diff --git a/blackbox.php b/blackbox.php index c402132..c109cd0 100644 --- a/blackbox.php +++ b/blackbox.php @@ -11,6 +11,10 @@ }; Application::new($argv) + ->when( + \getenv('BLACKBOX_SET_SIZE') !== false, + static fn(Application $app) => $app->scenariiPerProof((int) \getenv('BLACKBOX_SET_SIZE')), + ) ->when( \getenv('ENABLE_COVERAGE') !== false, static fn(Application $app) => $app diff --git a/composer.json b/composer.json index bd21b72..72978a7 100644 --- a/composer.json +++ b/composer.json @@ -15,12 +15,13 @@ "issues": "http://github.com/innmind/io/issues" }, "require": { - "php": "~8.2", - "innmind/immutable": "~5.13", - "innmind/url": "~4.3", - "innmind/time-continuum": "^4.0.2", - "innmind/ip": "~3.2", - "innmind/validation": "~2.0" + "php": "~8.4", + "innmind/immutable": "~6.0", + "innmind/mutable": "~1.0", + "innmind/url": "~5.0", + "innmind/time": "~1.0", + "innmind/ip": "~4.0", + "innmind/validation": "~3.0" }, "autoload": { "psr-4": { @@ -33,7 +34,7 @@ } }, "require-dev": { - "innmind/static-analysis": "^1.2.1", + "innmind/static-analysis": "~1.3", "innmind/black-box": "~6.1", "innmind/coding-standard": "~2.0" } diff --git a/documentation/sockets.md b/documentation/sockets.md index 9675364..a6a243a 100644 --- a/documentation/sockets.md +++ b/documentation/sockets.md @@ -81,7 +81,7 @@ This is the default behaviour. ### `->timeoutAfter()` ```php -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; $server = $server->timeoutAfter(Period::second(1)); ``` @@ -172,7 +172,7 @@ This is default behaviour. ### `->timeoutAfter()` ```php -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; $client = $client->timeoutAfter(Period::second(1)); ``` diff --git a/documentation/streams.md b/documentation/streams.md index b7ccc11..b6b05a7 100644 --- a/documentation/streams.md +++ b/documentation/streams.md @@ -52,7 +52,7 @@ This is default behaviour. ### `->timeoutAfter()` ```php -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; $read = $read->timeoutAfter(Period::second(1)); ``` diff --git a/documentation/use-cases/socket/heartbeat.md b/documentation/use-cases/socket/heartbeat.md index ce6acb1..9a26117 100644 --- a/documentation/use-cases/socket/heartbeat.md +++ b/documentation/use-cases/socket/heartbeat.md @@ -11,7 +11,7 @@ use Innmind\IO\{ Frame, Sockets\Internet\Transport, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Url\Url; use Innmind\Immutable\{ Str, diff --git a/documentation/use-cases/socket/read.md b/documentation/use-cases/socket/read.md index 65d8f8b..5abddb0 100644 --- a/documentation/use-cases/socket/read.md +++ b/documentation/use-cases/socket/read.md @@ -11,7 +11,7 @@ use Innmind\IO\{ Frame, Sockets\Internet\Transport, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Url\Url; $status = IO::fromAmbienAuthority() diff --git a/fixtures/to-load.php b/fixtures/to-load.php new file mode 100644 index 0000000..188daf9 --- /dev/null +++ b/fixtures/to-load.php @@ -0,0 +1,3 @@ +number($loaded->size()) ->int() ->greaterThan(0); - $loaded + $_ = $loaded ->dropEnd(1) ->foreach(static fn($chunk) => $assert->same( $size, @@ -72,7 +72,7 @@ static function($assert, $chunks, $size) { $assert->same( $data, $loaded - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); }, @@ -90,7 +90,7 @@ static function($assert, $chunks, $size, $encoding) { $data = \implode('', $chunks); \file_put_contents($tmp, $data); - IO::fromAmbientAuthority() + $_ = IO::fromAmbientAuthority() ->files() ->read(Path::of($tmp)) ->toEncoding($encoding) @@ -128,7 +128,7 @@ static function($assert, $lines) { ->number($loaded->size()) ->int() ->greaterThan(0); - $loaded + $_ = $loaded ->dropEnd(1) ->foreach(static fn($line) => $assert->true( $line->endsWith("\n"), @@ -143,7 +143,7 @@ static function($assert, $lines) { $assert->same( $data, $loaded - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); @@ -175,7 +175,7 @@ static function($assert, $lines, $encoding) { $data = \implode("\n", $lines); \file_put_contents($tmp, $data); - IO::fromAmbientAuthority() + $_ = IO::fromAmbientAuthority() ->files() ->read(Path::of($tmp)) ->toEncoding($encoding) @@ -298,14 +298,14 @@ static function($assert, $chunks) { $expected, $read ->lines() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); $assert->same( $expected, $read ->lines() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), 'Temporary file should be accessible multiple times', ); @@ -386,7 +386,7 @@ static function($assert, $chunks, $encoding) { $tmp ->read() ->chunks(8192) - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); }, @@ -430,4 +430,29 @@ static function($assert, $chunks) { ); }, ); + + yield test( + 'IO::files()->require()', + static function($assert) { + $assert->same( + 42, + IO::fromAmbientAuthority() + ->files() + ->require(Path::of('fixtures/to-load.php')) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->false( + IO::fromAmbientAuthority() + ->files() + ->require(Path::of('fixtures/unknown.php')) + ->match( + static fn() => true, + static fn() => false, + ), + ); + }, + ); }; diff --git a/proofs/frames.php b/proofs/frames.php index 34af997..365d92b 100644 --- a/proofs/frames.php +++ b/proofs/frames.php @@ -154,7 +154,7 @@ static function($assert, $lines) use ($reader) { ->maybe(static fn($lines, $line) => $line->maybe()->map($lines)), ) ->flatMap(Frame::maybe(...)); - $values = $frame($reader(Str::of($data)))->match( + $values = $frame($reader(Str::of($data, Str\Encoding::ascii)))->match( static fn($values) => $values, static fn() => null, ); diff --git a/proofs/sockets.php b/proofs/sockets.php index 8d9d834..1b7fa0d 100644 --- a/proofs/sockets.php +++ b/proofs/sockets.php @@ -6,7 +6,7 @@ Frame, Sockets\Unix\Address, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Url\Path; use Innmind\Immutable\{ Sequence, @@ -99,8 +99,8 @@ static function($assert) { ); $assert->same('foo', $result); - $client->close()->memoize(); - $server->close()->memoize(); + $_ = $client->close()->memoize(); + $_ = $server->close()->memoize(); }, ); }; diff --git a/proofs/streams.php b/proofs/streams.php index 0482c27..59c5f10 100644 --- a/proofs/streams.php +++ b/proofs/streams.php @@ -159,7 +159,7 @@ static function($assert, $lines) { $assert->same( \implode("\n", $lines), $load() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); }, @@ -207,7 +207,7 @@ static function($assert, $lines) { $assert->same( \implode("\n", $lines), $load() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); }, @@ -248,7 +248,7 @@ static function($assert, $lines) { $assert->same( \implode("\n", $lines), $sequence - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); }, @@ -285,7 +285,7 @@ static function($assert, $a, $b, $encoding) { ->chunks(); $assert->same(2, $chunks->size()); - $chunks->foreach(static fn($chunk) => $assert->same( + $_ = $chunks->foreach(static fn($chunk) => $assert->same( $encoding, $chunk->value()->encoding(), )); @@ -338,7 +338,7 @@ static function($assert, $a, $b, $encoding) { ->chunks(); $assert->same(2, $chunks->size()); - $chunks->foreach(static fn($chunk) => $assert->same( + $_ = $chunks->foreach(static fn($chunk) => $assert->same( $encoding, $chunk->value()->encoding(), )); diff --git a/src/Exception/FailedToLoadStream.php b/src/Exception/FailedToLoadStream.php deleted file mode 100644 index 25ddbac..0000000 --- a/src/Exception/FailedToLoadStream.php +++ /dev/null @@ -1,8 +0,0 @@ -capabilities, $path); } + /** + * @return Maybe + */ + public function require(Path $path): Maybe + { + return $this->capabilities->files()->require($path); + } + /** * @param Sequence $chunks * @@ -60,4 +73,40 @@ public function temporary(Sequence $chunks): Attempt ->map(static fn() => Temporary::of($capabilities, $tmp)), ); } + + /** + * @return Attempt + */ + public function access(Path $path): Attempt + { + return $this + ->capabilities + ->files() + ->kind($path) + ->map(fn($kind) => match ($kind) { + Kind::directory => Directory::of($this->capabilities, $path), + Kind::file => File::of($this->capabilities, $path), + Kind::link => Link::of(), + }); + } + + /** + * @return Attempt + */ + public function create(Path $path): Attempt + { + return $this + ->capabilities + ->files() + ->create($path) + ->map(fn() => match ($path->directory()) { + true => Directory::of($this->capabilities, $path), + false => File::of($this->capabilities, $path), + }); + } + + public function exists(Path $path): bool + { + return $this->capabilities->files()->exists($path); + } } diff --git a/src/Files/Directory.php b/src/Files/Directory.php new file mode 100644 index 0000000..2dc8d80 --- /dev/null +++ b/src/Files/Directory.php @@ -0,0 +1,57 @@ +directory()) { + $path = Path::of($path->toString().'/'); + } + + return new self($capabilities, $path); + } + + /** + * @return Sequence + */ + public function list(): Sequence + { + return $this + ->capabilities + ->files() + ->list($this->path); + } + + /** + * @return Attempt + */ + public function remove(): Attempt + { + return $this + ->capabilities + ->files() + ->remove($this->path); + } +} diff --git a/src/Files/File.php b/src/Files/File.php new file mode 100644 index 0000000..686bcfb --- /dev/null +++ b/src/Files/File.php @@ -0,0 +1,62 @@ +capabilities, $this->path); + } + + public function write(): Write + { + return Write::of($this->capabilities, $this->path); + } + + /** + * @return Attempt + */ + public function remove(): Attempt + { + return $this + ->capabilities + ->files() + ->remove($this->path); + } + + /** + * @return Attempt + */ + public function mediaType(): Attempt + { + return $this + ->capabilities + ->files() + ->mediaType($this->path); + } +} diff --git a/src/Files/Kind.php b/src/Files/Kind.php new file mode 100644 index 0000000..c896c3b --- /dev/null +++ b/src/Files/Kind.php @@ -0,0 +1,14 @@ +kind; + } + + /** + * @return non-empty-string + */ + public function toString(): string + { + return $this->name; + } +} diff --git a/src/Files/Read.php b/src/Files/Read.php index 1813413..9182811 100644 --- a/src/Files/Read.php +++ b/src/Files/Read.php @@ -6,7 +6,6 @@ use Innmind\IO\{ Stream\Size, Internal, - Exception\FailedToLoadStream, Internal\Capabilities, Internal\Watch, }; @@ -15,6 +14,7 @@ Str, Maybe, Sequence, + SideEffect, }; final class Read @@ -45,10 +45,7 @@ public static function of( static fn() => $capabilities ->files() ->read($path) - ->match( - static fn($stream) => $stream, - static fn() => throw new \RuntimeException('Failed to read file'), - ), + ->unwrap(), $capabilities->watch(), $encoding, true, @@ -126,19 +123,13 @@ public function chunks(int $size): Sequence $chunks = Sequence::lazy(static function($register) use ($size, $load, $watch, $autoClose) { $stream = $load(); $wait = Internal\Stream\Wait::of($watch, $stream); - $rewind = static fn(): null => $stream->rewind()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $rewind = static fn(): SideEffect => $stream->rewind()->unwrap(); $register(static function() use ($rewind, $stream, $autoClose) { $rewind(); if ($autoClose) { - $stream->close()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $_ = $stream->close()->unwrap(); } }); $rewind(); @@ -161,10 +152,7 @@ public function chunks(int $size): Sequence $rewind(); if ($autoClose) { - $stream->close()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $_ = $stream->close()->unwrap(); } }); @@ -188,19 +176,13 @@ public function lines(): Sequence $chunks = Sequence::lazy(static function($register) use ($load, $watch, $autoClose) { $stream = $load(); $wait = Internal\Stream\Wait::of($watch, $stream); - $rewind = static fn(): null => $stream->rewind()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $rewind = static fn(): SideEffect => $stream->rewind()->unwrap(); $register(static function() use ($rewind, $stream, $autoClose) { $rewind(); if ($autoClose) { - $stream->close()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $_ = $stream->close()->unwrap(); } }); $rewind(); @@ -223,10 +205,7 @@ public function lines(): Sequence $rewind(); if ($autoClose) { - $stream->close()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $_ = $stream->close()->unwrap(); } }); diff --git a/src/Files/Temporary/Push.php b/src/Files/Temporary/Push.php index 3068d05..16235b2 100644 --- a/src/Files/Temporary/Push.php +++ b/src/Files/Temporary/Push.php @@ -64,10 +64,7 @@ public function chunk(Str $chunk): Attempt static fn($ready) => $ready ->toWrite() ->find(static fn($ready) => $ready === $stream) - ->match( - Attempt::result(...), - static fn() => Attempt::error(new \RuntimeException('Stream not ready')), - ), + ->attempt(static fn() => new \RuntimeException('Stream not ready')), ) ->flatMap(static fn($stream) => $stream->write($chunk->toEncoding(Str\Encoding::ascii))); } diff --git a/src/Files/Write.php b/src/Files/Write.php index fd4f27e..d4cda91 100644 --- a/src/Files/Write.php +++ b/src/Files/Write.php @@ -42,10 +42,7 @@ public static function of(Capabilities $capabilities, Path $path): self static fn() => $capabilities ->files() ->write($path) - ->match( - static fn($stream) => $stream, - static fn() => throw new \RuntimeException('Failed to open file'), - ), + ->unwrap(), true, false, ); @@ -103,17 +100,14 @@ public function sink(Sequence $chunks): Attempt return $chunks ->map(static fn($chunk) => $chunk->toEncoding(Str\Encoding::ascii)) - ->sink(new SideEffect) + ->sink(SideEffect::identity) ->attempt( static fn($_, $chunk) => $watch() ->map(static fn($ready) => $ready->toWrite()) ->flatMap( static fn($toWrite) => $toWrite ->find(static fn($ready) => $ready === $stream) - ->match( - static fn($stream) => Attempt::result($stream), - static fn() => Attempt::error(new RuntimeException('Stream not ready to write to')), - ), + ->attempt(static fn() => new RuntimeException('Stream not ready to write to')), ) ->flatMap(static fn($stream) => $stream->write($chunk)), ) diff --git a/src/Frame/Maybe.php b/src/Frame/Maybe.php index 9b5e957..4c7aa7b 100644 --- a/src/Frame/Maybe.php +++ b/src/Frame/Maybe.php @@ -35,9 +35,8 @@ private function __construct( #[\Override] public function __invoke(Reader|Reader\Buffer $reader): Attempt { - return $this->value->match( - static fn($value) => Attempt::result($value), - static fn() => Attempt::error(new RuntimeException('No value provided')), + return $this->value->attempt( + static fn() => new RuntimeException('No value provided'), ); } diff --git a/src/IO.php b/src/IO.php index 1d2a94c..84b6271 100644 --- a/src/IO.php +++ b/src/IO.php @@ -4,7 +4,7 @@ namespace Innmind\IO; use Innmind\IO\Internal\Capabilities; -use Innmind\TimeContinuum\Clock; +use Innmind\Time\Clock; final class IO { @@ -23,9 +23,27 @@ public static function fromAmbientAuthority(): self * * @internal */ - public static function async(Clock $clock): self + public static function async(self $io, Clock $clock): self { - return new self(Capabilities::async($clock)); + return new self(Capabilities::async( + $io->capabilities, + $clock, + )); + } + + /** + * This is an internal feature for the innmind/testing package. + * + * @internal + */ + public static function simulation( + self $io, + Simulation\Disk $disk, + ): self { + return new self(Capabilities::simulation( + $io->capabilities, + $disk, + )); } public function files(): Files diff --git a/src/Internal/Async/Suspended.php b/src/Internal/Async/Suspended.php index 0c1cdd5..0841182 100644 --- a/src/Internal/Async/Suspended.php +++ b/src/Internal/Async/Suspended.php @@ -9,9 +9,9 @@ Stream, Socket\Server, }; -use Innmind\TimeContinuum\{ +use Innmind\Time\{ Clock, - PointInTime, + Point, Period, }; use Innmind\Immutable\{ @@ -33,7 +33,7 @@ final class Suspended * @param Sequence $write */ private function __construct( - private PointInTime $at, + private Point $at, private Maybe $timeout, private Sequence $read, private Sequence $write, @@ -49,7 +49,7 @@ private function __construct( * @param Sequence $write */ public static function of( - PointInTime $at, + Point $at, Maybe $timeout, Sequence $read, Sequence $write, diff --git a/src/Internal/Capabilities.php b/src/Internal/Capabilities.php index 24b72bd..3a66a73 100644 --- a/src/Internal/Capabilities.php +++ b/src/Internal/Capabilities.php @@ -3,20 +3,22 @@ namespace Innmind\IO\Internal; -use Innmind\TimeContinuum\Clock; +use Innmind\IO\{ + Internal\Capabilities\Implementation, + Internal\Capabilities\AmbientAuthority, + Internal\Capabilities\Async, + Internal\Capabilities\Simulation, + Simulation\Disk, +}; +use Innmind\Time\Clock; /** * @internal */ final class Capabilities { - /** - * The async nature is determined by the presence of the clock only as the - * sync implementation don't need one. And having a separate bool flag would - * not be understood by Psalm. - */ private function __construct( - private ?Clock $clock, + private Implementation $implementation, ) { } @@ -25,37 +27,48 @@ private function __construct( */ public static function fromAmbientAuthority(): self { - return new self(null); + return new self(AmbientAuthority::of()); + } + + /** + * @internal + */ + public static function async(self $capabilities, Clock $clock): self + { + return new self(Async::of( + $capabilities->implementation, + $clock, + )); } /** * @internal */ - public static function async(Clock $clock): self + public static function simulation(self $capabilities, Disk $disk): self { - return new self($clock); + return new self(Simulation::of( + $capabilities->implementation, + $disk, + )); } public function files(): Capabilities\Files { - return Capabilities\Files::of(); + return $this->implementation->files(); } public function streams(): Capabilities\Streams { - return Capabilities\Streams::of(); + return $this->implementation->streams(); } public function sockets(): Capabilities\Sockets { - return Capabilities\Sockets::of(); + return $this->implementation->sockets(); } public function watch(): Watch { - return match ($this->clock) { - null => Watch::sync(), - default => Watch::async($this->clock), - }; + return $this->implementation->watch(); } } diff --git a/src/Internal/Capabilities/AmbientAuthority.php b/src/Internal/Capabilities/AmbientAuthority.php new file mode 100644 index 0000000..3a3035a --- /dev/null +++ b/src/Internal/Capabilities/AmbientAuthority.php @@ -0,0 +1,48 @@ +capabilities->files(); + } + + #[\Override] + public function streams(): Streams + { + return $this->capabilities->streams(); + } + + #[\Override] + public function sockets(): Sockets + { + return $this->capabilities->sockets(); + } + + #[\Override] + public function watch(): Watch + { + return Watch::async($this->clock); + } +} diff --git a/src/Internal/Capabilities/Files.php b/src/Internal/Capabilities/Files.php index 42398aa..ce28ee4 100644 --- a/src/Internal/Capabilities/Files.php +++ b/src/Internal/Capabilities/Files.php @@ -5,26 +5,48 @@ use Innmind\IO\{ Internal\Stream, - Exception\RuntimeException, + Internal\Capabilities\Files\Implementation, + Internal\Capabilities\Files\AmbientAuthority, + Internal\Capabilities\Files\Simulation, + Files\Name, + Files\Kind, + Simulation\Disk, }; use Innmind\Url\Path; -use Innmind\Immutable\Attempt; +use Innmind\Immutable\{ + Attempt, + Maybe, + Sequence, + SideEffect, +}; /** * @internal */ final class Files { - private function __construct() + private function __construct( + private Implementation $implementation, + ) { + } + + /** + * @internal + */ + public static function fromAmbientAuthority(): self { + return new self(AmbientAuthority::of()); } /** * @internal */ - public static function of(): self + public static function simulation(self $files, Disk $disk): self { - return new self; + return new self(Simulation::of( + $files->implementation, + $disk, + )); } /** @@ -32,7 +54,7 @@ public static function of(): self */ public function read(Path $path): Attempt { - return $this->open($path->toString(), 'r'); + return $this->implementation->read($path); } /** @@ -40,7 +62,7 @@ public function read(Path $path): Attempt */ public function write(Path $path): Attempt { - return $this->open($path->toString(), 'w'); + return $this->implementation->write($path); } /** @@ -48,29 +70,59 @@ public function write(Path $path): Attempt */ public function temporary(): Attempt { - return $this->open('php://temp', 'r+'); + return $this->implementation->temporary(); } /** - * @param resource $resource + * @return Maybe */ - public function acquire($resource): Stream + public function require(Path $path): Maybe { - return Stream::of($resource); + return $this->implementation->require($path); } /** - * @return Attempt + * @return Sequence + */ + public function list(Path $path): Sequence + { + return $this->implementation->list($path); + } + + /** + * @return Attempt + */ + public function mediaType(Path $path): Attempt + { + return $this->implementation->mediaType($path); + } + + /** + * @return Attempt */ - private function open(string $path, string $mode): Attempt + public function kind(Path $path): Attempt { - $stream = \fopen($path, $mode); + return $this->implementation->kind($path); + } + + public function exists(Path $path): bool + { + return $this->implementation->exists($path); + } - if ($stream === false) { - /** @var Attempt */ - return Attempt::error(new RuntimeException("Failed to open file '$path'")); - } + /** + * @return Attempt + */ + public function create(Path $path): Attempt + { + return $this->implementation->create($path); + } - return Attempt::result(Stream::file($stream)); + /** + * @return Attempt + */ + public function remove(Path $path): Attempt + { + return $this->implementation->remove($path); } } diff --git a/src/Internal/Capabilities/Files/AmbientAuthority.php b/src/Internal/Capabilities/Files/AmbientAuthority.php new file mode 100644 index 0000000..172bb8a --- /dev/null +++ b/src/Internal/Capabilities/Files/AmbientAuthority.php @@ -0,0 +1,273 @@ +open($path->toString(), 'r'); + } + + #[\Override] + public function write(Path $path): Attempt + { + return $this->open($path->toString(), 'w'); + } + + #[\Override] + public function temporary(): Attempt + { + return $this->open('php://temp', 'r+'); + } + + #[\Override] + public function require(Path $path): Maybe + { + $path = $path->toString(); + + if (!\file_exists($path) || \is_dir($path)) { + /** @var Maybe */ + return Maybe::nothing(); + } + + /** + * @psalm-suppress UnresolvableInclude + * @psalm-suppress MixedArgument + * @var Maybe + */ + return Maybe::just(require $path); + } + + #[\Override] + public function list(Path $path): Sequence + { + return Sequence::lazy(static function() use ($path): \Generator { + $files = new \FilesystemIterator($path->toString()); + + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + $name = $file->getBasename(); + + if ($name === '') { + continue; + } + + yield Name::of( + $name, + match (true) { + $file->isDir() => Kind::directory, + $file->isLink() => Kind::link, + default => Kind::file, + }, + ); + } + }); + } + + #[\Override] + public function mediaType(Path $path): Attempt + { + $mediaType = @\mime_content_type($path->toString()); + + return match ($mediaType) { + false => Attempt::error(new \RuntimeException('Failed to access media type')), + default => Attempt::result($mediaType), + }; + } + + #[\Override] + public function kind(Path $path): Attempt + { + $path = $path->toString(); + + if (!\file_exists($path)) { + return Attempt::error(new \RuntimeException('File not found')); + } + + return Attempt::result(match (true) { + \is_dir($path) => Kind::directory, + \is_link($path) => Kind::link, + default => Kind::file, + }); + } + + #[\Override] + public function exists(Path $path): bool + { + if (!\file_exists($path->toString())) { + return false; + } + + return match ($path->directory()) { + false => true, + true => \is_dir($path->toString()), + }; + } + + #[\Override] + public function create(Path $path): Attempt + { + return match ($path->directory()) { + true => $this->createDirectory($path), + false => $this->touch($path), + }; + } + + #[\Override] + public function remove(Path $path): Attempt + { + if (!\file_exists($path->toString())) { + return Attempt::result(SideEffect::identity); + } + + if ($path->directory() && \is_dir($path->toString())) { + return $this->rmdir($path->toString()); + } + + $path = $path->toString(); + + if (\is_dir($path)) { + return $this->rmdir($path.'/'); + } + + return $this->unlink($path); + } + + /** + * @return Attempt + */ + private function open(string $path, string $mode): Attempt + { + $stream = \fopen($path, $mode); + + if ($stream === false) { + /** @var Attempt */ + return Attempt::error(new RuntimeException("Failed to open file '$path'")); + } + + return Attempt::result(Stream::file($stream)); + } + + /** + * @return Attempt + */ + private function createDirectory(Path $path): Attempt + { + $path = $path->toString(); + + // We do not check the result of this function as it will return false + // if the path already exist. This can lead to race conditions where + // another process created the directory between the condition that + // checked if it existed and the call to this method. The only important + // part is to check wether the directory exists or not afterward. + @\mkdir($path, recursive: true); + + if (!\is_dir($path)) { + return Attempt::error(new \RuntimeException(\sprintf( + "Failed to create directory '%s'", + $path, + ))); + } + + return Attempt::result(SideEffect::identity); + } + + /** + * @return Attempt + */ + private function touch(Path $path): Attempt + { + $path = $path->toString(); + + if (!@\touch($path)) { + return Attempt::error(new \RuntimeException(\sprintf( + "Failed to create file '%s'", + $path, + ))); + } + + if (!\file_exists($path)) { + return Attempt::error(new \RuntimeException(\sprintf( + "Failed to create file '%s'", + $path, + ))); + } + + return Attempt::result(SideEffect::identity); + } + + /** + * @return Attempt + */ + private function rmdir(string $path): Attempt + { + return $this + ->list(Path::of($path)) + ->map(static fn($name) => \sprintf( + '%s%s%s', + $path, + $name->toString(), + match ($name->kind()) { + Kind::directory => '/', + default => '', + }, + )) + ->map(Path::of(...)) + ->sink(SideEffect::identity) + ->attempt(fn($_, $file) => $this->remove($file)) + ->map(static fn() => @\rmdir($path)) + ->flatMap(static fn($removed) => match ($removed) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove directory '%s'", + $path, + ))), + }); + } + + /** + * @return Attempt + */ + private function unlink(string $path): Attempt + { + return match (@\unlink($path)) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove file '%s'", + $path, + ))), + }; + } +} diff --git a/src/Internal/Capabilities/Files/Implementation.php b/src/Internal/Capabilities/Files/Implementation.php new file mode 100644 index 0000000..7040580 --- /dev/null +++ b/src/Internal/Capabilities/Files/Implementation.php @@ -0,0 +1,70 @@ + + */ + public function read(Path $path): Attempt; + + /** + * @return Attempt + */ + public function write(Path $path): Attempt; + + /** + * @return Attempt + */ + public function temporary(): Attempt; + + /** + * @return Maybe + */ + public function require(Path $path): Maybe; + + /** + * @return Sequence + */ + public function list(Path $path): Sequence; + + /** + * @return Attempt + */ + public function mediaType(Path $path): Attempt; + + /** + * @return Attempt + */ + public function kind(Path $path): Attempt; + + public function exists(Path $path): bool; + + /** + * @return Attempt + */ + public function create(Path $path): Attempt; + + /** + * @return Attempt + */ + public function remove(Path $path): Attempt; +} diff --git a/src/Internal/Capabilities/Files/Simulation.php b/src/Internal/Capabilities/Files/Simulation.php new file mode 100644 index 0000000..4551d10 --- /dev/null +++ b/src/Internal/Capabilities/Files/Simulation.php @@ -0,0 +1,128 @@ +disk + ->access($path) + ->flatMap(static fn($file) => match (true) { + $file instanceof Disk\File => $file->content()->stream(), + default => Attempt::error(new \RuntimeException('No such file')), + }); + } + + #[\Override] + public function write(Path $path): Attempt + { + // simulated files streams must be readable and writable at the same time + return $this->read($path); + } + + #[\Override] + public function temporary(): Attempt + { + return $this->files->temporary(); + } + + #[\Override] + public function require(Path $path): Maybe + { + return $this + ->disk + ->access($path) + ->maybe() + ->keep(Instance::of(Disk\File::class)) + ->map(static fn($file) => $file->content()->read()) + ->map(static fn($file): mixed => eval($file)); + } + + #[\Override] + public function list(Path $path): Sequence + { + return Sequence::lazy(function() use ($path) { + // to make sure to have the current state of the filesystem + yield $this + ->disk + ->list($path) + ->snapshot() + ->map(static fn($name, $file) => match (true) { + $file instanceof Disk\Directory => Name::of($name, Kind::directory), + $file instanceof Disk\File => Name::of($name, Kind::file), + }) + ->values(); + })->flatMap(static fn($files) => $files); + } + + #[\Override] + public function mediaType(Path $path): Attempt + { + return Attempt::error(new \LogicException('Media types not supported in simulated environment')); + } + + #[\Override] + public function kind(Path $path): Attempt + { + return $this + ->disk + ->access($path) + ->map(static fn($file) => match (true) { + $file instanceof Disk\File => Kind::file, + $file instanceof Disk\Directory => Kind::directory, + }); + } + + #[\Override] + public function exists(Path $path): bool + { + return $this->disk->exists($path); + } + + #[\Override] + public function create(Path $path): Attempt + { + // todo should the IO be injected at the creation of the disk ? + return $this->disk->create($this->files, $path); + } + + #[\Override] + public function remove(Path $path): Attempt + { + return $this->disk->remove($path); + } +} diff --git a/src/Internal/Capabilities/Implementation.php b/src/Internal/Capabilities/Implementation.php new file mode 100644 index 0000000..ca8706b --- /dev/null +++ b/src/Internal/Capabilities/Implementation.php @@ -0,0 +1,17 @@ +capabilities->files(), + $this->disk, + ); + } + + #[\Override] + public function streams(): Streams + { + return $this->capabilities->streams(); + } + + #[\Override] + public function sockets(): Sockets + { + return $this->capabilities->sockets(); + } + + #[\Override] + public function watch(): Watch + { + return $this->capabilities->watch(); + } +} diff --git a/src/Internal/Socket/Server/Unix.php b/src/Internal/Socket/Server/Unix.php index 8df761c..cb671cf 100644 --- a/src/Internal/Socket/Server/Unix.php +++ b/src/Internal/Socket/Server/Unix.php @@ -73,7 +73,7 @@ public function close(): Attempt }); } - return Attempt::result(new SideEffect); + return Attempt::result(SideEffect::identity); } /** diff --git a/src/Internal/Stream.php b/src/Internal/Stream.php index d2f4045..d492e01 100644 --- a/src/Internal/Stream.php +++ b/src/Internal/Stream.php @@ -4,27 +4,17 @@ namespace Innmind\IO\Internal; use Innmind\IO\{ + Internal\Stream\Implementation, + Internal\Stream\AmbientAuthority, + Internal\Stream\Simulated, Stream\Size, - Exception\InvalidArgumentException, - Exception\DataPartiallyWritten, - Exception\FailedToCloseStream, - Exception\FailedToWriteToStream, - Exception\PositionNotSeekable, - Exception\RuntimeException, -}; -use Innmind\Validation\{ - Is, - Of, - Constraint, - Failure, }; +use Innmind\Validation\Of; use Innmind\Immutable\{ Str, Maybe, Attempt, SideEffect, - Validation, - Predicate\Instance, }; /** @@ -32,39 +22,19 @@ */ final class Stream { - /** @var resource */ - private $resource; - private bool $file; - private bool $closed = false; - private bool $seekable = false; - private bool $syncable = false; + private function __construct( + private Implementation $implementation, + ) { + } /** + * @internal + * * @param resource $resource */ - private function __construct($resource, bool $file) + public static function of($resource): self { - /** - * @psalm-suppress DocblockTypeContradiction - * @psalm-suppress RedundantConditionGivenDocblockType - */ - if (!\is_resource($resource) || \get_resource_type($resource) !== 'stream') { - throw new InvalidArgumentException; - } - - $this->resource = $resource; - $this->file = $file; - $meta = \stream_get_meta_data($resource); - - if ($meta['seekable'] && \substr($meta['uri'], 0, 9) !== 'php://std') { - //stdin, stdout and stderr are not seekable - $this->seekable = true; - $this->rewind(); - } - - if ($this->seekable && \substr($meta['uri'] ?? '', 0, 10) !== 'php://temp') { - $this->syncable = true; - } + return new self(AmbientAuthority::of($resource)); } /** @@ -72,24 +42,22 @@ private function __construct($resource, bool $file) * * @param resource $resource */ - public static function of($resource): self + public static function file($resource): self { - return new self($resource, false); + return new self(AmbientAuthority::file($resource)); } /** * @internal - * - * @param resource $resource */ - public static function file($resource): self + public static function simulated(self $stream): self { - return new self($resource, true); + return new self(Simulated::of($stream->implementation)); } public function isFile(): bool { - return $this->file; + return $this->implementation->isFile(); } /** @@ -97,22 +65,7 @@ public function isFile(): bool */ public function nonBlocking(): Maybe { - if ($this->closed()) { - /** @var Maybe */ - return Maybe::nothing(); - } - - $return = \stream_set_blocking($this->resource, false); - - if ($return === false) { - /** @var Maybe */ - return Maybe::nothing(); - } - - $_ = \stream_set_write_buffer($this->resource, 0); - $_ = \stream_set_read_buffer($this->resource, 0); - - return Maybe::just(new SideEffect); + return $this->implementation->nonBlocking(); } /** @@ -120,19 +73,7 @@ public function nonBlocking(): Maybe */ public function blocking(): Maybe { - if ($this->closed()) { - /** @var Maybe */ - return Maybe::nothing(); - } - - $return = \stream_set_blocking($this->resource, false); - - if ($return === false) { - /** @var Maybe */ - return Maybe::nothing(); - } - - return Maybe::just(new SideEffect); + return $this->implementation->blocking(); } /** @@ -142,7 +83,7 @@ public function blocking(): Maybe */ public function resource() { - return $this->resource; + return $this->implementation->resource(); } /** @@ -150,23 +91,7 @@ public function resource() */ public function rewind(): Attempt { - if (!$this->seekable) { - /** @var Attempt */ - return Attempt::error(new PositionNotSeekable); - } - - if ($this->closed()) { - /** @var Attempt */ - return Attempt::result(new SideEffect); - } - - $status = \fseek($this->resource, 0); - - /** @var Attempt */ - return match ($status) { - -1 => Attempt::error(new PositionNotSeekable), - default => Attempt::result(new SideEffect), - }; + return $this->implementation->rewind(); } /** @@ -174,11 +99,7 @@ public function rewind(): Attempt */ public function end(): bool { - if ($this->closed()) { - return true; - } - - return \feof($this->resource); + return $this->implementation->end(); } /** @@ -188,35 +109,7 @@ public function end(): bool */ public function size(): Maybe { - if ($this->closed()) { - /** @var Maybe */ - return Maybe::nothing(); - } - - /** @var Constraint> */ - $positive = Is::value(0)->or( - Is::int()->positive(), - ); - /** @var Constraint */ - $resource = Of::callable(static fn(mixed $resource) => match (\is_resource($resource)) { - true => Validation::success($resource), - false => Validation::fail(Failure::of('not a resource')), - }); - $validate = $resource - ->map(\fstat(...)) - ->and(Is::shape( - 'size', - Is::string() - ->or(Is::int()) - ->map(static fn($size) => (int) $size) - ->and($positive) - ->map(Size::of(...)), - )) - ->map(static fn(array $stat): mixed => $stat['size']); - - return $validate($this->resource) - ->maybe() - ->keep(Instance::of(Size::class)); + return $this->implementation->size(); } /** @@ -224,20 +117,7 @@ public function size(): Maybe */ public function close(): Attempt { - if ($this->closed()) { - return Attempt::result(new SideEffect); - } - - /** @psalm-suppress InvalidPropertyAssignmentValue */ - $return = \fclose($this->resource); - - if ($return === false) { - return Attempt::error(new FailedToCloseStream); - } - - $this->closed = true; - - return Attempt::result(new SideEffect); + return $this->implementation->close(); } /** @@ -245,8 +125,7 @@ public function close(): Attempt */ public function closed(): bool { - /** @psalm-suppress DocblockTypeContradiction */ - return $this->closed || !\is_resource($this->resource); + return $this->implementation->closed(); } /** @@ -256,20 +135,7 @@ public function closed(): bool */ public function read(?int $length = null): Attempt { - if ($this->closed()) { - /** @var Attempt */ - return Attempt::error(new RuntimeException('Stream closed')); - } - - $data = \stream_get_contents( - $this->resource, - $length ?? -1, - ); - - return match ($data) { - false => Attempt::error(new RuntimeException('Failed to read the stream')), - default => Attempt::result(Str::of($data)), - }; + return $this->implementation->read($length); } /** @@ -277,17 +143,7 @@ public function read(?int $length = null): Attempt */ public function readLine(): Attempt { - if ($this->closed()) { - /** @var Attempt */ - return Attempt::error(new RuntimeException('Stream closed')); - } - - $line = \fgets($this->resource); - - return match ($line) { - false => Attempt::error(new RuntimeException('Failed to read the stream')), - default => Attempt::result(Str::of($line)), - }; + return $this->implementation->readLine(); } /** @@ -295,25 +151,7 @@ public function readLine(): Attempt */ public function write(Str $data): Attempt { - if ($this->closed()) { - /** @var Attempt */ - return Attempt::error(new FailedToWriteToStream); - } - - $written = @\fwrite($this->resource, $data->toString()); - - if ($written === false) { - /** @var Attempt */ - return Attempt::error(new FailedToWriteToStream); - } - - if ($written !== $data->length()) { - /** @var Attempt */ - return Attempt::error(DataPartiallyWritten::of($data, $written)); - } - - /** @var Attempt */ - return Attempt::result(new SideEffect); + return $this->implementation->write($data); } /** @@ -321,23 +159,6 @@ public function write(Str $data): Attempt */ public function sync(): Attempt { - if ($this->closed()) { - /** @var Attempt */ - return Attempt::error(new FailedToWriteToStream); - } - - if (!$this->syncable) { - return Attempt::result(new SideEffect); - } - - $written = @\fsync($this->resource); - - if ($written === false) { - /** @var Attempt */ - return Attempt::error(new FailedToWriteToStream); - } - - /** @var Attempt */ - return Attempt::result(new SideEffect); + return $this->implementation->sync(); } } diff --git a/src/Internal/Stream/AmbientAuthority.php b/src/Internal/Stream/AmbientAuthority.php new file mode 100644 index 0000000..7d08128 --- /dev/null +++ b/src/Internal/Stream/AmbientAuthority.php @@ -0,0 +1,323 @@ +resource = $resource; + $this->file = $file; + $meta = \stream_get_meta_data($resource); + + if ($meta['seekable'] && \substr($meta['uri'], 0, 9) !== 'php://std') { + //stdin, stdout and stderr are not seekable + $this->seekable = true; + $this->rewind(); + } + + if ($this->seekable && \substr($meta['uri'] ?? '', 0, 10) !== 'php://temp') { + $this->syncable = true; + } + } + + /** + * @internal + * + * @param resource $resource + */ + public static function of($resource): self + { + return new self($resource, false); + } + + /** + * @internal + * + * @param resource $resource + */ + public static function file($resource): self + { + return new self($resource, true); + } + + #[\Override] + public function isFile(): bool + { + return $this->file; + } + + #[\Override] + public function nonBlocking(): Maybe + { + if ($this->closed()) { + /** @var Maybe */ + return Maybe::nothing(); + } + + $return = \stream_set_blocking($this->resource, false); + + if ($return === false) { + /** @var Maybe */ + return Maybe::nothing(); + } + + $_ = \stream_set_write_buffer($this->resource, 0); + $_ = \stream_set_read_buffer($this->resource, 0); + + return Maybe::just(SideEffect::identity); + } + + #[\Override] + public function blocking(): Maybe + { + if ($this->closed()) { + /** @var Maybe */ + return Maybe::nothing(); + } + + $return = \stream_set_blocking($this->resource, false); + + if ($return === false) { + /** @var Maybe */ + return Maybe::nothing(); + } + + return Maybe::just(SideEffect::identity); + } + + #[\Override] + public function resource() + { + return $this->resource; + } + + #[\Override] + public function rewind(): Attempt + { + if (!$this->seekable) { + /** @var Attempt */ + return Attempt::error(new PositionNotSeekable); + } + + if ($this->closed()) { + /** @var Attempt */ + return Attempt::result(SideEffect::identity); + } + + $status = \fseek($this->resource, 0); + + /** @var Attempt */ + return match ($status) { + -1 => Attempt::error(new PositionNotSeekable), + default => Attempt::result(SideEffect::identity), + }; + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function end(): bool + { + if ($this->closed()) { + return true; + } + + return \feof($this->resource); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function size(): Maybe + { + if ($this->closed()) { + /** @var Maybe */ + return Maybe::nothing(); + } + + /** @var Constraint> */ + $positive = Is::value(0)->or( + Is::int()->positive(), + ); + /** @var Constraint */ + $resource = Of::callable(static fn(mixed $resource) => match (\is_resource($resource)) { + true => Validation::success($resource), + false => Validation::fail(Failure::of('not a resource')), + }); + $validate = $resource + ->map(\fstat(...)) + ->and(Is::shape( + 'size', + Is::string() + ->or(Is::int()) + ->map(static fn($size) => (int) $size) + ->and($positive) + ->map(Size::of(...)), + )) + ->map(static fn(array $stat): mixed => $stat['size']); + + return $validate($this->resource) + ->maybe() + ->keep(Instance::of(Size::class)); + } + + #[\Override] + public function close(): Attempt + { + if ($this->closed()) { + return Attempt::result(SideEffect::identity); + } + + /** @psalm-suppress InvalidPropertyAssignmentValue */ + $return = \fclose($this->resource); + + if ($return === false) { + return Attempt::error(new FailedToCloseStream); + } + + $this->closed = true; + + return Attempt::result(SideEffect::identity); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function closed(): bool + { + /** @psalm-suppress DocblockTypeContradiction */ + return $this->closed || !\is_resource($this->resource); + } + + #[\Override] + public function read(?int $length = null): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new RuntimeException('Stream closed')); + } + + $data = \stream_get_contents( + $this->resource, + $length ?? -1, + ); + + return match ($data) { + false => Attempt::error(new RuntimeException('Failed to read the stream')), + default => Attempt::result(Str::of($data)), + }; + } + + #[\Override] + public function readLine(): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new RuntimeException('Stream closed')); + } + + $line = \fgets($this->resource); + + return match ($line) { + false => Attempt::error(new RuntimeException('Failed to read the stream')), + default => Attempt::result(Str::of($line)), + }; + } + + #[\Override] + public function write(Str $data): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new FailedToWriteToStream); + } + + $written = @\fwrite($this->resource, $data->toString()); + + if ($written === false) { + /** @var Attempt */ + return Attempt::error(new FailedToWriteToStream); + } + + if ($written !== $data->length()) { + /** @var Attempt */ + return Attempt::error(DataPartiallyWritten::of($data, $written)); + } + + /** @var Attempt */ + return Attempt::result(SideEffect::identity); + } + + #[\Override] + public function sync(): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new FailedToWriteToStream); + } + + if (!$this->syncable) { + return Attempt::result(SideEffect::identity); + } + + $written = @\fsync($this->resource); + + if ($written === false) { + /** @var Attempt */ + return Attempt::error(new FailedToWriteToStream); + } + + /** @var Attempt */ + return Attempt::result(SideEffect::identity); + } +} diff --git a/src/Internal/Stream/Implementation.php b/src/Internal/Stream/Implementation.php new file mode 100644 index 0000000..528e6c0 --- /dev/null +++ b/src/Internal/Stream/Implementation.php @@ -0,0 +1,90 @@ + + */ + public function nonBlocking(): Maybe; + + /** + * @return Maybe + */ + public function blocking(): Maybe; + + /** + * @psalm-mutation-free + * + * @return resource stream + */ + public function resource(); + + /** + * @return Attempt + */ + public function rewind(): Attempt; + + /** + * @psalm-mutation-free + */ + public function end(): bool; + + /** + * @psalm-mutation-free + * + * @return Maybe + */ + public function size(): Maybe; + + /** + * @return Attempt + */ + public function close(): Attempt; + + /** + * @psalm-mutation-free + */ + public function closed(): bool; + + /** + * @param int<1, max>|null $length When omitted will read the remaining of the stream + * + * @return Attempt + */ + public function read(?int $length = null): Attempt; + + /** + * @return Attempt + */ + public function readLine(): Attempt; + + /** + * @return Attempt + */ + public function write(Str $data): Attempt; + + /** + * @return Attempt + */ + public function sync(): Attempt; +} diff --git a/src/Internal/Stream/Simulated.php b/src/Internal/Stream/Simulated.php new file mode 100644 index 0000000..1316884 --- /dev/null +++ b/src/Internal/Stream/Simulated.php @@ -0,0 +1,183 @@ +closed()) { + /** @var Maybe */ + return Maybe::nothing(); + } + + return $this->implementation->nonBlocking(); + } + + #[\Override] + public function blocking(): Maybe + { + if ($this->closed()) { + /** @var Maybe */ + return Maybe::nothing(); + } + + return $this->implementation->blocking(); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function resource() + { + return $this->implementation->resource(); + } + + #[\Override] + public function rewind(): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::result(SideEffect::identity); + } + + return $this->implementation->rewind(); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function end(): bool + { + if ($this->closed()) { + return true; + } + + return $this->implementation->end(); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function size(): Maybe + { + if ($this->closed()) { + /** @var Maybe */ + return Maybe::nothing(); + } + + return $this->implementation->size(); + } + + #[\Override] + public function close(): Attempt + { + if ($this->closed()) { + return Attempt::result(SideEffect::identity); + } + + $this->closed = true; + + return Attempt::result(SideEffect::identity); + } + + /** + * @psalm-mutation-free + */ + #[\Override] + public function closed(): bool + { + return $this->closed || $this->implementation->closed(); + } + + #[\Override] + public function read(?int $length = null): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new RuntimeException('Stream closed')); + } + + return $this->implementation->read($length); + } + + #[\Override] + public function readLine(): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new RuntimeException('Stream closed')); + } + + return $this->implementation->readLine(); + } + + #[\Override] + public function write(Str $data): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new FailedToWriteToStream); + } + + return $this->implementation->write($data); + } + + #[\Override] + public function sync(): Attempt + { + if ($this->closed()) { + /** @var Attempt */ + return Attempt::error(new FailedToWriteToStream); + } + + return $this->implementation->sync(); + } +} diff --git a/src/Internal/Stream/Wait.php b/src/Internal/Stream/Wait.php index b13e7a7..f5bacfc 100644 --- a/src/Internal/Stream/Wait.php +++ b/src/Internal/Stream/Wait.php @@ -41,10 +41,7 @@ public function __invoke(): Attempt fn($toRead) => $toRead ->find(fn($ready) => $ready === $this->stream) ->keep(Instance::of(Stream::class)) - ->match( - static fn($stream) => Attempt::result($stream), - static fn() => Attempt::error(new RuntimeException('Stream not ready')), - ), + ->attempt(static fn() => new RuntimeException('Stream not ready')), ); } diff --git a/src/Internal/Watch.php b/src/Internal/Watch.php index 07bdf4b..68d2515 100644 --- a/src/Internal/Watch.php +++ b/src/Internal/Watch.php @@ -9,7 +9,7 @@ Internal\Watch\Ready, Internal\Socket\Server, }; -use Innmind\TimeContinuum\{ +use Innmind\Time\{ Clock, Period, }; diff --git a/src/Internal/Watch/Async.php b/src/Internal/Watch/Async.php index 36f6efa..eb037e4 100644 --- a/src/Internal/Watch/Async.php +++ b/src/Internal/Watch/Async.php @@ -9,7 +9,7 @@ Internal\Async\Suspended, Internal\Async\Resumable, }; -use Innmind\TimeContinuum\{ +use Innmind\Time\{ Clock, Period, }; diff --git a/src/Internal/Watch/Sync.php b/src/Internal/Watch/Sync.php index dd1ba35..75831e7 100644 --- a/src/Internal/Watch/Sync.php +++ b/src/Internal/Watch/Sync.php @@ -8,7 +8,7 @@ Internal\Socket\Server, Exception\RuntimeException, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Immutable\{ Map, Maybe, diff --git a/src/Simulation/Disk.php b/src/Simulation/Disk.php new file mode 100644 index 0000000..124a16e --- /dev/null +++ b/src/Simulation/Disk.php @@ -0,0 +1,164 @@ + + */ + public function access(Path $path): Attempt + { + if ($path instanceof RelativePath) { + return Attempt::error(new \LogicException(\sprintf( + 'Path "%s" must absolute', + $path->toString(), + ))); + } + + /** @var Directory|File */ + $parent = $this->root; + + return self::parts($path) + ->sink($parent) + ->attempt(static fn($parent, $part) => match (true) { + $parent instanceof File => Attempt::error(new \RuntimeException('No such file or directory')), + $parent instanceof Directory => $parent->get($part), + }); + } + + /** + * @return Attempt + */ + public function create(Files $files, Path $path): Attempt + { + return $this + ->parent($path) + ->flatMap( + static fn($parent) => self::parts($path) + ->last() + ->attempt(static fn() => new \LogicException('Empty path')) + ->flatMap(static fn($name) => match ($path->directory()) { + true => $parent->add($name, Directory::new()), + false => File::new($files)->flatMap( + static fn($file) => $parent->add($name, $file), + ), + }), + ); + } + + /** + * @return Attempt + */ + public function remove(Path $path): Attempt + { + if ($path->equals(Path::of('/'))) { + return Attempt::error(new \RuntimeException('Root directory cannot be removed')); + } + + return $this + ->parent($path) + ->flatMap( + static fn($parent) => self::parts($path) + ->last() + ->attempt(static fn() => new \LogicException('Empty path')) + ->flatMap($parent->remove(...)), + ); + } + + public function exists(Path $path): bool + { + return $this->access($path)->match( + static fn() => true, + static fn() => false, + ); + } + + /** + * @return Map + */ + public function list(Path $path): Map + { + return $this + ->access($path) + ->flatMap(static fn($file) => match (true) { + $file instanceof Directory => Attempt::result($file), + default => Attempt::error(new \Exception), + }) + ->match( + static fn($directory) => $directory->list(), + static fn() => Map::of(), + ); + } + + /** + * @return Attempt + */ + private function parent(Path $path): Attempt + { + if ($path instanceof RelativePath) { + return Attempt::error(new \LogicException(\sprintf( + 'Path "%s" must absolute', + $path->toString(), + ))); + } + + /** @var Directory|File */ + $parent = $this->root; + + return self::parts($path) + ->dropEnd(1) + ->sink($parent) + ->attempt(static fn($parent, $part) => match (true) { + $parent instanceof File => Attempt::error(new \RuntimeException('No such file or directory')), + $parent instanceof Directory => $parent->get($part), + }) + ->flatMap(static fn($file) => match (true) { + $file instanceof File => Attempt::error(new \RuntimeException('No such file or directory')), + $file instanceof Directory => Attempt::result($file), + }); + } + + /** + * @return Sequence + */ + private static function parts(Path $path): Sequence + { + return Sequence::of(...\explode('/', $path->toString())) + ->keep(Is::string()->nonEmpty()->asPredicate()); + } +} diff --git a/src/Simulation/Disk/Directory.php b/src/Simulation/Disk/Directory.php new file mode 100644 index 0000000..6d3efac --- /dev/null +++ b/src/Simulation/Disk/Directory.php @@ -0,0 +1,77 @@ + $files + */ + private function __construct( + private Map $files, + ) { + } + + /** + * @internal + */ + public static function new(): self + { + return new self(Map::of()); + } + + /** + * @param non-empty-string $name + * + * @return Attempt + */ + public function get(string $name): Attempt + { + return $this + ->files + ->get($name) + ->attempt(static fn() => new \RuntimeException('No such file or directory')); + } + + /** + * @param non-empty-string $name + * + * @return Attempt + */ + public function add(string $name, self|File $child): Attempt + { + $this->files->put($name, $child); + + return Attempt::result(SideEffect::identity); + } + + /** + * @param non-empty-string $name + * + * @return Attempt + */ + public function remove(string $name): Attempt + { + $this->files->remove($name); + + return Attempt::result(SideEffect::identity); + } + + /** + * @return Map + */ + public function list(): Map + { + return $this->files; + } +} diff --git a/src/Simulation/Disk/File.php b/src/Simulation/Disk/File.php new file mode 100644 index 0000000..4a05a67 --- /dev/null +++ b/src/Simulation/Disk/File.php @@ -0,0 +1,39 @@ + + */ + public static function new(Files $files): Attempt + { + return $files + ->temporary() + ->map(Content::of(...)) + ->map(static fn($content) => new self($content)); + } + + public function content(): Content + { + return $this->content; + } +} diff --git a/src/Simulation/Disk/File/Content.php b/src/Simulation/Disk/File/Content.php new file mode 100644 index 0000000..64797fe --- /dev/null +++ b/src/Simulation/Disk/File/Content.php @@ -0,0 +1,51 @@ + + */ + public function stream(): Attempt + { + // By wrapping the stream each time it's accessed we allow to reset the + // closed flag. This way it simulates a real file by "reopening it" each + // time it's accessed. + $stream = Stream::simulated($this->stream); + + return $stream + ->rewind() + ->map(static fn() => $stream); + } + + public function read(): string + { + return $this + ->stream + ->rewind() + ->flatMap(fn() => $this->stream->read()) + ->unwrap() + ->toString(); + } +} diff --git a/src/Sockets/Clients/Client.php b/src/Sockets/Clients/Client.php index 07e93b9..9c893d7 100644 --- a/src/Sockets/Clients/Client.php +++ b/src/Sockets/Clients/Client.php @@ -8,7 +8,7 @@ Streams\Stream, Frame, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Immutable\{ Str, Attempt, diff --git a/src/Sockets/Servers/Server.php b/src/Sockets/Servers/Server.php index 4ae1847..b7a2e1d 100644 --- a/src/Sockets/Servers/Server.php +++ b/src/Sockets/Servers/Server.php @@ -12,7 +12,7 @@ Internal\Watch, Exception\RuntimeException, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Immutable\{ Attempt, SideEffect, diff --git a/src/Streams/Stream/Read.php b/src/Streams/Stream/Read.php index c14f085..00b2c01 100644 --- a/src/Streams/Stream/Read.php +++ b/src/Streams/Stream/Read.php @@ -11,7 +11,7 @@ Internal\Stream, Internal\Watch, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Immutable\{ Str, Maybe, diff --git a/src/Streams/Stream/Read/Frames/Lazy.php b/src/Streams/Stream/Read/Frames/Lazy.php index 7bc6cd6..24d75b8 100644 --- a/src/Streams/Stream/Read/Frames/Lazy.php +++ b/src/Streams/Stream/Read/Frames/Lazy.php @@ -6,7 +6,6 @@ use Innmind\IO\{ Streams\Stream\Write, Frame, - Exception\FailedToLoadStream, Internal\Stream, Internal\Watch, Internal\Reader, @@ -140,23 +139,16 @@ public function sequence(): Sequence true => $stream->blocking(), false => $stream->nonBlocking(), }; - $result->match( - static fn() => null, - static fn() => throw new \RuntimeException('Failed to set blocking mode'), - ); + $_ = $result + ->attempt(static fn() => new \RuntimeException('Failed to set blocking mode')) + ->unwrap(); if ($rewindable) { - $stream->rewind()->match( - static fn() => null, - static fn() => throw new FailedToLoadStream, - ); + $_ = $stream->rewind()->unwrap(); } while (!$stream->end()) { - yield $frame($reader)->match( - static fn($frame): mixed => $frame, - static fn($e) => throw $e, - ); + yield $frame($reader)->unwrap(); } }); } diff --git a/src/Streams/Stream/Read/Pool.php b/src/Streams/Stream/Read/Pool.php index a177a6c..baddd0c 100644 --- a/src/Streams/Stream/Read/Pool.php +++ b/src/Streams/Stream/Read/Pool.php @@ -8,7 +8,7 @@ Internal, Internal\Capabilities, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Immutable\{ Map, Sequence, diff --git a/src/Streams/Stream/Write.php b/src/Streams/Stream/Write.php index e086f63..9a60839 100644 --- a/src/Streams/Stream/Write.php +++ b/src/Streams/Stream/Write.php @@ -108,7 +108,7 @@ public function sinkAttempts(Sequence $chunks): Attempt ->map(static fn($chunk) => $chunk->map( static fn($chunk) => $chunk->toEncoding(Str\Encoding::ascii), )) - ->sink(new SideEffect) + ->sink(SideEffect::identity) ->attempt( static fn($_, $chunk) => $chunk ->flatMap(static fn($chunk) => match ($abort()) { @@ -121,10 +121,7 @@ public function sinkAttempts(Sequence $chunks): Attempt ->flatMap( static fn($toWrite) => $toWrite ->find(static fn($ready) => $ready === $stream) - ->match( - static fn($stream) => Attempt::result($stream), - static fn() => Attempt::error(new RuntimeException('Stream not ready to write to')), - ), + ->attempt(static fn() => new RuntimeException('Stream not ready to write to')), ) ->flatMap(static fn($stream) => $stream->write($chunk)), ), diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index 86131fb..ab1e427 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -8,7 +8,7 @@ Frame, Sockets\Unix\Address, }; -use Innmind\TimeContinuum\Period; +use Innmind\Time\Period; use Innmind\Url\Path; use Innmind\Immutable\{ Sequence, @@ -486,8 +486,8 @@ public function testSocketClientSend() ), ); - $client->close()->memoize(); - $server->close()->memoize(); + $_ = $client->close()->memoize(); + $_ = $server->close()->memoize(); } public function testSocketClientHeartbeatWithSocketClosing() @@ -543,7 +543,7 @@ public function testSocketClientHeartbeatWithSocketClosing() static fn() => null, ), ); - $client->close()->memoize(); + $_ = $client->close()->memoize(); } return Sequence::of(); @@ -557,7 +557,7 @@ public function testSocketClientHeartbeatWithSocketClosing() $this->assertSame(2, $heartbeats); $this->assertFalse($result, 'It should fail due to the closing of the socket'); - $server->close(); + $_ = $server->close(); } public function testSocketClientHeartbeat() @@ -631,8 +631,8 @@ public function testSocketClientHeartbeat() $this->assertSame(2, $heartbeats); $this->assertSame('bar', $result); - $client->close()->memoize(); - $server->close()->memoize(); + $_ = $client->close()->memoize(); + $_ = $server->close()->memoize(); } public function testSocketAbort() @@ -703,8 +703,8 @@ public function testSocketAbort() $this->assertSame(3, $heartbeats); $this->assertNull($result); - $client->close()->memoize(); - $server->close()->memoize(); + $_ = $client->close()->memoize(); + $_ = $server->close()->memoize(); } public function testServerAcceptConnection() @@ -754,8 +754,8 @@ public function testServerAcceptConnection() ); $this->assertSame('foo', $result); - $client->close()->memoize(); - $server->close()->memoize(); + $_ = $client->close()->memoize(); + $_ = $server->close()->memoize(); } public function testServerPool() @@ -860,15 +860,15 @@ public function testServerPool() ) ->map(static fn($data) => $data->toString()); - $this->assertCount(3, $result); + $this->assertSame(3, $result->size()); $this->assertTrue($result->contains('foo')); $this->assertTrue($result->contains('bar')); $this->assertTrue($result->contains('baz')); - $clientFoo->close()->memoize(); - $clientBar->close()->memoize(); - $clientBaz->close()->memoize(); - $serverFoo->close()->memoize(); - $serverBar->close()->memoize(); - $serverBaz->close()->memoize(); + $_ = $clientFoo->close()->memoize(); + $_ = $clientBar->close()->memoize(); + $_ = $clientBaz->close()->memoize(); + $_ = $serverFoo->close()->memoize(); + $_ = $serverBar->close()->memoize(); + $_ = $serverBaz->close()->memoize(); } }