diff --git a/composer.json b/composer.json index a8ed947..e941248 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,11 @@ "Elazar\\Flystream\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Elazar\\Flystream\\Tests\\": "tests/" + } + }, "scripts": { "cs": "php-cs-fixer fix", "test": "XDEBUG_MODE=coverage pest --coverage" diff --git a/src/StreamWrapper.php b/src/StreamWrapper.php index 51dc5d1..aa19e8a 100644 --- a/src/StreamWrapper.php +++ b/src/StreamWrapper.php @@ -371,16 +371,22 @@ public function url_stat(string $path, int $flags) $filesystem = $this->getFilesystem($path); $visibility = $this->get(VisibilityConverter::class); - if (!$filesystem->fileExists($path)) { + if ($filesystem->fileExists($path)) { + $mode = 0100000 | $visibility->forFile( + $filesystem->visibility($path) + ); + $size = $filesystem->fileSize($path); + $mtime = $filesystem->lastModified($path); + } elseif ($filesystem->directoryExists($path)) { + $mode = 0040000 | $visibility->forDirectory( + $filesystem->visibility($path) + ); + $size = 0; + $mtime = $filesystem->lastModified($path); + } else { return false; } - $mode = 0100000 | $visibility->forFile( - $filesystem->visibility($path) - ); - $size = $filesystem->fileSize($path); - $mtime = $filesystem->lastModified($path); - return [ 'dev' => 0, 'ino' => 0, @@ -402,7 +408,7 @@ private function getConfig(string $path, array $overrides = []): array { $config = []; if ($this->context !== null) { - $protocol = parse_url($path, PHP_URL_SCHEME); + $protocol = $this->getProtocol($path); $context = stream_context_get_options($this->context); $config = $context[$protocol] ?? []; } @@ -411,11 +417,26 @@ private function getConfig(string $path, array $overrides = []): array private function getFilesystem(string $path): FilesystemOperator { - $protocol = parse_url($path, PHP_URL_SCHEME); + $protocol = $this->getProtocol($path); $registry = $this->get(FilesystemRegistry::class); return $registry->get($protocol); } + /** + * parse_url() chokes on path-less URLs (like foo://), so in that case, fall back to manual parsing. + * + * @param string $path + * @return string|null + */ + private function getProtocol(string $path): ?string + { + $protocol = parse_url($path, PHP_URL_SCHEME); + if ($protocol === false && ($pos = strpos($path, ':/')) !== false) { + $protocol = substr($path, 0, $pos); + } + return $protocol ?: null; + } + private function get(string $key) { return ServiceLocator::get($key); diff --git a/tests/StreamWrapperTest.php b/tests/StreamWrapperTest.php index 4969734..d9657f7 100644 --- a/tests/StreamWrapperTest.php +++ b/tests/StreamWrapperTest.php @@ -2,9 +2,9 @@ use Elazar\Flystream\FilesystemRegistry; use Elazar\Flystream\ServiceLocator; +use Elazar\Flystream\Tests\TestInMemoryFilesystemAdapter; use League\Flysystem\FileAttributes; use League\Flysystem\Filesystem; -use League\Flysystem\InMemory\InMemoryFilesystemAdapter; use League\Flysystem\PathNormalizer; use Monolog\Handler\TestHandler; use Monolog\Logger; @@ -20,7 +20,11 @@ $this->registry = ServiceLocator::get(FilesystemRegistry::class); - $this->filesystem = new Filesystem(new InMemoryFilesystemAdapter()); + $this->filesystem = new Filesystem( + new TestInMemoryFilesystemAdapter(), + [], + ServiceLocator::get(PathNormalizer::class), + ); $this->registry->register('fly', $this->filesystem); }); @@ -34,6 +38,12 @@ rmdir('fly://foo'); }); +it('can detect a directory', function () { + mkdir('fly://foo'); + expect(is_dir('fly://foo'))->toBeTrue(); + rmdir('fly://foo'); +}); + it('can copy an empty file', function () { $success = touch('fly://src'); expect($success)->toBe(true); @@ -70,7 +80,7 @@ fclose($file); $dir = opendir('fly://foo'); $result = readdir($dir); - expect($result)->toBe('fly:/foo/bar'); + expect($result)->toBe('foo/bar'); closedir($dir); }); @@ -80,10 +90,10 @@ fclose($file); $dir = opendir('fly://foo'); $result = readdir($dir); - expect($result)->toBe('fly:/foo/bar'); + expect($result)->toBe('foo/bar'); rewinddir($dir); $result = readdir($dir); - expect($result)->toBe('fly:/foo/bar'); + expect($result)->toBe('foo/bar'); closedir($dir); }); @@ -263,19 +273,11 @@ }); it('can read and write to a Flysystem filesystem', function () { - $this->filesystem = new Filesystem( - new InMemoryFilesystemAdapter(), - [], - ServiceLocator::get(PathNormalizer::class), - ); - $this->registry->register('mem', $this->filesystem); - $path = 'foo'; $expected = 'bar'; $this->filesystem->write($path, $expected); - $actual = file_get_contents("mem://$path"); - $this->registry->unregister('mem'); + $actual = file_get_contents("fly://$path"); expect($actual)->toBe($expected); }); diff --git a/tests/TestInMemoryFilesystemAdapter.php b/tests/TestInMemoryFilesystemAdapter.php new file mode 100644 index 0000000..702c75e --- /dev/null +++ b/tests/TestInMemoryFilesystemAdapter.php @@ -0,0 +1,155 @@ + */ + private array $directories; + + public function __construct( + private string $defaultVisibility = Visibility::PUBLIC, + ?MimeTypeDetector $mimeTypeDetector = null + ) { + $this->adapter = new InMemoryFilesystemAdapter( + $defaultVisibility, + $mimeTypeDetector ?? new FinfoMimeTypeDetector(), + ); + $this->directories = []; + } + + public function deleteDirectory(string $path): void + { + unset($this->directories[$this->preparePath($path)]); + + $this->adapter->deleteDirectory($path); + } + + public function createDirectory(string $path, Config $config): void + { + $directoryPath = $this->preparePath($path); + $visibility = $config->get(Config::OPTION_VISIBILITY, $this->defaultVisibility); + $lastModified = $config->get('timestamp') ?? 0; + $this->directories[$directoryPath] = new FileAttributes( + $directoryPath, + 0, + $visibility, + $lastModified, + ); + + $this->adapter->createDirectory($path, $config); + } + + public function directoryExists(string $path): bool + { + return isset($this->directories[$this->preparePath($path)]); + } + + public function setVisibility(string $path, string $visibility): void + { + $directoryPath = $this->preparePath($path); + if ($this->directoryExists($directoryPath)) { + $this->directories[$directoryPath] = new FileAttributes( + $directoryPath, + 0, + $visibility, + time() + ); + } + + $this->adapter->setVisibility($path, $visibility); + } + + public function visibility(string $path): FileAttributes + { + $directoryPath = $this->preparePath($path); + return $this->directories[$directoryPath] + ?? $this->adapter->visibility($path); + } + + public function lastModified(string $path): FileAttributes + { + $directoryPath = $this->preparePath($path); + return $this->directories[$directoryPath] + ?? $this->adapter->lastModified($path); + } + + public function fileExists(string $path): bool + { + return $this->adapter->fileExists($path); + } + + public function write(string $path, string $contents, Config $config): void + { + $this->adapter->write($path, $contents, $config); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->adapter->writeStream($path, $contents, $config); + } + + public function read(string $path): string + { + return $this->adapter->read($path); + } + + public function readStream(string $path) + { + return $this->adapter->readStream($path); + } + + public function delete(string $path): void + { + $this->adapter->delete($path); + } + + public function mimeType(string $path): FileAttributes + { + return $this->adapter->mimeType($path); + } + + public function fileSize(string $path): FileAttributes + { + return $this->adapter->fileSize($path); + } + + public function listContents(string $path, bool $deep): iterable + { + return $this->adapter->listContents($path, $deep); + } + + public function move(string $source, string $destination, Config $config): void + { + $this->adapter->move($source, $destination, $config); + } + + public function copy(string $source, string $destination, Config $config): void + { + $this->adapter->copy($source, $destination, $config); + } + + private function preparePath(string $path): string + { + return '/' . trim($path, '/') . '/'; + } +}