diff --git a/README.md b/README.md index 5e8883d..15b621c 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,120 @@ This is a tiny wrapper around the [Symfony filesystem]. It provides a `FileSyste a few more utilities. +## New methods + +```php +interface FileSystem extends SymfonyFileSystem +{ + /** + * Replaces the path directory separator with the system one. + * + * For example, on Windows: + * 'C:/path/to/file' => 'C:\path\to\file', + */ + public function escapePath(string $path): string; + + /** + * Returns the absolute path, but the path will not be normalized. + * + * For example, `::realpath('C:\Users\Name\file.txt')` on Windows will + * return "C:\Users\Name\file.txt" (backslashes). + * + * @see https://php.net/manual/en/function.realpath.php + * + * @throws IOException When the file or symlink target does not exist. + */ + public function realPath(string $file): string; + + /** + * Returns the absolute normalized path. + * + * For example, `::realpath('C:\Users\Name\file.txt')` on Windows will + * return "C:/Users/Name/file.txt". + * + * @see https://php.net/manual/en/function.realpath.php + * + * @throws IOException When the file or symlink target does not exist. + */ + public function normalizedRealPath(string $file): string; + + /** + * Creates a temporary file with support for custom stream wrappers. Same as tempnam(), + * but targets the system default temporary directory by default and has a more consistent + * name with tmpDir. + * + * For example: + * + * ```php + * tmpFile('build') + * + * // on OSX + * => '/var/folders/p3/lkw0cgjj2fq0656q_9rd0mk80000gn/T/build8d9e0f1a' + * // on Windows + * => C:\Windows\Temp\build8d9e0f1a.tmp + * ``` + * + * @param string $prefix The prefix of the generated temporary file name. + * @param string $suffix The suffix of the generated temporary file name. + * @param string $targetDirectory The directory where to create the temporary directory. + * Defaults to the system default temporary directory. + * + * @return string The new temporary file pathname. + * + * @throws IOException + * + * @see tempnam() + * @see SymfonyFileSystem::tempnam() + * @see self::tmpDir() + */ + public function tmpFile(string $prefix, string $suffix = '', ?string $targetDirectory = null): string; + + /** + * Creates a temporary directory with support for custom stream wrappers. Similar to tempnam() + * but creates a directory instead of a file. + * + * For example: + * + * ```php + * tmpDir('build') + * + * // on OSX + * => '/var/folders/p3/lkw0cgjj2fq0656q_9rd0mk80000gn/T/build8d9e0f1a' + * // on Windows + * => C:\Windows\Temp\build8d9e0f1a.tmp + * ``` + * + * @param string|null $prefix The prefix of the generated temporary directory name. + * @param string $targetDirectory The directory where to create the temporary directory. + * Defaults to the system default temporary directory. + * + * @throws IOException + * + * @return string The new temporary directory pathname. + * + * @see tempnam() + */ + public function tmpDir(string $prefix, ?string $targetDirectory = null): string; + + /** + * Tells whether a file exists and is readable. + * + * @throws IOException When Window's path is longer than 258 characters + */ + public function isReadable(string $filename): bool; + + public function isReadableFile(string $filename): bool; + + public function isReadableDirectory(string $filename): bool; + + public function createFinder(): Finder; +} +``` + + ## FileSystemTestCase -An example of PHPUnit test: +An example of a PHPUnit test: ```php diff --git a/infection.json.dist b/infection.json.dist index 850280d..0a7e046 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -18,6 +18,8 @@ // Too dangerous to mutate outside a container "Fidry\\FileSystem\\NativeFileSystem::makeTmpDir", "Fidry\\FileSystem\\NativeFileSystem::getNamespacedTmpDir", + "Fidry\\FileSystem\\NativeFileSystem::tmpDir", + "Fidry\\FileSystem\\NativeFileSystem::tmpFile", "Fidry\\FileSystem\\Test\\FileSystemTestCase::tearDown" ], diff --git a/src/FS.php b/src/FS.php index 974ed61..a123604 100644 --- a/src/FS.php +++ b/src/FS.php @@ -253,6 +253,9 @@ public static function mirror(string $originDir, string $targetDir, ?Traversable } /** + * @deprecated Deprecated since 2.0. Use `Path::isAbsolute()` instead. + * @see Path::isAbsolute() + * * Returns whether the file path is an absolute path. */ public static function isAbsolutePath(string $file): bool @@ -299,6 +302,9 @@ public static function appendToFile(string $filename, $content/* , bool $lock = } /** + * @deprecated Deprecated since 2.0. Use `Path::isRelative()` instead. Will be removed in 3.0. + * @see Path::isRelative()) + * * Returns whether a path is relative. * * @param string $path a path string @@ -317,6 +323,9 @@ public static function escapePath(string $path): string } /** + * @deprecated Use the `::readFile()` method. Deprecated since 2.0 and it will be removed in 3.0. + * @see SymfonyFileSystem::readFile() + * * Gets the contents of a file. * * @param string $file File path @@ -341,6 +350,9 @@ public static function readFile(string $file): string } /** + * @deprecated Use the `::tmpDir()` method. Deprecated since 2.0 and it will be removed in 3.0. + * @see self::tmpDir() + * * Creates a temporary directory. * * @param string $namespace the directory path in the system's temporary directory @@ -354,6 +366,72 @@ public static function makeTmpDir(string $namespace, string $className): string } /** + * Creates a temporary file with support for custom stream wrappers. Same as tempnam(), + * but targets the system default temporary directory by default and has a more consistent + * name with tmpDir. + * + * For example: + * + * ```php + * tmpFile('build') + * + * // on OSX + * => '/var/folders/p3/lkw0cgjj2fq0656q_9rd0mk80000gn/T/build8d9e0f1a' + * // on Windows + * => C:\Windows\Temp\build8d9e0f1a.tmp + * ``` + * + * @param string $prefix The prefix of the generated temporary file name. + * @param string $suffix The suffix of the generated temporary file name. + * @param string $targetDirectory The directory where to create the temporary directory. + * Defaults to the system default temporary directory. + * + * @throws IOException + * @return string The new temporary file pathname. + * + * @see tempnam() + * @see SymfonyFileSystem::tempnam() + * @see self::tmpDir() + */ + public function tmpFile(string $prefix, string $suffix = '', ?string $targetDirectory = null): string + { + return self::getInstance()->tmpFile(...func_get_args()); + } + + /** + * Creates a temporary directory with support for custom stream wrappers. Similar to tempnam() + * but creates a directory instead of a file. + * + * For example: + * + * ```php + * tmpDir('build') + * + * // on OSX + * => '/var/folders/p3/lkw0cgjj2fq0656q_9rd0mk80000gn/T/build8d9e0f1a' + * // on Windows + * => C:\Windows\Temp\build8d9e0f1a.tmp + * ``` + * + * @param string|null $prefix The prefix of the generated temporary directory name. + * @param string $targetDirectory The directory where to create the temporary directory. + * Defaults to the system default temporary directory. + * + * @throws IOException + * + * @return string The new temporary directory pathname. + * + * @see tempnam() + */ + public function tmpDir(string $prefix, ?string $targetDirectory = null): string + { + return self::getInstance()->tmpDir(...func_get_args()); + } + + /** + * @deprecated Deprecated since 2.0. Use `Path::isRelative()` instead. Will be removed in 3.0. + * Using a namespaced dir is an antipattern with parallel testing. + * * Gets a namespaced temporary directory. * * @param string $namespace the directory path in the system's temporary directory diff --git a/src/FakeFileSystem.php b/src/FakeFileSystem.php index 0c9b43c..a452a3a 100644 --- a/src/FakeFileSystem.php +++ b/src/FakeFileSystem.php @@ -199,4 +199,17 @@ public function normalizedRealPath(string $file): string { throw new DomainException('Unexpected call.'); } + + public function tmpFile( + string $prefix, + string $suffix = '', + ?string $targetDirectory = null, + ): string { + throw new DomainException('Unexpected call.'); + } + + public function tmpDir(string $prefix, ?string $targetDirectory = null): string + { + throw new DomainException('Unexpected call.'); + } } diff --git a/src/FileSystem.php b/src/FileSystem.php index 928249e..c26744a 100644 --- a/src/FileSystem.php +++ b/src/FileSystem.php @@ -48,6 +48,7 @@ use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Finder\Finder; +use function tempnam; interface FileSystem extends SymfonyFileSystem { @@ -57,6 +58,12 @@ interface FileSystem extends SymfonyFileSystem */ public function isRelativePath(string $path): bool; + /** + * Replaces the path directory separator with the system one. + * + * For example, on Windows: + * 'C:/path/to/file' => 'C:\path\to\file', + */ public function escapePath(string $path): string; /** @@ -92,6 +99,9 @@ public function normalizedRealPath(string $file): string; public function getFileContents(string $file): string; /** + * @deprecated Use the `::tmpDir()` method. Deprecated since 2.0 and it will be removed in 3.0. + * @see self::tmpDir() + * * Creates a temporary directory. * * @param string $namespace the directory path in the system's temporary directory @@ -102,6 +112,66 @@ public function getFileContents(string $file): string; public function makeTmpDir(string $namespace, string $className): string; /** + * Creates a temporary file with support for custom stream wrappers. Same as tempnam(), + * but targets the system default temporary directory by default and has a more consistent + * name with tmpDir. + * + * For example: + * + * ```php + * tmpFile('build') + * + * // on OSX + * => '/var/folders/p3/lkw0cgjj2fq0656q_9rd0mk80000gn/T/build8d9e0f1a' + * // on Windows + * => C:\Windows\Temp\build8d9e0f1a.tmp + * ``` + * + * @param string $prefix The prefix of the generated temporary file name. + * @param string $suffix The suffix of the generated temporary file name. + * @param string $targetDirectory The directory where to create the temporary directory. + * Defaults to the system default temporary directory. + * + * @throws IOException + * @return string The new temporary file pathname. + * + * @see tempnam() + * @see SymfonyFileSystem::tempnam() + * @see self::tmpDir() + */ + public function tmpFile(string $prefix, string $suffix = '', ?string $targetDirectory = null): string; + + /** + * Creates a temporary directory with support for custom stream wrappers. Similar to tempnam() + * but creates a directory instead of a file. + * + * For example: + * + * ```php + * tmpDir('build') + * + * // on OSX + * => '/var/folders/p3/lkw0cgjj2fq0656q_9rd0mk80000gn/T/build8d9e0f1a' + * // on Windows + * => C:\Windows\Temp\build8d9e0f1a.tmp + * ``` + * + * @param string|null $prefix The prefix of the generated temporary directory name. + * @param string $targetDirectory The directory where to create the temporary directory. + * Defaults to the system default temporary directory. + * + * @throws IOException + * + * @return string The new temporary directory pathname. + * + * @see tempnam() + */ + public function tmpDir(string $prefix, ?string $targetDirectory = null): string; + + /** + * @deprecated Deprecated since 2.0. Use `Path::isRelative()` instead. Will be removed in 3.0. + * Using a namespaced dir is an antipattern with parallel testing. + * * Gets a namespaced temporary directory. * * @param string $namespace the directory path in the system's temporary directory diff --git a/src/NativeFileSystem.php b/src/NativeFileSystem.php index 297e044..6f6c48b 100644 --- a/src/NativeFileSystem.php +++ b/src/NativeFileSystem.php @@ -51,10 +51,12 @@ use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; use Webmozart\Assert\Assert; +use function bin2hex; use function function_exists; use function is_dir; use function is_file; use function is_readable; +use function random_bytes; use function random_int; use function realpath; use function restore_error_handler; @@ -137,6 +139,51 @@ public function makeTmpDir(string $namespace, string $className): string return $tmpDir; } + public function tmpFile(string $prefix, string $suffix = '', ?string $targetDirectory = null): string + { + return $this->escapePath( + $this->tempnam( + $targetDirectory ?? sys_get_temp_dir(), + $prefix, + $suffix, + ), + ); + } + + public function tmpDir(string $prefix, ?string $targetDirectory = null): string + { + $targetDirectory ??= sys_get_temp_dir(); + + for ($i = 0; $i < 10; ++$i) { + // Create a unique directory name using the same pattern as Symfony's tempnam() + $tmpDir = $targetDirectory.DIRECTORY_SEPARATOR.$prefix.bin2hex(random_bytes(4)); + + // Might be better to us the Filesystem::box(); but its an internal + // API. + // It is probably not a big deal given the usage of this library but + // would need to be adjusted if merged to Symfony. + if ($this->exists($tmpDir)) { + continue; + } + + try { + $this->mkdir($tmpDir); + + return $tmpDir; + } catch (IOException) { + continue; + } + } + + throw new IOException( + sprintf( + 'A temporary directory could not be created in "%s": %s', + $targetDirectory, + self::$lastError, + ) + ); + } + public function getNamespacedTmpDir(string $namespace): string { // Usage of realpath() is important if the temporary directory is a diff --git a/src/ReadOnlyFileSystem.php b/src/ReadOnlyFileSystem.php index 9f326c1..de9ff7c 100644 --- a/src/ReadOnlyFileSystem.php +++ b/src/ReadOnlyFileSystem.php @@ -178,6 +178,23 @@ public function getNamespacedTmpDir(string $namespace): string return ''; } + public function tmpFile( + string $prefix, + string $suffix = '', + ?string $targetDirectory = null, + ): string { + $this->handleWrite(__METHOD__); + + return ''; + } + + public function tmpDir(string $prefix, ?string $targetDirectory = null): string + { + $this->handleWrite(__METHOD__); + + return ''; + } + private function handleWrite(string $methodName): void { if ($this->failOnWrite) { diff --git a/tests/NativeFileSystem/NativeFileSystemTest.php b/tests/NativeFileSystem/NativeFileSystemTest.php index 90f9a36..46f4c1d 100644 --- a/tests/NativeFileSystem/NativeFileSystemTest.php +++ b/tests/NativeFileSystem/NativeFileSystemTest.php @@ -37,16 +37,24 @@ namespace Fidry\FileSystem\Tests\NativeFileSystem; use Fidry\FileSystem\FileSystem; +use Fidry\FileSystem\FS; use Fidry\FileSystem\NativeFileSystem; use Fidry\FileSystem\Test\FileSystemTestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; +use function array_map; +use function iterator_to_array; use function realpath; +use function sort; use function str_replace; +use function sys_get_temp_dir; use const DIRECTORY_SEPARATOR; use const PHP_OS_FAMILY; +use const SORT_STRING; /** * @internal @@ -244,8 +252,168 @@ public static function normalizedRealPathProvider(): iterable ]; } + public function test_it_can_create_a_temporary_file_in_a_target_directory(): void + { + $targetDirectory = sys_get_temp_dir(); + $before = self::snapshotDirContent($targetDirectory); + + try { + $temporaryFile = $this->fileSystem->tempnam($targetDirectory, 'Prefix', 'Suffix'); + + $expected = [...$before, FS::escapePath($temporaryFile)]; + $actual = self::snapshotDirContent($targetDirectory); + + self::assertFileIsReadable($temporaryFile); + self::assertEqualsCanonicalizing($expected, $actual); + } finally { + FS::remove($temporaryFile); + } + + $fileName = str_replace( + $targetDirectory.'/', // the incorrect directory separator on Windows is a bug in Symfony. + '', + $temporaryFile, + ); + + self::assertMatchesRegularExpression( + '/^Prefix[\p{L}\d\.]+Suffix$/', + $fileName, + ); + } + + public function test_it_can_create_a_temporary_file_in_a_target_directory_with_the_tmp_file_method(): void + { + $targetDirectory = $this->tmp; + $before = self::snapshotDirContent($targetDirectory); + + try { + $temporaryFile = $this->fileSystem->tmpFile('Prefix', 'Suffix', $targetDirectory); + + $expected = [...$before, FS::escapePath($temporaryFile)]; + $actual = self::snapshotDirContent($targetDirectory); + + self::assertFileIsReadable($temporaryFile); + self::assertEqualsCanonicalizing($expected, $actual); + } finally { + FS::remove($temporaryFile); + } + + $fileName = str_replace( + $targetDirectory.DIRECTORY_SEPARATOR, + '', + $temporaryFile, + ); + + self::assertMatchesRegularExpression( + '/^Prefix[\p{L}\d\.]+Suffix$/', + $fileName, + ); + } + + public function test_it_can_create_a_temporary_file_which_targets_the_system_default_tmp_dir_by_default(): void + { + $targetDirectory = sys_get_temp_dir(); + $before = self::snapshotDirContent($targetDirectory); + + try { + $temporaryFile = $this->fileSystem->tmpFile('Prefix', 'Suffix'); + + $expected = [...$before, FS::escapePath($temporaryFile)]; + $actual = self::snapshotDirContent($targetDirectory); + + self::assertFileIsReadable($temporaryFile); + self::assertEqualsCanonicalizing($expected, $actual); + } finally { + FS::remove($temporaryFile); + } + + $fileName = str_replace( + $targetDirectory.DIRECTORY_SEPARATOR, + '', + $temporaryFile, + ); + + self::assertMatchesRegularExpression( + '/^Prefix[\p{L}\d\.]+Suffix$/', + $fileName, + ); + } + + public function test_it_can_create_a_temporary_directory(): void + { + $targetDirectory = sys_get_temp_dir(); + $before = self::snapshotDirContent($targetDirectory); + + try { + $temporaryDirectory = $this->fileSystem->tmpDir('Prefix'); + + $expected = [...$before, $temporaryDirectory]; + $actual = self::snapshotDirContent($targetDirectory); + + self::assertDirectoryIsReadable($temporaryDirectory); + self::assertEqualsCanonicalizing($expected, $actual); + } finally { + FS::remove($temporaryDirectory); + } + + $directoryName = str_replace( + $targetDirectory.DIRECTORY_SEPARATOR, + '', + $temporaryDirectory, + ); + + self::assertMatchesRegularExpression( + '/^Prefix[\p{L}\d]+$/', + $directoryName, + ); + } + + public function test_it_can_create_a_temporary_directory_in_a_target_directory(): void + { + $targetDirectory = $this->tmp; + $before = self::snapshotDirContent($targetDirectory); + + try { + $temporaryDirectory = $this->fileSystem->tmpDir('Prefix', $targetDirectory); + + $expected = [...$before, $temporaryDirectory]; + $actual = self::snapshotDirContent($targetDirectory); + + self::assertDirectoryIsReadable($temporaryDirectory); + self::assertEqualsCanonicalizing($expected, $actual); + } finally { + FS::remove($temporaryDirectory); + } + + $directoryName = str_replace( + $targetDirectory.DIRECTORY_SEPARATOR, + '', + $temporaryDirectory, + ); + + self::assertMatchesRegularExpression( + '/^Prefix[\p{L}\d]+$/', + $directoryName, + ); + } + private static function isWindows(): bool { return PHP_OS_FAMILY === 'Windows'; } + + private static function snapshotDirContent(string $directory): array + { + $filesAndDirectories = Finder::create() + ->in($directory) + ->depth(0); + + $names = array_map( + static fn (SplFileInfo $fileInfo) => $fileInfo->getPathname(), + iterator_to_array($filesAndDirectories, preserve_keys: false), + ); + sort($names, SORT_STRING); + + return $names; + } }