From d13e04021a2f967006d0f4bb61c3f0a9cc0e03cd Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 13:21:35 +0100 Subject: [PATCH 01/70] require php 8.4 --- .github/workflows/ci.yml | 10 ++++------ CHANGELOG.md | 6 ++++++ composer.json | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c48d37..75d191e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,15 +4,13 @@ on: [push] jobs: blackbox: - uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@next with: scenarii: 20 coverage: - uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@next secrets: inherit psalm: - uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@next cs: - uses: innmind/github-workflows/.github/workflows/cs.yml@main - with: - php-version: '8.2' + uses: innmind/github-workflows/.github/workflows/cs.yml@next diff --git a/CHANGELOG.md b/CHANGELOG.md index af9fb77..7f67dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- Requires PHP `8.4` + ## 8.1.0 - 2025-05-09 ### Added diff --git a/composer.json b/composer.json index 0434cda..bc6feed 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "issues": "http://github.com/Innmind/filesystem/issues" }, "require": { - "php": "~8.2", + "php": "~8.4", "innmind/immutable": "~4.15|~5.0", "symfony/filesystem": "~6.0|~7.0", "innmind/media-type": "~2.1", From 148d0639d70e0731e45af5d6a8c9ec930d8ba3b2 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 13:24:43 +0100 Subject: [PATCH 02/70] remove remnants of phpunit --- .gitignore | 1 - phpunit.xml.dist | 26 -------------------------- 2 files changed, 27 deletions(-) delete mode 100644 phpunit.xml.dist diff --git a/.gitignore b/.gitignore index b292da5..a696500 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ composer.lock vendor -.phpunit.result.cache .cache diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 9d3a4b0..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - ./tests - - - - - . - - - ./tests - ./vendor - - - From 4d4143e6837b25ab28b4442fb18b82d07de699e2 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 13:31:13 +0100 Subject: [PATCH 03/70] update dependencies --- composer.json | 12 +++++++----- tests/Adapter/FilesystemTest.php | 6 +++--- tests/DirectoryTest.php | 8 ++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index bc6feed..e6f5e29 100644 --- a/composer.json +++ b/composer.json @@ -16,13 +16,15 @@ }, "require": { "php": "~8.4", - "innmind/immutable": "~4.15|~5.0", + "innmind/immutable": "dev-next", "symfony/filesystem": "~6.0|~7.0", - "innmind/media-type": "~2.1", - "innmind/url": "~4.2", + "innmind/media-type": "dev-next", + "innmind/url": "dev-next", "psr/log": "~3.0", - "innmind/io": "^3.0.1", - "innmind/validation": "~2.0" + "innmind/io": "dev-next", + "innmind/validation": "dev-next", + "innmind/time-continuum": "dev-next", + "innmind/ip": "dev-next" }, "autoload": { "psr-4": { diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index 9284e79..39a9824 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -173,7 +173,7 @@ public function testRemoveFileWhenRemovedFromFolder() $a->add($d)->unwrap(); $d = $d->remove(Name::of('bar')); $a->add($d)->unwrap(); - $this->assertSame(1, $d->removed()->count()); + $this->assertSame(1, $d->removed()->size()); $a = Filesystem::mount(Path::of('/tmp/')); $this->assertFalse( $a->get(Name::of('foo'))->match( @@ -196,7 +196,7 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi $d = $d->remove(Name::of('bar')); $a->add($d)->unwrap(); $a->add($d)->unwrap(); - $this->assertSame(1, $d->removed()->count()); + $this->assertSame(1, $d->removed()->size()); $a = Filesystem::mount(Path::of('/tmp/')); $this->assertFalse( $a->get(Name::of('foo'))->match( @@ -246,7 +246,7 @@ public function testRoot() \file_put_contents('/tmp/test/baz/foobar', 'baz'); $all = $adapter->root()->all(); - $this->assertCount(3, $all); + $this->assertSame(3, $all->size()); $all = Map::of( ...$all ->map(static fn($file) => [$file->name()->toString(), $file]) diff --git a/tests/DirectoryTest.php b/tests/DirectoryTest.php index 1d1fd75..7bfca09 100644 --- a/tests/DirectoryTest.php +++ b/tests/DirectoryTest.php @@ -51,8 +51,8 @@ public function testAdd() $this->assertInstanceOf(Directory::class, $d2); $this->assertNotSame($d, $d2); $this->assertSame($d->name(), $d2->name()); - $this->assertSame(0, $d->removed()->count()); - $this->assertSame(0, $d2->removed()->count()); + $this->assertSame(0, $d->removed()->size()); + $this->assertSame(0, $d2->removed()->size()); $this->assertFalse($d->contains($file->name())); $this->assertTrue($d2->contains($file->name())); $this->assertSame($file, $d2->get($file->name())->match( @@ -106,8 +106,8 @@ public function testRemove() $this->assertInstanceOf(Directory::class, $d2); $this->assertNotSame($d, $d2); $this->assertSame($d->name(), $d2->name()); - $this->assertSame(0, $d->removed()->count()); - $this->assertSame(1, $d2->removed()->count()); + $this->assertSame(0, $d->removed()->size()); + $this->assertSame(1, $d2->removed()->size()); $this->assertSame( 'bar', $d2 From bd41d17490fa8e67dffe9083fdb882127464b5c2 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 14:52:39 +0100 Subject: [PATCH 04/70] remove dependency to symfony --- composer.json | 2 +- src/Adapter/Filesystem.php | 193 +++++++++++++++++++++++-------- tests/Adapter/FilesystemTest.php | 4 +- 3 files changed, 146 insertions(+), 53 deletions(-) diff --git a/composer.json b/composer.json index e6f5e29..8c70f76 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "require": { "php": "~8.4", "innmind/immutable": "dev-next", - "symfony/filesystem": "~6.0|~7.0", "innmind/media-type": "dev-next", "innmind/url": "dev-next", "psr/log": "~3.0", @@ -42,6 +41,7 @@ "innmind/static-analysis": "^1.2.1", "innmind/black-box": "^6.0.2", "innmind/coding-standard": "~2.0", + "symfony/filesystem": "~6.0|~7.0", "ramsey/uuid": "^4.6" }, "conflict": { diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index cf01b47..d3123e5 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -10,12 +10,12 @@ Directory, CaseSensitivity, Exception\PathDoesntRepresentADirectory, - Exception\PathTooLong, Exception\LinksAreNotSupported, }; use Innmind\IO\IO; use Innmind\MediaType\MediaType; use Innmind\Url\Path; +use Innmind\Validation\Is; use Innmind\Immutable\{ Sequence, Str, @@ -24,7 +24,6 @@ SideEffect, Set, }; -use Symfony\Component\Filesystem\Filesystem as FS; final class Filesystem implements Adapter { @@ -32,7 +31,6 @@ final class Filesystem implements Adapter private IO $io; private Path $path; private CaseSensitivity $case; - private FS $filesystem; /** @var \WeakMap */ private \WeakMap $loaded; @@ -48,12 +46,11 @@ private function __construct( $this->io = $io; $this->path = $path; $this->case = $case; - $this->filesystem = new FS; /** @var \WeakMap */ $this->loaded = new \WeakMap; - if (!$this->filesystem->exists($this->path->toString())) { - $this->filesystem->mkdir($this->path->toString()); + if (!self::doExist($this->path->toString())->unwrap()) { + self::mkdir($this->path->toString())->unwrap(); } } @@ -93,17 +90,13 @@ public function get(Name $file): Maybe #[\Override] public function contains(Name $file): bool { - return $this->filesystem->exists($this->path->toString().'/'.$file->toString()); + return self::doExist($this->path->toString().$file->toString())->unwrap(); } #[\Override] public function remove(Name $file): Attempt { - return Attempt::of( - fn() => $this->filesystem->remove( - $this->path->toString().'/'.$file->toString(), - ), - )->map(static fn() => SideEffect::identity()); + return self::doRemove($this->path->toString().$file->toString()); } #[\Override] @@ -142,9 +135,7 @@ private function createFileAt(Path $path, File|Directory $file): Attempt /** @var Set */ $names = Set::of(); - return Attempt::of( - fn() => $this->filesystem->mkdir($path->toString()), - ) + return self::mkdir($path->toString()) ->flatMap( fn() => $file ->all() @@ -163,44 +154,26 @@ private function createFileAt(Path $path, File|Directory $file): Attempt $persisted, )) ->unsorted() - ->sink(null) - ->attempt( - fn($_, $file) => Attempt::of( - fn() => $this->filesystem->remove( - $path->toString().$file->toString(), - ), - ), - ) - ->map(static fn() => SideEffect::identity()), + ->sink(SideEffect::identity) + ->attempt(static fn($_, $file) => self::doRemove( + $path->toString().$file->toString(), + )), ); } - if (\is_dir($path->toString())) { - try { - $this->filesystem->remove($path->toString()); - } catch (\Throwable $e) { - return Attempt::error($e); - } - } - - $chunks = $file->content()->chunks(); - - try { - $this->filesystem->touch($path->toString()); - } catch (\Throwable $e) { - if (\PHP_OS === 'Darwin' && Str::of($path->toString(), Str\Encoding::ascii)->length() > 1014) { - return Attempt::error(new PathTooLong($path->toString(), 0, $e)); - } - - return Attempt::error($e); - } - - return $this - ->io - ->files() - ->write($path) - ->watch() - ->sink($chunks); + return self::doRemove($path->toString()) + ->map(static fn() => $file->content()->chunks()) + ->flatMap(static fn($chunks) => self::touch($path->toString())->map( + static fn() => $chunks, + )) + ->flatMap( + fn($chunks) => $this + ->io + ->files() + ->write($path) + ->watch() + ->sink($chunks), + ); } /** @@ -263,4 +236,124 @@ private function list(Path $path): Sequence } }); } + + /** + * @return Attempt + */ + private static function doExist(string $path): Attempt + { + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + + return Attempt::result(@\file_exists($path)); + } + + /** + * @return Attempt + */ + private static function mkdir(string $path): Attempt + { + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + + // 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 static function touch(string $path): Attempt + { + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + + 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); + } + + /** + * This method only relies on the returned boolean to know if the deletion + * was successful or not. It doesn't check afterward if the content is no + * longer there as it may lead to race conditions with other processes. + * + * Such race condition could be P1 removes a file, P2 creates the same file + * and then P1 check the file doesn't exist. This scenario would report a + * failure. + * + * This package doesn't want to bleed this global state between processes. + * If you end up here, know that you should design your app in a way that + * there is as little as possible race conditions like these. + * + * @return Attempt + */ + private static function doRemove(string $path): Attempt + { + if (!\file_exists($path)) { + return Attempt::result(SideEffect::identity); + } + + if (\is_link($path)) { + return Attempt::error(new LinksAreNotSupported); + } + + if (\is_dir($path)) { + $files = new \FilesystemIterator( + $path, + \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS, + ); + + return Sequence::lazy(static fn() => yield from $files) + ->keep(Is::string()->asPredicate()) + ->sink(SideEffect::identity) + ->attempt(static fn($_, $file) => self::doRemove($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, + ))), + }); + } + + $removed = @\unlink($path); + + return match ($removed) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove file '%s'", + $path, + ))), + }; + } } diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index 39a9824..11b89ac 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -12,7 +12,6 @@ Directory as DirectoryInterface, Directory, Exception\PathDoesntRepresentADirectory, - Exception\PathTooLong, Exception\LinksAreNotSupported, }; use Innmind\Url\Path; @@ -326,7 +325,8 @@ public function testPathTooLongThrowAnException() $filesystem = Filesystem::mount(Path::of($path)); - $this->expectException(PathTooLong::class); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Path too long'); $filesystem->add(Directory::of( Name::of(\str_repeat('a', 255)), From 65fa4e8eb6e1d86e67bb07e177e7fc7023730716 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:04:36 +0100 Subject: [PATCH 05/70] silently ignore links when reading from filesystem --- CHANGELOG.md | 1 + src/Adapter/Filesystem.php | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f67dbb..daa9358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Requires PHP `8.4` +- Links are not silently ignored when listing files/directories. An error is still returned when trying to remove a link. ## 8.1.0 - 2025-05-09 diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index d3123e5..9176072 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -23,6 +23,7 @@ Attempt, SideEffect, Set, + Predicate\Instance, }; final class Filesystem implements Adapter @@ -84,7 +85,7 @@ public function get(Name $file): Maybe return Maybe::nothing(); } - return Maybe::just($this->open($this->path, $file)); + return Maybe::of($this->open($this->path, $file)); } #[\Override] @@ -179,7 +180,7 @@ private function createFileAt(Path $path, File|Directory $file): Attempt /** * Open the file in the given folder */ - private function open(Path $folder, Name $file): File|Directory + private function open(Path $folder, Name $file): File|Directory|null { $path = $folder->resolve(Path::of($file->toString())); @@ -194,7 +195,7 @@ private function open(Path $folder, Name $file): File|Directory } if (\is_link($path->toString())) { - throw new LinksAreNotSupported($path->toString()); + return null; } $file = File::of( @@ -221,20 +222,19 @@ private function open(Path $folder, Name $file): File|Directory */ private function list(Path $path): Sequence { - /** @var Sequence */ return Sequence::lazy(function() use ($path): \Generator { $files = new \FilesystemIterator($path->toString()); /** @var \SplFileInfo $file */ foreach ($files as $file) { - if (\is_link($file->getPathname())) { - throw new LinksAreNotSupported($file->getPathname()); - } - /** @psalm-suppress ArgumentTypeCoercion */ yield $this->open($path, Name::of($file->getBasename())); } - }); + })->keep( + Instance::of(File::class)->or( + Instance::of(Directory::class), + ), + ); } /** From a821bfb22d751647940d901dd2ee4a2ae4fe965e Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:07:03 +0100 Subject: [PATCH 06/70] verify path length before trying to remove it --- src/Adapter/Filesystem.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 9176072..bbc9820 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -318,6 +318,10 @@ private static function touch(string $path): Attempt */ private static function doRemove(string $path): Attempt { + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + if (!\file_exists($path)) { return Attempt::result(SideEffect::identity); } From 2f8f1d21210a185446f205b6c37c9477fc351cc6 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:10:17 +0100 Subject: [PATCH 07/70] remove unused constant --- src/Adapter/Filesystem.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index bbc9820..71ad702 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -28,7 +28,6 @@ final class Filesystem implements Adapter { - private const INVALID_FILES = ['.', '..']; private IO $io; private Path $path; private CaseSensitivity $case; From 2e481ef39db2c19ae9eaf909768fe74d3d754b05 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:19:15 +0100 Subject: [PATCH 08/70] Filesystem::mount() now returns an Attempt instead of throwing --- CHANGELOG.md | 1 + proofs/adapter/filesystem.php | 30 +++++++++++++++---------- src/Adapter/Filesystem.php | 32 +++++++++++++++------------ tests/Adapter/FilesystemTest.php | 38 +++++++++++++++++--------------- 4 files changed, 57 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa9358..cc797f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Requires PHP `8.4` - Links are not silently ignored when listing files/directories. An error is still returned when trying to remove a link. +- `Innmind\Filesystem\Adapter\Filesystem::mount()` now returns an `Innmind\Immutable\Attempt` ## 8.1.0 - 2025-05-09 diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index f66a8a0..0a18c38 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -21,10 +21,12 @@ $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - return Filesystem::mount(Path::of($path))->withCaseSensitivity(match (\PHP_OS) { - 'Darwin' => CaseSensitivity::insensitive, - default => CaseSensitivity::sensitive, - }); + return Filesystem::mount(Path::of($path)) + ->unwrap() + ->withCaseSensitivity(match (\PHP_OS) { + 'Darwin' => CaseSensitivity::insensitive, + default => CaseSensitivity::sensitive, + }); }), ); @@ -35,10 +37,12 @@ $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - return Filesystem::mount(Path::of($path))->withCaseSensitivity(match (\PHP_OS) { - 'Darwin' => CaseSensitivity::insensitive, - default => CaseSensitivity::sensitive, - }); + return Filesystem::mount(Path::of($path)) + ->unwrap() + ->withCaseSensitivity(match (\PHP_OS) { + 'Darwin' => CaseSensitivity::insensitive, + default => CaseSensitivity::sensitive, + }); }), )->named('Filesystem'); } @@ -55,10 +59,12 @@ static function($assert) { $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $adapter = Filesystem::mount(Path::of($path))->withCaseSensitivity(match (\PHP_OS) { - 'Darwin' => CaseSensitivity::insensitive, - default => CaseSensitivity::sensitive, - }); + $adapter = Filesystem::mount(Path::of($path)) + ->unwrap() + ->withCaseSensitivity(match (\PHP_OS) { + 'Darwin' => CaseSensitivity::insensitive, + default => CaseSensitivity::sensitive, + }); $property->ensureHeldBy($assert, $adapter); diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 71ad702..d69515c 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -39,30 +39,34 @@ private function __construct( Path $path, CaseSensitivity $case, ) { - if (!$path->directory()) { - throw new PathDoesntRepresentADirectory($path->toString()); - } - $this->io = $io; $this->path = $path; $this->case = $case; /** @var \WeakMap */ $this->loaded = new \WeakMap; - - if (!self::doExist($this->path->toString())->unwrap()) { - self::mkdir($this->path->toString())->unwrap(); - } } + /** + * @return Attempt + */ public static function mount( Path $path, ?IO $io = null, - ): self { - return new self( - $io ?? IO::fromAmbientAuthority(), - $path, - CaseSensitivity::sensitive, - ); + ): Attempt { + if (!$path->directory()) { + return Attempt::error(new PathDoesntRepresentADirectory($path->toString())); + } + + return self::doExist($path->toString()) + ->flatMap(static fn($exist) => match ($exist) { + false => self::mkdir($path->toString()), + default => Attempt::result(SideEffect::identity), + }) + ->map(static fn() => new self( + $io ?? IO::fromAmbientAuthority(), + $path, + CaseSensitivity::sensitive, + )); } public function withCaseSensitivity(CaseSensitivity $case): self diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index 11b89ac..c3318f7 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -41,7 +41,7 @@ public function setUp(): void public function testInterface() { - $adapter = Filesystem::mount(Path::of('/tmp/')); + $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $this->assertInstanceOf(Adapter::class, $adapter); $this->assertFalse($adapter->contains(Name::of('foo'))); @@ -66,13 +66,14 @@ public function testThrowWhenPathToMountIsNotADirectory() $this->expectException(PathDoesntRepresentADirectory::class); $this->expectExceptionMessage('path/to/somewhere'); - Filesystem::mount(Path::of('path/to/somewhere')); + Filesystem::mount(Path::of('path/to/somewhere'))->unwrap(); } public function testReturnNothingWhenGettingUnknownFile() { $this->assertNull( Filesystem::mount(Path::of('/tmp/')) + ->unwrap() ->get(Name::of('foo')) ->match( static fn($file) => $file, @@ -86,6 +87,7 @@ public function testRemovingUnknownFileDoesntThrow() $this->assertInstanceOf( SideEffect::class, Filesystem::mount(Path::of('/tmp/')) + ->unwrap() ->remove(Name::of('foo')) ->unwrap(), ); @@ -93,7 +95,7 @@ public function testRemovingUnknownFileDoesntThrow() public function testCreateNestedStructure() { - $adapter = Filesystem::mount(Path::of('/tmp/')); + $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $directory = Directory::of(Name::of('foo')) ->add(File::of(Name::of('foo.md'), Content::ofString('# Foo'))) @@ -126,7 +128,7 @@ public function testCreateNestedStructure() ), ); - $adapter = Filesystem::mount(Path::of('/tmp/')); + $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $this->assertTrue($adapter->contains(Name::of('foo'))); $this->assertSame( '# Foo', @@ -165,7 +167,7 @@ public function testCreateNestedStructure() public function testRemoveFileWhenRemovedFromFolder() { - $a = Filesystem::mount(Path::of('/tmp/')); + $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $d = Directory::of(Name::of('foo')); $d = $d->add(File::of(Name::of('bar'), Content::ofString('some content'))); @@ -173,7 +175,7 @@ public function testRemoveFileWhenRemovedFromFolder() $d = $d->remove(Name::of('bar')); $a->add($d)->unwrap(); $this->assertSame(1, $d->removed()->size()); - $a = Filesystem::mount(Path::of('/tmp/')); + $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $this->assertFalse( $a->get(Name::of('foo'))->match( static fn($dir) => $dir->contains(Name::of('bar')), @@ -187,7 +189,7 @@ public function testRemoveFileWhenRemovedFromFolder() public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFile() { - $a = Filesystem::mount(Path::of('/tmp/')); + $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $d = Directory::of(Name::of('foo')); $d = $d->add(File::of(Name::of('bar'), Content::ofString('some content'))); @@ -196,7 +198,7 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi $a->add($d)->unwrap(); $a->add($d)->unwrap(); $this->assertSame(1, $d->removed()->size()); - $a = Filesystem::mount(Path::of('/tmp/')); + $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $this->assertFalse( $a->get(Name::of('foo'))->match( static fn($dir) => $dir->contains(Name::of('bar')), @@ -210,7 +212,7 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi public function testLoadWithMediaType() { - $a = Filesystem::mount(Path::of('/tmp/')); + $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); \file_put_contents( '/tmp/some_content.html', '', @@ -233,7 +235,7 @@ public function testLoadWithMediaType() public function testRoot() { - $adapter = Filesystem::mount(Path::of('/tmp/test/')); + $adapter = Filesystem::mount(Path::of('/tmp/test/'))->unwrap(); $adapter ->add(File::of( Name::of('foo'), @@ -294,7 +296,7 @@ public function testRoot() public function testAddingTheSameFileTwiceDoesNothing() { - $adapter = Filesystem::mount(Path::of('/tmp/')); + $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); $file = File::of( Name::of('foo'), Content::ofString('foo'), @@ -323,7 +325,7 @@ public function testPathTooLongThrowAnException() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Path too long'); @@ -363,7 +365,7 @@ public function testPersistedNameCanStartWithAnyAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -396,7 +398,7 @@ public function testPersistedNameCanContainWithAnyAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -431,7 +433,7 @@ public function testPersistedNameCanContainOnlyOneAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -457,7 +459,7 @@ public function testThrowsWhenTryingToGetLink() (new FS)->dumpFile($path.'foo', 'bar'); \symlink($path.'foo', $path.'bar'); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $this->expectException(LinksAreNotSupported::class); $this->expectExceptionMessage($path.'bar'); @@ -472,7 +474,7 @@ public function testThrowsWhenListContainsALink() (new FS)->dumpFile($path.'foo', 'bar'); \symlink($path.'foo', $path.'bar'); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $this->expectException(LinksAreNotSupported::class); $this->expectExceptionMessage($path.'bar'); @@ -490,7 +492,7 @@ public function testDotFilesAreListed() (new FS)->mkdir($path); \file_put_contents($path.$name, 'bar'); - $filesystem = Filesystem::mount(Path::of($path)); + $filesystem = Filesystem::mount(Path::of($path))->unwrap(); $all = $filesystem->root()->all()->toList(); $this->assertCount(1, $all); From 8615a1af9e07cb8ebea5385a20ac503f49d2b2c0 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:24:56 +0100 Subject: [PATCH 09/70] remove deprecated InMemory::new() --- CHANGELOG.md | 4 ++++ proofs/adapter/inMemory.php | 9 --------- src/Adapter/InMemory.php | 8 -------- tests/Adapter/InMemoryTest.php | 8 ++++---- tests/Adapter/LoggerTest.php | 12 ++++++------ 5 files changed, 14 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc797f3..c4ad9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Links are not silently ignored when listing files/directories. An error is still returned when trying to remove a link. - `Innmind\Filesystem\Adapter\Filesystem::mount()` now returns an `Innmind\Immutable\Attempt` +### Removed + +- `Innmind\Filesystem\Adapter\InMemory::new()` + ## 8.1.0 - 2025-05-09 ### Added diff --git a/proofs/adapter/inMemory.php b/proofs/adapter/inMemory.php index c794480..d7305b1 100644 --- a/proofs/adapter/inMemory.php +++ b/proofs/adapter/inMemory.php @@ -6,11 +6,6 @@ use Innmind\BlackBox\Set; return static function() { - yield properties( - 'InMemory properties', - Adapter::properties(), - Set::call(InMemory::new(...)), - ); yield properties( 'InMemory properties emulating filesystem', Adapter::properties(), @@ -18,10 +13,6 @@ ); foreach (Adapter::alwaysApplicable() as $property) { - yield property( - $property, - Set::call(InMemory::new(...)), - )->named('InMemory'); yield property( $property, Set::call(InMemory::emulateFilesystem(...)), diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index 112746f..5e72186 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -25,14 +25,6 @@ private function __construct() $this->root = Directory::named('root'); } - /** - * @deprecated Use self::emulateFilesystem() - */ - public static function new(): self - { - return self::emulateFilesystem(); - } - public static function emulateFilesystem(): self { return new self; diff --git a/tests/Adapter/InMemoryTest.php b/tests/Adapter/InMemoryTest.php index e132ea6..0ccff03 100644 --- a/tests/Adapter/InMemoryTest.php +++ b/tests/Adapter/InMemoryTest.php @@ -21,7 +21,7 @@ class InMemoryTest extends TestCase { public function testInterface() { - $a = InMemory::new(); + $a = InMemory::emulateFilesystem(); $this->assertInstanceOf(Adapter::class, $a); $this->assertFalse($a->contains(Name::of('foo'))); @@ -50,7 +50,7 @@ public function testInterface() public function testReturnNothingWhenGettingUnknownFile() { - $this->assertNull(InMemory::new()->get(Name::of('foo'))->match( + $this->assertNull(InMemory::emulateFilesystem()->get(Name::of('foo'))->match( static fn($file) => $file, static fn() => null, )); @@ -60,7 +60,7 @@ public function testRemovingUnknownFileDoesntThrow() { $this->assertInstanceOf( SideEffect::class, - InMemory::new() + InMemory::emulateFilesystem() ->remove(Name::of('foo')) ->unwrap(), ); @@ -68,7 +68,7 @@ public function testRemovingUnknownFileDoesntThrow() public function testRoot() { - $adapter = InMemory::new(); + $adapter = InMemory::emulateFilesystem(); $adapter ->add($foo = File::of( Name::of('foo'), diff --git a/tests/Adapter/LoggerTest.php b/tests/Adapter/LoggerTest.php index ab43fdc..b55ce2e 100644 --- a/tests/Adapter/LoggerTest.php +++ b/tests/Adapter/LoggerTest.php @@ -22,7 +22,7 @@ public function testInterface() $this->assertInstanceOf( Adapter::class, Logger::psr( - InMemory::new(), + InMemory::emulateFilesystem(), new NullLogger, ), ); @@ -31,7 +31,7 @@ public function testInterface() public function testAdd() { $adapter = Logger::psr( - $inner = InMemory::new(), + $inner = InMemory::emulateFilesystem(), new NullLogger, ); $file = File::of(Name::of('foo'), Content::none()); @@ -48,7 +48,7 @@ public function testAdd() public function testGet() { $adapter = Logger::psr( - $inner = InMemory::new(), + $inner = InMemory::emulateFilesystem(), new NullLogger, ); $name = Name::of('foo'); @@ -69,7 +69,7 @@ public function testGet() public function testContains() { $adapter = Logger::psr( - $inner = InMemory::new(), + $inner = InMemory::emulateFilesystem(), new NullLogger, ); $name = Name::of('foo'); @@ -83,7 +83,7 @@ public function testContains() public function testRemove() { $adapter = Logger::psr( - $inner = InMemory::new(), + $inner = InMemory::emulateFilesystem(), new NullLogger, ); $name = Name::of('foo'); @@ -103,7 +103,7 @@ public function testRemove() public function testRoot() { $adapter = Logger::psr( - $inner = InMemory::new(), + $inner = InMemory::emulateFilesystem(), new NullLogger, ); $file = File::named( From 1e51a7b6ea0df6cbe9ce1883cc8cd521bd5a50ba Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:37:20 +0100 Subject: [PATCH 10/70] use promoted properties --- src/Adapter/Filesystem.php | 12 +++--------- src/Adapter/InMemory.php | 10 ++++------ src/Adapter/Logger.php | 11 ++++------- src/Directory.php | 16 +++++----------- src/File.php | 17 +++++------------ src/File/Content.php | 5 +---- src/File/Content/Chunks.php | 8 ++------ src/File/Content/Line.php | 5 +---- src/File/Content/Lines.php | 12 ++++-------- src/File/Content/OneShot.php | 4 +--- 10 files changed, 30 insertions(+), 70 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index d69515c..ee43dc3 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -28,20 +28,14 @@ final class Filesystem implements Adapter { - private IO $io; - private Path $path; - private CaseSensitivity $case; /** @var \WeakMap */ private \WeakMap $loaded; private function __construct( - IO $io, - Path $path, - CaseSensitivity $case, + private IO $io, + private Path $path, + private CaseSensitivity $case, ) { - $this->io = $io; - $this->path = $path; - $this->case = $case; /** @var \WeakMap */ $this->loaded = new \WeakMap; } diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index 5e72186..c4a2319 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -18,16 +18,14 @@ final class InMemory implements Adapter { - private Directory $root; - - private function __construct() - { - $this->root = Directory::named('root'); + private function __construct( + private Directory $root, + ) { } public static function emulateFilesystem(): self { - return new self; + return new self(Directory::named('root')); } #[\Override] diff --git a/src/Adapter/Logger.php b/src/Adapter/Logger.php index 3f3f2c1..4bcab4e 100644 --- a/src/Adapter/Logger.php +++ b/src/Adapter/Logger.php @@ -17,13 +17,10 @@ final class Logger implements Adapter { - private Adapter $filesystem; - private LoggerInterface $logger; - - private function __construct(Adapter $filesystem, LoggerInterface $logger) - { - $this->filesystem = $filesystem; - $this->logger = $logger; + private function __construct( + private Adapter $filesystem, + private LoggerInterface $logger, + ) { } public static function psr(Adapter $filesystem, LoggerInterface $logger): self diff --git a/src/Directory.php b/src/Directory.php index 5638458..7a60fee 100644 --- a/src/Directory.php +++ b/src/Directory.php @@ -16,21 +16,15 @@ */ final class Directory { - private Name $name; - /** @var Sequence */ - private Sequence $files; - /** @var Set */ - private Set $removed; - /** * @param Sequence $files * @param Set $removed */ - private function __construct(Name $name, Sequence $files, Set $removed) - { - $this->name = $name; - $this->files = $files; - $this->removed = $removed; + private function __construct( + private Name $name, + private Sequence $files, + private Set $removed, + ) { } /** diff --git a/src/File.php b/src/File.php index f1f9e85..bc0470f 100644 --- a/src/File.php +++ b/src/File.php @@ -11,18 +11,11 @@ */ final class File { - private Name $name; - private Content $content; - private MediaType $mediaType; - private function __construct( - Name $name, - Content $content, - ?MediaType $mediaType = null, + private Name $name, + private Content $content, + private MediaType $mediaType, ) { - $this->name = $name; - $this->content = $content; - $this->mediaType = $mediaType ?? MediaType::null(); } /** @@ -33,7 +26,7 @@ public static function of( Content $content, ?MediaType $mediaType = null, ): self { - return new self($name, $content, $mediaType); + return new self($name, $content, $mediaType ?? MediaType::null()); } /** @@ -46,7 +39,7 @@ public static function named( Content $content, ?MediaType $mediaType = null, ): self { - return new self(Name::of($name), $content, $mediaType); + return self::of(Name::of($name), $content, $mediaType); } public function name(): Name diff --git a/src/File/Content.php b/src/File/Content.php index f60b1aa..08a5150 100644 --- a/src/File/Content.php +++ b/src/File/Content.php @@ -26,11 +26,8 @@ */ final class Content { - private Implementation $implementation; - - private function __construct(Implementation $implementation) + private function __construct(private Implementation $implementation) { - $this->implementation = $implementation; } /** diff --git a/src/File/Content/Chunks.php b/src/File/Content/Chunks.php index ea829aa..ba1b9c7 100644 --- a/src/File/Content/Chunks.php +++ b/src/File/Content/Chunks.php @@ -19,15 +19,11 @@ */ final class Chunks implements Implementation { - /** @var Sequence */ - private Sequence $chunks; - /** * @param Sequence $chunks */ - private function __construct(Sequence $chunks) + private function __construct(private Sequence $chunks) { - $this->chunks = $chunks->pad(1, Str::of('')); } /** @@ -37,7 +33,7 @@ private function __construct(Sequence $chunks) */ public static function of(Sequence $chunks): self { - return new self($chunks); + return new self($chunks->pad(1, Str::of(''))); } #[\Override] diff --git a/src/File/Content/Line.php b/src/File/Content/Line.php index f4c350f..17bf5ef 100644 --- a/src/File/Content/Line.php +++ b/src/File/Content/Line.php @@ -11,11 +11,8 @@ */ final class Line { - private Str $content; - - private function __construct(Str $content) + private function __construct(private Str $content) { - $this->content = $content; } /** diff --git a/src/File/Content/Lines.php b/src/File/Content/Lines.php index a932372..46d374a 100644 --- a/src/File/Content/Lines.php +++ b/src/File/Content/Lines.php @@ -17,15 +17,11 @@ */ final class Lines implements Implementation { - /** @var Sequence */ - private Sequence $lines; - /** * @param Sequence $lines */ - private function __construct(Sequence $lines) + private function __construct(private Sequence $lines) { - $this->lines = $lines->pad(1, Line::of(Str::of(''))); } /** @@ -35,7 +31,7 @@ private function __construct(Sequence $lines) */ public static function of(Sequence $lines): self { - return new self($lines); + return new self($lines->pad(1, Line::of(Str::of('')))); } #[\Override] @@ -53,7 +49,7 @@ public function map(callable $map): Implementation #[\Override] public function flatMap(callable $map): Implementation { - return new self($this->lines->flatMap( + return self::of($this->lines->flatMap( static fn($line) => $map($line)->lines(), )); } @@ -61,7 +57,7 @@ public function flatMap(callable $map): Implementation #[\Override] public function filter(callable $filter): Implementation { - return new self($this->lines->filter($filter)); + return self::of($this->lines->filter($filter)); } #[\Override] diff --git a/src/File/Content/OneShot.php b/src/File/Content/OneShot.php index 69509ec..f70f1cf 100644 --- a/src/File/Content/OneShot.php +++ b/src/File/Content/OneShot.php @@ -21,12 +21,10 @@ */ final class OneShot implements Implementation { - private Stream $io; private bool $loaded = false; - private function __construct(Stream $io) + private function __construct(private Stream $io) { - $this->io = $io; } /** From d25f0b73640ece8c478d36253ea729dade9170de Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:41:52 +0100 Subject: [PATCH 11/70] use exclude method instead of negating condition --- src/Adapter/Filesystem.php | 2 +- src/Adapter/InMemory.php | 2 +- src/Directory.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index ee43dc3..a09ef78 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -147,7 +147,7 @@ private function createFileAt(Path $path, File|Directory $file): Attempt ->flatMap( fn($persisted) => $file ->removed() - ->filter(fn($file): bool => !$this->case->contains( + ->exclude(fn($file): bool => $this->case->contains( $file, $persisted, )) diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index c4a2319..51645d0 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -85,7 +85,7 @@ private function mergeDirectories( ): Directory { $existing = $new ->removed() - ->filter(static fn($name) => !$new->contains($name)) + ->exclude($new->contains(...)) ->reduce( $existing, static fn(Directory $existing, $name) => $existing->remove($name), diff --git a/src/Directory.php b/src/Directory.php index 7a60fee..ebd04be 100644 --- a/src/Directory.php +++ b/src/Directory.php @@ -96,7 +96,7 @@ public function add(File|self $file): self $this->name, $this ->files - ->filter(static fn(File|self $known): bool => !$known->name()->equals($file->name())) + ->exclude(static fn(File|self $known): bool => $known->name()->equals($file->name())) ->add($file), $this->removed, ); @@ -122,7 +122,7 @@ public function remove(Name $name): self { return new self( $this->name, - $this->files->filter(static fn(File|self $file) => !$file->name()->equals($name)), + $this->files->exclude(static fn(File|self $file) => $file->name()->equals($name)), ($this->removed)($name), ); } From da97e8a9198a570415eda6827aca20ccd7efd768 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 15:54:24 +0100 Subject: [PATCH 12/70] discard errors when checking if a file exists --- src/Adapter/Filesystem.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index a09ef78..c36ab13 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -88,7 +88,10 @@ public function get(Name $file): Maybe #[\Override] public function contains(Name $file): bool { - return self::doExist($this->path->toString().$file->toString())->unwrap(); + return self::doExist($this->path->toString().$file->toString())->match( + static fn($exists) => $exists, + static fn() => false, + ); } #[\Override] From 52f12f84766357abcec93f2cfca493bb6d33f5a9 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 16:03:30 +0100 Subject: [PATCH 13/70] flag Directory::removed() as internal --- CHANGELOG.md | 1 + src/Directory.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ad9e6..01182ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Requires PHP `8.4` - Links are not silently ignored when listing files/directories. An error is still returned when trying to remove a link. - `Innmind\Filesystem\Adapter\Filesystem::mount()` now returns an `Innmind\Immutable\Attempt` +- `Innmind\Filesystem\Directory::removed()` is now flagged as internal ### Removed diff --git a/src/Directory.php b/src/Directory.php index ebd04be..27989a6 100644 --- a/src/Directory.php +++ b/src/Directory.php @@ -200,6 +200,8 @@ public function reduce($carry, callable $reducer) * This method should only be used for implementations of the Adapter * interface, normal users should never have to use this method * + * @internal + * * @return Set */ public function removed(): Set From 30dae568f6d9f03e91c3a7a05c060c5b689a761a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 12:58:27 +0100 Subject: [PATCH 14/70] force specifying the case sensitivity at mount time --- CHANGELOG.md | 1 + proofs/adapter/filesystem.php | 27 +++++++++++++++------------ src/Adapter/Filesystem.php | 8 ++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01182ba..44b95ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Removed - `Innmind\Filesystem\Adapter\InMemory::new()` +- `Innmind\Filesystem\Adapter\Filesystem::withCaseSensitivity()`, case sensitivity can be specified as the second argument of `::mount()` ## 8.1.0 - 2025-05-09 diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index 0a18c38..1ff7474 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -21,12 +21,13 @@ $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - return Filesystem::mount(Path::of($path)) - ->unwrap() - ->withCaseSensitivity(match (\PHP_OS) { + return Filesystem::mount( + Path::of($path), + match (\PHP_OS) { 'Darwin' => CaseSensitivity::insensitive, default => CaseSensitivity::sensitive, - }); + }, + )->unwrap(); }), ); @@ -37,12 +38,13 @@ $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - return Filesystem::mount(Path::of($path)) - ->unwrap() - ->withCaseSensitivity(match (\PHP_OS) { + return Filesystem::mount( + Path::of($path), + match (\PHP_OS) { 'Darwin' => CaseSensitivity::insensitive, default => CaseSensitivity::sensitive, - }); + }, + )->unwrap(); }), )->named('Filesystem'); } @@ -59,12 +61,13 @@ static function($assert) { $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $adapter = Filesystem::mount(Path::of($path)) - ->unwrap() - ->withCaseSensitivity(match (\PHP_OS) { + $adapter = Filesystem::mount( + Path::of($path), + match (\PHP_OS) { 'Darwin' => CaseSensitivity::insensitive, default => CaseSensitivity::sensitive, - }); + }, + )->unwrap(); $property->ensureHeldBy($assert, $adapter); diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index c36ab13..8c618c4 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -45,6 +45,7 @@ private function __construct( */ public static function mount( Path $path, + CaseSensitivity $case = CaseSensitivity::sensitive, ?IO $io = null, ): Attempt { if (!$path->directory()) { @@ -59,15 +60,10 @@ public static function mount( ->map(static fn() => new self( $io ?? IO::fromAmbientAuthority(), $path, - CaseSensitivity::sensitive, + $case, )); } - public function withCaseSensitivity(CaseSensitivity $case): self - { - return new self($this->io, $this->path, $case); - } - #[\Override] public function add(File|Directory $file): Attempt { From 0942c01ff7e05c7df2d978216c5bc84ca866ce9b Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 14:08:32 +0100 Subject: [PATCH 15/70] better integrate case sensitivity tests in blackbox --- tests/CaseSensitivityTest.php | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/CaseSensitivityTest.php b/tests/CaseSensitivityTest.php index 9954aa6..b413bfa 100644 --- a/tests/CaseSensitivityTest.php +++ b/tests/CaseSensitivityTest.php @@ -19,39 +19,31 @@ class CaseSensitivityTest extends TestCase { use BlackBox; - public function testContains() + public function testContainsSensitive(): BlackBox\Proof { - $this + return $this ->forAll( FName::strings(), - FName::strings(), + Set::sequence(FName::strings()), ) - ->filter(static fn($a, $b) => $a !== $b) - ->then(function($a, $b) { + ->filter(static fn($a, $b) => !\in_array($a, $b, true)) + ->prove(function($a, $b) { $this->assertTrue(CaseSensitivity::sensitive->contains( Name::of($a), ISet::of(Name::of($a)), )); - $this->assertFalse(CaseSensitivity::sensitive->contains( - Name::of($a), - ISet::of(Name::of($b)), - )); - }); - $this - ->forAll( - FName::strings(), - Set::sequence(FName::strings()), - ) - ->filter(static fn($a, $b) => !\in_array($a, $b, true)) - ->then(function($a, $b) { $this->assertFalse(CaseSensitivity::sensitive->contains( Name::of($a), ISet::of(...$b)->map(Name::of(...)), )); }); - $this + } + + public function testContainsInsensitive(): BlackBox\Proof + { + return $this ->forAll(FName::strings()) - ->then(function($a) { + ->prove(function($a) { $this->assertTrue(CaseSensitivity::insensitive->contains( Name::of($a), ISet::of($a)->map(\strtolower(...))->map(Name::of(...)), From fa23c1fde1b934e9d4df4522c663e4a9e46d6bf6 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 14:35:13 +0100 Subject: [PATCH 16/70] add representation of relative tree path to translate it to a concrete path later on --- src/Adapter/Filesystem.php | 73 ++++++++++++++++------------- src/Adapter/TreePath.php | 94 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 src/Adapter/TreePath.php diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 8c618c4..9484f82 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -52,9 +52,9 @@ public static function mount( return Attempt::error(new PathDoesntRepresentADirectory($path->toString())); } - return self::doExist($path->toString()) + return self::doExist($path) ->flatMap(static fn($exist) => match ($exist) { - false => self::mkdir($path->toString()), + false => self::mkdir($path), default => Attempt::result(SideEffect::identity), }) ->map(static fn() => new self( @@ -67,7 +67,7 @@ public static function mount( #[\Override] public function add(File|Directory $file): Attempt { - return $this->createFileAt($this->path, $file); + return $this->createFileAt(TreePath::root(), $file); } #[\Override] @@ -78,13 +78,13 @@ public function get(Name $file): Maybe return Maybe::nothing(); } - return Maybe::of($this->open($this->path, $file)); + return Maybe::of($this->open(TreePath::root(), $file)); } #[\Override] public function contains(Name $file): bool { - return self::doExist($this->path->toString().$file->toString())->match( + return self::doExist(TreePath::of($file)->asPath($this->path))->match( static fn($exists) => $exists, static fn() => false, ); @@ -93,7 +93,7 @@ public function contains(Name $file): bool #[\Override] public function remove(Name $file): Attempt { - return self::doRemove($this->path->toString().$file->toString()); + return self::doRemove(TreePath::of($file)->asPath($this->path)); } #[\Override] @@ -101,7 +101,7 @@ public function root(): Directory { return Directory::lazy( Name::of('root'), - $this->list($this->path), + $this->list(TreePath::root()), ); } @@ -110,15 +110,11 @@ public function root(): Directory * * @return Attempt */ - private function createFileAt(Path $path, File|Directory $file): Attempt + private function createFileAt(TreePath $parent, File|Directory $file): Attempt { - $name = $file->name()->toString(); - - if ($file instanceof Directory) { - $name .= '/'; - } - - $path = $path->resolve(Path::of($name)); + $path = TreePath::of($file) + ->under($parent) + ->asPath($this->path); /** @psalm-suppress PossiblyNullReference */ if ($this->loaded->offsetExists($file) && $this->loaded[$file]->equals($path)) { @@ -131,15 +127,16 @@ private function createFileAt(Path $path, File|Directory $file): Attempt if ($file instanceof Directory) { /** @var Set */ $names = Set::of(); + $parent = TreePath::of($file)->under($parent); - return self::mkdir($path->toString()) + return self::mkdir($path) ->flatMap( fn() => $file ->all() ->sink($names) ->attempt( fn($persisted, $file) => $this - ->createFileAt($path, $file) + ->createFileAt($parent, $file) ->map(static fn() => ($persisted)($file->name())), ), ) @@ -151,16 +148,17 @@ private function createFileAt(Path $path, File|Directory $file): Attempt $persisted, )) ->unsorted() + ->map(TreePath::of(...)) ->sink(SideEffect::identity) ->attempt(static fn($_, $file) => self::doRemove( - $path->toString().$file->toString(), + $file->asPath($path), )), ); } - return self::doRemove($path->toString()) + return self::doRemove($path) ->map(static fn() => $file->content()->chunks()) - ->flatMap(static fn($chunks) => self::touch($path->toString())->map( + ->flatMap(static fn($chunks) => self::touch($path)->map( static fn() => $chunks, )) ->flatMap( @@ -176,16 +174,18 @@ private function createFileAt(Path $path, File|Directory $file): Attempt /** * Open the file in the given folder */ - private function open(Path $folder, Name $file): File|Directory|null + private function open(TreePath $parent, Name $file): File|Directory|null { - $path = $folder->resolve(Path::of($file->toString())); + $path = TreePath::of($file) + ->under($parent) + ->asPath($this->path); if (\is_dir($path->toString())) { - $directoryPath = $folder->resolve(Path::of($file->toString().'/')); + $directoryPath = TreePath::directory($file)->under($parent); $files = $this->list($directoryPath); $directory = Directory::lazy($file, $files); - $this->loaded[$directory] = $directoryPath; + $this->loaded[$directory] = $directoryPath->asPath($this->path); return $directory; } @@ -216,15 +216,15 @@ private function open(Path $folder, Name $file): File|Directory|null /** * @return Sequence */ - private function list(Path $path): Sequence + private function list(TreePath $parent): Sequence { - return Sequence::lazy(function() use ($path): \Generator { - $files = new \FilesystemIterator($path->toString()); + return Sequence::lazy(function() use ($parent): \Generator { + $files = new \FilesystemIterator($parent->asPath($this->path)->toString()); /** @var \SplFileInfo $file */ foreach ($files as $file) { /** @psalm-suppress ArgumentTypeCoercion */ - yield $this->open($path, Name::of($file->getBasename())); + yield $this->open($parent, Name::of($file->getBasename())); } })->keep( Instance::of(File::class)->or( @@ -236,8 +236,10 @@ private function list(Path $path): Sequence /** * @return Attempt */ - private static function doExist(string $path): Attempt + private static function doExist(Path $path): Attempt { + $path = $path->toString(); + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } @@ -248,8 +250,10 @@ private static function doExist(string $path): Attempt /** * @return Attempt */ - private static function mkdir(string $path): Attempt + private static function mkdir(Path $path): Attempt { + $path = $path->toString(); + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } @@ -274,8 +278,10 @@ private static function mkdir(string $path): Attempt /** * @return Attempt */ - private static function touch(string $path): Attempt + private static function touch(Path $path): Attempt { + $path = $path->toString(); + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } @@ -312,8 +318,10 @@ private static function touch(string $path): Attempt * * @return Attempt */ - private static function doRemove(string $path): Attempt + private static function doRemove(Path $path): Attempt { + $path = $path->toString(); + if (Str::of($path)->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } @@ -334,6 +342,7 @@ private static function doRemove(string $path): Attempt return Sequence::lazy(static fn() => yield from $files) ->keep(Is::string()->asPredicate()) + ->map(Path::of(...)) ->sink(SideEffect::identity) ->attempt(static fn($_, $file) => self::doRemove($file)) ->map(static fn() => @\rmdir($path)) diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php new file mode 100644 index 0000000..cba8877 --- /dev/null +++ b/src/Adapter/TreePath.php @@ -0,0 +1,94 @@ + $path + */ + private function __construct( + private Sequence $path, + private bool $directory, + ) { + } + + /** + * @psalm-pure + */ + public static function of(Name|File|Directory $file): self + { + return new self( + Sequence::of(match (true) { + $file instanceof Name => $file, + default => $file->name(), + }), + $file instanceof Directory, + ); + } + + /** + * @psalm-pure + */ + public static function directory(Name $file): self + { + return new self( + Sequence::of($file), + true, + ); + } + + /** + * @psalm-pure + */ + public static function root(): self + { + return new self( + Sequence::of(), + false, + ); + } + + public function under(self $parent): self + { + return new self( + $this->path->append($parent->path), + $this->directory, + ); + } + + public function asPath(Path $root): Path + { + if ($this->path->empty()) { + return $root; + } + + $path = $this + ->path + ->reverse() + ->map(static fn($name) => $name->str()->append('/')) + ->fold(new Concat); + + if (!$this->directory) { + $path = $path->dropEnd(1); // remove trailing '/' + } + + return $root->resolve(Path::of($path->toString())); + } +} From b371e075b7850a16bb1f90dde9d3334be3faf9da Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 14:58:53 +0100 Subject: [PATCH 17/70] fix path containing a double / --- proofs/adapter/filesystem.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index 1ff7474..4388ebd 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -14,11 +14,12 @@ use Symfony\Component\Filesystem\Filesystem as FS; return static function() { + $path = \rtrim(\sys_get_temp_dir(), '/').'/innmind/filesystem/'; + yield properties( 'Filesystem properties', Adapter::properties(), - Set::call(static function() { - $path = \sys_get_temp_dir().'/innmind/filesystem/'; + Set::call(static function() use ($path) { (new FS)->remove($path); return Filesystem::mount( @@ -34,8 +35,7 @@ foreach (Adapter::alwaysApplicable() as $property) { yield property( $property, - Set::call(static function() { - $path = \sys_get_temp_dir().'/innmind/filesystem/'; + Set::call(static function() use ($path) { (new FS)->remove($path); return Filesystem::mount( @@ -51,7 +51,7 @@ yield test( 'Regression adding file in directory due to case sensitivity', - static function($assert) { + static function($assert) use ($path) { $property = new Adapter\AddRemoveAddModificationsStillAddTheFile( Directory::named('0') ->add($file = File::named('L', Content::none())) @@ -59,7 +59,6 @@ static function($assert) { File::named('l', Content::none()), ); - $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); $adapter = Filesystem::mount( Path::of($path), From ca5d0918692fbabb386a7c8d555adde3106e9455 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 15:00:22 +0100 Subject: [PATCH 18/70] compute the absolute path to remove as late as possible --- src/Adapter/Filesystem.php | 136 ++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 9484f82..bd0d1c9 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -93,7 +93,7 @@ public function contains(Name $file): bool #[\Override] public function remove(Name $file): Attempt { - return self::doRemove(TreePath::of($file)->asPath($this->path)); + return $this->doRemove(TreePath::of($file)); } #[\Override] @@ -149,14 +149,14 @@ private function createFileAt(TreePath $parent, File|Directory $file): Attempt )) ->unsorted() ->map(TreePath::of(...)) + ->map(static fn($file) => $file->under($parent)) ->sink(SideEffect::identity) - ->attempt(static fn($_, $file) => self::doRemove( - $file->asPath($path), - )), + ->attempt(fn($_, $file) => $this->doRemove($file)), ); } - return self::doRemove($path) + return $this + ->doRemove(TreePath::of($file)->under($parent)) ->map(static fn() => $file->content()->chunks()) ->flatMap(static fn($chunks) => self::touch($path)->map( static fn() => $chunks, @@ -233,6 +233,69 @@ private function list(TreePath $parent): Sequence ); } + /** + * This method only relies on the returned boolean to know if the deletion + * was successful or not. It doesn't check afterward if the content is no + * longer there as it may lead to race conditions with other processes. + * + * Such race condition could be P1 removes a file, P2 creates the same file + * and then P1 check the file doesn't exist. This scenario would report a + * failure. + * + * This package doesn't want to bleed this global state between processes. + * If you end up here, know that you should design your app in a way that + * there is as little as possible race conditions like these. + * + * @return Attempt + */ + private function doRemove(TreePath $path): Attempt + { + $absolutePath = $path->asPath($this->path)->toString(); + + if (Str::of($absolutePath)->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + + if (!\file_exists($absolutePath)) { + return Attempt::result(SideEffect::identity); + } + + if (\is_link($absolutePath)) { + return Attempt::error(new LinksAreNotSupported); + } + + if (\is_dir($absolutePath)) { + $files = new \FilesystemIterator($absolutePath); + + return Sequence::lazy(static fn() => yield from $files) + ->map(static fn($file) => $file->getBasename()) + ->keep(Is::string()->nonEmpty()->asPredicate()) + ->map(Name::of(...)) + ->map(TreePath::of(...)) + ->map(static fn($file) => $file->under($path)) + ->sink(SideEffect::identity) + ->attempt(fn($_, $file) => $this->doRemove($file)) + ->map(static fn() => @\rmdir($absolutePath)) + ->flatMap(static fn($removed) => match ($removed) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove directory '%s'", + $absolutePath, + ))), + }); + } + + $removed = @\unlink($absolutePath); + + return match ($removed) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove file '%s'", + $absolutePath, + ))), + }; + } + /** * @return Attempt */ @@ -302,67 +365,4 @@ private static function touch(Path $path): Attempt return Attempt::result(SideEffect::identity); } - - /** - * This method only relies on the returned boolean to know if the deletion - * was successful or not. It doesn't check afterward if the content is no - * longer there as it may lead to race conditions with other processes. - * - * Such race condition could be P1 removes a file, P2 creates the same file - * and then P1 check the file doesn't exist. This scenario would report a - * failure. - * - * This package doesn't want to bleed this global state between processes. - * If you end up here, know that you should design your app in a way that - * there is as little as possible race conditions like these. - * - * @return Attempt - */ - private static function doRemove(Path $path): Attempt - { - $path = $path->toString(); - - if (Str::of($path)->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - if (!\file_exists($path)) { - return Attempt::result(SideEffect::identity); - } - - if (\is_link($path)) { - return Attempt::error(new LinksAreNotSupported); - } - - if (\is_dir($path)) { - $files = new \FilesystemIterator( - $path, - \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS, - ); - - return Sequence::lazy(static fn() => yield from $files) - ->keep(Is::string()->asPredicate()) - ->map(Path::of(...)) - ->sink(SideEffect::identity) - ->attempt(static fn($_, $file) => self::doRemove($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, - ))), - }); - } - - $removed = @\unlink($path); - - return match ($removed) { - true => Attempt::result(SideEffect::identity), - false => Attempt::error(new \RuntimeException(\sprintf( - "Failed to remove file '%s'", - $path, - ))), - }; - } } From be44a52b8aac58db18b7a42b55a1c9bf9f72f5b7 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 15:09:17 +0100 Subject: [PATCH 19/70] add bridge while transitionning from old interface to a final class --- src/Adapter/Bridge.php | 62 ++++++++++++++++++++++++++++++++++++++ src/Adapter/Filesystem.php | 5 +-- 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/Adapter/Bridge.php diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php new file mode 100644 index 0000000..57be68f --- /dev/null +++ b/src/Adapter/Bridge.php @@ -0,0 +1,62 @@ +adapter->add($file); + } + + #[\Override] + public function get(Name $file): Maybe + { + return $this->adapter->get($file); + } + + #[\Override] + public function contains(Name $file): bool + { + return $this->adapter->contains($file); + } + + #[\Override] + public function remove(Name $file): Attempt + { + return $this->adapter->remove($file); + } + + #[\Override] + public function root(): Directory + { + return $this->adapter->root()->rename(Name::of('root')); + } +} diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index bd0d1c9..200a151 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -41,7 +41,7 @@ private function __construct( } /** - * @return Attempt + * @return Attempt */ public static function mount( Path $path, @@ -61,7 +61,8 @@ public static function mount( $io ?? IO::fromAmbientAuthority(), $path, $case, - )); + )) + ->map(Bridge::of(...)); } #[\Override] From 0d2ad0817601f7fbc41f86a7767beab03b2f5c10 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 15:11:14 +0100 Subject: [PATCH 20/70] add the next internal interface --- src/Adapter/Bridge.php | 4 ++-- src/Adapter/Filesystem.php | 2 +- src/Adapter/Implementation.php | 11 +++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 src/Adapter/Implementation.php diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 57be68f..d4ce2d4 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -21,11 +21,11 @@ final class Bridge implements Adapter { private function __construct( - private Adapter $adapter, + private Adapter&Implementation $adapter, ) { } - public static function of(Adapter $adapter): self + public static function of(Adapter&Implementation $adapter): self { return new self($adapter); } diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 200a151..a3442b6 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -26,7 +26,7 @@ Predicate\Instance, }; -final class Filesystem implements Adapter +final class Filesystem implements Adapter, Implementation { /** @var \WeakMap */ private \WeakMap $loaded; diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php new file mode 100644 index 0000000..4367285 --- /dev/null +++ b/src/Adapter/Implementation.php @@ -0,0 +1,11 @@ + Date: Mon, 10 Nov 2025 15:20:40 +0100 Subject: [PATCH 21/70] add Implementation::exists() --- src/Adapter/Bridge.php | 5 ++++- src/Adapter/Filesystem.php | 6 ++++++ src/Adapter/Implementation.php | 6 ++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index d4ce2d4..01dd946 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -45,7 +45,10 @@ public function get(Name $file): Maybe #[\Override] public function contains(Name $file): bool { - return $this->adapter->contains($file); + return $this->adapter->exists(TreePath::of($file))->match( + static fn($exists) => $exists, + static fn() => false, + ); } #[\Override] diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index a3442b6..dc86d4c 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -106,6 +106,12 @@ public function root(): Directory ); } + #[\Override] + public function exists(TreePath $path): Attempt + { + return self::doExist($path->asPath($this->path)); + } + /** * Create the wished file at the given absolute path * diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 4367285..a7e31dc 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -3,9 +3,15 @@ namespace Innmind\Filesystem\Adapter; +use Innmind\Immutable\Attempt; + /** * @internal */ interface Implementation { + /** + * @return Attempt + */ + public function exists(TreePath $path): Attempt; } From 64dce4c5f49a2ffff70925ac96dfc47f4bac2c32 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 15:24:49 +0100 Subject: [PATCH 22/70] remove the need for Filesystem to implement Adapter --- src/Adapter/Bridge.php | 4 ++-- src/Adapter/Filesystem.php | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 01dd946..caaa705 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -21,11 +21,11 @@ final class Bridge implements Adapter { private function __construct( - private Adapter&Implementation $adapter, + private Filesystem&Implementation $adapter, ) { } - public static function of(Adapter&Implementation $adapter): self + public static function of(Filesystem&Implementation $adapter): self { return new self($adapter); } diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index dc86d4c..8c12a83 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -26,7 +26,7 @@ Predicate\Instance, }; -final class Filesystem implements Adapter, Implementation +final class Filesystem implements Implementation { /** @var \WeakMap */ private \WeakMap $loaded; @@ -65,13 +65,17 @@ public static function mount( ->map(Bridge::of(...)); } - #[\Override] + /** + * @return Attempt + */ public function add(File|Directory $file): Attempt { return $this->createFileAt(TreePath::root(), $file); } - #[\Override] + /** + * @return Maybe + */ public function get(Name $file): Maybe { if (!$this->contains($file)) { @@ -82,7 +86,6 @@ public function get(Name $file): Maybe return Maybe::of($this->open(TreePath::root(), $file)); } - #[\Override] public function contains(Name $file): bool { return self::doExist(TreePath::of($file)->asPath($this->path))->match( @@ -91,13 +94,14 @@ public function contains(Name $file): bool ); } - #[\Override] + /** + * @return Attempt + */ public function remove(Name $file): Attempt { return $this->doRemove(TreePath::of($file)); } - #[\Override] public function root(): Directory { return Directory::lazy( From 5635e1d5c381e31a9388fe3d01b69c96065b0294 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 16:34:52 +0100 Subject: [PATCH 23/70] add method to read from a path --- src/Adapter/Bridge.php | 25 ++++++++++++++++++++++++- src/Adapter/Filesystem.php | 26 ++++++++++++++++++++++++++ src/Adapter/Implementation.php | 13 ++++++++++++- src/Adapter/TreePath.php | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index caaa705..3879aa5 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -39,7 +39,7 @@ public function add(File|Directory $file): Attempt #[\Override] public function get(Name $file): Maybe { - return $this->adapter->get($file); + return $this->read(TreePath::of($file)); } #[\Override] @@ -62,4 +62,27 @@ public function root(): Directory { return $this->adapter->root()->rename(Name::of('root')); } + + /** + * @return Maybe + */ + private function read(TreePath $path): Maybe + { + return $this + ->adapter + ->read($path) + ->maybe() + ->flatMap(fn($file) => match (true) { + $file instanceof File => Maybe::just($file), + default => $path + ->name() + ->map(fn($name) => Directory::of( + $name, + $file + ->map(static fn($file) => $file->under($path)) + ->map($this->read(...)) + ->flatMap(static fn($read) => $read->toSequence()), + )), + }); + } } diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 8c12a83..2a5049e 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -116,6 +116,32 @@ public function exists(TreePath $path): Attempt return self::doExist($path->asPath($this->path)); } + #[\Override] + public function read(TreePath $path): Attempt + { + return $this + ->exists($path) + ->flatMap(static fn($exists) => match ($exists) { + true => Attempt::result(true), + false => Attempt::error(new \RuntimeException('File not found')), + }) + ->flatMap( + fn() => $path->match( + fn($name, $parent) => Maybe::of($this->open($parent, $name)) + ->map(static fn($file) => match (true) { + $file instanceof File => $file, + default => $file->all()->map(TreePath::of(...)), + }) + ->attempt(static fn() => new \RuntimeException('File not found')), + fn() => Attempt::result( + $this + ->list(TreePath::root()) + ->map(TreePath::of(...)), + ), + ), + ); + } + /** * Create the wished file at the given absolute path * diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index a7e31dc..a5c33cd 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -3,7 +3,13 @@ namespace Innmind\Filesystem\Adapter; -use Innmind\Immutable\Attempt; +use Innmind\Filesystem\{ + File, +}; +use Innmind\Immutable\{ + Attempt, + Sequence, +}; /** * @internal @@ -14,4 +20,9 @@ interface Implementation * @return Attempt */ public function exists(TreePath $path): Attempt; + + /** + * @return Attempt> + */ + public function read(TreePath $path): Attempt; } diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php index cba8877..fe1f8fa 100644 --- a/src/Adapter/TreePath.php +++ b/src/Adapter/TreePath.php @@ -11,6 +11,7 @@ use Innmind\Url\Path; use Innmind\Immutable\{ Sequence, + Maybe, Monoid\Concat, }; @@ -91,4 +92,37 @@ public function asPath(Path $root): Path return $root->resolve(Path::of($path->toString())); } + + /** + * Name of the file the path points to. If no name it means the path + * represent the root directory. + * + * @return Maybe + */ + public function name(): Maybe + { + return $this->path->first(); + } + + /** + * @template R + * + * @param callable(Name, self, bool): R $file + * @param callable(): R $root + * + * @return R + */ + public function match( + callable $file, + callable $root, + ): mixed { + return $this->path->match( + fn($name, $parent) => $file( + $name, + new self($parent, true), // since there's a child the parent is necessarily a directory + $this->directory, + ), + $root, + ); + } } From 414cec56ab3e38f63a33eb013cd48a16261fc010 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 16:38:46 +0100 Subject: [PATCH 24/70] add method to list tree paths --- src/Adapter/Bridge.php | 9 ++++++++- src/Adapter/Filesystem.php | 22 ++++++++++++++++++---- src/Adapter/Implementation.php | 5 +++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 3879aa5..a2523f7 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -60,7 +60,14 @@ public function remove(Name $file): Attempt #[\Override] public function root(): Directory { - return $this->adapter->root()->rename(Name::of('root')); + return Directory::named( + 'root', + $this + ->adapter + ->list(TreePath::root()) + ->map($this->read(...)) + ->flatMap(static fn($read) => $read->toSequence()), + ); } /** diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 2a5049e..629dc87 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -106,7 +106,7 @@ public function root(): Directory { return Directory::lazy( Name::of('root'), - $this->list(TreePath::root()), + $this->doList(TreePath::root()), ); } @@ -135,13 +135,27 @@ public function read(TreePath $path): Attempt ->attempt(static fn() => new \RuntimeException('File not found')), fn() => Attempt::result( $this - ->list(TreePath::root()) + ->doList(TreePath::root()) ->map(TreePath::of(...)), ), ), ); } + #[\Override] + public function list(TreePath $parent): Sequence + { + return Sequence::lazy(function() use ($parent): \Generator { + $files = new \FilesystemIterator($parent->asPath($this->path)->toString()); + + /** @var \SplFileInfo $file */ + foreach ($files as $file) { + /** @psalm-suppress ArgumentTypeCoercion */ + yield TreePath::of(Name::of($file->getBasename())); + } + }); + } + /** * Create the wished file at the given absolute path * @@ -219,7 +233,7 @@ private function open(TreePath $parent, Name $file): File|Directory|null if (\is_dir($path->toString())) { $directoryPath = TreePath::directory($file)->under($parent); - $files = $this->list($directoryPath); + $files = $this->doList($directoryPath); $directory = Directory::lazy($file, $files); $this->loaded[$directory] = $directoryPath->asPath($this->path); @@ -253,7 +267,7 @@ private function open(TreePath $parent, Name $file): File|Directory|null /** * @return Sequence */ - private function list(TreePath $parent): Sequence + private function doList(TreePath $parent): Sequence { return Sequence::lazy(function() use ($parent): \Generator { $files = new \FilesystemIterator($parent->asPath($this->path)->toString()); diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index a5c33cd..2176e6b 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -25,4 +25,9 @@ public function exists(TreePath $path): Attempt; * @return Attempt> */ public function read(TreePath $path): Attempt; + + /** + * @return Sequence + */ + public function list(TreePath $parent): Sequence; } From e804a25e18d96208b072726f794a302add048fa0 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 16:40:44 +0100 Subject: [PATCH 25/70] remove dead code --- src/Adapter/Filesystem.php | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 629dc87..0125cee 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -73,27 +73,6 @@ public function add(File|Directory $file): Attempt return $this->createFileAt(TreePath::root(), $file); } - /** - * @return Maybe - */ - public function get(Name $file): Maybe - { - if (!$this->contains($file)) { - /** @var Maybe */ - return Maybe::nothing(); - } - - return Maybe::of($this->open(TreePath::root(), $file)); - } - - public function contains(Name $file): bool - { - return self::doExist(TreePath::of($file)->asPath($this->path))->match( - static fn($exists) => $exists, - static fn() => false, - ); - } - /** * @return Attempt */ @@ -102,14 +81,6 @@ public function remove(Name $file): Attempt return $this->doRemove(TreePath::of($file)); } - public function root(): Directory - { - return Directory::lazy( - Name::of('root'), - $this->doList(TreePath::root()), - ); - } - #[\Override] public function exists(TreePath $path): Attempt { From 34c574406a3e8f0a67287fdeeed6ec977a3d635b Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 17:14:03 +0100 Subject: [PATCH 26/70] remove duplicated logic to recursively traverse directories --- src/Adapter/Bridge.php | 8 +++++--- src/Adapter/Filesystem.php | 8 ++------ src/Adapter/Implementation.php | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index a2523f7..95315ef 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -81,12 +81,14 @@ private function read(TreePath $path): Maybe ->maybe() ->flatMap(fn($file) => match (true) { $file instanceof File => Maybe::just($file), - default => $path + default => $file ->name() ->map(fn($name) => Directory::of( $name, - $file - ->map(static fn($file) => $file->under($path)) + $this + ->adapter + ->list($path) + ->map(static fn($found) => $found->under($path)) ->map($this->read(...)) ->flatMap(static fn($read) => $read->toSequence()), )), diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 0125cee..98a5193 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -101,14 +101,10 @@ public function read(TreePath $path): Attempt fn($name, $parent) => Maybe::of($this->open($parent, $name)) ->map(static fn($file) => match (true) { $file instanceof File => $file, - default => $file->all()->map(TreePath::of(...)), + default => TreePath::of($file), }) ->attempt(static fn() => new \RuntimeException('File not found')), - fn() => Attempt::result( - $this - ->doList(TreePath::root()) - ->map(TreePath::of(...)), - ), + static fn() => Attempt::error(new \RuntimeException('Root folder is not accessible')), ), ); } diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 2176e6b..f693bfe 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -22,7 +22,7 @@ interface Implementation public function exists(TreePath $path): Attempt; /** - * @return Attempt> + * @return Attempt */ public function read(TreePath $path): Attempt; From 44455da432815f4686cc61b4a72c68a17ea486f7 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 17:21:24 +0100 Subject: [PATCH 27/70] make sure the implementations only return relative names and not the full tree path --- src/Adapter/Bridge.php | 24 +++++++++++------------- src/Adapter/Filesystem.php | 2 +- src/Adapter/Implementation.php | 5 +++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 95315ef..ae93cc3 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -79,19 +79,17 @@ private function read(TreePath $path): Maybe ->adapter ->read($path) ->maybe() - ->flatMap(fn($file) => match (true) { - $file instanceof File => Maybe::just($file), - default => $file - ->name() - ->map(fn($name) => Directory::of( - $name, - $this - ->adapter - ->list($path) - ->map(static fn($found) => $found->under($path)) - ->map($this->read(...)) - ->flatMap(static fn($read) => $read->toSequence()), - )), + ->map(fn($file) => match (true) { + $file instanceof File => $file, + default => Directory::of( + $file, + $this + ->adapter + ->list($path) + ->map(static fn($found) => $found->under($path)) + ->map($this->read(...)) + ->flatMap(static fn($read) => $read->toSequence()), + ), }); } } diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 98a5193..acd047e 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -101,7 +101,7 @@ public function read(TreePath $path): Attempt fn($name, $parent) => Maybe::of($this->open($parent, $name)) ->map(static fn($file) => match (true) { $file instanceof File => $file, - default => TreePath::of($file), + default => $file->name(), }) ->attempt(static fn() => new \RuntimeException('File not found')), static fn() => Attempt::error(new \RuntimeException('Root folder is not accessible')), diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index f693bfe..4c5c8c3 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -5,6 +5,7 @@ use Innmind\Filesystem\{ File, + Name, }; use Innmind\Immutable\{ Attempt, @@ -22,12 +23,12 @@ interface Implementation public function exists(TreePath $path): Attempt; /** - * @return Attempt + * @return Attempt */ public function read(TreePath $path): Attempt; /** - * @return Sequence + * @return Sequence The paths must be relative */ public function list(TreePath $parent): Sequence; } From 1a7fa3b582f6e0995ec5a6708940adc128da2990 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 17:28:41 +0100 Subject: [PATCH 28/70] remove dead code --- src/Adapter/TreePath.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php index fe1f8fa..187c2b9 100644 --- a/src/Adapter/TreePath.php +++ b/src/Adapter/TreePath.php @@ -11,7 +11,6 @@ use Innmind\Url\Path; use Innmind\Immutable\{ Sequence, - Maybe, Monoid\Concat, }; @@ -93,17 +92,6 @@ public function asPath(Path $root): Path return $root->resolve(Path::of($path->toString())); } - /** - * Name of the file the path points to. If no name it means the path - * represent the root directory. - * - * @return Maybe - */ - public function name(): Maybe - { - return $this->path->first(); - } - /** * @template R * From 9302ecae1ee7d90ffeb41daaa6900aa8789b8a42 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 17:44:22 +0100 Subject: [PATCH 29/70] remove dead code --- src/Adapter/Filesystem.php | 51 +++++++++++--------------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index acd047e..d71e4a1 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -19,11 +19,9 @@ use Innmind\Immutable\{ Sequence, Str, - Maybe, Attempt, SideEffect, Set, - Predicate\Instance, }; final class Filesystem implements Implementation @@ -98,12 +96,7 @@ public function read(TreePath $path): Attempt }) ->flatMap( fn() => $path->match( - fn($name, $parent) => Maybe::of($this->open($parent, $name)) - ->map(static fn($file) => match (true) { - $file instanceof File => $file, - default => $file->name(), - }) - ->attempt(static fn() => new \RuntimeException('File not found')), + fn($name, $parent) => $this->open($parent, $name), static fn() => Attempt::error(new \RuntimeException('Root folder is not accessible')), ), ); @@ -191,25 +184,29 @@ private function createFileAt(TreePath $parent, File|Directory $file): Attempt /** * Open the file in the given folder + * + * @return Attempt A Name represent a directory */ - private function open(TreePath $parent, Name $file): File|Directory|null + private function open(TreePath $parent, Name $file): Attempt { $path = TreePath::of($file) ->under($parent) ->asPath($this->path); - if (\is_dir($path->toString())) { - $directoryPath = TreePath::directory($file)->under($parent); - $files = $this->doList($directoryPath); + if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } - $directory = Directory::lazy($file, $files); - $this->loaded[$directory] = $directoryPath->asPath($this->path); + if (!\file_exists($path->toString())) { + return Attempt::error(new \RuntimeException('File not found')); + } - return $directory; + if (\is_dir($path->toString())) { + return Attempt::result($file); } if (\is_link($path->toString())) { - return null; + return Attempt::error(new LinksAreNotSupported); } $file = File::of( @@ -228,27 +225,7 @@ private function open(TreePath $parent, Name $file): File|Directory|null ); $this->loaded[$file] = $path; - return $file; - } - - /** - * @return Sequence - */ - private function doList(TreePath $parent): Sequence - { - return Sequence::lazy(function() use ($parent): \Generator { - $files = new \FilesystemIterator($parent->asPath($this->path)->toString()); - - /** @var \SplFileInfo $file */ - foreach ($files as $file) { - /** @psalm-suppress ArgumentTypeCoercion */ - yield $this->open($parent, Name::of($file->getBasename())); - } - })->keep( - Instance::of(File::class)->or( - Instance::of(Directory::class), - ), - ); + return Attempt::result($file); } /** From 8683979a380a0c46e471ba516f8d2504ef089a8b Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 10 Nov 2025 17:50:33 +0100 Subject: [PATCH 30/70] add Implementation::remove() --- src/Adapter/Bridge.php | 2 +- src/Adapter/Filesystem.php | 137 +++++++++++++++------------------ src/Adapter/Implementation.php | 6 ++ 3 files changed, 71 insertions(+), 74 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index ae93cc3..3869fd5 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -54,7 +54,7 @@ public function contains(Name $file): bool #[\Override] public function remove(Name $file): Attempt { - return $this->adapter->remove($file); + return $this->adapter->remove(TreePath::of($file)); } #[\Override] diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index d71e4a1..550e72d 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -71,14 +71,6 @@ public function add(File|Directory $file): Attempt return $this->createFileAt(TreePath::root(), $file); } - /** - * @return Attempt - */ - public function remove(Name $file): Attempt - { - return $this->doRemove(TreePath::of($file)); - } - #[\Override] public function exists(TreePath $path): Attempt { @@ -116,6 +108,68 @@ public function list(TreePath $parent): Sequence }); } + /** + * This method only relies on the returned boolean to know if the deletion + * was successful or not. It doesn't check afterward if the content is no + * longer there as it may lead to race conditions with other processes. + * + * Such race condition could be P1 removes a file, P2 creates the same file + * and then P1 check the file doesn't exist. This scenario would report a + * failure. + * + * This package doesn't want to bleed this global state between processes. + * If you end up here, know that you should design your app in a way that + * there is as little as possible race conditions like these. + */ + #[\Override] + public function remove(TreePath $path): Attempt + { + $absolutePath = $path->asPath($this->path)->toString(); + + if (Str::of($absolutePath)->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + + if (!\file_exists($absolutePath)) { + return Attempt::result(SideEffect::identity); + } + + if (\is_link($absolutePath)) { + return Attempt::error(new LinksAreNotSupported); + } + + if (\is_dir($absolutePath)) { + $files = new \FilesystemIterator($absolutePath); + + return Sequence::lazy(static fn() => yield from $files) + ->map(static fn($file) => $file->getBasename()) + ->keep(Is::string()->nonEmpty()->asPredicate()) + ->map(Name::of(...)) + ->map(TreePath::of(...)) + ->map(static fn($file) => $file->under($path)) + ->sink(SideEffect::identity) + ->attempt(fn($_, $file) => $this->remove($file)) + ->map(static fn() => @\rmdir($absolutePath)) + ->flatMap(static fn($removed) => match ($removed) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove directory '%s'", + $absolutePath, + ))), + }); + } + + $removed = @\unlink($absolutePath); + + return match ($removed) { + true => Attempt::result(SideEffect::identity), + false => Attempt::error(new \RuntimeException(\sprintf( + "Failed to remove file '%s'", + $absolutePath, + ))), + }; + } + /** * Create the wished file at the given absolute path * @@ -162,12 +216,12 @@ private function createFileAt(TreePath $parent, File|Directory $file): Attempt ->map(TreePath::of(...)) ->map(static fn($file) => $file->under($parent)) ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->doRemove($file)), + ->attempt(fn($_, $file) => $this->remove($file)), ); } return $this - ->doRemove(TreePath::of($file)->under($parent)) + ->remove(TreePath::of($file)->under($parent)) ->map(static fn() => $file->content()->chunks()) ->flatMap(static fn($chunks) => self::touch($path)->map( static fn() => $chunks, @@ -228,69 +282,6 @@ private function open(TreePath $parent, Name $file): Attempt return Attempt::result($file); } - /** - * This method only relies on the returned boolean to know if the deletion - * was successful or not. It doesn't check afterward if the content is no - * longer there as it may lead to race conditions with other processes. - * - * Such race condition could be P1 removes a file, P2 creates the same file - * and then P1 check the file doesn't exist. This scenario would report a - * failure. - * - * This package doesn't want to bleed this global state between processes. - * If you end up here, know that you should design your app in a way that - * there is as little as possible race conditions like these. - * - * @return Attempt - */ - private function doRemove(TreePath $path): Attempt - { - $absolutePath = $path->asPath($this->path)->toString(); - - if (Str::of($absolutePath)->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - if (!\file_exists($absolutePath)) { - return Attempt::result(SideEffect::identity); - } - - if (\is_link($absolutePath)) { - return Attempt::error(new LinksAreNotSupported); - } - - if (\is_dir($absolutePath)) { - $files = new \FilesystemIterator($absolutePath); - - return Sequence::lazy(static fn() => yield from $files) - ->map(static fn($file) => $file->getBasename()) - ->keep(Is::string()->nonEmpty()->asPredicate()) - ->map(Name::of(...)) - ->map(TreePath::of(...)) - ->map(static fn($file) => $file->under($path)) - ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->doRemove($file)) - ->map(static fn() => @\rmdir($absolutePath)) - ->flatMap(static fn($removed) => match ($removed) { - true => Attempt::result(SideEffect::identity), - false => Attempt::error(new \RuntimeException(\sprintf( - "Failed to remove directory '%s'", - $absolutePath, - ))), - }); - } - - $removed = @\unlink($absolutePath); - - return match ($removed) { - true => Attempt::result(SideEffect::identity), - false => Attempt::error(new \RuntimeException(\sprintf( - "Failed to remove file '%s'", - $absolutePath, - ))), - }; - } - /** * @return Attempt */ diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 4c5c8c3..6d7274d 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -10,6 +10,7 @@ use Innmind\Immutable\{ Attempt, Sequence, + SideEffect, }; /** @@ -31,4 +32,9 @@ public function read(TreePath $path): Attempt; * @return Sequence The paths must be relative */ public function list(TreePath $parent): Sequence; + + /** + * @return Attempt + */ + public function remove(TreePath $path): Attempt; } From 87446885affafecee47fcb8190fa8b974a3ab0b2 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 13:06:16 +0100 Subject: [PATCH 31/70] simplify reading from the filesystem --- src/Adapter/Filesystem.php | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 550e72d..12ef1c7 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -80,18 +80,10 @@ public function exists(TreePath $path): Attempt #[\Override] public function read(TreePath $path): Attempt { - return $this - ->exists($path) - ->flatMap(static fn($exists) => match ($exists) { - true => Attempt::result(true), - false => Attempt::error(new \RuntimeException('File not found')), - }) - ->flatMap( - fn() => $path->match( - fn($name, $parent) => $this->open($parent, $name), - static fn() => Attempt::error(new \RuntimeException('Root folder is not accessible')), - ), - ); + return $path->match( + fn($name, $parent) => $this->open($parent, $name), + static fn() => Attempt::error(new \RuntimeException('Root folder is not accessible')), + ); } #[\Override] From 1c2749f1b148ef9cd23efbf6b4b5b27b144f93de Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 13:58:38 +0100 Subject: [PATCH 32/70] let the bridge handle the logic to create/delete files/directories --- src/Adapter/Bridge.php | 55 +++++++++++++++++++++++++++++++++- src/Adapter/Filesystem.php | 41 ++++++++++++++++++++++++- src/Adapter/Implementation.php | 11 +++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 3869fd5..36e5067 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -12,6 +12,8 @@ use Innmind\Immutable\{ Attempt, Maybe, + Set, + SideEffect, }; /** @@ -33,7 +35,7 @@ public static function of(Filesystem&Implementation $adapter): self #[\Override] public function add(File|Directory $file): Attempt { - return $this->adapter->add($file); + return $this->write(TreePath::root(), $file); } #[\Override] @@ -92,4 +94,55 @@ private function read(TreePath $path): Maybe ), }); } + + /** + * @return Attempt + */ + private function write(TreePath $path, File|Directory $file): Attempt + { + if ($file instanceof Directory) { + /** @var Set */ + $names = Set::of(); + $parent = TreePath::of($file)->under($path); + + return $this + ->adapter + ->createDirectory($parent) + ->flatMap( + fn() => $file + ->all() + ->sink($names) + ->attempt( + fn($persisted, $file) => $this + ->write($parent, $file) + ->map(static fn() => ($persisted)($file->name())), + ), + ) + ->flatMap( + fn($persisted) => $file + ->removed() + // todo handle case sensitivity somehow + ->exclude( + static fn($file) => $persisted + ->map(static fn($name) => $name->toString()) + ->contains($file->toString()), + ) + ->unsorted() + ->map(TreePath::of(...)) + ->map(static fn($file) => $file->under($parent)) + ->sink(SideEffect::identity) + ->attempt(fn($_, $path) => $this->adapter->remove($path)), + ); + } + + $path = TreePath::of($file)->under($path); + + return $this + ->adapter + ->remove($path) + ->flatMap(fn() => $this->adapter->write( + $path, + $file->content(), + )); + } } diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 12ef1c7..8de7dbb 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -6,6 +6,7 @@ use Innmind\Filesystem\{ Adapter, File, + File\Content, Name, Directory, CaseSensitivity, @@ -162,6 +163,44 @@ public function remove(TreePath $path): Attempt }; } + #[\Override] + public function createDirectory(TreePath $path): Attempt + { + $absolutePath = $path->asPath($this->path); + + return $this + ->exists($path) + ->flatMap(function($exists) use ($path, $absolutePath) { + if ($exists && \is_dir($absolutePath->toString())) { + return Attempt::result(SideEffect::identity); + } + + if ($exists) { + return $this + ->remove($path) + ->flatMap(static fn() => self::mkdir($absolutePath)); + } + + return self::mkdir($absolutePath); + }); + } + + #[\Override] + public function write(TreePath $path, Content $content): Attempt + { + $absolutePath = $path->asPath($this->path); + $chunks = $content->chunks(); + + return self::touch($absolutePath)->flatMap( + fn() => $this + ->io + ->files() + ->write($absolutePath) + ->watch() + ->sink($chunks), + ); + } + /** * Create the wished file at the given absolute path * @@ -257,7 +296,7 @@ private function open(TreePath $parent, Name $file): Attempt $file = File::of( $file, - File\Content::atPath( + Content::atPath( $this->io, $path, ), diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 6d7274d..6bb476c 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -5,6 +5,7 @@ use Innmind\Filesystem\{ File, + File\Content, Name, }; use Innmind\Immutable\{ @@ -37,4 +38,14 @@ public function list(TreePath $parent): Sequence; * @return Attempt */ public function remove(TreePath $path): Attempt; + + /** + * @return Attempt + */ + public function createDirectory(TreePath $path): Attempt; + + /** + * @return Attempt + */ + public function write(TreePath $path, Content $content): Attempt; } From 611a7e0de5eb48f37f46bf128749a8b6acb36de2 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 14:02:30 +0100 Subject: [PATCH 33/70] handle case sensitivity in the bridge --- src/Adapter/Bridge.php | 20 +++++++++++--------- src/Adapter/Filesystem.php | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 36e5067..3760930 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -8,6 +8,7 @@ File, Directory, Name, + CaseSensitivity, }; use Innmind\Immutable\{ Attempt, @@ -24,12 +25,15 @@ final class Bridge implements Adapter { private function __construct( private Filesystem&Implementation $adapter, + private CaseSensitivity $case, ) { } - public static function of(Filesystem&Implementation $adapter): self - { - return new self($adapter); + public static function of( + Filesystem&Implementation $adapter, + CaseSensitivity $case, + ): self { + return new self($adapter, $case); } #[\Override] @@ -121,12 +125,10 @@ private function write(TreePath $path, File|Directory $file): Attempt ->flatMap( fn($persisted) => $file ->removed() - // todo handle case sensitivity somehow - ->exclude( - static fn($file) => $persisted - ->map(static fn($name) => $name->toString()) - ->contains($file->toString()), - ) + ->exclude(fn($file): bool => $this->case->contains( + $file, + $persisted, + )) ->unsorted() ->map(TreePath::of(...)) ->map(static fn($file) => $file->under($parent)) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 8de7dbb..c3034da 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -61,7 +61,7 @@ public static function mount( $path, $case, )) - ->map(Bridge::of(...)); + ->map(static fn($self) => Bridge::of($self, $case)); } /** From 447eadef7a45f021e8792f18479b5d127e5e0fc4 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 14:13:20 +0100 Subject: [PATCH 34/70] prevent writing unchanged file/directory --- src/Adapter/Bridge.php | 32 ++++++++++++++++++++++++++------ src/Adapter/TreePath.php | 7 +++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 3760930..e5771a2 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -23,10 +23,15 @@ */ final class Bridge implements Adapter { + /** @var \WeakMap */ + private \WeakMap $loaded; + private function __construct( private Filesystem&Implementation $adapter, private CaseSensitivity $case, ) { + /** @var \WeakMap */ + $this->loaded = new \WeakMap; } public static function of( @@ -96,6 +101,11 @@ private function read(TreePath $path): Maybe ->map($this->read(...)) ->flatMap(static fn($read) => $read->toSequence()), ), + }) + ->map(function($file) use ($path) { + $this->loaded[$file] = TreePath::of($file)->under($path); + + return $file; }); } @@ -104,21 +114,33 @@ private function read(TreePath $path): Maybe */ private function write(TreePath $path, File|Directory $file): Attempt { + $path = TreePath::of($file)->under($path); + + /** @psalm-suppress PossiblyNullReference */ + if ( + $this->loaded->offsetExists($file) && + $this->loaded[$file]->equals($path) + ) { + // no need to persist untouched file where it was loaded from + return Attempt::result(SideEffect::identity); + } + + $this->loaded[$file] = $path; + if ($file instanceof Directory) { /** @var Set */ $names = Set::of(); - $parent = TreePath::of($file)->under($path); return $this ->adapter - ->createDirectory($parent) + ->createDirectory($path) ->flatMap( fn() => $file ->all() ->sink($names) ->attempt( fn($persisted, $file) => $this - ->write($parent, $file) + ->write($path, $file) ->map(static fn() => ($persisted)($file->name())), ), ) @@ -131,14 +153,12 @@ private function write(TreePath $path, File|Directory $file): Attempt )) ->unsorted() ->map(TreePath::of(...)) - ->map(static fn($file) => $file->under($parent)) + ->map(static fn($file) => $file->under($path)) ->sink(SideEffect::identity) ->attempt(fn($_, $path) => $this->adapter->remove($path)), ); } - $path = TreePath::of($file)->under($path); - return $this ->adapter ->remove($path) diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php index 187c2b9..fce16cb 100644 --- a/src/Adapter/TreePath.php +++ b/src/Adapter/TreePath.php @@ -73,6 +73,13 @@ public function under(self $parent): self ); } + public function equals(self $other): bool + { + $root = Path::of('/'); + + return $this->asPath($root)->equals($other->asPath($root)); + } + public function asPath(Path $root): Path { if ($this->path->empty()) { From f68182075736e548d40fff4393f9483024088b0f Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 14:17:56 +0100 Subject: [PATCH 35/70] remove dead code --- src/Adapter/Filesystem.php | 83 -------------------------------------- 1 file changed, 83 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index c3034da..f4b20ca 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -22,21 +22,14 @@ Str, Attempt, SideEffect, - Set, }; final class Filesystem implements Implementation { - /** @var \WeakMap */ - private \WeakMap $loaded; - private function __construct( private IO $io, private Path $path, - private CaseSensitivity $case, ) { - /** @var \WeakMap */ - $this->loaded = new \WeakMap; } /** @@ -59,19 +52,10 @@ public static function mount( ->map(static fn() => new self( $io ?? IO::fromAmbientAuthority(), $path, - $case, )) ->map(static fn($self) => Bridge::of($self, $case)); } - /** - * @return Attempt - */ - public function add(File|Directory $file): Attempt - { - return $this->createFileAt(TreePath::root(), $file); - } - #[\Override] public function exists(TreePath $path): Attempt { @@ -201,72 +185,6 @@ public function write(TreePath $path, Content $content): Attempt ); } - /** - * Create the wished file at the given absolute path - * - * @return Attempt - */ - private function createFileAt(TreePath $parent, File|Directory $file): Attempt - { - $path = TreePath::of($file) - ->under($parent) - ->asPath($this->path); - - /** @psalm-suppress PossiblyNullReference */ - if ($this->loaded->offsetExists($file) && $this->loaded[$file]->equals($path)) { - // no need to persist untouched file where it was loaded from - return Attempt::result(SideEffect::identity()); - } - - $this->loaded[$file] = $path; - - if ($file instanceof Directory) { - /** @var Set */ - $names = Set::of(); - $parent = TreePath::of($file)->under($parent); - - return self::mkdir($path) - ->flatMap( - fn() => $file - ->all() - ->sink($names) - ->attempt( - fn($persisted, $file) => $this - ->createFileAt($parent, $file) - ->map(static fn() => ($persisted)($file->name())), - ), - ) - ->flatMap( - fn($persisted) => $file - ->removed() - ->exclude(fn($file): bool => $this->case->contains( - $file, - $persisted, - )) - ->unsorted() - ->map(TreePath::of(...)) - ->map(static fn($file) => $file->under($parent)) - ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->remove($file)), - ); - } - - return $this - ->remove(TreePath::of($file)->under($parent)) - ->map(static fn() => $file->content()->chunks()) - ->flatMap(static fn($chunks) => self::touch($path)->map( - static fn() => $chunks, - )) - ->flatMap( - fn($chunks) => $this - ->io - ->files() - ->write($path) - ->watch() - ->sink($chunks), - ); - } - /** * Open the file in the given folder * @@ -308,7 +226,6 @@ private function open(TreePath $parent, Name $file): Attempt static fn() => MediaType::null(), ), ); - $this->loaded[$file] = $path; return Attempt::result($file); } From fadb579f35edb6785b4c8cb90406f91c45111bcf Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 14:36:00 +0100 Subject: [PATCH 36/70] change implementation type to prevent reaching an impossible case --- src/Adapter/Bridge.php | 26 ++++++---- src/Adapter/Filesystem.php | 87 +++++++++++++++------------------- src/Adapter/Implementation.php | 6 +-- 3 files changed, 56 insertions(+), 63 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index e5771a2..00327c8 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -50,7 +50,7 @@ public function add(File|Directory $file): Attempt #[\Override] public function get(Name $file): Maybe { - return $this->read(TreePath::of($file)); + return $this->read(TreePath::root(), $file); } #[\Override] @@ -71,12 +71,14 @@ public function remove(Name $file): Attempt #[\Override] public function root(): Directory { + $root = TreePath::root(); + return Directory::named( 'root', $this ->adapter - ->list(TreePath::root()) - ->map($this->read(...)) + ->list($root) + ->map(fn($name) => $this->read($root, $name)) ->flatMap(static fn($read) => $read->toSequence()), ); } @@ -84,11 +86,13 @@ public function root(): Directory /** * @return Maybe */ - private function read(TreePath $path): Maybe + private function read(TreePath $path, Name $name): Maybe { + $fullPath = TreePath::of($name)->under($path); + return $this ->adapter - ->read($path) + ->read($path, $name) ->maybe() ->map(fn($file) => match (true) { $file instanceof File => $file, @@ -96,14 +100,16 @@ private function read(TreePath $path): Maybe $file, $this ->adapter - ->list($path) - ->map(static fn($found) => $found->under($path)) - ->map($this->read(...)) + ->list($fullPath) + ->map(fn($file) => $this->read( + $fullPath, + $file, + )) ->flatMap(static fn($read) => $read->toSequence()), ), }) - ->map(function($file) use ($path) { - $this->loaded[$file] = TreePath::of($file)->under($path); + ->map(function($file) use ($fullPath) { + $this->loaded[$file] = $fullPath; return $file; }); diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index f4b20ca..3adab5f 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -63,12 +63,44 @@ public function exists(TreePath $path): Attempt } #[\Override] - public function read(TreePath $path): Attempt + public function read(TreePath $parent, Name $name): Attempt { - return $path->match( - fn($name, $parent) => $this->open($parent, $name), - static fn() => Attempt::error(new \RuntimeException('Root folder is not accessible')), + $path = TreePath::of($name) + ->under($parent) + ->asPath($this->path); + + if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { + return Attempt::error(new \RuntimeException('Path too long')); + } + + if (!\file_exists($path->toString())) { + return Attempt::error(new \RuntimeException('File not found')); + } + + if (\is_dir($path->toString())) { + return Attempt::result($name); + } + + if (\is_link($path->toString())) { + return Attempt::error(new LinksAreNotSupported); + } + + $file = File::of( + $name, + Content::atPath( + $this->io, + $path, + ), + MediaType::maybe(match ($mediaType = @\mime_content_type($path->toString())) { + false => '', + default => $mediaType, + })->match( + static fn($mediaType) => $mediaType, + static fn() => MediaType::null(), + ), ); + + return Attempt::result($file); } #[\Override] @@ -80,7 +112,7 @@ public function list(TreePath $parent): Sequence /** @var \SplFileInfo $file */ foreach ($files as $file) { /** @psalm-suppress ArgumentTypeCoercion */ - yield TreePath::of(Name::of($file->getBasename())); + yield Name::of($file->getBasename()); } }); } @@ -185,51 +217,6 @@ public function write(TreePath $path, Content $content): Attempt ); } - /** - * Open the file in the given folder - * - * @return Attempt A Name represent a directory - */ - private function open(TreePath $parent, Name $file): Attempt - { - $path = TreePath::of($file) - ->under($parent) - ->asPath($this->path); - - if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - if (!\file_exists($path->toString())) { - return Attempt::error(new \RuntimeException('File not found')); - } - - if (\is_dir($path->toString())) { - return Attempt::result($file); - } - - if (\is_link($path->toString())) { - return Attempt::error(new LinksAreNotSupported); - } - - $file = File::of( - $file, - Content::atPath( - $this->io, - $path, - ), - MediaType::maybe(match ($mediaType = @\mime_content_type($path->toString())) { - false => '', - default => $mediaType, - })->match( - static fn($mediaType) => $mediaType, - static fn() => MediaType::null(), - ), - ); - - return Attempt::result($file); - } - /** * @return Attempt */ diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 6bb476c..4f7a527 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -25,12 +25,12 @@ interface Implementation public function exists(TreePath $path): Attempt; /** - * @return Attempt + * @return Attempt A Name represent a directory */ - public function read(TreePath $path): Attempt; + public function read(TreePath $parent, Name $name): Attempt; /** - * @return Sequence The paths must be relative + * @return Sequence Todo encapsulate if the name represent a file/directory/unknown */ public function list(TreePath $parent): Sequence; From 42236ce09e50c3d34b3c60fa4d90b99672c449ba Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 14:39:42 +0100 Subject: [PATCH 37/70] use null when media type is not parseable to let use the default one --- src/Adapter/Filesystem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 3adab5f..e351c41 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -96,7 +96,7 @@ public function read(TreePath $parent, Name $name): Attempt default => $mediaType, })->match( static fn($mediaType) => $mediaType, - static fn() => MediaType::null(), + static fn() => null, ), ); From 192cc67a043019c73279742d002f7e88cd90b38d Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 15:10:04 +0100 Subject: [PATCH 38/70] encapsulate the knownledge if the path to read is a file/directory or unknown yet --- src/Adapter/Bridge.php | 16 +++++++++++----- src/Adapter/Filesystem.php | 21 +++++++++++++++++---- src/Adapter/Implementation.php | 10 ++++++---- src/Adapter/Name/Directory.php | 31 +++++++++++++++++++++++++++++++ src/Adapter/Name/File.php | 31 +++++++++++++++++++++++++++++++ src/Adapter/Name/Unknown.php | 31 +++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 src/Adapter/Name/Directory.php create mode 100644 src/Adapter/Name/File.php create mode 100644 src/Adapter/Name/Unknown.php diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 00327c8..2fdeb9c 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -5,6 +5,7 @@ use Innmind\Filesystem\{ Adapter, + Adapter\Name as Name_, File, Directory, Name, @@ -50,7 +51,10 @@ public function add(File|Directory $file): Attempt #[\Override] public function get(Name $file): Maybe { - return $this->read(TreePath::root(), $file); + return $this->read( + TreePath::root(), + Name_\Unknown::of($file), + ); } #[\Override] @@ -86,9 +90,11 @@ public function root(): Directory /** * @return Maybe */ - private function read(TreePath $path, Name $name): Maybe - { - $fullPath = TreePath::of($name)->under($path); + private function read( + TreePath $path, + Name_\File|Name_\Directory|Name_\Unknown $name, + ): Maybe { + $fullPath = TreePath::of($name->unwrap())->under($path); return $this ->adapter @@ -97,7 +103,7 @@ private function read(TreePath $path, Name $name): Maybe ->map(fn($file) => match (true) { $file instanceof File => $file, default => Directory::of( - $file, + $file->unwrap(), $this ->adapter ->list($fullPath) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index e351c41..3472eb9 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -5,6 +5,7 @@ use Innmind\Filesystem\{ Adapter, + Adapter\Name as Name_, File, File\Content, Name, @@ -63,8 +64,15 @@ public function exists(TreePath $path): Attempt } #[\Override] - public function read(TreePath $parent, Name $name): Attempt - { + public function read( + TreePath $parent, + Name_\File|Name_\Directory|Name_\Unknown $name, + ): Attempt { + if ($name instanceof Name_\Directory) { + return Attempt::result($name); + } + + $name = $name->unwrap(); $path = TreePath::of($name) ->under($parent) ->asPath($this->path); @@ -78,7 +86,7 @@ public function read(TreePath $parent, Name $name): Attempt } if (\is_dir($path->toString())) { - return Attempt::result($name); + return Attempt::result(Name_\Directory::of($name)); } if (\is_link($path->toString())) { @@ -112,7 +120,12 @@ public function list(TreePath $parent): Sequence /** @var \SplFileInfo $file */ foreach ($files as $file) { /** @psalm-suppress ArgumentTypeCoercion */ - yield Name::of($file->getBasename()); + $name = Name::of($file->getBasename()); + + yield match ($file->isDir()) { + true => Name_\Directory::of($name), + false => Name_\File::of($name), + }; } }); } diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 4f7a527..9a386f2 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -6,7 +6,6 @@ use Innmind\Filesystem\{ File, File\Content, - Name, }; use Innmind\Immutable\{ Attempt, @@ -25,12 +24,15 @@ interface Implementation public function exists(TreePath $path): Attempt; /** - * @return Attempt A Name represent a directory + * @return Attempt */ - public function read(TreePath $parent, Name $name): Attempt; + public function read( + TreePath $parent, + Name\File|Name\Directory|Name\Unknown $name, + ): Attempt; /** - * @return Sequence Todo encapsulate if the name represent a file/directory/unknown + * @return Sequence */ public function list(TreePath $parent): Sequence; diff --git a/src/Adapter/Name/Directory.php b/src/Adapter/Name/Directory.php new file mode 100644 index 0000000..c933d23 --- /dev/null +++ b/src/Adapter/Name/Directory.php @@ -0,0 +1,31 @@ +name; + } +} diff --git a/src/Adapter/Name/File.php b/src/Adapter/Name/File.php new file mode 100644 index 0000000..80b7fe3 --- /dev/null +++ b/src/Adapter/Name/File.php @@ -0,0 +1,31 @@ +name; + } +} diff --git a/src/Adapter/Name/Unknown.php b/src/Adapter/Name/Unknown.php new file mode 100644 index 0000000..7f9e45f --- /dev/null +++ b/src/Adapter/Name/Unknown.php @@ -0,0 +1,31 @@ +name; + } +} From 3c09017f3ce0ca78a3729d9aabdf20d2020c85f9 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 15:10:31 +0100 Subject: [PATCH 39/70] let the bridge work with any implementation --- src/Adapter/Bridge.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 2fdeb9c..703a886 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -28,7 +28,7 @@ final class Bridge implements Adapter private \WeakMap $loaded; private function __construct( - private Filesystem&Implementation $adapter, + private Implementation $adapter, private CaseSensitivity $case, ) { /** @var \WeakMap */ @@ -36,7 +36,7 @@ private function __construct( } public static function of( - Filesystem&Implementation $adapter, + Implementation $adapter, CaseSensitivity $case, ): self { return new self($adapter, $case); From 78819db8921f547c9536261cc8994f70c4bf2007 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 16:02:55 +0100 Subject: [PATCH 40/70] give a more easy access to the file name being written --- src/Adapter/Bridge.php | 16 ++++++++-------- src/Adapter/Filesystem.php | 6 +++--- src/Adapter/Implementation.php | 7 ++----- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 703a886..a9b2fa8 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -126,18 +126,18 @@ private function read( */ private function write(TreePath $path, File|Directory $file): Attempt { - $path = TreePath::of($file)->under($path); + $fullPath = TreePath::of($file)->under($path); /** @psalm-suppress PossiblyNullReference */ if ( $this->loaded->offsetExists($file) && - $this->loaded[$file]->equals($path) + $this->loaded[$file]->equals($fullPath) ) { // no need to persist untouched file where it was loaded from return Attempt::result(SideEffect::identity); } - $this->loaded[$file] = $path; + $this->loaded[$file] = $fullPath; if ($file instanceof Directory) { /** @var Set */ @@ -145,14 +145,14 @@ private function write(TreePath $path, File|Directory $file): Attempt return $this ->adapter - ->createDirectory($path) + ->createDirectory($fullPath) ->flatMap( fn() => $file ->all() ->sink($names) ->attempt( fn($persisted, $file) => $this - ->write($path, $file) + ->write($fullPath, $file) ->map(static fn() => ($persisted)($file->name())), ), ) @@ -165,7 +165,7 @@ private function write(TreePath $path, File|Directory $file): Attempt )) ->unsorted() ->map(TreePath::of(...)) - ->map(static fn($file) => $file->under($path)) + ->map(static fn($file) => $file->under($fullPath)) ->sink(SideEffect::identity) ->attempt(fn($_, $path) => $this->adapter->remove($path)), ); @@ -173,10 +173,10 @@ private function write(TreePath $path, File|Directory $file): Attempt return $this ->adapter - ->remove($path) + ->remove($fullPath) ->flatMap(fn() => $this->adapter->write( $path, - $file->content(), + $file, )); } } diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 3472eb9..82250b2 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -215,10 +215,10 @@ public function createDirectory(TreePath $path): Attempt } #[\Override] - public function write(TreePath $path, Content $content): Attempt + public function write(TreePath $parent, File $file): Attempt { - $absolutePath = $path->asPath($this->path); - $chunks = $content->chunks(); + $absolutePath = TreePath::of($file)->under($parent)->asPath($this->path); + $chunks = $file->content()->chunks(); return self::touch($absolutePath)->flatMap( fn() => $this diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 9a386f2..3a6006b 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -3,10 +3,7 @@ namespace Innmind\Filesystem\Adapter; -use Innmind\Filesystem\{ - File, - File\Content, -}; +use Innmind\Filesystem\File; use Innmind\Immutable\{ Attempt, Sequence, @@ -49,5 +46,5 @@ public function createDirectory(TreePath $path): Attempt; /** * @return Attempt */ - public function write(TreePath $path, Content $content): Attempt; + public function write(TreePath $parent, File $file): Attempt; } From 5cde856d7b3e8992c651581219001b813947c3e9 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 16:14:57 +0100 Subject: [PATCH 41/70] give a more easy access to the directory name being created --- src/Adapter/Bridge.php | 2 +- src/Adapter/Filesystem.php | 3 ++- src/Adapter/Implementation.php | 14 +++++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index a9b2fa8..778a4ad 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -145,7 +145,7 @@ private function write(TreePath $path, File|Directory $file): Attempt return $this ->adapter - ->createDirectory($fullPath) + ->createDirectory($path, $file->name()) ->flatMap( fn() => $file ->all() diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 82250b2..fab3319 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -193,8 +193,9 @@ public function remove(TreePath $path): Attempt } #[\Override] - public function createDirectory(TreePath $path): Attempt + public function createDirectory(TreePath $parent, Name $name): Attempt { + $path = TreePath::directory($name)->under($parent); $absolutePath = $path->asPath($this->path); return $this diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 3a6006b..2862391 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -3,7 +3,11 @@ namespace Innmind\Filesystem\Adapter; -use Innmind\Filesystem\File; +use Innmind\Filesystem\{ + File, + Name, + Adapter\Name as Name_, +}; use Innmind\Immutable\{ Attempt, Sequence, @@ -21,15 +25,15 @@ interface Implementation public function exists(TreePath $path): Attempt; /** - * @return Attempt + * @return Attempt */ public function read( TreePath $parent, - Name\File|Name\Directory|Name\Unknown $name, + Name_\File|Name_\Directory|Name_\Unknown $name, ): Attempt; /** - * @return Sequence + * @return Sequence */ public function list(TreePath $parent): Sequence; @@ -41,7 +45,7 @@ public function remove(TreePath $path): Attempt; /** * @return Attempt */ - public function createDirectory(TreePath $path): Attempt; + public function createDirectory(TreePath $parent, Name $name): Attempt; /** * @return Attempt From 31803a47e0fb04aaaace9ae621d40325ad736403 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 16:32:27 +0100 Subject: [PATCH 42/70] give a more easy access to the file/directory name to remove --- src/Adapter/Bridge.php | 8 +++----- src/Adapter/Filesystem.php | 11 +++++------ src/Adapter/Implementation.php | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index 778a4ad..fb9fd17 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -69,7 +69,7 @@ public function contains(Name $file): bool #[\Override] public function remove(Name $file): Attempt { - return $this->adapter->remove(TreePath::of($file)); + return $this->adapter->remove(TreePath::root(), $file); } #[\Override] @@ -164,16 +164,14 @@ private function write(TreePath $path, File|Directory $file): Attempt $persisted, )) ->unsorted() - ->map(TreePath::of(...)) - ->map(static fn($file) => $file->under($fullPath)) ->sink(SideEffect::identity) - ->attempt(fn($_, $path) => $this->adapter->remove($path)), + ->attempt(fn($_, $file) => $this->adapter->remove($fullPath, $file)), ); } return $this ->adapter - ->remove($fullPath) + ->remove($path, $file->name()) ->flatMap(fn() => $this->adapter->write( $path, $file, diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index fab3319..6509be3 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -144,8 +144,9 @@ public function list(TreePath $parent): Sequence * there is as little as possible race conditions like these. */ #[\Override] - public function remove(TreePath $path): Attempt + public function remove(TreePath $parent, Name $name): Attempt { + $path = TreePath::of($name)->under($parent); $absolutePath = $path->asPath($this->path)->toString(); if (Str::of($absolutePath)->length() > \PHP_MAXPATHLEN) { @@ -167,10 +168,8 @@ public function remove(TreePath $path): Attempt ->map(static fn($file) => $file->getBasename()) ->keep(Is::string()->nonEmpty()->asPredicate()) ->map(Name::of(...)) - ->map(TreePath::of(...)) - ->map(static fn($file) => $file->under($path)) ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->remove($file)) + ->attempt(fn($_, $file) => $this->remove($path, $file)) ->map(static fn() => @\rmdir($absolutePath)) ->flatMap(static fn($removed) => match ($removed) { true => Attempt::result(SideEffect::identity), @@ -200,14 +199,14 @@ public function createDirectory(TreePath $parent, Name $name): Attempt return $this ->exists($path) - ->flatMap(function($exists) use ($path, $absolutePath) { + ->flatMap(function($exists) use ($parent, $name, $absolutePath) { if ($exists && \is_dir($absolutePath->toString())) { return Attempt::result(SideEffect::identity); } if ($exists) { return $this - ->remove($path) + ->remove($parent, $name) ->flatMap(static fn() => self::mkdir($absolutePath)); } diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 2862391..54c074d 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -40,7 +40,7 @@ public function list(TreePath $parent): Sequence; /** * @return Attempt */ - public function remove(TreePath $path): Attempt; + public function remove(TreePath $parent, Name $name): Attempt; /** * @return Attempt From a3465b9f6b7903201caad27bcc5820d9b47a410a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 16:59:33 +0100 Subject: [PATCH 43/70] make sure to always list with a tree path representing a directory --- src/Adapter/Bridge.php | 2 +- src/Adapter/TreePath.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php index fb9fd17..951a234 100644 --- a/src/Adapter/Bridge.php +++ b/src/Adapter/Bridge.php @@ -106,7 +106,7 @@ private function read( $file->unwrap(), $this ->adapter - ->list($fullPath) + ->list(TreePath::directory($name->unwrap())->under($path)) ->map(fn($file) => $this->read( $fullPath, $file, diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php index fce16cb..378c733 100644 --- a/src/Adapter/TreePath.php +++ b/src/Adapter/TreePath.php @@ -61,7 +61,7 @@ public static function root(): self { return new self( Sequence::of(), - false, + true, ); } From e4a42138205d69c36a58c679c17e2a8822122e8e Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 17:01:47 +0100 Subject: [PATCH 44/70] make InMemory implement Implementation --- src/Adapter/InMemory.php | 176 ++++++++++++++++++++++++--------- tests/Adapter/InMemoryTest.php | 4 +- 2 files changed, 130 insertions(+), 50 deletions(-) diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index 51645d0..d99daa1 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -5,95 +5,175 @@ use Innmind\Filesystem\{ Adapter, + Adapter\Name as Name_, File, Name, - Directory, + CaseSensitivity, }; +use Innmind\Url\Path; use Innmind\Immutable\{ - Maybe, + Str, + Map, Attempt, + Sequence, SideEffect, - Predicate\Instance, }; -final class InMemory implements Adapter +/** + * @internal + */ +final class InMemory implements Implementation { + /** + * @param Map $files + * @param Map> $directories + */ private function __construct( - private Directory $root, + private Map $files, + private Map $directories, ) { } - public static function emulateFilesystem(): self + public static function emulateFilesystem(): Adapter { - return new self(Directory::named('root')); + return Bridge::of( + new self( + Map::of(), + Map::of(), + ), + CaseSensitivity::sensitive, + ); } #[\Override] - public function add(File|Directory $file): Attempt + public function exists(TreePath $path): Attempt { - $this->root = $this->merge($this->root, $file); + $path = $this->path($path); - return Attempt::result(SideEffect::identity()); + return Attempt::result($this->files->contains($path) || $this->directories->contains("$path/")); } #[\Override] - public function get(Name $file): Maybe - { - return $this->root->get($file); + public function read( + TreePath $parent, + Name_\File|Name_\Directory|Name_\Unknown $name, + ): Attempt { + if ($name instanceof Name_\Directory) { + return $this + ->directories + ->get($this->path(TreePath::directory($name->unwrap())->under($parent))) + ->map(static fn() => $name) + ->attempt(static fn() => new \RuntimeException('Directory not found')); + } + + if ($name instanceof Name_\File) { + return $this + ->files + ->get($this->path(TreePath::of($name->unwrap())->under($parent))) + ->attempt(static fn() => new \RuntimeException('File not found')); + } + + return $this + ->read($parent, Name_\Directory::of($name->unwrap())) + ->recover(fn() => $this->read( + $parent, + Name_\File::of($name->unwrap()), + )); } #[\Override] - public function contains(Name $file): bool + public function list(TreePath $parent): Sequence { - return $this->root->contains($file); + $path = $this->path($parent); + + /** @psalm-suppress ArgumentTypeCoercion Due to Name::of() */ + return $this + ->directories + ->get($path) + ->toSequence() + ->flatMap(static fn($files) => $files) + ->map(fn($file) => match ($this->directories->contains("$path$file/")) { + true => Name_\Directory::of(Name::of($file)), + false => Name_\File::of(Name::of($file)), + }); } #[\Override] - public function remove(Name $file): Attempt + public function remove(TreePath $parent, Name $name): Attempt { - $this->root = $this->root->remove($file); + $asDirectory = $this->path(TreePath::of($name)->under($parent)); + $this->files = $this + ->files + ->remove($this->path(TreePath::of($name)->under($parent))) + ->exclude(static fn($path) => Str::of($path)->startsWith($asDirectory)); + $parent = $this->path($parent); + $directories = $this + ->directories + ->exclude(static fn($path) => Str::of($path)->startsWith($asDirectory)); + $files = $directories + ->get($parent) + ->toSequence() + ->flatMap(static fn($files) => $files) + ->exclude(static fn($file) => $file === $name->toString()); + $this->directories = ($directories)( + $parent, + $files, + ); - return Attempt::result(SideEffect::identity()); + return Attempt::result(SideEffect::identity); } #[\Override] - public function root(): Directory + public function createDirectory(TreePath $parent, Name $name): Attempt { - return $this->root; - } + $path = $this->path(TreePath::directory($name)->under($parent)); + $asFile = Str::of($path) + ->dropEnd(1) // trailing / + ->toString(); - private function merge(Directory $parent, File|Directory $file): Directory - { - if (!$file instanceof Directory) { - return $parent->add($file); - } + $this->files = $this->files->remove($asFile); - $file = $parent - ->get($file->name()) - ->keep(Instance::of(Directory::class)) - ->match( - fn($existing) => $this->mergeDirectories($existing, $file), - static fn() => $file, + if (!$this->directories->contains($path)) { + $this->directories = ($this->directories)( + $path, + Sequence::strings(), + ); + $parent = $this->path($parent); + $files = $this + ->directories + ->get($parent) + ->toSequence() + ->flatMap(static fn($files) => $files) + ->add($name->toString()); + $this->directories = ($this->directories)( + $parent, + $files, ); + } - return $parent->add($file); + return Attempt::result(SideEffect::identity); } - private function mergeDirectories( - Directory $existing, - Directory $new, - ): Directory { - $existing = $new - ->removed() - ->exclude($new->contains(...)) - ->reduce( - $existing, - static fn(Directory $existing, $name) => $existing->remove($name), - ); + #[\Override] + public function write(TreePath $parent, File $file): Attempt + { + $fullPath = $this->path(TreePath::of($file)->under($parent)); + $parent = $this->path($parent); + + $this->files = ($this->files)($fullPath, $file); + $files = $this + ->directories + ->get($parent) + ->toSequence() + ->flatMap(static fn($files) => $files) + ->add($file->name()->toString()); + $this->directories = ($this->directories)($parent, $files); + + return Attempt::result(SideEffect::identity); + } - return $new->reduce( - $existing, - fn(Directory $directory, $file) => $this->merge($directory, $file), - ); + private function path(TreePath $path): string + { + return $path->asPath(Path::of('/'))->toString(); } } diff --git a/tests/Adapter/InMemoryTest.php b/tests/Adapter/InMemoryTest.php index 0ccff03..0feef53 100644 --- a/tests/Adapter/InMemoryTest.php +++ b/tests/Adapter/InMemoryTest.php @@ -32,8 +32,8 @@ public function testInterface() ->unwrap(), ); $this->assertTrue($a->contains(Name::of('foo'))); - $this->assertSame( - $d, + $this->assertInstanceOf( + Directory::class, $a->get(Name::of('foo'))->match( static fn($file) => $file, static fn() => null, From f7c9f4d5411db1b9f938f6ccabeb7bda129a9921 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 17:35:39 +0100 Subject: [PATCH 45/70] make Adapter a final class --- CHANGELOG.md | 4 + proofs/adapter/filesystem.php | 16 +-- proofs/adapter/inMemory.php | 12 +- src/Adapter.php | 189 +++++++++++++++++++++++++++++-- src/Adapter/Bridge.php | 180 ----------------------------- src/Adapter/Filesystem.php | 8 +- src/Adapter/InMemory.php | 13 +-- src/Adapter/Logger.php | 103 ++++++++++++----- tests/Adapter/FilesystemTest.php | 41 ++++--- tests/Adapter/InMemoryTest.php | 12 +- tests/Adapter/LoggerTest.php | 26 ++--- 11 files changed, 319 insertions(+), 285 deletions(-) delete mode 100644 src/Adapter/Bridge.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b95ff..19bc6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Links are not silently ignored when listing files/directories. An error is still returned when trying to remove a link. - `Innmind\Filesystem\Adapter\Filesystem::mount()` now returns an `Innmind\Immutable\Attempt` - `Innmind\Filesystem\Directory::removed()` is now flagged as internal +- `Innmind\Filesystem\Adapter` is now a final class +- `Innmind\Filesystem\Adapter\Filesystem` is now flagged as internal +- `Innmind\Filesystem\Adapter\InMemory` is now flagged as internal +- `Innmind\Filesystem\Adapter\Logger` is now flagged as internal ### Removed diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index 4388ebd..c27bd18 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -2,14 +2,14 @@ declare(strict_types = 1); use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, Directory, File, File\Content, CaseSensitivity, }; use Innmind\Url\Path; -use Properties\Innmind\Filesystem\Adapter; +use Properties\Innmind\Filesystem\Adapter as PAdapter; use Innmind\BlackBox\Set; use Symfony\Component\Filesystem\Filesystem as FS; @@ -18,11 +18,11 @@ yield properties( 'Filesystem properties', - Adapter::properties(), + PAdapter::properties(), Set::call(static function() use ($path) { (new FS)->remove($path); - return Filesystem::mount( + return Adapter::mount( Path::of($path), match (\PHP_OS) { 'Darwin' => CaseSensitivity::insensitive, @@ -32,13 +32,13 @@ }), ); - foreach (Adapter::alwaysApplicable() as $property) { + foreach (PAdapter::alwaysApplicable() as $property) { yield property( $property, Set::call(static function() use ($path) { (new FS)->remove($path); - return Filesystem::mount( + return Adapter::mount( Path::of($path), match (\PHP_OS) { 'Darwin' => CaseSensitivity::insensitive, @@ -52,7 +52,7 @@ yield test( 'Regression adding file in directory due to case sensitivity', static function($assert) use ($path) { - $property = new Adapter\AddRemoveAddModificationsStillAddTheFile( + $property = new PAdapter\AddRemoveAddModificationsStillAddTheFile( Directory::named('0') ->add($file = File::named('L', Content::none())) ->remove($file->name()), @@ -60,7 +60,7 @@ static function($assert) use ($path) { ); (new FS)->remove($path); - $adapter = Filesystem::mount( + $adapter = Adapter::mount( Path::of($path), match (\PHP_OS) { 'Darwin' => CaseSensitivity::insensitive, diff --git a/proofs/adapter/inMemory.php b/proofs/adapter/inMemory.php index d7305b1..558f6a3 100644 --- a/proofs/adapter/inMemory.php +++ b/proofs/adapter/inMemory.php @@ -1,21 +1,21 @@ named('InMemory emulating filesystem'); } }; diff --git a/src/Adapter.php b/src/Adapter.php index 01a9711..4e23065 100644 --- a/src/Adapter.php +++ b/src/Adapter.php @@ -3,31 +3,206 @@ namespace Innmind\Filesystem; +use Innmind\Filesystem\{ + Adapter\Name as Name_, + Adapter\TreePath, + Adapter\Implementation, + Adapter\Filesystem, + Adapter\InMemory, + Adapter\Logger, +}; +use Innmind\IO\IO; +use Innmind\Url\Path; use Innmind\Immutable\{ - Maybe, Attempt, + Maybe, + Set, SideEffect, }; +use Psr\Log\LoggerInterface; /** * Layer between value objects and concrete implementation */ -interface Adapter +final class Adapter { + /** @var \WeakMap */ + private \WeakMap $loaded; + + private function __construct( + private Implementation $adapter, + private CaseSensitivity $case, + ) { + /** @var \WeakMap */ + $this->loaded = new \WeakMap; + } + + public static function mount( + Path $path, + CaseSensitivity $case = CaseSensitivity::sensitive, + ?IO $io = null, + ): Attempt { + return Filesystem::mount($path, $io)->map(static fn($implementation) => new self( + $implementation, + $case, + )); + } + + public static function inMemory(): self + { + return new self( + InMemory::emulateFilesystem(), + CaseSensitivity::sensitive, + ); + } + + public static function logger( + self $adapter, + LoggerInterface $logger, + ): self { + return new self( + Logger::psr($adapter->adapter, $logger), + $adapter->case, + ); + } + + /** + * @return Attempt + */ + public function add(File|Directory $file): Attempt + { + return $this->write(TreePath::root(), $file); + } + + /** + * @return Maybe + */ + public function get(Name $file): Maybe + { + return $this->read( + TreePath::root(), + Name_\Unknown::of($file), + ); + } + + public function contains(Name $file): bool + { + return $this->adapter->exists(TreePath::of($file))->match( + static fn($exists) => $exists, + static fn() => false, + ); + } + /** * @return Attempt */ - public function add(File|Directory $file): Attempt; + public function remove(Name $file): Attempt + { + return $this->adapter->remove(TreePath::root(), $file); + } + + public function root(): Directory + { + $root = TreePath::root(); + + return Directory::named( + 'root', + $this + ->adapter + ->list($root) + ->map(fn($name) => $this->read($root, $name)) + ->flatMap(static fn($read) => $read->toSequence()), + ); + } /** * @return Maybe */ - public function get(Name $file): Maybe; - public function contains(Name $file): bool; + private function read( + TreePath $path, + Name_\File|Name_\Directory|Name_\Unknown $name, + ): Maybe { + $fullPath = TreePath::of($name->unwrap())->under($path); + + return $this + ->adapter + ->read($path, $name) + ->maybe() + ->map(fn($file) => match (true) { + $file instanceof File => $file, + default => Directory::of( + $file->unwrap(), + $this + ->adapter + ->list(TreePath::directory($name->unwrap())->under($path)) + ->map(fn($file) => $this->read( + $fullPath, + $file, + )) + ->flatMap(static fn($read) => $read->toSequence()), + ), + }) + ->map(function($file) use ($fullPath) { + $this->loaded[$file] = $fullPath; + + return $file; + }); + } /** * @return Attempt */ - public function remove(Name $file): Attempt; - public function root(): Directory; + private function write(TreePath $path, File|Directory $file): Attempt + { + $fullPath = TreePath::of($file)->under($path); + + /** @psalm-suppress PossiblyNullReference */ + if ( + $this->loaded->offsetExists($file) && + $this->loaded[$file]->equals($fullPath) + ) { + // no need to persist untouched file where it was loaded from + return Attempt::result(SideEffect::identity); + } + + $this->loaded[$file] = $fullPath; + + if ($file instanceof Directory) { + /** @var Set */ + $names = Set::of(); + + return $this + ->adapter + ->createDirectory($path, $file->name()) + ->flatMap( + fn() => $file + ->all() + ->sink($names) + ->attempt( + fn($persisted, $file) => $this + ->write($fullPath, $file) + ->map(static fn() => ($persisted)($file->name())), + ), + ) + ->flatMap( + fn($persisted) => $file + ->removed() + ->exclude(fn($file): bool => $this->case->contains( + $file, + $persisted, + )) + ->unsorted() + ->sink(SideEffect::identity) + ->attempt(fn($_, $file) => $this->adapter->remove($fullPath, $file)), + ); + } + + return $this + ->adapter + ->remove($path, $file->name()) + ->flatMap(fn() => $this->adapter->write( + $path, + $file, + )); + } } diff --git a/src/Adapter/Bridge.php b/src/Adapter/Bridge.php deleted file mode 100644 index 951a234..0000000 --- a/src/Adapter/Bridge.php +++ /dev/null @@ -1,180 +0,0 @@ - */ - private \WeakMap $loaded; - - private function __construct( - private Implementation $adapter, - private CaseSensitivity $case, - ) { - /** @var \WeakMap */ - $this->loaded = new \WeakMap; - } - - public static function of( - Implementation $adapter, - CaseSensitivity $case, - ): self { - return new self($adapter, $case); - } - - #[\Override] - public function add(File|Directory $file): Attempt - { - return $this->write(TreePath::root(), $file); - } - - #[\Override] - public function get(Name $file): Maybe - { - return $this->read( - TreePath::root(), - Name_\Unknown::of($file), - ); - } - - #[\Override] - public function contains(Name $file): bool - { - return $this->adapter->exists(TreePath::of($file))->match( - static fn($exists) => $exists, - static fn() => false, - ); - } - - #[\Override] - public function remove(Name $file): Attempt - { - return $this->adapter->remove(TreePath::root(), $file); - } - - #[\Override] - public function root(): Directory - { - $root = TreePath::root(); - - return Directory::named( - 'root', - $this - ->adapter - ->list($root) - ->map(fn($name) => $this->read($root, $name)) - ->flatMap(static fn($read) => $read->toSequence()), - ); - } - - /** - * @return Maybe - */ - private function read( - TreePath $path, - Name_\File|Name_\Directory|Name_\Unknown $name, - ): Maybe { - $fullPath = TreePath::of($name->unwrap())->under($path); - - return $this - ->adapter - ->read($path, $name) - ->maybe() - ->map(fn($file) => match (true) { - $file instanceof File => $file, - default => Directory::of( - $file->unwrap(), - $this - ->adapter - ->list(TreePath::directory($name->unwrap())->under($path)) - ->map(fn($file) => $this->read( - $fullPath, - $file, - )) - ->flatMap(static fn($read) => $read->toSequence()), - ), - }) - ->map(function($file) use ($fullPath) { - $this->loaded[$file] = $fullPath; - - return $file; - }); - } - - /** - * @return Attempt - */ - private function write(TreePath $path, File|Directory $file): Attempt - { - $fullPath = TreePath::of($file)->under($path); - - /** @psalm-suppress PossiblyNullReference */ - if ( - $this->loaded->offsetExists($file) && - $this->loaded[$file]->equals($fullPath) - ) { - // no need to persist untouched file where it was loaded from - return Attempt::result(SideEffect::identity); - } - - $this->loaded[$file] = $fullPath; - - if ($file instanceof Directory) { - /** @var Set */ - $names = Set::of(); - - return $this - ->adapter - ->createDirectory($path, $file->name()) - ->flatMap( - fn() => $file - ->all() - ->sink($names) - ->attempt( - fn($persisted, $file) => $this - ->write($fullPath, $file) - ->map(static fn() => ($persisted)($file->name())), - ), - ) - ->flatMap( - fn($persisted) => $file - ->removed() - ->exclude(fn($file): bool => $this->case->contains( - $file, - $persisted, - )) - ->unsorted() - ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->adapter->remove($fullPath, $file)), - ); - } - - return $this - ->adapter - ->remove($path, $file->name()) - ->flatMap(fn() => $this->adapter->write( - $path, - $file, - )); - } -} diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 6509be3..e4d0349 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -4,13 +4,11 @@ namespace Innmind\Filesystem\Adapter; use Innmind\Filesystem\{ - Adapter, Adapter\Name as Name_, File, File\Content, Name, Directory, - CaseSensitivity, Exception\PathDoesntRepresentADirectory, Exception\LinksAreNotSupported, }; @@ -34,11 +32,10 @@ private function __construct( } /** - * @return Attempt + * @return Attempt */ public static function mount( Path $path, - CaseSensitivity $case = CaseSensitivity::sensitive, ?IO $io = null, ): Attempt { if (!$path->directory()) { @@ -53,8 +50,7 @@ public static function mount( ->map(static fn() => new self( $io ?? IO::fromAmbientAuthority(), $path, - )) - ->map(static fn($self) => Bridge::of($self, $case)); + )); } #[\Override] diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index d99daa1..efe6cf3 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -4,11 +4,9 @@ namespace Innmind\Filesystem\Adapter; use Innmind\Filesystem\{ - Adapter, Adapter\Name as Name_, File, Name, - CaseSensitivity, }; use Innmind\Url\Path; use Innmind\Immutable\{ @@ -34,14 +32,11 @@ private function __construct( ) { } - public static function emulateFilesystem(): Adapter + public static function emulateFilesystem(): self { - return Bridge::of( - new self( - Map::of(), - Map::of(), - ), - CaseSensitivity::sensitive, + return new self( + Map::of(), + Map::of(), ); } diff --git a/src/Adapter/Logger.php b/src/Adapter/Logger.php index 4bcab4e..a5eb87b 100644 --- a/src/Adapter/Logger.php +++ b/src/Adapter/Logger.php @@ -4,77 +4,124 @@ namespace Innmind\Filesystem\Adapter; use Innmind\Filesystem\{ - Adapter, + Adapter\Name as Name_, File, Name, - Directory, }; +use Innmind\Url\Path; use Innmind\Immutable\{ - Maybe, + Sequence, Attempt, }; use Psr\Log\LoggerInterface; -final class Logger implements Adapter +/** + * @internal + */ +final class Logger implements Implementation { private function __construct( - private Adapter $filesystem, + private Implementation $filesystem, private LoggerInterface $logger, ) { } - public static function psr(Adapter $filesystem, LoggerInterface $logger): self + public static function psr(Implementation $filesystem, LoggerInterface $logger): self { return new self($filesystem, $logger); } #[\Override] - public function add(File|Directory $file): Attempt + public function exists(TreePath $path): Attempt { - $this->logger->debug('Adding file {file}', ['file' => $file->name()->toString()]); + return $this + ->filesystem + ->exists($path) + ->map(function($exists) use ($path) { + $this->logger->debug('Cheking if filesystem contains {file}', [ + 'file' => self::path($path), + 'contains' => $exists, + ]); - return $this->filesystem->add($file); + return $exists; + }); } #[\Override] - public function get(Name $file): Maybe - { + public function read( + TreePath $parent, + Name_\File|Name_\Directory|Name_\Unknown $name, + ): Attempt { return $this ->filesystem - ->get($file) - ->map(function($file) { - $this->logger->debug( - 'Accessing file {name}', - ['name' => $file->name()->toString()], - ); + ->read($parent, $name) + ->map(function($file) use ($parent, $name) { + $this->logger->debug('Accessing file {file}', [ + 'file' => self::path(TreePath::of($name->unwrap())->under($parent)), + ]); return $file; }); } #[\Override] - public function contains(Name $file): bool + public function list(TreePath $parent): Sequence { - $contains = $this->filesystem->contains($file); - $this->logger->debug('Cheking if filesystem contains {file}', [ - 'file' => $file->toString(), - 'contains' => $contains, + $this->logger->debug('Listing files in {directory}', [ + 'directory' => self::path($parent), ]); - return $contains; + return $this->filesystem->list($parent); + } + + #[\Override] + public function remove(TreePath $parent, Name $name): Attempt + { + return $this + ->filesystem + ->remove($parent, $name) + ->map(function($_) use ($parent, $name) { + $this->logger->debug('File removed {file}', [ + 'file' => self::path(TreePath::of($name)->under($parent)), + ]); + + return $_; + }); } #[\Override] - public function remove(Name $file): Attempt + public function createDirectory(TreePath $parent, Name $name): Attempt { - $this->logger->debug('Removing file {file}', ['file' => $file->toString()]); + return $this + ->filesystem + ->createDirectory($parent, $name) + ->map(function($_) use ($parent, $name) { + $this->logger->debug('Directory created {directory}', [ + 'directory' => self::path(TreePath::directory($name)->under($parent)), + ]); - return $this->filesystem->remove($file); + return $_; + }); } #[\Override] - public function root(): Directory + public function write(TreePath $parent, File $file): Attempt + { + return $this + ->filesystem + ->write($parent, $file) + ->map(function($_) use ($parent, $file) { + $this->logger->debug('File written {file}', [ + 'file' => self::path(TreePath::of($file)->under($parent)), + 'mediaType' => $file->mediaType()->toString(), + ]); + + return $_; + }); + } + + private static function path(TreePath $path): string { - return $this->filesystem->root(); + return $path->asPath(Path::of('/'))->toString(); } } diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index c3318f7..bffe0c2 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -4,7 +4,6 @@ namespace Tests\Innmind\Filesystem\Adapter; use Innmind\Filesystem\{ - Adapter\Filesystem, Adapter, File, File\Content, @@ -41,7 +40,7 @@ public function setUp(): void public function testInterface() { - $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/'))->unwrap(); $this->assertInstanceOf(Adapter::class, $adapter); $this->assertFalse($adapter->contains(Name::of('foo'))); @@ -66,13 +65,13 @@ public function testThrowWhenPathToMountIsNotADirectory() $this->expectException(PathDoesntRepresentADirectory::class); $this->expectExceptionMessage('path/to/somewhere'); - Filesystem::mount(Path::of('path/to/somewhere'))->unwrap(); + Adapter::mount(Path::of('path/to/somewhere'))->unwrap(); } public function testReturnNothingWhenGettingUnknownFile() { $this->assertNull( - Filesystem::mount(Path::of('/tmp/')) + Adapter::mount(Path::of('/tmp/')) ->unwrap() ->get(Name::of('foo')) ->match( @@ -86,7 +85,7 @@ public function testRemovingUnknownFileDoesntThrow() { $this->assertInstanceOf( SideEffect::class, - Filesystem::mount(Path::of('/tmp/')) + Adapter::mount(Path::of('/tmp/')) ->unwrap() ->remove(Name::of('foo')) ->unwrap(), @@ -95,7 +94,7 @@ public function testRemovingUnknownFileDoesntThrow() public function testCreateNestedStructure() { - $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/'))->unwrap(); $directory = Directory::of(Name::of('foo')) ->add(File::of(Name::of('foo.md'), Content::ofString('# Foo'))) @@ -128,7 +127,7 @@ public function testCreateNestedStructure() ), ); - $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/'))->unwrap(); $this->assertTrue($adapter->contains(Name::of('foo'))); $this->assertSame( '# Foo', @@ -167,7 +166,7 @@ public function testCreateNestedStructure() public function testRemoveFileWhenRemovedFromFolder() { - $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); $d = Directory::of(Name::of('foo')); $d = $d->add(File::of(Name::of('bar'), Content::ofString('some content'))); @@ -175,7 +174,7 @@ public function testRemoveFileWhenRemovedFromFolder() $d = $d->remove(Name::of('bar')); $a->add($d)->unwrap(); $this->assertSame(1, $d->removed()->size()); - $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); $this->assertFalse( $a->get(Name::of('foo'))->match( static fn($dir) => $dir->contains(Name::of('bar')), @@ -189,7 +188,7 @@ public function testRemoveFileWhenRemovedFromFolder() public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFile() { - $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); $d = Directory::of(Name::of('foo')); $d = $d->add(File::of(Name::of('bar'), Content::ofString('some content'))); @@ -198,7 +197,7 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi $a->add($d)->unwrap(); $a->add($d)->unwrap(); $this->assertSame(1, $d->removed()->size()); - $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); $this->assertFalse( $a->get(Name::of('foo'))->match( static fn($dir) => $dir->contains(Name::of('bar')), @@ -212,7 +211,7 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi public function testLoadWithMediaType() { - $a = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); \file_put_contents( '/tmp/some_content.html', '', @@ -235,7 +234,7 @@ public function testLoadWithMediaType() public function testRoot() { - $adapter = Filesystem::mount(Path::of('/tmp/test/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/test/'))->unwrap(); $adapter ->add(File::of( Name::of('foo'), @@ -296,7 +295,7 @@ public function testRoot() public function testAddingTheSameFileTwiceDoesNothing() { - $adapter = Filesystem::mount(Path::of('/tmp/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/'))->unwrap(); $file = File::of( Name::of('foo'), Content::ofString('foo'), @@ -325,7 +324,7 @@ public function testPathTooLongThrowAnException() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Path too long'); @@ -365,7 +364,7 @@ public function testPersistedNameCanStartWithAnyAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -398,7 +397,7 @@ public function testPersistedNameCanContainWithAnyAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -433,7 +432,7 @@ public function testPersistedNameCanContainOnlyOneAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -459,7 +458,7 @@ public function testThrowsWhenTryingToGetLink() (new FS)->dumpFile($path.'foo', 'bar'); \symlink($path.'foo', $path.'bar'); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $this->expectException(LinksAreNotSupported::class); $this->expectExceptionMessage($path.'bar'); @@ -474,7 +473,7 @@ public function testThrowsWhenListContainsALink() (new FS)->dumpFile($path.'foo', 'bar'); \symlink($path.'foo', $path.'bar'); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $this->expectException(LinksAreNotSupported::class); $this->expectExceptionMessage($path.'bar'); @@ -492,7 +491,7 @@ public function testDotFilesAreListed() (new FS)->mkdir($path); \file_put_contents($path.$name, 'bar'); - $filesystem = Filesystem::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path))->unwrap(); $all = $filesystem->root()->all()->toList(); $this->assertCount(1, $all); diff --git a/tests/Adapter/InMemoryTest.php b/tests/Adapter/InMemoryTest.php index 0feef53..2ddd4eb 100644 --- a/tests/Adapter/InMemoryTest.php +++ b/tests/Adapter/InMemoryTest.php @@ -4,7 +4,6 @@ namespace Tests\Innmind\Filesystem\Adapter; use Innmind\Filesystem\{ - Adapter\InMemory, Adapter, Directory, File, @@ -21,7 +20,7 @@ class InMemoryTest extends TestCase { public function testInterface() { - $a = InMemory::emulateFilesystem(); + $a = Adapter::inMemory(); $this->assertInstanceOf(Adapter::class, $a); $this->assertFalse($a->contains(Name::of('foo'))); @@ -50,7 +49,7 @@ public function testInterface() public function testReturnNothingWhenGettingUnknownFile() { - $this->assertNull(InMemory::emulateFilesystem()->get(Name::of('foo'))->match( + $this->assertNull(Adapter::inMemory()->get(Name::of('foo'))->match( static fn($file) => $file, static fn() => null, )); @@ -60,7 +59,7 @@ public function testRemovingUnknownFileDoesntThrow() { $this->assertInstanceOf( SideEffect::class, - InMemory::emulateFilesystem() + Adapter::inMemory() ->remove(Name::of('foo')) ->unwrap(), ); @@ -68,7 +67,7 @@ public function testRemovingUnknownFileDoesntThrow() public function testRoot() { - $adapter = InMemory::emulateFilesystem(); + $adapter = Adapter::inMemory(); $adapter ->add($foo = File::of( Name::of('foo'), @@ -89,9 +88,10 @@ public function testRoot() ); } + #[\PHPUnit\Framework\Attributes\Group('wip')] public function testEmulateFilesystem() { - $adapter = InMemory::emulateFilesystem(); + $adapter = Adapter::inMemory(); $adapter->add(Directory::of( Name::of('foo'), Sequence::of( diff --git a/tests/Adapter/LoggerTest.php b/tests/Adapter/LoggerTest.php index b55ce2e..99e76d3 100644 --- a/tests/Adapter/LoggerTest.php +++ b/tests/Adapter/LoggerTest.php @@ -5,8 +5,6 @@ use Innmind\Filesystem\{ Adapter, - Adapter\Logger, - Adapter\InMemory, File, File\Content, Name, @@ -21,8 +19,8 @@ public function testInterface() { $this->assertInstanceOf( Adapter::class, - Logger::psr( - InMemory::emulateFilesystem(), + Adapter::logger( + Adapter::inMemory(), new NullLogger, ), ); @@ -30,8 +28,8 @@ public function testInterface() public function testAdd() { - $adapter = Logger::psr( - $inner = InMemory::emulateFilesystem(), + $adapter = Adapter::logger( + $inner = Adapter::inMemory(), new NullLogger, ); $file = File::of(Name::of('foo'), Content::none()); @@ -47,8 +45,8 @@ public function testAdd() public function testGet() { - $adapter = Logger::psr( - $inner = InMemory::emulateFilesystem(), + $adapter = Adapter::logger( + $inner = Adapter::inMemory(), new NullLogger, ); $name = Name::of('foo'); @@ -68,8 +66,8 @@ public function testGet() public function testContains() { - $adapter = Logger::psr( - $inner = InMemory::emulateFilesystem(), + $adapter = Adapter::logger( + $inner = Adapter::inMemory(), new NullLogger, ); $name = Name::of('foo'); @@ -82,8 +80,8 @@ public function testContains() public function testRemove() { - $adapter = Logger::psr( - $inner = InMemory::emulateFilesystem(), + $adapter = Adapter::logger( + $inner = Adapter::inMemory(), new NullLogger, ); $name = Name::of('foo'); @@ -102,8 +100,8 @@ public function testRemove() public function testRoot() { - $adapter = Logger::psr( - $inner = InMemory::emulateFilesystem(), + $adapter = Adapter::logger( + $inner = Adapter::inMemory(), new NullLogger, ); $file = File::named( From 6739d8c54bae21ea386719bf774519cac76db4e4 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 17:37:50 +0100 Subject: [PATCH 46/70] CS --- src/Adapter.php | 22 +++++++++++----------- src/Adapter/Logger.php | 18 +++++++++--------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Adapter.php b/src/Adapter.php index 4e23065..5ebc76a 100644 --- a/src/Adapter.php +++ b/src/Adapter.php @@ -30,7 +30,7 @@ final class Adapter private \WeakMap $loaded; private function __construct( - private Implementation $adapter, + private Implementation $implementation, private CaseSensitivity $case, ) { /** @var \WeakMap */ @@ -61,7 +61,7 @@ public static function logger( LoggerInterface $logger, ): self { return new self( - Logger::psr($adapter->adapter, $logger), + Logger::psr($adapter->implementation, $logger), $adapter->case, ); } @@ -87,7 +87,7 @@ public function get(Name $file): Maybe public function contains(Name $file): bool { - return $this->adapter->exists(TreePath::of($file))->match( + return $this->implementation->exists(TreePath::of($file))->match( static fn($exists) => $exists, static fn() => false, ); @@ -98,7 +98,7 @@ public function contains(Name $file): bool */ public function remove(Name $file): Attempt { - return $this->adapter->remove(TreePath::root(), $file); + return $this->implementation->remove(TreePath::root(), $file); } public function root(): Directory @@ -108,7 +108,7 @@ public function root(): Directory return Directory::named( 'root', $this - ->adapter + ->implementation ->list($root) ->map(fn($name) => $this->read($root, $name)) ->flatMap(static fn($read) => $read->toSequence()), @@ -125,7 +125,7 @@ private function read( $fullPath = TreePath::of($name->unwrap())->under($path); return $this - ->adapter + ->implementation ->read($path, $name) ->maybe() ->map(fn($file) => match (true) { @@ -133,7 +133,7 @@ private function read( default => Directory::of( $file->unwrap(), $this - ->adapter + ->implementation ->list(TreePath::directory($name->unwrap())->under($path)) ->map(fn($file) => $this->read( $fullPath, @@ -172,7 +172,7 @@ private function write(TreePath $path, File|Directory $file): Attempt $names = Set::of(); return $this - ->adapter + ->implementation ->createDirectory($path, $file->name()) ->flatMap( fn() => $file @@ -193,14 +193,14 @@ private function write(TreePath $path, File|Directory $file): Attempt )) ->unsorted() ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->adapter->remove($fullPath, $file)), + ->attempt(fn($_, $file) => $this->implementation->remove($fullPath, $file)), ); } return $this - ->adapter + ->implementation ->remove($path, $file->name()) - ->flatMap(fn() => $this->adapter->write( + ->flatMap(fn() => $this->implementation->write( $path, $file, )); diff --git a/src/Adapter/Logger.php b/src/Adapter/Logger.php index a5eb87b..3d677a8 100644 --- a/src/Adapter/Logger.php +++ b/src/Adapter/Logger.php @@ -21,21 +21,21 @@ final class Logger implements Implementation { private function __construct( - private Implementation $filesystem, + private Implementation $implementation, private LoggerInterface $logger, ) { } - public static function psr(Implementation $filesystem, LoggerInterface $logger): self + public static function psr(Implementation $implementation, LoggerInterface $logger): self { - return new self($filesystem, $logger); + return new self($implementation, $logger); } #[\Override] public function exists(TreePath $path): Attempt { return $this - ->filesystem + ->implementation ->exists($path) ->map(function($exists) use ($path) { $this->logger->debug('Cheking if filesystem contains {file}', [ @@ -53,7 +53,7 @@ public function read( Name_\File|Name_\Directory|Name_\Unknown $name, ): Attempt { return $this - ->filesystem + ->implementation ->read($parent, $name) ->map(function($file) use ($parent, $name) { $this->logger->debug('Accessing file {file}', [ @@ -71,14 +71,14 @@ public function list(TreePath $parent): Sequence 'directory' => self::path($parent), ]); - return $this->filesystem->list($parent); + return $this->implementation->list($parent); } #[\Override] public function remove(TreePath $parent, Name $name): Attempt { return $this - ->filesystem + ->implementation ->remove($parent, $name) ->map(function($_) use ($parent, $name) { $this->logger->debug('File removed {file}', [ @@ -93,7 +93,7 @@ public function remove(TreePath $parent, Name $name): Attempt public function createDirectory(TreePath $parent, Name $name): Attempt { return $this - ->filesystem + ->implementation ->createDirectory($parent, $name) ->map(function($_) use ($parent, $name) { $this->logger->debug('Directory created {directory}', [ @@ -108,7 +108,7 @@ public function createDirectory(TreePath $parent, Name $name): Attempt public function write(TreePath $parent, File $file): Attempt { return $this - ->filesystem + ->implementation ->write($parent, $file) ->map(function($_) use ($parent, $file) { $this->logger->debug('File written {file}', [ From 3a74e909bc4ddab922549547fc3be4acd9e9d403 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 17:41:19 +0100 Subject: [PATCH 47/70] bump blackbox --- blackbox.php | 5 +---- composer.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/blackbox.php b/blackbox.php index a8adaf5..a2e2be2 100644 --- a/blackbox.php +++ b/blackbox.php @@ -26,9 +26,6 @@ ) ->scenariiPerProof(1), ) - ->when( - \method_exists(Application::class, 'allowProofsToNotMakeAnyAssertions'), - static fn($app) => $app->allowProofsToNotMakeAnyAssertions(), - ) + ->allowProofsToNotMakeAnyAssertions() ->tryToProve(Load::everythingIn(__DIR__.'/proofs/')) ->exit(); diff --git a/composer.json b/composer.json index 8c70f76..d773af8 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ }, "require-dev": { "innmind/static-analysis": "^1.2.1", - "innmind/black-box": "^6.0.2", + "innmind/black-box": "~6.5", "innmind/coding-standard": "~2.0", "symfony/filesystem": "~6.0|~7.0", "ramsey/uuid": "^4.6" From f297bd47404b3dad82b3cdaf888349a27171278f Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 17:54:51 +0100 Subject: [PATCH 48/70] remove wip --- tests/Adapter/InMemoryTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Adapter/InMemoryTest.php b/tests/Adapter/InMemoryTest.php index 2ddd4eb..7d7e605 100644 --- a/tests/Adapter/InMemoryTest.php +++ b/tests/Adapter/InMemoryTest.php @@ -88,7 +88,6 @@ public function testRoot() ); } - #[\PHPUnit\Framework\Attributes\Group('wip')] public function testEmulateFilesystem() { $adapter = Adapter::inMemory(); From 6b6fa9f8193ece1d098999bd56d501591d90ccd8 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 11 Nov 2025 17:59:53 +0100 Subject: [PATCH 49/70] remove custom exceptions --- CHANGELOG.md | 1 + .../ThrowWhenFlatMappingToSameFileTwice.php | 3 +- .../ThrowWhenMappingToSameFileTwice.php | 3 +- src/Adapter/Filesystem.php | 11 ++++---- src/Directory.php | 16 +++-------- src/Exception/CannotPersistClosedStream.php | 8 ------ src/Exception/DomainException.php | 8 ------ src/Exception/DuplicatedFile.php | 14 ---------- src/Exception/Exception.php | 8 ------ src/Exception/LinksAreNotSupported.php | 8 ------ src/Exception/LogicException.php | 8 ------ .../PathDoesntRepresentADirectory.php | 8 ------ src/Exception/PathTooLong.php | 8 ------ src/Exception/RuntimeException.php | 8 ------ src/File/Content/Line.php | 3 +- src/File/Content/OneShot.php | 3 +- src/Name.php | 15 +++++----- tests/Adapter/FilesystemTest.php | 28 +++++++++++-------- tests/DirectoryTest.php | 5 ++-- tests/File/Content/LineTest.php | 9 ++---- tests/NameTest.php | 19 ++++++------- 21 files changed, 51 insertions(+), 143 deletions(-) delete mode 100644 src/Exception/CannotPersistClosedStream.php delete mode 100644 src/Exception/DomainException.php delete mode 100644 src/Exception/DuplicatedFile.php delete mode 100644 src/Exception/Exception.php delete mode 100644 src/Exception/LinksAreNotSupported.php delete mode 100644 src/Exception/LogicException.php delete mode 100644 src/Exception/PathDoesntRepresentADirectory.php delete mode 100644 src/Exception/PathTooLong.php delete mode 100644 src/Exception/RuntimeException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 19bc6df..e6c4c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - `Innmind\Filesystem\Adapter\InMemory::new()` - `Innmind\Filesystem\Adapter\Filesystem::withCaseSensitivity()`, case sensitivity can be specified as the second argument of `::mount()` +- `Innmind\Filesystem\Exception\*` ## 8.1.0 - 2025-05-09 diff --git a/properties/Directory/ThrowWhenFlatMappingToSameFileTwice.php b/properties/Directory/ThrowWhenFlatMappingToSameFileTwice.php index 9bd1d10..4805bee 100644 --- a/properties/Directory/ThrowWhenFlatMappingToSameFileTwice.php +++ b/properties/Directory/ThrowWhenFlatMappingToSameFileTwice.php @@ -7,7 +7,6 @@ File, Directory, Name, - Exception\DuplicatedFile, }; use Innmind\Immutable\Sequence; use Innmind\BlackBox\{ @@ -71,7 +70,7 @@ public function ensureHeldBy(Assert $assert, object $directory): object } catch (\Exception $e) { $assert ->object($e) - ->instance(DuplicatedFile::class); + ->instance(\LogicException::class); } return $directory; diff --git a/properties/Directory/ThrowWhenMappingToSameFileTwice.php b/properties/Directory/ThrowWhenMappingToSameFileTwice.php index b61a87a..d1d382d 100644 --- a/properties/Directory/ThrowWhenMappingToSameFileTwice.php +++ b/properties/Directory/ThrowWhenMappingToSameFileTwice.php @@ -6,7 +6,6 @@ use Innmind\Filesystem\{ Directory, File, - Exception\DuplicatedFile, }; use Innmind\BlackBox\{ Property, @@ -54,7 +53,7 @@ public function ensureHeldBy(Assert $assert, object $directory): object } catch (\Exception $e) { $assert ->object($e) - ->instance(DuplicatedFile::class); + ->instance(\LogicException::class); } return $directory; diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index e4d0349..43e17a9 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -9,8 +9,6 @@ File\Content, Name, Directory, - Exception\PathDoesntRepresentADirectory, - Exception\LinksAreNotSupported, }; use Innmind\IO\IO; use Innmind\MediaType\MediaType; @@ -39,7 +37,10 @@ public static function mount( ?IO $io = null, ): Attempt { if (!$path->directory()) { - return Attempt::error(new PathDoesntRepresentADirectory($path->toString())); + return Attempt::error(new \LogicException(\sprintf( + "Path doesn't represent a directory '%s'", + $path->toString(), + ))); } return self::doExist($path) @@ -86,7 +87,7 @@ public function read( } if (\is_link($path->toString())) { - return Attempt::error(new LinksAreNotSupported); + return Attempt::error(new \RuntimeException('Links are not supported')); } $file = File::of( @@ -154,7 +155,7 @@ public function remove(TreePath $parent, Name $name): Attempt } if (\is_link($absolutePath)) { - return Attempt::error(new LinksAreNotSupported); + return Attempt::error(new \RuntimeException('Links are not supported')); } if (\is_dir($absolutePath)) { diff --git a/src/Directory.php b/src/Directory.php index 27989a6..63ce2ca 100644 --- a/src/Directory.php +++ b/src/Directory.php @@ -3,7 +3,6 @@ namespace Innmind\Filesystem; -use Innmind\Filesystem\Exception\DuplicatedFile; use Innmind\Immutable\{ Set, Sequence, @@ -31,8 +30,6 @@ private function __construct( * @psalm-pure * * @param Sequence|null $files - * - * @throws DuplicatedFile */ public static function of(Name $name, ?Sequence $files = null): self { @@ -48,8 +45,6 @@ public static function of(Name $name, ?Sequence $files = null): self * * @param non-empty-string $name * @param Sequence|null $files - * - * @throws DuplicatedFile */ public static function named(string $name, ?Sequence $files = null): self { @@ -154,8 +149,6 @@ public function filter(callable $predicate): self /** * @param callable(File|self): File $map - * - * @throws DuplicatedFile */ public function map(callable $map): self { @@ -168,8 +161,6 @@ public function map(callable $map): self /** * @param callable(File|self): self $map - * - * @throws DuplicatedFile */ public function flatMap(callable $map): self { @@ -222,8 +213,6 @@ public function all(): Sequence * * @param Sequence $files * - * @throws DuplicatedFile - * * @return Sequence */ private static function safeguard(Sequence $files): Sequence @@ -231,7 +220,10 @@ private static function safeguard(Sequence $files): Sequence return $files->safeguard( Set::strings(), static fn(Set $names, $file) => match ($names->contains($file->name()->toString())) { - true => throw new DuplicatedFile($file->name()), + true => throw new \LogicException(\sprintf( + "Same file '%s' found multiple times", + $file->name()->toString(), + )), false => ($names)($file->name()->toString()), }, ); diff --git a/src/Exception/CannotPersistClosedStream.php b/src/Exception/CannotPersistClosedStream.php deleted file mode 100644 index fbace93..0000000 --- a/src/Exception/CannotPersistClosedStream.php +++ /dev/null @@ -1,8 +0,0 @@ -toString()}' found multiple times"); - } -} diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php deleted file mode 100644 index 549d910..0000000 --- a/src/Exception/Exception.php +++ /dev/null @@ -1,8 +0,0 @@ -contains("\n")) { - throw new DomainException('New line delimiter should not appear in the line content'); + throw new \DomainException('New line delimiter should not appear in the line content'); } return new self($content); diff --git a/src/File/Content/OneShot.php b/src/File/Content/OneShot.php index f70f1cf..df03fe9 100644 --- a/src/File/Content/OneShot.php +++ b/src/File/Content/OneShot.php @@ -3,7 +3,6 @@ namespace Innmind\Filesystem\File\Content; -use Innmind\Filesystem\Exception\LogicException; use Innmind\IO\{ Streams\Stream, Frame, @@ -118,7 +117,7 @@ public function chunks(): Sequence private function guard(): void { if ($this->loaded) { - throw new LogicException("Content can't be loaded twice"); + throw new \LogicException("Content can't be loaded twice"); } /** @psalm-suppress InaccessibleProperty */ diff --git a/src/Name.php b/src/Name.php index 513bfa8..e285f1a 100644 --- a/src/Name.php +++ b/src/Name.php @@ -3,7 +3,6 @@ namespace Innmind\Filesystem; -use Innmind\Filesystem\Exception\DomainException; use Innmind\Immutable\Str; /** @@ -20,29 +19,29 @@ final class Name private function __construct(string $value) { if (Str::of($value)->empty()) { - throw new DomainException('A file name can\'t be empty'); + throw new \DomainException('A file name can\'t be empty'); } if (Str::of($value, Str\Encoding::ascii)->length() > 255) { - throw new DomainException($value); + throw new \DomainException($value); } if (Str::of($value)->contains('/')) { - throw new DomainException("A file name can't contain a slash, $value given"); + throw new \DomainException("A file name can't contain a slash, $value given"); } if (Str::of($value)->contains(\chr(0))) { - throw new DomainException("A file name can't contain the null control character, $value given"); + throw new \DomainException("A file name can't contain the null control character, $value given"); } // name with only _spaces_ are not accepted as it is not as valid path if (Str::of($value)->matches('~^\s+$~')) { - throw new DomainException($value); + throw new \DomainException($value); } if ($value === '.' || $value === '..') { // as they are special links on unix filesystems - throw new DomainException("'.' and '..' can't be used"); + throw new \DomainException("'.' and '..' can't be used"); } $this->value = $value; @@ -53,7 +52,7 @@ private function __construct(string $value) * * @param non-empty-string $value * - * @throws DomainException + * @throws \DomainException */ public static function of(string $value): self { diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index bffe0c2..1cbf3d5 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -10,8 +10,6 @@ Name, Directory as DirectoryInterface, Directory, - Exception\PathDoesntRepresentADirectory, - Exception\LinksAreNotSupported, }; use Innmind\Url\Path; use Innmind\Immutable\{ @@ -62,8 +60,8 @@ public function testInterface() public function testThrowWhenPathToMountIsNotADirectory() { - $this->expectException(PathDoesntRepresentADirectory::class); - $this->expectExceptionMessage('path/to/somewhere'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage("Path doesn't represent a directory 'path/to/somewhere'"); Adapter::mount(Path::of('path/to/somewhere'))->unwrap(); } @@ -460,10 +458,12 @@ public function testThrowsWhenTryingToGetLink() $filesystem = Adapter::mount(Path::of($path))->unwrap(); - $this->expectException(LinksAreNotSupported::class); - $this->expectExceptionMessage($path.'bar'); - - $filesystem->get(Name::of('bar')); + $this->assertNull( + $filesystem->get(Name::of('bar'))->match( + static fn($file) => $file, + static fn() => null, + ), + ); } public function testThrowsWhenListContainsALink() @@ -475,10 +475,14 @@ public function testThrowsWhenListContainsALink() $filesystem = Adapter::mount(Path::of($path))->unwrap(); - $this->expectException(LinksAreNotSupported::class); - $this->expectExceptionMessage($path.'bar'); - - $filesystem->root()->all()->toList(); + $this->assertSame( + ['foo'], + $filesystem + ->root() + ->all() + ->map(static fn($file) => $file->name()->toString()) + ->toList(), + ); } public function testDotFilesAreListed() diff --git a/tests/DirectoryTest.php b/tests/DirectoryTest.php index 7bfca09..72ce96f 100644 --- a/tests/DirectoryTest.php +++ b/tests/DirectoryTest.php @@ -8,7 +8,6 @@ File, Name, File\Content, - Exception\DuplicatedFile, }; use Innmind\Immutable\{ Set, @@ -195,7 +194,7 @@ public function testDirectoryLoadedWithDifferentFilesWithTheSameNameThrows() FName::any(), ) ->then(function($directory, $file) { - $this->expectException(DuplicatedFile::class); + $this->expectException(\LogicException::class); $this->expectExceptionMessage("Same file '{$file->toString()}' found multiple times"); Directory::of( @@ -216,7 +215,7 @@ public function testNamedDirectoryLoadedWithDifferentFilesWithTheSameNameThrows( FName::any(), ) ->then(function($directory, $file) { - $this->expectException(DuplicatedFile::class); + $this->expectException(\LogicException::class); $this->expectExceptionMessage("Same file '{$file->toString()}' found multiple times"); Directory::named( diff --git a/tests/File/Content/LineTest.php b/tests/File/Content/LineTest.php index a88c2b6..992cced 100644 --- a/tests/File/Content/LineTest.php +++ b/tests/File/Content/LineTest.php @@ -3,10 +3,7 @@ namespace Tests\Innmind\Filesystem\File\Content; -use Innmind\Filesystem\{ - File\Content\Line, - Exception\DomainException, -}; +use Innmind\Filesystem\File\Content\Line; use Innmind\Immutable\Str; use Innmind\BlackBox\{ PHPUnit\BlackBox, @@ -31,7 +28,7 @@ public function testDoesntAcceptNewLineDelimiter() $this->fail('it should throw'); } catch (\Exception $e) { - $this->assertInstanceOf(DomainException::class, $e); + $this->assertInstanceOf(\DomainException::class, $e); } }); } @@ -90,7 +87,7 @@ public function testMappedLineCannotContainEndOfLineDelimiter() $this->fail('it should throw'); } catch (\Exception $e) { - $this->assertInstanceOf(DomainException::class, $e); + $this->assertInstanceOf(\DomainException::class, $e); } }); } diff --git a/tests/NameTest.php b/tests/NameTest.php index de20b19..1060ca6 100644 --- a/tests/NameTest.php +++ b/tests/NameTest.php @@ -3,10 +3,7 @@ namespace Innmind\Filesystem\Tests; -use Innmind\Filesystem\{ - Name, - Exception\DomainException, -}; +use Innmind\Filesystem\Name; use Innmind\Url\Path; use Innmind\Immutable\Str; use Innmind\BlackBox\{ @@ -29,7 +26,7 @@ public function testInterface() public function testThrowWhenABuildingNameWithASlash() { - $this->expectException(DomainException::class); + $this->expectException(\DomainException::class); $this->expectExceptionMessage('A file name can\'t contain a slash, foo/bar given'); Name::of('foo/bar'); @@ -43,7 +40,7 @@ public function testEquals() public function testEmptyNameIsNotAllowed() { - $this->expectException(DomainException::class); + $this->expectException(\DomainException::class); $this->expectExceptionMessage('A file name can\'t be empty'); Name::of(''); @@ -70,7 +67,7 @@ public function testNameContainingASlashIsNotAccepted() Fixture::strings(), ) ->then(function($a, $b) { - $this->expectException(DomainException::class); + $this->expectException(\DomainException::class); Name::of("$a/$b"); }); @@ -116,7 +113,7 @@ public function testDotFoldersAreNotAccepted() Name::of($name); $this->fail('it should throw'); - } catch (DomainException $e) { + } catch (\DomainException $e) { $this->assertSame("'.' and '..' can't be used", $e->getMessage()); } }); @@ -124,7 +121,7 @@ public function testDotFoldersAreNotAccepted() public function testChr0IsNotAccepted() { - $this->expectException(DomainException::class); + $this->expectException(\DomainException::class); Name::of('a'.\chr(0).'a'); } @@ -150,7 +147,7 @@ public function testNamesLongerThan255AreNotAccepted() $this->fail('it should throw'); } catch (\Throwable $e) { - $this->assertInstanceOf(DomainException::class, $e); + $this->assertInstanceOf(\DomainException::class, $e); } }); } @@ -167,7 +164,7 @@ public function testNameWithOnlyWhiteSpacesIsNotAccepted() Name::of(\chr($ord)); $this->fail('it should throw'); - } catch (DomainException $e) { + } catch (\DomainException $e) { $this->assertTrue(true); } }); From c9aa41be027f62961587a26e097f19a1985f12cc Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Tue, 18 Nov 2025 12:11:41 +0100 Subject: [PATCH 50/70] add missing return type --- src/Adapter.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Adapter.php b/src/Adapter.php index 5ebc76a..debb06e 100644 --- a/src/Adapter.php +++ b/src/Adapter.php @@ -37,6 +37,9 @@ private function __construct( $this->loaded = new \WeakMap; } + /** + * @return Attempt + */ public static function mount( Path $path, CaseSensitivity $case = CaseSensitivity::sensitive, From 29202989f4444e93711f2a9dc0ee1d75e2d4d4c4 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 19 Nov 2025 12:02:01 +0100 Subject: [PATCH 51/70] add mechanism to let the caller decide if the mounted directory should be created automatically --- CHANGELOG.md | 5 ++++ proofs/adapter/filesystem.php | 13 ++++++--- src/Adapter.php | 22 ++++++++++++--- src/Adapter/Filesystem.php | 12 +++++++-- src/Exception/MountPathDoesntExist.php | 29 ++++++++++++++++++++ src/Exception/RecoverMount.php | 29 ++++++++++++++++++++ src/Recover.php | 25 +++++++++++++++++ tests/Adapter/FilesystemTest.php | 37 +++++++++++++++++++------- 8 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 src/Exception/MountPathDoesntExist.php create mode 100644 src/Exception/RecoverMount.php create mode 100644 src/Recover.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c4c46..b8d93c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `Innmind\Filesystem\Recover` + ### Changed - Requires PHP `8.4` @@ -12,6 +16,7 @@ - `Innmind\Filesystem\Adapter\Filesystem` is now flagged as internal - `Innmind\Filesystem\Adapter\InMemory` is now flagged as internal - `Innmind\Filesystem\Adapter\Logger` is now flagged as internal +- `Innmind\Filesystem\Adapter::mount()` no longer automatically create the directory if it doesn't exist ### Removed diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index c27bd18..af3e828 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -7,6 +7,7 @@ File, File\Content, CaseSensitivity, + Recover, }; use Innmind\Url\Path; use Properties\Innmind\Filesystem\Adapter as PAdapter; @@ -28,7 +29,9 @@ 'Darwin' => CaseSensitivity::insensitive, default => CaseSensitivity::sensitive, }, - )->unwrap(); + ) + ->recover(Recover::mount(...)) + ->unwrap(); }), ); @@ -44,7 +47,9 @@ 'Darwin' => CaseSensitivity::insensitive, default => CaseSensitivity::sensitive, }, - )->unwrap(); + ) + ->recover(Recover::mount(...)) + ->unwrap(); }), )->named('Filesystem'); } @@ -66,7 +71,9 @@ static function($assert) use ($path) { 'Darwin' => CaseSensitivity::insensitive, default => CaseSensitivity::sensitive, }, - )->unwrap(); + ) + ->recover(Recover::mount(...)) + ->unwrap(); $property->ensureHeldBy($assert, $adapter); diff --git a/src/Adapter.php b/src/Adapter.php index debb06e..f4bbc06 100644 --- a/src/Adapter.php +++ b/src/Adapter.php @@ -10,6 +10,8 @@ Adapter\Filesystem, Adapter\InMemory, Adapter\Logger, + Exception\MountPathDoesntExist, + Exception\RecoverMount, }; use Innmind\IO\IO; use Innmind\Url\Path; @@ -45,10 +47,22 @@ public static function mount( CaseSensitivity $case = CaseSensitivity::sensitive, ?IO $io = null, ): Attempt { - return Filesystem::mount($path, $io)->map(static fn($implementation) => new self( - $implementation, - $case, - )); + return Filesystem::mount($path, $io) + ->map(static fn($implementation) => new self( + $implementation, + $case, + )) + ->mapError(static fn($e) => match (true) { + $e instanceof MountPathDoesntExist => new RecoverMount( + static fn() => $e + ->recover() + ->map(static fn($implementation) => new self( + $implementation, + $case, + )), + ), + default => $e, + }); } public static function inMemory(): self diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 43e17a9..0542265 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -9,6 +9,7 @@ File\Content, Name, Directory, + Exception\MountPathDoesntExist, }; use Innmind\IO\IO; use Innmind\MediaType\MediaType; @@ -43,13 +44,20 @@ public static function mount( ))); } + $io ??= IO::fromAmbientAuthority(); + return self::doExist($path) ->flatMap(static fn($exist) => match ($exist) { - false => self::mkdir($path), + false => Attempt::error(new MountPathDoesntExist( + static fn() => self::mkdir($path)->map(static fn() => new self( + $io, + $path, + )), + )), default => Attempt::result(SideEffect::identity), }) ->map(static fn() => new self( - $io ?? IO::fromAmbientAuthority(), + $io, $path, )); } diff --git a/src/Exception/MountPathDoesntExist.php b/src/Exception/MountPathDoesntExist.php new file mode 100644 index 0000000..2c53a6f --- /dev/null +++ b/src/Exception/MountPathDoesntExist.php @@ -0,0 +1,29 @@ + $recover + */ + public function __construct( + private \Closure $recover, + ) { + } + + /** + * @return Attempt + */ + public function recover(): Attempt + { + return ($this->recover)(); + } +} diff --git a/src/Exception/RecoverMount.php b/src/Exception/RecoverMount.php new file mode 100644 index 0000000..a2764ec --- /dev/null +++ b/src/Exception/RecoverMount.php @@ -0,0 +1,29 @@ + $recover + */ + public function __construct( + private \Closure $recover, + ) { + } + + /** + * @return Attempt + */ + public function recover(): Attempt + { + return ($this->recover)(); + } +} diff --git a/src/Recover.php b/src/Recover.php new file mode 100644 index 0000000..ee183e1 --- /dev/null +++ b/src/Recover.php @@ -0,0 +1,25 @@ + + */ + public static function mount(\Throwable $e): Attempt + { + return match (true) { + $e instanceof RecoverMount => $e->recover(), + default => Attempt::error($e), + }; + } +} diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index 1cbf3d5..b63c574 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -10,6 +10,7 @@ Name, Directory as DirectoryInterface, Directory, + Recover, }; use Innmind\Url\Path; use Innmind\Immutable\{ @@ -232,7 +233,9 @@ public function testLoadWithMediaType() public function testRoot() { - $adapter = Adapter::mount(Path::of('/tmp/test/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/test/')) + ->recover(Recover::mount(...)) + ->unwrap(); $adapter ->add(File::of( Name::of('foo'), @@ -293,7 +296,9 @@ public function testRoot() public function testAddingTheSameFileTwiceDoesNothing() { - $adapter = Adapter::mount(Path::of('/tmp/'))->unwrap(); + $adapter = Adapter::mount(Path::of('/tmp/')) + ->recover(Recover::mount(...)) + ->unwrap(); $file = File::of( Name::of('foo'), Content::ofString('foo'), @@ -322,7 +327,9 @@ public function testPathTooLongThrowAnException() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Path too long'); @@ -362,7 +369,9 @@ public function testPersistedNameCanStartWithAnyAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -395,7 +404,9 @@ public function testPersistedNameCanContainWithAnyAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -430,7 +441,9 @@ public function testPersistedNameCanContainOnlyOneAsciiCharacter() $path = \sys_get_temp_dir().'/innmind/filesystem/'; (new FS)->remove($path); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $this->assertInstanceOf( SideEffect::class, @@ -456,7 +469,9 @@ public function testThrowsWhenTryingToGetLink() (new FS)->dumpFile($path.'foo', 'bar'); \symlink($path.'foo', $path.'bar'); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $this->assertNull( $filesystem->get(Name::of('bar'))->match( @@ -473,7 +488,9 @@ public function testThrowsWhenListContainsALink() (new FS)->dumpFile($path.'foo', 'bar'); \symlink($path.'foo', $path.'bar'); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $this->assertSame( ['foo'], @@ -495,7 +512,9 @@ public function testDotFilesAreListed() (new FS)->mkdir($path); \file_put_contents($path.$name, 'bar'); - $filesystem = Adapter::mount(Path::of($path))->unwrap(); + $filesystem = Adapter::mount(Path::of($path)) + ->recover(Recover::mount(...)) + ->unwrap(); $all = $filesystem->root()->all()->toList(); $this->assertCount(1, $all); From c36151c29935b1899424cc377eab132d73ef04c4 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 13 Dec 2025 16:33:41 +0100 Subject: [PATCH 52/70] remove warnings --- ...hSameNameAsDirectoryDeleteTheDirectory.php | 2 +- ...dRemoveAddModificationsStillAddTheFile.php | 2 +- .../Adapter/AllRootFilesAreAccessible.php | 2 +- .../Adapter/ReAddingFilesHasNoSideEffect.php | 4 +-- ...AddRemoveModificationsDoesntAddTheFile.php | 2 +- properties/Content/Lines.php | 2 +- properties/Content/Size.php | 5 +-- tests/Adapter/FilesystemTest.php | 32 +++++++++---------- tests/Adapter/InMemoryTest.php | 8 ++--- tests/Adapter/LoggerTest.php | 8 ++--- 10 files changed, 34 insertions(+), 33 deletions(-) diff --git a/properties/Adapter/AddFileWithSameNameAsDirectoryDeleteTheDirectory.php b/properties/Adapter/AddFileWithSameNameAsDirectoryDeleteTheDirectory.php index cfa1594..91ffa89 100644 --- a/properties/Adapter/AddFileWithSameNameAsDirectoryDeleteTheDirectory.php +++ b/properties/Adapter/AddFileWithSameNameAsDirectoryDeleteTheDirectory.php @@ -51,7 +51,7 @@ public function applicableTo(object $adapter): bool public function ensureHeldBy(Assert $assert, object $adapter): object { - $adapter->remove($this->file->name())->unwrap(); + $_ = $adapter->remove($this->file->name())->unwrap(); $assert ->object($adapter->add($this->directory)->unwrap()) ->instance(SideEffect::class); diff --git a/properties/Adapter/AddRemoveAddModificationsStillAddTheFile.php b/properties/Adapter/AddRemoveAddModificationsStillAddTheFile.php index 33f484a..2a93d1f 100644 --- a/properties/Adapter/AddRemoveAddModificationsStillAddTheFile.php +++ b/properties/Adapter/AddRemoveAddModificationsStillAddTheFile.php @@ -48,7 +48,7 @@ public function applicableTo(object $adapter): bool public function ensureHeldBy(Assert $assert, object $adapter): object { - $adapter + $_ = $adapter ->add( $this ->directory diff --git a/properties/Adapter/AllRootFilesAreAccessible.php b/properties/Adapter/AllRootFilesAreAccessible.php index 3fe8a37..3c1b2b2 100644 --- a/properties/Adapter/AllRootFilesAreAccessible.php +++ b/properties/Adapter/AllRootFilesAreAccessible.php @@ -30,7 +30,7 @@ public function applicableTo(object $adapter): bool public function ensureHeldBy(Assert $assert, object $adapter): object { - $adapter + $_ = $adapter ->root() ->foreach(static function($file) use ($assert, $adapter) { $assert->true($adapter->contains($file->name())); diff --git a/properties/Adapter/ReAddingFilesHasNoSideEffect.php b/properties/Adapter/ReAddingFilesHasNoSideEffect.php index fd14a52..cd8daa2 100644 --- a/properties/Adapter/ReAddingFilesHasNoSideEffect.php +++ b/properties/Adapter/ReAddingFilesHasNoSideEffect.php @@ -30,10 +30,10 @@ public function applicableTo(object $adapter): bool public function ensureHeldBy(Assert $assert, object $adapter): object { - $adapter + $_ = $adapter ->root() ->foreach(static function($file) use ($assert, $adapter) { - $adapter->add($file)->unwrap(); + $_ = $adapter->add($file)->unwrap(); $assert->true($adapter->contains($file->name())); if ($file instanceof Directory) { diff --git a/properties/Adapter/RemoveAddRemoveModificationsDoesntAddTheFile.php b/properties/Adapter/RemoveAddRemoveModificationsDoesntAddTheFile.php index 5a74719..f7b0603 100644 --- a/properties/Adapter/RemoveAddRemoveModificationsDoesntAddTheFile.php +++ b/properties/Adapter/RemoveAddRemoveModificationsDoesntAddTheFile.php @@ -53,7 +53,7 @@ public function applicableTo(object $adapter): bool public function ensureHeldBy(Assert $assert, object $adapter): object { - $adapter + $_ = $adapter ->add( $this ->directory diff --git a/properties/Content/Lines.php b/properties/Content/Lines.php index 53a0a65..f9fef07 100644 --- a/properties/Content/Lines.php +++ b/properties/Content/Lines.php @@ -28,7 +28,7 @@ public function applicableTo(object $systemUnderTest): bool public function ensureHeldBy(Assert $assert, object $systemUnderTest): object { $content = $assert->string($systemUnderTest->toString()); - $systemUnderTest + $_ = $systemUnderTest ->lines() ->foreach(static fn($line) => $content->contains($line->toString())); diff --git a/properties/Content/Size.php b/properties/Content/Size.php index 9a7a786..ea86aa9 100644 --- a/properties/Content/Size.php +++ b/properties/Content/Size.php @@ -28,12 +28,13 @@ public function applicableTo(object $systemUnderTest): bool public function ensureHeldBy(Assert $assert, object $systemUnderTest): object { $expected = \mb_strlen($systemUnderTest->toString(), 'ascii'); - $systemUnderTest + $size = $systemUnderTest ->size() ->match( - static fn($size) => $assert->same($expected, $size->toInt()), + static fn($size) => $size->toInt(), static fn() => null, ); + $assert->same($expected, $size); return $systemUnderTest; } diff --git a/tests/Adapter/FilesystemTest.php b/tests/Adapter/FilesystemTest.php index b63c574..423e380 100644 --- a/tests/Adapter/FilesystemTest.php +++ b/tests/Adapter/FilesystemTest.php @@ -64,7 +64,7 @@ public function testThrowWhenPathToMountIsNotADirectory() $this->expectException(\LogicException::class); $this->expectExceptionMessage("Path doesn't represent a directory 'path/to/somewhere'"); - Adapter::mount(Path::of('path/to/somewhere'))->unwrap(); + $_ = Adapter::mount(Path::of('path/to/somewhere'))->unwrap(); } public function testReturnNothingWhenGettingUnknownFile() @@ -101,7 +101,7 @@ public function testCreateNestedStructure() Directory::of(Name::of('bar')) ->add(File::of(Name::of('bar.md'), Content::ofString('# Bar'))), ); - $adapter->add($directory)->unwrap(); + $_ = $adapter->add($directory)->unwrap(); $this->assertSame( '# Foo', $adapter @@ -158,7 +158,7 @@ public function testCreateNestedStructure() ), ); - $adapter + $_ = $adapter ->remove(Name::of('foo')) ->unwrap(); } @@ -169,9 +169,9 @@ public function testRemoveFileWhenRemovedFromFolder() $d = Directory::of(Name::of('foo')); $d = $d->add(File::of(Name::of('bar'), Content::ofString('some content'))); - $a->add($d)->unwrap(); + $_ = $a->add($d)->unwrap(); $d = $d->remove(Name::of('bar')); - $a->add($d)->unwrap(); + $_ = $a->add($d)->unwrap(); $this->assertSame(1, $d->removed()->size()); $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); $this->assertFalse( @@ -180,7 +180,7 @@ public function testRemoveFileWhenRemovedFromFolder() static fn() => true, ), ); - $a + $_ = $a ->remove(Name::of('foo')) ->unwrap(); } @@ -191,10 +191,10 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi $d = Directory::of(Name::of('foo')); $d = $d->add(File::of(Name::of('bar'), Content::ofString('some content'))); - $a->add($d)->unwrap(); + $_ = $a->add($d)->unwrap(); $d = $d->remove(Name::of('bar')); - $a->add($d)->unwrap(); - $a->add($d)->unwrap(); + $_ = $a->add($d)->unwrap(); + $_ = $a->add($d)->unwrap(); $this->assertSame(1, $d->removed()->size()); $a = Adapter::mount(Path::of('/tmp/'))->unwrap(); $this->assertFalse( @@ -203,7 +203,7 @@ public function testDoesntFailWhenAddindSameDirectoryTwiceThatContainsARemovedFi static fn() => true, ), ); - $a + $_ = $a ->remove(Name::of('foo')) ->unwrap(); } @@ -226,7 +226,7 @@ public function testLoadWithMediaType() static fn() => null, ), ); - $a + $_ = $a ->remove(Name::of('some_content.html')) ->unwrap(); } @@ -236,7 +236,7 @@ public function testRoot() $adapter = Adapter::mount(Path::of('/tmp/test/')) ->recover(Recover::mount(...)) ->unwrap(); - $adapter + $_ = $adapter ->add(File::of( Name::of('foo'), Content::ofString('foo'), @@ -283,13 +283,13 @@ public function testRoot() static fn() => null, ), ); - $adapter + $_ = $adapter ->remove(Name::of('foo')) ->unwrap(); - $adapter + $_ = $adapter ->remove(Name::of('bar')) ->unwrap(); - $adapter + $_ = $adapter ->remove(Name::of('baz')) ->unwrap(); } @@ -334,7 +334,7 @@ public function testPathTooLongThrowAnException() $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Path too long'); - $filesystem->add(Directory::of( + $_ = $filesystem->add(Directory::of( Name::of(\str_repeat('a', 255)), Sequence::of( Directory::of( diff --git a/tests/Adapter/InMemoryTest.php b/tests/Adapter/InMemoryTest.php index 7d7e605..7fe7d86 100644 --- a/tests/Adapter/InMemoryTest.php +++ b/tests/Adapter/InMemoryTest.php @@ -68,13 +68,13 @@ public function testRemovingUnknownFileDoesntThrow() public function testRoot() { $adapter = Adapter::inMemory(); - $adapter + $_ = $adapter ->add($foo = File::of( Name::of('foo'), Content::ofString('foo'), )) ->unwrap(); - $adapter + $_ = $adapter ->add($bar = File::of( Name::of('bar'), Content::ofString('bar'), @@ -91,14 +91,14 @@ public function testRoot() public function testEmulateFilesystem() { $adapter = Adapter::inMemory(); - $adapter->add(Directory::of( + $_ = $adapter->add(Directory::of( Name::of('foo'), Sequence::of( Directory::named('bar'), File::named('baz', Content::none()), ), ))->unwrap(); - $adapter->add(Directory::of( + $_ = $adapter->add(Directory::of( Name::of('foo'), Sequence::of( Directory::of( diff --git a/tests/Adapter/LoggerTest.php b/tests/Adapter/LoggerTest.php index 99e76d3..28c9679 100644 --- a/tests/Adapter/LoggerTest.php +++ b/tests/Adapter/LoggerTest.php @@ -51,7 +51,7 @@ public function testGet() ); $name = Name::of('foo'); $file = File::of($name, Content::none()); - $inner + $_ = $inner ->add($file) ->unwrap(); @@ -71,7 +71,7 @@ public function testContains() new NullLogger, ); $name = Name::of('foo'); - $inner + $_ = $inner ->add(File::of($name, Content::none())) ->unwrap(); @@ -85,7 +85,7 @@ public function testRemove() new NullLogger, ); $name = Name::of('foo'); - $inner + $_ = $inner ->add(File::of($name, Content::none())) ->unwrap(); @@ -108,7 +108,7 @@ public function testRoot() 'watev', Content::none(), ); - $inner + $_ = $inner ->add($file) ->unwrap(); From 6216fcc5c9ee7b6bf2bf45a6c59d7a41a4191fa9 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 13 Dec 2025 16:37:22 +0100 Subject: [PATCH 53/70] use IO to read file media type --- src/Adapter/Filesystem.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 0542265..bd3ea75 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -104,13 +104,16 @@ public function read( $this->io, $path, ), - MediaType::maybe(match ($mediaType = @\mime_content_type($path->toString())) { - false => '', - default => $mediaType, - })->match( - static fn($mediaType) => $mediaType, - static fn() => null, - ), + $this + ->io + ->files() + ->mediaType($path) + ->maybe() + ->flatMap(MediaType::maybe(...)) + ->match( + static fn($mediaType) => $mediaType, + static fn() => null, + ), ); return Attempt::result($file); From f615ed933dba8f8368e5dfacb71c2ab191244acb Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 13 Dec 2025 16:44:47 +0100 Subject: [PATCH 54/70] use IO to list files --- src/Adapter/Filesystem.php | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index bd3ea75..618ec0c 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -14,7 +14,6 @@ use Innmind\IO\IO; use Innmind\MediaType\MediaType; use Innmind\Url\Path; -use Innmind\Validation\Is; use Innmind\Immutable\{ Sequence, Str, @@ -122,20 +121,14 @@ public function read( #[\Override] public function list(TreePath $parent): Sequence { - return Sequence::lazy(function() use ($parent): \Generator { - $files = new \FilesystemIterator($parent->asPath($this->path)->toString()); - - /** @var \SplFileInfo $file */ - foreach ($files as $file) { - /** @psalm-suppress ArgumentTypeCoercion */ - $name = Name::of($file->getBasename()); - - yield match ($file->isDir()) { - true => Name_\Directory::of($name), - false => Name_\File::of($name), - }; - } - }); + return $this + ->io + ->files() + ->list($parent->asPath($this->path)) + ->map(static fn($name) => match ($name->directory()) { + true => Name_\Directory::of(Name::of($name->toString())), + false => Name_\File::of(Name::of($name->toString())), + }); } /** @@ -170,11 +163,11 @@ public function remove(TreePath $parent, Name $name): Attempt } if (\is_dir($absolutePath)) { - $files = new \FilesystemIterator($absolutePath); - - return Sequence::lazy(static fn() => yield from $files) - ->map(static fn($file) => $file->getBasename()) - ->keep(Is::string()->nonEmpty()->asPredicate()) + return $this + ->io + ->files() + ->list($path->asPath($this->path)) + ->map(static fn($name) => $name->toString()) ->map(Name::of(...)) ->sink(SideEffect::identity) ->attempt(fn($_, $file) => $this->remove($path, $file)) From ceea07583db69ffacd73de59bbc84932a6d77ad3 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 13 Dec 2025 17:12:35 +0100 Subject: [PATCH 55/70] use IO to check if files/directories exist --- src/Adapter/Filesystem.php | 67 ++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 618ec0c..21df120 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -45,7 +45,7 @@ public static function mount( $io ??= IO::fromAmbientAuthority(); - return self::doExist($path) + return self::doExist($io, $path) ->flatMap(static fn($exist) => match ($exist) { false => Attempt::error(new MountPathDoesntExist( static fn() => self::mkdir($path)->map(static fn() => new self( @@ -64,7 +64,7 @@ public static function mount( #[\Override] public function exists(TreePath $path): Attempt { - return self::doExist($path->asPath($this->path)); + return self::doExist($this->io, $path->asPath($this->path)); } #[\Override] @@ -80,21 +80,20 @@ public function read( $path = TreePath::of($name) ->under($parent) ->asPath($this->path); + $directory = TreePath::directory($name) + ->under($parent) + ->asPath($this->path); if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } - if (!\file_exists($path->toString())) { - return Attempt::error(new \RuntimeException('File not found')); - } - - if (\is_dir($path->toString())) { + if ($this->io->files()->exists($directory)) { return Attempt::result(Name_\Directory::of($name)); } - if (\is_link($path->toString())) { - return Attempt::error(new \RuntimeException('Links are not supported')); + if (!$this->io->files()->exists($path)) { + return Attempt::error(new \RuntimeException('File not found')); } $file = File::of( @@ -154,15 +153,11 @@ public function remove(TreePath $parent, Name $name): Attempt return Attempt::error(new \RuntimeException('Path too long')); } - if (!\file_exists($absolutePath)) { + if (!$this->io->files()->exists($path->asPath($this->path))) { return Attempt::result(SideEffect::identity); } - if (\is_link($absolutePath)) { - return Attempt::error(new \RuntimeException('Links are not supported')); - } - - if (\is_dir($absolutePath)) { + if ($this->io->files()->exists(TreePath::directory($name)->under($parent)->asPath($this->path))) { return $this ->io ->files() @@ -221,28 +216,30 @@ public function write(TreePath $parent, File $file): Attempt $absolutePath = TreePath::of($file)->under($parent)->asPath($this->path); $chunks = $file->content()->chunks(); - return self::touch($absolutePath)->flatMap( - fn() => $this - ->io - ->files() - ->write($absolutePath) - ->watch() - ->sink($chunks), - ); + return $this + ->touch($absolutePath) + ->flatMap( + fn() => $this + ->io + ->files() + ->write($absolutePath) + ->watch() + ->sink($chunks), + ); } /** * @return Attempt */ - private static function doExist(Path $path): Attempt + private static function doExist(IO $io, Path $path): Attempt { - $path = $path->toString(); - - if (Str::of($path)->length() > \PHP_MAXPATHLEN) { + if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } - return Attempt::result(@\file_exists($path)); + return Attempt::result( + $io->files()->exists($path), + ); } /** @@ -276,25 +273,23 @@ private static function mkdir(Path $path): Attempt /** * @return Attempt */ - private static function touch(Path $path): Attempt + private function touch(Path $path): Attempt { - $path = $path->toString(); - - if (Str::of($path)->length() > \PHP_MAXPATHLEN) { + if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } - if (!@\touch($path)) { + if (!@\touch($path->toString())) { return Attempt::error(new \RuntimeException(\sprintf( "Failed to create file '%s'", - $path, + $path->toString(), ))); } - if (!\file_exists($path)) { + if (!$this->io->files()->exists($path)) { return Attempt::error(new \RuntimeException(\sprintf( "Failed to create file '%s'", - $path, + $path->toString(), ))); } From 36171387206f32def550c88722227eb03078a249 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 13 Dec 2025 17:42:17 +0100 Subject: [PATCH 56/70] fix removing files starting with the same name as the one added --- proofs/adapter/inMemory.php | 27 ++++++++++++++++++++++++++- src/Adapter/InMemory.php | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/proofs/adapter/inMemory.php b/proofs/adapter/inMemory.php index 558f6a3..7ba4045 100644 --- a/proofs/adapter/inMemory.php +++ b/proofs/adapter/inMemory.php @@ -1,7 +1,12 @@ named('InMemory emulating filesystem'); } + + yield test( + 'Adding a file in a directory should not remove other files starting with the same name', + static function($assert) { + $adapter = Adapter::inMemory(); + $property = new PAdapter\AddDirectoryFromAnotherAdapterWithFileAdded( + Name::of('0'), + File::named( + '+1', + Content::none(), + ), + File::named( + '+', + Content::none(), + ), + ); + + $property->ensureHeldBy($assert, $adapter); + }, + ); }; diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index efe6cf3..a3b03c9 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -96,7 +96,7 @@ public function list(TreePath $parent): Sequence #[\Override] public function remove(TreePath $parent, Name $name): Attempt { - $asDirectory = $this->path(TreePath::of($name)->under($parent)); + $asDirectory = $this->path(TreePath::directory($name)->under($parent)); $this->files = $this ->files ->remove($this->path(TreePath::of($name)->under($parent))) From a06325fee494927d59918a2f12d7a9e25ce4a453 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 13:08:20 +0100 Subject: [PATCH 57/70] use IO to create files/directories --- src/Adapter/Filesystem.php | 70 +++++++++----------------------------- 1 file changed, 17 insertions(+), 53 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 21df120..b6c999a 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -48,10 +48,12 @@ public static function mount( return self::doExist($io, $path) ->flatMap(static fn($exist) => match ($exist) { false => Attempt::error(new MountPathDoesntExist( - static fn() => self::mkdir($path)->map(static fn() => new self( - $io, - $path, - )), + static fn() => self::assert($path) + ->flatMap($io->files()->create(...)) + ->map(static fn() => new self( + $io, + $path, + )), )), default => Attempt::result(SideEffect::identity), }) @@ -203,10 +205,14 @@ public function createDirectory(TreePath $parent, Name $name): Attempt if ($exists) { return $this ->remove($parent, $name) - ->flatMap(static fn() => self::mkdir($absolutePath)); + ->flatMap(fn() => self::assert($absolutePath)->flatMap( + $this->io->files()->create(...), + )); } - return self::mkdir($absolutePath); + return self::assert($absolutePath)->flatMap( + $this->io->files()->create(...), + ); }); } @@ -216,8 +222,8 @@ public function write(TreePath $parent, File $file): Attempt $absolutePath = TreePath::of($file)->under($parent)->asPath($this->path); $chunks = $file->content()->chunks(); - return $this - ->touch($absolutePath) + return self::assert($absolutePath) + ->flatMap($this->io->files()->create(...)) ->flatMap( fn() => $this ->io @@ -243,56 +249,14 @@ private static function doExist(IO $io, Path $path): Attempt } /** - * @return Attempt - */ - private static function mkdir(Path $path): Attempt - { - $path = $path->toString(); - - if (Str::of($path)->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - // 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 + * @return Attempt */ - private function touch(Path $path): Attempt + private static function assert(Path $path): Attempt { if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } - if (!@\touch($path->toString())) { - return Attempt::error(new \RuntimeException(\sprintf( - "Failed to create file '%s'", - $path->toString(), - ))); - } - - if (!$this->io->files()->exists($path)) { - return Attempt::error(new \RuntimeException(\sprintf( - "Failed to create file '%s'", - $path->toString(), - ))); - } - - return Attempt::result(SideEffect::identity); + return Attempt::result($path); } } From 14801f941146291cde1ee0096c383a4ed0df7a55 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 13:17:12 +0100 Subject: [PATCH 58/70] remove duplication of path length verification --- src/Adapter/Filesystem.php | 42 +++++++++++++++----------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index b6c999a..6830e32 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -45,7 +45,8 @@ public static function mount( $io ??= IO::fromAmbientAuthority(); - return self::doExist($io, $path) + return self::assert($path) + ->map($io->files()->exists(...)) ->flatMap(static fn($exist) => match ($exist) { false => Attempt::error(new MountPathDoesntExist( static fn() => self::assert($path) @@ -66,7 +67,9 @@ public static function mount( #[\Override] public function exists(TreePath $path): Attempt { - return self::doExist($this->io, $path->asPath($this->path)); + return self::assert($path->asPath($this->path))->map( + $this->io->files()->exists(...), + ); } #[\Override] @@ -149,42 +152,45 @@ public function list(TreePath $parent): Sequence public function remove(TreePath $parent, Name $name): Attempt { $path = TreePath::of($name)->under($parent); - $absolutePath = $path->asPath($this->path)->toString(); + $absolutePath = $path->asPath($this->path); + $asDirectory = TreePath::directory($name) + ->under($parent) + ->asPath($this->path); - if (Str::of($absolutePath)->length() > \PHP_MAXPATHLEN) { + if (Str::of($absolutePath->toString())->length() > \PHP_MAXPATHLEN) { return Attempt::error(new \RuntimeException('Path too long')); } - if (!$this->io->files()->exists($path->asPath($this->path))) { + if (!$this->io->files()->exists($absolutePath)) { return Attempt::result(SideEffect::identity); } - if ($this->io->files()->exists(TreePath::directory($name)->under($parent)->asPath($this->path))) { + if ($this->io->files()->exists($asDirectory)) { return $this ->io ->files() - ->list($path->asPath($this->path)) + ->list($absolutePath) ->map(static fn($name) => $name->toString()) ->map(Name::of(...)) ->sink(SideEffect::identity) ->attempt(fn($_, $file) => $this->remove($path, $file)) - ->map(static fn() => @\rmdir($absolutePath)) + ->map(static fn() => @\rmdir($absolutePath->toString())) ->flatMap(static fn($removed) => match ($removed) { true => Attempt::result(SideEffect::identity), false => Attempt::error(new \RuntimeException(\sprintf( "Failed to remove directory '%s'", - $absolutePath, + $absolutePath->toString(), ))), }); } - $removed = @\unlink($absolutePath); + $removed = @\unlink($absolutePath->toString()); return match ($removed) { true => Attempt::result(SideEffect::identity), false => Attempt::error(new \RuntimeException(\sprintf( "Failed to remove file '%s'", - $absolutePath, + $absolutePath->toString(), ))), }; } @@ -234,20 +240,6 @@ public function write(TreePath $parent, File $file): Attempt ); } - /** - * @return Attempt - */ - private static function doExist(IO $io, Path $path): Attempt - { - if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - return Attempt::result( - $io->files()->exists($path), - ); - } - /** * @return Attempt */ From 03188866edf1a27a42364108942aa5902c71dee8 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 13:50:43 +0100 Subject: [PATCH 59/70] use IO to remove files --- src/Adapter/Filesystem.php | 43 ++++---------------------------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 6830e32..0311655 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -151,48 +151,13 @@ public function list(TreePath $parent): Sequence #[\Override] public function remove(TreePath $parent, Name $name): Attempt { - $path = TreePath::of($name)->under($parent); - $absolutePath = $path->asPath($this->path); - $asDirectory = TreePath::directory($name) + $path = TreePath::of($name) ->under($parent) ->asPath($this->path); - if (Str::of($absolutePath->toString())->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - if (!$this->io->files()->exists($absolutePath)) { - return Attempt::result(SideEffect::identity); - } - - if ($this->io->files()->exists($asDirectory)) { - return $this - ->io - ->files() - ->list($absolutePath) - ->map(static fn($name) => $name->toString()) - ->map(Name::of(...)) - ->sink(SideEffect::identity) - ->attempt(fn($_, $file) => $this->remove($path, $file)) - ->map(static fn() => @\rmdir($absolutePath->toString())) - ->flatMap(static fn($removed) => match ($removed) { - true => Attempt::result(SideEffect::identity), - false => Attempt::error(new \RuntimeException(\sprintf( - "Failed to remove directory '%s'", - $absolutePath->toString(), - ))), - }); - } - - $removed = @\unlink($absolutePath->toString()); - - return match ($removed) { - true => Attempt::result(SideEffect::identity), - false => Attempt::error(new \RuntimeException(\sprintf( - "Failed to remove file '%s'", - $absolutePath->toString(), - ))), - }; + return self::assert($path)->flatMap( + $this->io->files()->remove(...), + ); } #[\Override] From 5609332e1579e0af31a4a20f3dee4bacf655ee1b Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 14:02:19 +0100 Subject: [PATCH 60/70] avoid validating the same path multiple times --- src/Adapter/Filesystem.php | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 0311655..3cec019 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -49,8 +49,9 @@ public static function mount( ->map($io->files()->exists(...)) ->flatMap(static fn($exist) => match ($exist) { false => Attempt::error(new MountPathDoesntExist( - static fn() => self::assert($path) - ->flatMap($io->files()->create(...)) + static fn() => $io + ->files() + ->create($path) ->map(static fn() => new self( $io, $path, @@ -163,27 +164,32 @@ public function remove(TreePath $parent, Name $name): Attempt #[\Override] public function createDirectory(TreePath $parent, Name $name): Attempt { - $path = TreePath::directory($name)->under($parent); - $absolutePath = $path->asPath($this->path); + $path = TreePath::directory($name) + ->under($parent) + ->asPath($this->path); - return $this - ->exists($path) - ->flatMap(function($exists) use ($parent, $name, $absolutePath) { - if ($exists && \is_dir($absolutePath->toString())) { + return self::assert($path) + ->map($this->io->files()->exists(...)) + ->flatMap(function($exists) use ($parent, $name, $path) { + if ($exists && \is_dir($path->toString())) { return Attempt::result(SideEffect::identity); } if ($exists) { return $this ->remove($parent, $name) - ->flatMap(fn() => self::assert($absolutePath)->flatMap( - $this->io->files()->create(...), - )); + ->flatMap( + fn() => $this + ->io + ->files() + ->create($path), + ); } - return self::assert($absolutePath)->flatMap( - $this->io->files()->create(...), - ); + return $this + ->io + ->files() + ->create($path); }); } From a4e34038d74d21da614c13e0b9265a56f48bf4bc Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 14:54:54 +0100 Subject: [PATCH 61/70] improve the way to access different kinds of files --- src/Adapter/Filesystem.php | 64 ++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 3cec019..2eb4af9 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -8,10 +8,12 @@ File, File\Content, Name, - Directory, Exception\MountPathDoesntExist, }; -use Innmind\IO\IO; +use Innmind\IO\{ + IO, + Files, +}; use Innmind\MediaType\MediaType; use Innmind\Url\Path; use Innmind\Immutable\{ @@ -90,37 +92,27 @@ public function read( ->under($parent) ->asPath($this->path); - if (Str::of($path->toString())->length() > \PHP_MAXPATHLEN) { - return Attempt::error(new \RuntimeException('Path too long')); - } - - if ($this->io->files()->exists($directory)) { - return Attempt::result(Name_\Directory::of($name)); - } - - if (!$this->io->files()->exists($path)) { - return Attempt::error(new \RuntimeException('File not found')); - } - - $file = File::of( - $name, - Content::atPath( - $this->io, - $path, - ), - $this - ->io - ->files() - ->mediaType($path) - ->maybe() - ->flatMap(MediaType::maybe(...)) - ->match( - static fn($mediaType) => $mediaType, - static fn() => null, + return self::assert($path) + ->flatMap($this->io->files()->access(...)) + ->flatMap(static fn($file) => match (true) { + $file instanceof Files\Link => Attempt::error(new \RuntimeException('Links are not supported')), + default => Attempt::result($file), + }) + ->map(static fn($file) => match (true) { + $file instanceof Files\Directory => Name_\Directory::of($name), + default => File::of( + $name, + Content::io($file->read()), + $file + ->mediaType() + ->maybe() + ->flatMap(MediaType::maybe(...)) + ->match( + static fn($mediaType) => $mediaType, + static fn() => null, + ), ), - ); - - return Attempt::result($file); + }); } #[\Override] @@ -129,7 +121,13 @@ public function list(TreePath $parent): Sequence return $this ->io ->files() - ->list($parent->asPath($this->path)) + ->access($parent->asPath($this->path)) + ->flatMap(static fn($file) => match (true) { + $file instanceof Files\Directory => Attempt::result($file), + default => Attempt::error(new \RuntimeException('Path is not a directory')), + }) + ->unwrap() // todo silently return an empty sequence ? + ->list() ->map(static fn($name) => match ($name->directory()) { true => Name_\Directory::of(Name::of($name->toString())), false => Name_\File::of(Name::of($name->toString())), From 862db59d89755555077b2b875cbbe7e9cfe0bebd Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 15:02:05 +0100 Subject: [PATCH 62/70] remove Content::atPath() --- CHANGELOG.md | 1 + proofs/file/content.php | 19 ++----- src/File/Content.php | 12 ---- src/File/Content/AtPath.php | 109 ------------------------------------ 4 files changed, 7 insertions(+), 134 deletions(-) delete mode 100644 src/File/Content/AtPath.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d93c0..911b0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - `Innmind\Filesystem\Adapter\InMemory::new()` - `Innmind\Filesystem\Adapter\Filesystem::withCaseSensitivity()`, case sensitivity can be specified as the second argument of `::mount()` - `Innmind\Filesystem\Exception\*` +- `Innmind\Filesystem\File\Content::atPath()`, use `Content::io()` instead ## 8.1.0 - 2025-05-09 diff --git a/proofs/file/content.php b/proofs/file/content.php index 0b9ff5b..459196e 100644 --- a/proofs/file/content.php +++ b/proofs/file/content.php @@ -26,15 +26,6 @@ static fn($lines) => Model::ofString(\implode("\n", $lines)), ), ], - [ - 'Content::atPath()', - Set::of('LICENSE', 'CHANGELOG.md', 'composer.json') - ->map(Path::of(...)) - ->map(static fn($path) => Model::atPath( - $io, - $path, - )), - ], [ 'Content::io()', Set::of('LICENSE', 'CHANGELOG.md', 'composer.json') @@ -262,10 +253,12 @@ static function($assert, $a, $b) use ($io) { yield test( 'Content::ofChunks()->size() does not load the whole file in memory', static function($assert) use ($io) { - $atPath = Model::atPath( - $io, - Path::of('samples/sample.pdf'), - ); + $atPath = $io + ->files() + ->access(Path::of('samples/sample.pdf')) + ->map(static fn($file) => $file->read()) + ->map(Model::io(...)) + ->unwrap(); $content = Model::ofChunks($atPath->chunks()); $assert diff --git a/src/File/Content.php b/src/File/Content.php index 08a5150..22e0ba0 100644 --- a/src/File/Content.php +++ b/src/File/Content.php @@ -8,12 +8,10 @@ Line, }; use Innmind\IO\{ - IO, Streams\Stream, Files\Read, Stream\Size, }; -use Innmind\Url\Path; use Innmind\Immutable\{ Str, Sequence, @@ -30,16 +28,6 @@ private function __construct(private Implementation $implementation) { } - /** - * @psalm-pure - */ - public static function atPath( - IO $io, - Path $path, - ): self { - return new self(Content\AtPath::of($io, $path)); - } - /** * @psalm-pure */ diff --git a/src/File/Content/AtPath.php b/src/File/Content/AtPath.php deleted file mode 100644 index 57181d0..0000000 --- a/src/File/Content/AtPath.php +++ /dev/null @@ -1,109 +0,0 @@ -lines()->foreach($function); - } - - #[\Override] - public function map(callable $map): Implementation - { - return Lines::of($this->lines()->map($map)); - } - - #[\Override] - public function flatMap(callable $map): Implementation - { - return Lines::of($this->lines())->flatMap($map); - } - - #[\Override] - public function filter(callable $filter): Implementation - { - return Lines::of($this->lines()->filter($filter)); - } - - #[\Override] - public function lines(): Sequence - { - /** @psalm-suppress ImpureMethodCall */ - return $this - ->io - ->files() - ->read($this->path) - ->watch() - ->lines() - ->map(Line::fromStream(...)); - } - - #[\Override] - public function reduce($carry, callable $reducer) - { - return $this->lines()->reduce($carry, $reducer); - } - - #[\Override] - public function size(): Maybe - { - /** @psalm-suppress ImpureMethodCall */ - return $this - ->io - ->files() - ->read($this->path) - ->size(); - } - - #[\Override] - public function toString(): string - { - return $this - ->chunks() - ->fold(new Concat) - ->toString(); - } - - #[\Override] - public function chunks(): Sequence - { - /** @psalm-suppress ImpureMethodCall */ - return $this - ->io - ->files() - ->read($this->path) - ->watch() - ->chunks(8192); - } -} From 394f8e36b7dab446d922b717e69b09ad9a3c59b5 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 15:27:38 +0100 Subject: [PATCH 63/70] rely on the kind of file to know what to do --- src/Adapter/Filesystem.php | 41 ++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 2eb4af9..7fb8062 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -88,9 +88,6 @@ public function read( $path = TreePath::of($name) ->under($parent) ->asPath($this->path); - $directory = TreePath::directory($name) - ->under($parent) - ->asPath($this->path); return self::assert($path) ->flatMap($this->io->files()->access(...)) @@ -167,27 +164,28 @@ public function createDirectory(TreePath $parent, Name $name): Attempt ->asPath($this->path); return self::assert($path) - ->map($this->io->files()->exists(...)) - ->flatMap(function($exists) use ($parent, $name, $path) { - if ($exists && \is_dir($path->toString())) { - return Attempt::result(SideEffect::identity); - } - - if ($exists) { - return $this + ->map($this->io->files()->access(...)) + ->flatMap(fn($file) => $file->eitherWay( + fn($file) => match (true) { + $file instanceof Files\Link => Attempt::error(new \RuntimeException('Links are not supported')), + $file instanceof Files\Directory => Attempt::result($file), + default => $this ->remove($parent, $name) ->flatMap( fn() => $this ->io ->files() ->create($path), - ); - } - - return $this + ), + }, + fn() => $this ->io ->files() - ->create($path); + ->create($path), + )) + ->flatMap(static fn($file) => match (true) { + $file instanceof Files\Directory => Attempt::result(SideEffect::identity), + default => Attempt::error(new \RuntimeException('File created instead of a directory')), }); } @@ -199,14 +197,13 @@ public function write(TreePath $parent, File $file): Attempt return self::assert($absolutePath) ->flatMap($this->io->files()->create(...)) - ->flatMap( - fn() => $this - ->io - ->files() - ->write($absolutePath) + ->flatMap(static fn($file) => match (true) { + $file instanceof Files\Directory => Attempt::error(new \RuntimeException('Directory created instead of a file')), + default => $file + ->write() ->watch() ->sink($chunks), - ); + }); } /** From 20b78b6df7495f28581c990c94a5cd851154b9bc Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 15:31:01 +0100 Subject: [PATCH 64/70] let the read function handles links listed in a directory --- src/Adapter/Filesystem.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 7fb8062..d75d856 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -125,9 +125,9 @@ public function list(TreePath $parent): Sequence }) ->unwrap() // todo silently return an empty sequence ? ->list() - ->map(static fn($name) => match ($name->directory()) { - true => Name_\Directory::of(Name::of($name->toString())), - false => Name_\File::of(Name::of($name->toString())), + ->map(static fn($name) => match ($name->kind()) { + Files\Kind::directory => Name_\Directory::of(Name::of($name->toString())), + default => Name_\File::of(Name::of($name->toString())), // let the read function handle the links }); } From 8bf8d4d748a0dd5e3a272a0345b8adc08205596f Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 15:39:35 +0100 Subject: [PATCH 65/70] fail when trying to remove a link --- src/Adapter/Filesystem.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index d75d856..045c0d0 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -151,9 +151,15 @@ public function remove(TreePath $parent, Name $name): Attempt ->under($parent) ->asPath($this->path); - return self::assert($path)->flatMap( - $this->io->files()->remove(...), - ); + return self::assert($path) + ->map($this->io->files()->access(...)) + ->flatMap(static fn($file) => $file->eitherWay( + static fn($file) => match (true) { + $file instanceof Files\Link => Attempt::error(new \RuntimeException('Links are not supported')), + default => $file->remove(), + }, + static fn() => Attempt::result(SideEffect::identity), + )); } #[\Override] From 5feb57f082773ce9986979768f1735941e415474 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 14 Dec 2025 17:51:46 +0100 Subject: [PATCH 66/70] add missing dependency --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d773af8..f54b67a 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "innmind/io": "dev-next", "innmind/validation": "dev-next", "innmind/time-continuum": "dev-next", - "innmind/ip": "dev-next" + "innmind/ip": "dev-next", + "innmind/mutable": "dev-next" }, "autoload": { "psr-4": { From c5fc22e2ba1a99d48875289be25705f64c0ee343 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 15 Dec 2025 18:16:47 +0100 Subject: [PATCH 67/70] add extensive ci --- .github/workflows/extensive.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/extensive.yml 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 From 3ad26a6947b7fceb61dc1f94fe35cdcb285d7852 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 15 Dec 2025 18:17:52 +0100 Subject: [PATCH 68/70] run properties on a simulated disk --- proofs/adapter/filesystem.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index af3e828..78f5c31 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -9,6 +9,10 @@ CaseSensitivity, Recover, }; +use Innmind\IO\{ + IO, + Simulation\Disk, +}; use Innmind\Url\Path; use Properties\Innmind\Filesystem\Adapter as PAdapter; use Innmind\BlackBox\Set; @@ -80,4 +84,31 @@ static function($assert) use ($path) { (new FS)->remove($path); }, ); + + yield properties( + 'Filesystem properties on simulated disk', + PAdapter::properties(), + Set::call(static fn() => Adapter::mount( + Path::of('/'), + CaseSensitivity::sensitive, + IO::simulation( + IO::fromAmbientAuthority(), + Disk::new(), + ), + )->unwrap()), + ); + + foreach (PAdapter::alwaysApplicable() as $property) { + yield property( + $property, + Set::call(static fn() => Adapter::mount( + Path::of('/'), + CaseSensitivity::sensitive, + IO::simulation( + IO::fromAmbientAuthority(), + Disk::new(), + ), + )->unwrap()), + )->named('Filesystem on simulated disk'); + } }; From 9c9ef47d3cee93ac8cdfb0cdded2b2c3168b9e55 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 17 Dec 2025 17:05:37 +0100 Subject: [PATCH 69/70] test case insensitive simulated disks --- proofs/adapter/filesystem.php | 38 ++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/proofs/adapter/filesystem.php b/proofs/adapter/filesystem.php index 78f5c31..b6c4c47 100644 --- a/proofs/adapter/filesystem.php +++ b/proofs/adapter/filesystem.php @@ -85,30 +85,32 @@ static function($assert) use ($path) { }, ); - yield properties( - 'Filesystem properties on simulated disk', - PAdapter::properties(), - Set::call(static fn() => Adapter::mount( - Path::of('/'), - CaseSensitivity::sensitive, - IO::simulation( - IO::fromAmbientAuthority(), - Disk::new(), - ), - )->unwrap()), - ); - - foreach (PAdapter::alwaysApplicable() as $property) { - yield property( - $property, + foreach (CaseSensitivity::cases() as $case) { + yield properties( + 'Filesystem properties on simulated disk', + PAdapter::properties(), Set::call(static fn() => Adapter::mount( Path::of('/'), - CaseSensitivity::sensitive, + $case, IO::simulation( IO::fromAmbientAuthority(), Disk::new(), ), )->unwrap()), - )->named('Filesystem on simulated disk'); + ); + + foreach (PAdapter::alwaysApplicable() as $property) { + yield property( + $property, + Set::call(static fn() => Adapter::mount( + Path::of('/'), + $case, + IO::simulation( + IO::fromAmbientAuthority(), + Disk::new(), + ), + )->unwrap()), + )->named('Filesystem on simulated disk'); + } } }; From 026ec1bfb4b8766a2e418781a39317c9ea18aed3 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 17 Dec 2025 17:16:26 +0100 Subject: [PATCH 70/70] rename read into access for consistency with innmind/io --- src/Adapter.php | 10 +++++----- src/Adapter/Filesystem.php | 2 +- src/Adapter/Implementation.php | 2 +- src/Adapter/InMemory.php | 6 +++--- src/Adapter/Logger.php | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Adapter.php b/src/Adapter.php index f4bbc06..53a9c21 100644 --- a/src/Adapter.php +++ b/src/Adapter.php @@ -96,7 +96,7 @@ public function add(File|Directory $file): Attempt */ public function get(Name $file): Maybe { - return $this->read( + return $this->access( TreePath::root(), Name_\Unknown::of($file), ); @@ -127,7 +127,7 @@ public function root(): Directory $this ->implementation ->list($root) - ->map(fn($name) => $this->read($root, $name)) + ->map(fn($name) => $this->access($root, $name)) ->flatMap(static fn($read) => $read->toSequence()), ); } @@ -135,7 +135,7 @@ public function root(): Directory /** * @return Maybe */ - private function read( + private function access( TreePath $path, Name_\File|Name_\Directory|Name_\Unknown $name, ): Maybe { @@ -143,7 +143,7 @@ private function read( return $this ->implementation - ->read($path, $name) + ->access($path, $name) ->maybe() ->map(fn($file) => match (true) { $file instanceof File => $file, @@ -152,7 +152,7 @@ private function read( $this ->implementation ->list(TreePath::directory($name->unwrap())->under($path)) - ->map(fn($file) => $this->read( + ->map(fn($file) => $this->access( $fullPath, $file, )) diff --git a/src/Adapter/Filesystem.php b/src/Adapter/Filesystem.php index 045c0d0..633247c 100644 --- a/src/Adapter/Filesystem.php +++ b/src/Adapter/Filesystem.php @@ -76,7 +76,7 @@ public function exists(TreePath $path): Attempt } #[\Override] - public function read( + public function access( TreePath $parent, Name_\File|Name_\Directory|Name_\Unknown $name, ): Attempt { diff --git a/src/Adapter/Implementation.php b/src/Adapter/Implementation.php index 54c074d..5139275 100644 --- a/src/Adapter/Implementation.php +++ b/src/Adapter/Implementation.php @@ -27,7 +27,7 @@ public function exists(TreePath $path): Attempt; /** * @return Attempt */ - public function read( + public function access( TreePath $parent, Name_\File|Name_\Directory|Name_\Unknown $name, ): Attempt; diff --git a/src/Adapter/InMemory.php b/src/Adapter/InMemory.php index a3b03c9..04d9895 100644 --- a/src/Adapter/InMemory.php +++ b/src/Adapter/InMemory.php @@ -49,7 +49,7 @@ public function exists(TreePath $path): Attempt } #[\Override] - public function read( + public function access( TreePath $parent, Name_\File|Name_\Directory|Name_\Unknown $name, ): Attempt { @@ -69,8 +69,8 @@ public function read( } return $this - ->read($parent, Name_\Directory::of($name->unwrap())) - ->recover(fn() => $this->read( + ->access($parent, Name_\Directory::of($name->unwrap())) + ->recover(fn() => $this->access( $parent, Name_\File::of($name->unwrap()), )); diff --git a/src/Adapter/Logger.php b/src/Adapter/Logger.php index 3d677a8..13e68dd 100644 --- a/src/Adapter/Logger.php +++ b/src/Adapter/Logger.php @@ -48,13 +48,13 @@ public function exists(TreePath $path): Attempt } #[\Override] - public function read( + public function access( TreePath $parent, Name_\File|Name_\Directory|Name_\Unknown $name, ): Attempt { return $this ->implementation - ->read($parent, $name) + ->access($parent, $name) ->map(function($file) use ($parent, $name) { $this->logger->debug('Accessing file {file}', [ 'file' => self::path(TreePath::of($name->unwrap())->under($parent)),