diff --git a/composer.json b/composer.json index 356812c..e46ab48 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ } }, "require-dev": { - "easyswoole/swoole-ide-helper": "^1.3" + "easyswoole/swoole-ide-helper": "^1.3", + "easyswoole/phpunit": "^1.0" }, "autoload-dev": { "psr-4": { diff --git a/src/FileSystem.php b/src/FileSystem.php new file mode 100644 index 0000000..011fafc --- /dev/null +++ b/src/FileSystem.php @@ -0,0 +1,416 @@ +exists($path); + } + + /** + * @param string $path + * @param bool $lock + * @return false|string + * @throws Exception + */ + public function get(string $path, bool $lock = false) + { + if (!$this->isFile($path)) { + throw new Exception("File does not exist at path {$path}."); + } + return $lock ? $this->sharedGet($path) : file_get_contents($path); + } + + /** + * @param string $path + * @return false|string + */ + public function sharedGet(string $path) + { + $contents = ''; + $handle = fopen($path, 'rb'); + if (!$handle) { + return $contents; + } + try { + if (!flock($handle, LOCK_SH)) { + return $contents; + } + clearstatcache(true, $path); + $contents = fread($handle, $this->size($path) ?: 1); + flock($handle, LOCK_UN); + } finally { + fclose($handle); + } + return $contents; + } + + /** + * @param string $path + * @return false|string + */ + public function hash(string $path) + { + return md5_file($path); + } + + /** + * @param string $path + * @param string $contents + * @param bool $lock + * @return false|int + */ + public function put(string $path, string $contents, bool $lock = false) + { + return file_put_contents($path, $contents, $lock ? LOCK_EX : 0); + } + + /** + * @param string $path + * @param string $content + */ + public function replace(string $path, string $content) + { + clearstatcache(true, $path); + $path = realpath($path) ?: $path; + $tempPath = tempnam(dirname($path), basename($path)); + chmod($tempPath, 0777 - umask()); + file_put_contents($tempPath, $content); + rename($tempPath, $path); + } + + /** + * @param string $path + * @param string $data + * @return false|int + * @throws Exception + */ + public function prepend(string $path, string $data) + { + if ($this->exists($path)) { + return $this->put($path, $data . $this->get($path)); + } + return $this->put($path, $data); + } + + /** + * @param string $path + * @param string $data + * @return false|int + */ + public function append(string $path, string $data) + { + return file_put_contents($path, $data, FILE_APPEND); + } + + /** + * @param string $path + * @param int|null $mode + * @return bool|string + */ + public function chmod(string $path, ?int $mode = null) + { + if ($mode) { + return chmod($path, $mode); + } + return substr(sprintf('%o', fileperms($path)), -4); + } + + /** + * @param string|array $paths + * @return bool + */ + public function delete($paths) + { + $paths = is_array($paths) ? $paths : func_get_args(); + $ret = true; + foreach ($paths as $path) { + try { + if (!unlink($path)) { + $ret = false; + } + } catch (Throwable $throwable) { + $ret = false; + } + } + return $ret; + } + + /** + * @param string $path + * @param string $target + * @return bool + */ + public function move(string $path, string $target) + { + return rename($path, $target); + } + + /** + * @param string $path + * @param string $target + * @return bool + */ + public function copy(string $path, string $target) + { + return copy($path, $target); + } + + /** + * @param string $path + * @return string + */ + public function name(string $path) + { + return pathinfo($path, PATHINFO_FILENAME); + } + + /** + * @param string $path + * @return string + */ + public function basename(string $path) + { + return pathinfo($path, PATHINFO_BASENAME); + } + + /** + * @param string $path + * @return string + */ + public function dirname(string $path) + { + return pathinfo($path, PATHINFO_DIRNAME); + } + + /** + * @param string $path + * @return string + */ + public function extension(string $path) + { + return pathinfo($path, PATHINFO_EXTENSION); + } + + /** + * @param string $path + * @return string + */ + public function type(string $path) + { + return filetype($path); + } + + /** + * @param string $path + * @return false|int + */ + public function size(string $path) + { + return filesize($path); + } + + /** + * @param string $path + * @return int + */ + public function lastModified(string $path) + { + return filemtime($path); + } + + /** + * @param string $directory + * @return bool + */ + public function isDirectory(string $directory) + { + return is_dir($directory); + } + + /** + * @param string $path + * @return bool + */ + public function isReadable(string $path) + { + return is_readable($path); + } + + /** + * @param string $path + * @return bool + */ + public function isWritable(string $path) + { + return is_writable($path); + } + + /** + * @param string $file + * @return bool + */ + public function isFile(string $file) + { + return is_file($file); + } + + /** + * @param string $pattern + * @param int $flags + * @return array|false + */ + public function glob(string $pattern, int $flags = 0) + { + return glob($pattern, $flags); + } + + /** + * @param string $path + * @param int $mode + * @param bool $recursive + */ + public function ensureDirectoryExists(string $path, $mode = 0755, bool $recursive = true) + { + if (!$this->isDirectory($path)) { + $this->makeDirectory($path, $mode, $recursive); + } + } + + /** + * @param string $path + * @param int $mode + * @param bool $recursive + * @param bool $force + * @return bool + */ + public function makeDirectory(string $path, $mode = 0755, bool $recursive = false, bool $force = false): bool + { + if ($force) { + return @mkdir($path, $mode, $recursive); + } + return mkdir($path, $mode, $recursive); + } + + /** + * @param string $from + * @param string $to + * @param bool $overwrite + * @return bool + */ + public function moveDirectory(string $from, string $to, bool $overwrite = false): bool + { + if ($overwrite && $this->isDirectory($to) && !$this->deleteDirectory($to)) { + return false; + } + return @rename($from, $to) === true; + } + + /** + * Copy a directory from one location to another. + * + * @param string $directory + * @param string $destination + * @param int|null $options + * @return bool + */ + public function copyDirectory(string $directory, string $destination, ?int $options = null): bool + { + if (!$this->isDirectory($directory)) { + return false; + } + $options = $options ?: FilesystemIterator::SKIP_DOTS; + // If the destination directory does not actually exist, we will go ahead and + // create it recursively, which just gets the destination prepared to copy + // the files over. Once we make the directory we'll proceed the copying. + $this->ensureDirectoryExists($destination, 0777); + $items = new FilesystemIterator($directory, $options); + foreach ($items as $item) { + // As we spin through items, we will check to see if the current file is actually + // a directory or a file. When it is actually a directory we will need to call + // back into this function recursively to keep copying these nested folders. + $target = $destination . '/' . $item->getBasename(); + if ($item->isDir()) { + $path = $item->getPathname(); + if (!$this->copyDirectory($path, $target, $options)) { + return false; + } + } + // If the current items is just a regular file, we will just copy this to the new + // location and keep looping. If for some reason the copy fails we'll bail out + // and return false, so the developer is aware that the copy process failed. + else { + if (!$this->copy($item->getPathname(), $target)) { + return false; + } + } + } + return true; + } + + /** + * @param string $directory + * @param bool $preserve + * @return bool + */ + public function deleteDirectory(string $directory, bool $preserve = false): bool + { + if (!$this->isDirectory($directory)) { + return false; + } + $items = new FilesystemIterator($directory); + foreach ($items as $item) { + // If the item is a directory, we can just recurse into the function and + // delete that sub-directory otherwise we'll just delete the file and + // keep iterating through each file until the directory is cleaned. + if ($item->isDir() && !$item->isLink()) { + $this->deleteDirectory($item->getPathname()); + } + // If the item is just a file, we can go ahead and delete it since we're + // just looping through and waxing all of the files in this directory + // and calling directories recursively, so we delete the real path. + else { + $this->delete($item->getPathname()); + } + } + if (!$preserve) { + @rmdir($directory); + } + return true; + } + + /** + * @param string $directory + * @return bool + */ + public function cleanDirectory(string $directory): bool + { + return $this->deleteDirectory($directory, true); + } +} diff --git a/tests/FileSystemTest.php b/tests/FileSystemTest.php new file mode 100644 index 0000000..9146558 --- /dev/null +++ b/tests/FileSystemTest.php @@ -0,0 +1,401 @@ +makeDirectory(static::$tempDir); + } + + public static function tearDownAfterClass(): void + { + $files = new FileSystem(); + $files->deleteDirectory(static::$tempDir); + } + + protected function tearDown(): void + { + $files = new FileSystem(); + $files->deleteDirectory(static::$tempDir, true); + } + + public function testGet() + { + file_put_contents(self::$tempDir . '/file.txt', 'Hello World'); + $files = new Filesystem; + $this->assertEquals('Hello World', $files->get(self::$tempDir . '/file.txt')); + } + + public function testPut() + { + $files = new Filesystem; + $files->put(self::$tempDir . '/file.txt', 'Hello World'); + $this->assertStringEqualsFile(self::$tempDir . '/file.txt', 'Hello World'); + } + + public function testReplace() + { + $tempFile = self::$tempDir . '/file.txt'; + $filesystem = new Filesystem; + $filesystem->replace($tempFile, 'Hello World'); + $this->assertStringEqualsFile($tempFile, 'Hello World'); + } + + public function testSetChmod() + { + file_put_contents(self::$tempDir . '/file.txt', 'Hello World'); + $files = new Filesystem; + $files->chmod(self::$tempDir . '/file.txt', 0755); + $filePermission = substr(sprintf('%o', fileperms(self::$tempDir . '/file.txt')), -4); + $expectedPermissions = DIRECTORY_SEPARATOR == '\\' ? '0666' : '0755'; + $this->assertEquals($expectedPermissions, $filePermission); + } + + public function testGetChmod() + { + file_put_contents(self::$tempDir . '/file.txt', 'Hello World'); + chmod(self::$tempDir . '/file.txt', 0755); + $files = new Filesystem; + $filePermission = $files->chmod(self::$tempDir . '/file.txt'); + $expectedPermissions = DIRECTORY_SEPARATOR == '\\' ? '0666' : '0755'; + $this->assertEquals($expectedPermissions, $filePermission); + } + + public function testDelete() + { + file_put_contents(self::$tempDir . '/file1.txt', 'Hello World'); + file_put_contents(self::$tempDir . '/file2.txt', 'Hello World'); + file_put_contents(self::$tempDir . '/file3.txt', 'Hello World'); + + $files = new Filesystem; + $files->delete(self::$tempDir . '/file1.txt'); + Assert::assertFileDoesNotExist(self::$tempDir . '/file1.txt'); + + $files->delete([self::$tempDir . '/file2.txt', self::$tempDir . '/file3.txt']); + Assert::assertFileDoesNotExist(self::$tempDir . '/file2.txt'); + Assert::assertFileDoesNotExist(self::$tempDir . '/file3.txt'); + } + + public function testPrependExistingFiles() + { + $files = new Filesystem; + $files->put(self::$tempDir . '/file.txt', 'World'); + $files->prepend(self::$tempDir . '/file.txt', 'Hello '); + $this->assertStringEqualsFile(self::$tempDir . '/file.txt', 'Hello World'); + } + + public function testPrependNewFiles() + { + $files = new Filesystem; + $files->prepend(self::$tempDir . '/file.txt', 'Hello World'); + $this->assertStringEqualsFile(self::$tempDir . '/file.txt', 'Hello World'); + } + + public function testMissingFile() + { + $files = new Filesystem; + $this->assertTrue($files->missing(self::$tempDir . '/file.txt')); + } + + public function testDeleteDirectory() + { + mkdir(self::$tempDir . '/foo'); + file_put_contents(self::$tempDir . '/foo/file.txt', 'Hello World'); + $files = new Filesystem; + $files->deleteDirectory(self::$tempDir . '/foo'); + Assert::assertDirectoryDoesNotExist(self::$tempDir . '/foo'); + Assert::assertFileDoesNotExist(self::$tempDir . '/foo/file.txt'); + } + + public function testDeleteDirectoryReturnFalseWhenNotADirectory() + { + mkdir(self::$tempDir . '/bar'); + file_put_contents(self::$tempDir . '/bar/file.txt', 'Hello World'); + $files = new Filesystem; + $this->assertFalse($files->deleteDirectory(self::$tempDir . '/bar/file.txt')); + } + + public function testCleanDirectory() + { + mkdir(self::$tempDir . '/baz'); + file_put_contents(self::$tempDir . '/baz/file.txt', 'Hello World'); + $files = new Filesystem; + $files->cleanDirectory(self::$tempDir . '/baz'); + $this->assertDirectoryExists(self::$tempDir . '/baz'); + Assert::assertFileDoesNotExist(self::$tempDir . '/baz/file.txt'); + } + + public function testCopyDirectoryReturnsFalse() + { + $files = new Filesystem; + $this->assertFalse($files->copyDirectory(self::$tempDir . '/breeze/boom/foo/bar/baz', self::$tempDir)); + } + + public function testCopyDirectoryMovesEntireDirectory() + { + mkdir(self::$tempDir . '/tmp', 0777, true); + file_put_contents(self::$tempDir . '/tmp/foo.txt', ''); + file_put_contents(self::$tempDir . '/tmp/bar.txt', ''); + mkdir(self::$tempDir . '/tmp/nested', 0777, true); + file_put_contents(self::$tempDir . '/tmp/nested/baz.txt', ''); + + $files = new Filesystem; + $files->copyDirectory(self::$tempDir . '/tmp', self::$tempDir . '/tmp2'); + $this->assertDirectoryExists(self::$tempDir . '/tmp2'); + $this->assertFileExists(self::$tempDir . '/tmp2/foo.txt'); + $this->assertFileExists(self::$tempDir . '/tmp2/bar.txt'); + $this->assertDirectoryExists(self::$tempDir . '/tmp2/nested'); + $this->assertFileExists(self::$tempDir . '/tmp2/nested/baz.txt'); + } + + public function testMoveDirectoryMovesEntireDirectory() + { + mkdir(self::$tempDir . '/tmp2', 0777, true); + file_put_contents(self::$tempDir . '/tmp2/foo.txt', ''); + file_put_contents(self::$tempDir . '/tmp2/bar.txt', ''); + mkdir(self::$tempDir . '/tmp2/nested', 0777, true); + file_put_contents(self::$tempDir . '/tmp2/nested/baz.txt', ''); + + $files = new Filesystem; + $files->moveDirectory(self::$tempDir . '/tmp2', self::$tempDir . '/tmp3'); + $this->assertDirectoryExists(self::$tempDir . '/tmp3'); + $this->assertFileExists(self::$tempDir . '/tmp3/foo.txt'); + $this->assertFileExists(self::$tempDir . '/tmp3/bar.txt'); + $this->assertDirectoryExists(self::$tempDir . '/tmp3/nested'); + $this->assertFileExists(self::$tempDir . '/tmp3/nested/baz.txt'); + Assert::assertDirectoryDoesNotExist(self::$tempDir . '/tmp2'); + } + + public function testMoveDirectoryMovesEntireDirectoryAndOverwrites() + { + mkdir(self::$tempDir . '/tmp4', 0777, true); + file_put_contents(self::$tempDir . '/tmp4/foo.txt', ''); + file_put_contents(self::$tempDir . '/tmp4/bar.txt', ''); + mkdir(self::$tempDir . '/tmp4/nested', 0777, true); + file_put_contents(self::$tempDir . '/tmp4/nested/baz.txt', ''); + mkdir(self::$tempDir . '/tmp5', 0777, true); + file_put_contents(self::$tempDir . '/tmp5/foo2.txt', ''); + file_put_contents(self::$tempDir . '/tmp5/bar2.txt', ''); + + $files = new Filesystem; + $files->moveDirectory(self::$tempDir . '/tmp4', self::$tempDir . '/tmp5', true); + $this->assertDirectoryExists(self::$tempDir . '/tmp5'); + $this->assertFileExists(self::$tempDir . '/tmp5/foo.txt'); + $this->assertFileExists(self::$tempDir . '/tmp5/bar.txt'); + $this->assertDirectoryExists(self::$tempDir . '/tmp5/nested'); + $this->assertFileExists(self::$tempDir . '/tmp5/nested/baz.txt'); + Assert::assertFileDoesNotExist(self::$tempDir . '/tmp5/foo2.txt'); + Assert::assertFileDoesNotExist(self::$tempDir . '/tmp5/bar2.txt'); + Assert::assertDirectoryDoesNotExist(self::$tempDir . '/tmp4'); + } + + public function testMoveDirectoryReturns() + { + mkdir(self::$tempDir . '/tmp6', 0777, true); + file_put_contents(self::$tempDir . '/tmp6/foo.txt', ''); + mkdir(self::$tempDir . '/tmp7', 0777, true); + + $files = new FileSystem(); + $this->assertTrue($files->moveDirectory(self::$tempDir . '/tmp6', self::$tempDir . '/tmp7', true)); + } + + public function testGetThrowsException() + { + $this->expectException(\Exception::class); + + $files = new Filesystem; + $files->get(self::$tempDir . '/unknown-file.txt'); + } + + public function testAppendAddsDataToFile() + { + file_put_contents(self::$tempDir . '/file.txt', 'foo'); + $files = new Filesystem; + $bytesWritten = $files->append(self::$tempDir . '/file.txt', 'bar'); + $this->assertEquals(mb_strlen('bar', '8bit'), $bytesWritten); + $this->assertFileExists(self::$tempDir . '/file.txt'); + $this->assertStringEqualsFile(self::$tempDir . '/file.txt', 'foobar'); + } + + public function testMoveMovesFiles() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + $files->move(self::$tempDir . '/foo.txt', self::$tempDir . '/bar.txt'); + $this->assertFileExists(self::$tempDir . '/bar.txt'); + Assert::assertFileDoesNotExist(self::$tempDir . '/foo.txt'); + } + + public function testNameReturnsName() + { + file_put_contents(self::$tempDir . '/foobar.txt', 'foo'); + $filesystem = new Filesystem; + $this->assertSame('foobar', $filesystem->name(self::$tempDir . '/foobar.txt')); + } + + public function testExtensionReturnsExtension() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + $this->assertSame('txt', $files->extension(self::$tempDir . '/foo.txt')); + } + + public function testBasenameReturnsBasename() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + $this->assertSame('foo.txt', $files->basename(self::$tempDir . '/foo.txt')); + } + + public function testDirnameReturnsDirectory() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + $this->assertEquals(self::$tempDir, $files->dirname(self::$tempDir . '/foo.txt')); + } + + public function testTypeIdentifiesFile() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + $this->assertSame('file', $files->type(self::$tempDir . '/foo.txt')); + } + + public function testTypeIdentifiesDirectory() + { + mkdir(self::$tempDir . '/foo-dir'); + $files = new Filesystem; + $this->assertSame('dir', $files->type(self::$tempDir . '/foo-dir')); + } + + public function testSizeOutputsSize() + { + $size = file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + $this->assertEquals($size, $files->size(self::$tempDir . '/foo.txt')); + } + + public function testIsWritable() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + @chmod(self::$tempDir . '/foo.txt', 0444); + $this->assertFalse($files->isWritable(self::$tempDir . '/foo.txt')); + @chmod(self::$tempDir . '/foo.txt', 0777); + $this->assertTrue($files->isWritable(self::$tempDir . '/foo.txt')); + } + + public function testIsReadable() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $files = new Filesystem; + // chmod is noneffective on Windows + if (DIRECTORY_SEPARATOR === '\\') { + $this->assertTrue($files->isReadable(self::$tempDir . '/foo.txt')); + } else { + @chmod(self::$tempDir . '/foo.txt', 0000); + $this->assertFalse($files->isReadable(self::$tempDir . '/foo.txt')); + @chmod(self::$tempDir . '/foo.txt', 0777); + $this->assertTrue($files->isReadable(self::$tempDir . '/foo.txt')); + } + $this->assertFalse($files->isReadable(self::$tempDir . '/doesnotexist.txt')); + } + + public function testGlobFindsFiles() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + file_put_contents(self::$tempDir . '/bar.txt', 'bar'); + $files = new Filesystem; + $glob = $files->glob(self::$tempDir . '/*.txt'); + $this->assertContains(self::$tempDir . '/foo.txt', $glob); + $this->assertContains(self::$tempDir . '/bar.txt', $glob); + } + + public function testMakeDirectory() + { + $files = new Filesystem; + $this->assertTrue($files->makeDirectory(self::$tempDir . '/created')); + $this->assertFileExists(self::$tempDir . '/created'); + } + + /** + * @requires extension pcntl + * @requires function pcntl_fork + */ + public function testSharedGet() + { + if (PHP_OS == 'Darwin') { + $this->markTestSkipped('The operating system is MacOS.'); + } + + $content = str_repeat('123456', 1000000); + $result = 1; + + posix_setpgid(0, 0); + + for ($i = 1; $i <= 20; $i++) { + $pid = pcntl_fork(); + + if (!$pid) { + $files = new Filesystem; + $files->put(self::$tempDir . '/file.txt', $content, true); + $read = $files->get(self::$tempDir . '/file.txt', true); + + exit(strlen($read) === strlen($content) ? 1 : 0); + } + } + + while (pcntl_waitpid(0, $status) != -1) { + $status = pcntl_wexitstatus($status); + $result *= $status; + } + + $this->assertSame(1, $result); + } + + public function testCopyCopiesFileProperly() + { + $filesystem = new Filesystem; + $data = 'contents'; + mkdir(self::$tempDir . '/text'); + file_put_contents(self::$tempDir . '/text/foo.txt', $data); + $filesystem->copy(self::$tempDir . '/text/foo.txt', self::$tempDir . '/text/foo2.txt'); + $this->assertFileExists(self::$tempDir . '/text/foo2.txt'); + $this->assertEquals($data, file_get_contents(self::$tempDir . '/text/foo2.txt')); + } + + public function testIsFileChecksFilesProperly() + { + $filesystem = new Filesystem; + mkdir(self::$tempDir . '/help'); + file_put_contents(self::$tempDir . '/help/foo.txt', 'contents'); + $this->assertTrue($filesystem->isFile(self::$tempDir . '/help/foo.txt')); + $this->assertFalse($filesystem->isFile(self::$tempDir . './help')); + } + + public function testHash() + { + file_put_contents(self::$tempDir . '/foo.txt', 'foo'); + $filesystem = new Filesystem; + $this->assertSame('acbd18db4cc2f85cedef654fccc4a4d8', $filesystem->hash(self::$tempDir . '/foo.txt')); + } + +}