From d13e04021a2f967006d0f4bb61c3f0a9cc0e03cd Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 9 Nov 2025 13:21:35 +0100 Subject: [PATCH 01/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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)), From 7d7a6d382cf005b65f3a5e68862fad2c7daeea03 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:19:52 +0100 Subject: [PATCH 71/77] take into account the number of scenarii per proof --- blackbox.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/blackbox.php b/blackbox.php index a2e2be2..6195716 100644 --- a/blackbox.php +++ b/blackbox.php @@ -12,6 +12,10 @@ Application::new($argv) ->disableMemoryLimit() // because the generated trees can be quite large ->scenariiPerProof(20) + ->when( + \getenv('BLACKBOX_SET_SIZE') !== false, + static fn(Application $app) => $app->scenariiPerProof((int) \getenv('BLACKBOX_SET_SIZE')), + ) ->when( \getenv('ENABLE_COVERAGE') !== false, static fn(Application $app) => $app From b02113796ed3949a7dff6a686b120bac20644c89 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:21:48 +0100 Subject: [PATCH 72/77] tag dependencies --- .github/workflows/ci.yml | 8 ++++---- composer.json | 17 +++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75d191e..bfa5286 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,13 @@ on: [push] jobs: blackbox: - uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@next + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main with: scenarii: 20 coverage: - uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@next + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main secrets: inherit psalm: - uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@next + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main cs: - uses: innmind/github-workflows/.github/workflows/cs.yml@next + uses: innmind/github-workflows/.github/workflows/cs.yml@main diff --git a/composer.json b/composer.json index f54b67a..8100d45 100644 --- a/composer.json +++ b/composer.json @@ -16,15 +16,12 @@ }, "require": { "php": "~8.4", - "innmind/immutable": "dev-next", - "innmind/media-type": "dev-next", - "innmind/url": "dev-next", + "innmind/immutable": "~6.0", + "innmind/media-type": "~3.0", + "innmind/url": "~5.0", "psr/log": "~3.0", - "innmind/io": "dev-next", - "innmind/validation": "dev-next", - "innmind/time-continuum": "dev-next", - "innmind/ip": "dev-next", - "innmind/mutable": "dev-next" + "innmind/io": "~4.0", + "innmind/validation": "~3.0" }, "autoload": { "psr-4": { @@ -39,10 +36,10 @@ } }, "require-dev": { - "innmind/static-analysis": "^1.2.1", + "innmind/static-analysis": "~1.3", "innmind/black-box": "~6.5", "innmind/coding-standard": "~2.0", - "symfony/filesystem": "~6.0|~7.0", + "symfony/filesystem": "~6.0|~7.0|~8.0", "ramsey/uuid": "^4.6" }, "conflict": { From cfdc73cd1804f8abf825f1052c104c91ffbbb8af Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:36:56 +0100 Subject: [PATCH 73/77] fix documentation --- README.md | 9 +++---- documentation/case_sensitivity.md | 10 ++++--- documentation/testing/in_app.md | 8 +++--- documentation/testing/own_adapter.md | 27 ------------------- documentation/use_cases/backup_directory.md | 6 ++--- documentation/use_cases/delete_file.md | 8 +++--- documentation/use_cases/load_ftp_files.md | 7 ++--- documentation/use_cases/modify_file.md | 16 +++++------ documentation/use_cases/persist_hand_file.md | 12 ++++----- .../use_cases/persist_process_output.md | 4 +-- .../use_cases/persist_uploaded_file.md | 6 ++--- documentation/use_cases/read_file.md | 8 +++--- mkdocs.yml | 1 - 13 files changed, 48 insertions(+), 74 deletions(-) delete mode 100644 documentation/testing/own_adapter.md diff --git a/README.md b/README.md index 9feabc5..ac45b6c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ use Innmind\Filesystem\{ File, File\Content, Directory, - Adapter\Filesystem, + Adapter, }; use Innmind\Url\Path; @@ -34,7 +34,7 @@ $directory = Directory::named('uploads')->add( Content::ofString(\file_get_contents($_FILES['my_upload']['tmp_name'])), ), ); -$adapter = Filesystem::mount(Path::of('/var/www/web/')); +$adapter = Adapter::mount(Path::of('/var/www/web/'))->unwrap(); $_ = $adapter ->add($directory) ->unwrap(); @@ -42,6 +42,5 @@ $_ = $adapter This example show you how you can create a new directory `uploads` in the folder `/var/www/web/` of your filesystem and create the uploaded file into it. -**Note**: For performance reasons the filesystem adapter only persist to disk the files that have changed (achievable via the immutable nature of file objects). - -All adapters implements [`Adapter`](src/Adapter.php), so you can easily replace them; especially for unit tests, that's why the library comes with an [`InMemory`](src/Adapter/InMemory.php) adapter that only keeps the files into memory so you won't mess up your file system. +> [!NOTE] +> For performance reasons the filesystem adapter only persist to disk the files that have changed (achievable via the immutable nature of file objects). diff --git a/documentation/case_sensitivity.md b/documentation/case_sensitivity.md index a736b68..52c53d1 100644 --- a/documentation/case_sensitivity.md +++ b/documentation/case_sensitivity.md @@ -12,14 +12,16 @@ If you're dealing with a case insensitive filesystem then you need to specify it ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, CaseSensitivity, }; use Innmind\Url\Path; -$adapter = Filesystem::mount(Path::of('somewhere/')) - ->withCaseSensitivity(CaseSensitivity::insensitive); -$adapter instanceof Filesystem; // true, use $adapter as usual +$adapter = Adapter::mount( + Path::of('somewhere/'), + CaseSensitivity::insensitive, +)->unwrap() +$adapter instanceof Adapter; // true, use $adapter as usual ``` !!! tip "" diff --git a/documentation/testing/in_app.md b/documentation/testing/in_app.md index 52349c9..d21617d 100644 --- a/documentation/testing/in_app.md +++ b/documentation/testing/in_app.md @@ -1,12 +1,12 @@ # In your application -In your application you'll use most of the time the `Filesystem` adapter but in your tests writing to the filesystem can be slow. Instead you can use the `InMemory` adapter. +In your application you'll use most of the time the `Adapter::mount()` adapter but in your tests writing to the filesystem can be slow. Instead you can use the `Adapter::inMemory()` one. ```php -use Innmind\Filesystem\Adapter\InMemory; +use Innmind\Filesystem\Adapter; -$filesystem = InMemory::emulateFilesystem(); +$filesystem = Adapter::inMemory(); // use $filesystem as usual ``` -This adapter is tested against the same [properties](own_adapter.md) as `Filesystem` to make sure there is no divergence of behaviour between the two. +This adapter is tested against the same [properties](https://innmind.org/BlackBox/getting-started/property/) as `Adapter::mount()` to make sure there is no divergence of behaviour between the two. diff --git a/documentation/testing/own_adapter.md b/documentation/testing/own_adapter.md deleted file mode 100644 index d1eb406..0000000 --- a/documentation/testing/own_adapter.md +++ /dev/null @@ -1,27 +0,0 @@ -# Your own adapter - -This library allows you to extend its behaviour by creating new implementations of the exposed interface `Adapter`. The interface is strict enough to guide you through the expected behaviour but the type system can't express all of them, leaving the door open to inconsistencies between implementations. That's why the library expose a set of properties (as declared by [`innmind/black-box`](https://packagist.org/packages/innmind/black-box)) to help you make sure your implementations fulfill the expected behaviours. - -You can test properties on your adapter as follow: - -```php -use Properties\Innmind\Filesystem\Adapter; -use Innmind\BlackBox\Set; - -return static function() { - yield properties( - 'YourAdapter', - Adapter::properties(), - Set::call(fn() => /* instanciate YourAdapter here */), - ); - - foreach (Adapter::alwaysApplicable() as $property) { - yield property( - $property, - Set::call(fn() => /* instanciate YourAdapter here */), - )->named('YourAdapter'); - } -}; -``` - -Then you can [run your proofs](https://innmind.github.io/BlackBox/organize/) via BlackBox. diff --git a/documentation/use_cases/backup_directory.md b/documentation/use_cases/backup_directory.md index b409fbd..bcc75ed 100644 --- a/documentation/use_cases/backup_directory.md +++ b/documentation/use_cases/backup_directory.md @@ -1,11 +1,11 @@ # Backup a directory ```php -use Innmind\Filesystem\Adapter\Filesystem; +use Innmind\Filesystem\Adapter; use Innmind\Url\Path; -$source = Filesystem::mount(Path::of('/var/data/')); -$backup = Filesystem::mount(Path::of('/volumes/backup/')); +$source = Adapter::mount(Path::of('/var/data/'))->unwrap(); +$backup = Adapter::mount(Path::of('/volumes/backup/'))->unwrap(); $source ->root() ->foreach(static fn($file) => $backup->add($file)->unwrap()); diff --git a/documentation/use_cases/delete_file.md b/documentation/use_cases/delete_file.md index 30505b1..2fae923 100644 --- a/documentation/use_cases/delete_file.md +++ b/documentation/use_cases/delete_file.md @@ -4,12 +4,12 @@ ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, Name, }; use Innmind\Url\Path; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $_ = $filesystem ->remove(Name::of('some file')) ->unwrap(); @@ -21,14 +21,14 @@ If the file doesn't exist it will do nothing and if the name corresponds to a di ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, Name, Directory, }; use Innmind\Url\Path; use Innmind\Immutable\Predicate\Instance; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $filesystem ->get(Name::of('some directory')) ->keep(Instance::of(Directory::class)) //(1) diff --git a/documentation/use_cases/load_ftp_files.md b/documentation/use_cases/load_ftp_files.md index ee3a802..d70de64 100644 --- a/documentation/use_cases/load_ftp_files.md +++ b/documentation/use_cases/load_ftp_files.md @@ -4,7 +4,7 @@ Say you have a client that push csv files in an unstructured manner inside a FTP ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, Directory, }; @@ -24,11 +24,12 @@ function flatten(File|Directory $file): Sequence return Sequence::of($file); } -Filesystem::mount(Path::of('/path/to/ftp/directory/')) +Adapter::mount(Path::of('/path/to/ftp/directory/')) + ->unwrap() ->root() ->all() ->flatMap(flatten(...)) ->foreach(static fn(File $csv) => doYourStuff($csv)); ``` -The advantage of this approach is that you can easily test the whole program behaviour by replacing the `Filesystem` adapter by a `InMemory` one. +The advantage of this approach is that you can easily test the whole program behaviour by replacing the `Adapter::mount()` adapter by `Adapter::inMemory()`. diff --git a/documentation/use_cases/modify_file.md b/documentation/use_cases/modify_file.md index 8ebd886..1c8f58d 100644 --- a/documentation/use_cases/modify_file.md +++ b/documentation/use_cases/modify_file.md @@ -7,7 +7,7 @@ ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, Name, }; @@ -34,8 +34,8 @@ $release = static function(File $changelog) use ($insertRelease): File { ), ); } -$filesystem = Filesystem::mount(Path::of('some/repository/')); -$tmp = Filesystem::mount(Path::of('/tmp/')); +$filesystem = Adapter::mount(Path::of('some/repository/'))->unwrap(); +$tmp = Adapter::mount(Path::of('/tmp/'))->unwrap(); $filesystem ->get(Name::of('CHANGELOG.md')) ->keep(Instance::of(File::class)) @@ -59,7 +59,7 @@ This example modifies the `CHANGELOG.md` file to replace the `## [Unreleased]` t ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, File\Content, File\Content\Line, @@ -88,8 +88,8 @@ $update = static function(File $users) use ($updateUser): File { static fn($content) => $content->flatMap(static fn($line) => $updateUser($line)), ); }; -$filesystem = Filesystem::mount(Path::of('/var/data/')); -$tmp = Filesystem::mount(Path::of('/tmp/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); +$tmp = Adapter::mount(Path::of('/tmp/'))->unwrap(); $filesystem ->get(Name::of('users.csv')) ->keep(Instance::of(File::class)) @@ -113,7 +113,7 @@ This example will insert the user `Jane Doe` after `John Doe` wherever he is in ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, File\Content, Name, @@ -134,7 +134,7 @@ $merge = static function(File $file1, File $file2): File { ), ); }; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $users1 = $filesystem ->get(Name::of('users1.csv')) ->keep(Instance::of(File::class)); diff --git a/documentation/use_cases/persist_hand_file.md b/documentation/use_cases/persist_hand_file.md index 123ac74..d5cef44 100644 --- a/documentation/use_cases/persist_hand_file.md +++ b/documentation/use_cases/persist_hand_file.md @@ -4,13 +4,13 @@ ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, File\Content, }; use Innmind\Url\Path; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $_ = $filesystem ->add(File::named('some name'), Content::none()) ->unwrap(); @@ -22,7 +22,7 @@ This is equivalent of running the cli command `touch '/var/data/some name'`. ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, File\Content, File\Content\Line, @@ -33,7 +33,7 @@ use Innmind\Immutable\{ Str, }; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $_ = $filesystem ->add(File::named( 'some name', @@ -52,14 +52,14 @@ When the file is persisted the _end of line_ character will be automatically add ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, File\Content, Directory, }; use Innmind\Url\Path; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $_ = $filesystem ->add( Directory::named('whatever')->add( diff --git a/documentation/use_cases/persist_process_output.md b/documentation/use_cases/persist_process_output.md index ae23dc4..423195d 100644 --- a/documentation/use_cases/persist_process_output.md +++ b/documentation/use_cases/persist_process_output.md @@ -6,14 +6,14 @@ This example uses the [`innmind/operating-system`](https://packagist.org/package use Innmind\Filesystem\{ File, File\Content, - Adapter\Filesystem, + Adapter, }; use Innmind\OperatingSystem\Factory; use Innmind\Server\Control\Server\Command; use Innmind\Url\Path; $os = Factory::build(); -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $fileContent = Content::ofChunks( $os ->control() diff --git a/documentation/use_cases/persist_uploaded_file.md b/documentation/use_cases/persist_uploaded_file.md index f96d851..23054aa 100644 --- a/documentation/use_cases/persist_uploaded_file.md +++ b/documentation/use_cases/persist_uploaded_file.md @@ -5,14 +5,14 @@ use Innmind\Filesystem\{ File, File\Content, Directory, - Adapter\Filesystem, + Adapter, Name, }; use Innmind\Url\Path; use Innmind\Immutable\Predicate\Instance; -$tmp = Filesystem::mount(Path::of(\dirname($_FILES['my_upload']['tmp_name']))); -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$tmp = Adapter::mount(Path::of(\dirname($_FILES['my_upload']['tmp_name'])))->unwrap(); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $_ = $tmp ->get(Name::of(\basename($_FILES['my_upload']['tmp_name']))) diff --git a/documentation/use_cases/read_file.md b/documentation/use_cases/read_file.md index 1c4b4c3..398c4d1 100644 --- a/documentation/use_cases/read_file.md +++ b/documentation/use_cases/read_file.md @@ -4,7 +4,7 @@ ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, Name, }; @@ -19,7 +19,7 @@ $print = static function(File $file): void { }); }; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $filesystem ->get(Name::of('some file')) ->keep(Instance::of(File::class)) @@ -35,7 +35,7 @@ This example will print each line to the screen, or nothing if the file doesn't ```php use Innmind\Filesystem\{ - Adapter\Filesystem, + Adapter, File, Name, Directory, @@ -51,7 +51,7 @@ $print = static function(File $file): void { }); }; -$filesystem = Filesystem::mount(Path::of('/var/data/')); +$filesystem = Adapter::mount(Path::of('/var/data/'))->unwrap(); $filesystem ->get(Name::of('some directory')) ->keep(Instance::of(Directory::class)) //(1) diff --git a/mkdocs.yml b/mkdocs.yml index 56b3820..829dfa7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,7 +17,6 @@ nav: - Case sensitivity: case_sensitivity.md - Testing: - In your app: testing/in_app.md - - Your own adapter: testing/own_adapter.md theme: name: material From 10cb9d234f9a3a5e1e93744be6103902d4e1aa06 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:41:24 +0100 Subject: [PATCH 74/77] fix Concat use --- src/Adapter/TreePath.php | 2 +- src/File/Content/Chunks.php | 2 +- src/File/Content/IO.php | 2 +- src/File/Content/OneShot.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php index 378c733..cd35166 100644 --- a/src/Adapter/TreePath.php +++ b/src/Adapter/TreePath.php @@ -90,7 +90,7 @@ public function asPath(Path $root): Path ->path ->reverse() ->map(static fn($name) => $name->str()->append('/')) - ->fold(new Concat); + ->fold(Concat::monoid); if (!$this->directory) { $path = $path->dropEnd(1); // remove trailing '/' diff --git a/src/File/Content/Chunks.php b/src/File/Content/Chunks.php index ba1b9c7..071ced7 100644 --- a/src/File/Content/Chunks.php +++ b/src/File/Content/Chunks.php @@ -110,7 +110,7 @@ public function toString(): string { return $this ->chunks - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(); } diff --git a/src/File/Content/IO.php b/src/File/Content/IO.php index a46c54e..635989b 100644 --- a/src/File/Content/IO.php +++ b/src/File/Content/IO.php @@ -105,7 +105,7 @@ public function toString(): string { return $this ->chunks() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(); } diff --git a/src/File/Content/OneShot.php b/src/File/Content/OneShot.php index df03fe9..3b5e98c 100644 --- a/src/File/Content/OneShot.php +++ b/src/File/Content/OneShot.php @@ -94,7 +94,7 @@ public function toString(): string { return $this ->chunks() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(); } From 88cbbbd4784bd62191c5e2322399cd019d42e1f5 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:45:12 +0100 Subject: [PATCH 75/77] fix Concat use --- proofs/file/content.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proofs/file/content.php b/proofs/file/content.php index 459196e..046a468 100644 --- a/proofs/file/content.php +++ b/proofs/file/content.php @@ -218,7 +218,7 @@ static function($assert) use ($io) { \file_get_contents('LICENSE'), $content ->chunks() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); }, From df056213e4ee26c2406779d25088406aa76f43ce Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:47:22 +0100 Subject: [PATCH 76/77] fix Concat use --- properties/Content/Chunks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/properties/Content/Chunks.php b/properties/Content/Chunks.php index 9b02dd0..69e0bc2 100644 --- a/properties/Content/Chunks.php +++ b/properties/Content/Chunks.php @@ -32,7 +32,7 @@ public function ensureHeldBy(Assert $assert, object $systemUnderTest): object $systemUnderTest->toString(), $systemUnderTest ->chunks() - ->fold(new Concat) + ->fold(Concat::monoid) ->toString(), ); $assert From 6587c45784c145f712237cfce726e88b262e32e9 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sun, 25 Jan 2026 17:53:37 +0100 Subject: [PATCH 77/77] remove dead code --- src/Adapter/TreePath.php | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/Adapter/TreePath.php b/src/Adapter/TreePath.php index cd35166..8660ebb 100644 --- a/src/Adapter/TreePath.php +++ b/src/Adapter/TreePath.php @@ -98,26 +98,4 @@ public function asPath(Path $root): Path return $root->resolve(Path::of($path->toString())); } - - /** - * @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, - ); - } }