diff --git a/framework/yii/helpers/base/FileHelper.php b/framework/yii/helpers/base/FileHelper.php index 954c86e..dc8aca6 100644 --- a/framework/yii/helpers/base/FileHelper.php +++ b/framework/yii/helpers/base/FileHelper.php @@ -10,6 +10,7 @@ namespace yii\helpers\base; use Yii; +use yii\helpers\StringHelper; /** * Filesystem helper @@ -95,7 +96,7 @@ class FileHelper } } - return $checkExtension ? self::getMimeTypeByExtension($file) : null; + return $checkExtension ? static::getMimeTypeByExtension($file) : null; } /** @@ -169,4 +170,153 @@ class FileHelper } closedir($handle); } + + /** + * Removes a directory recursively. + * @param string $dir to be deleted recursively. + */ + public static function removeDirectory($dir) + { + $items = glob($dir . DIRECTORY_SEPARATOR . '{,.}*', GLOB_MARK | GLOB_BRACE); + foreach ($items as $item) { + $itemBaseName = basename($item); + if ($itemBaseName === '.' || $itemBaseName === '..') { + continue; + } + if (StringHelper::substr($item, -1, 1) == DIRECTORY_SEPARATOR) { + static::removeDirectory($item); + } else { + unlink($item); + } + } + if (is_dir($dir)) { + rmdir($dir); + } + } + + /** + * Returns the files found under the specified directory and subdirectories. + * @param string $dir the directory under which the files will be looked for. + * @param array $options options for file searching. Valid options are: + * - filter: callback, a PHP callback that is called for each sub-directory or file. + * If the callback returns false, the the sub-directory or file will not be placed to result set. + * The signature of the callback should be: `function ($base, $name, $isFile)`, where `$base` is the name of directory, + * which contains file or sub-directory, `$name` file or sub-directory name, `$isFile` indicates if object is a file or a directory. + * - fileTypes: array, list of file name suffix (without dot). Only files with these suffixes will be returned. + * - exclude: array, list of directory and file exclusions. Each exclusion can be either a name or a path. + * If a file or directory name or path matches the exclusion, it will not be copied. For example, an exclusion of + * '.svn' will exclude all files and directories whose name is '.svn'. And an exclusion of '/a/b' will exclude + * file or directory '$src/a/b'. Note, that '/' should be used as separator regardless of the value of the DIRECTORY_SEPARATOR constant. + * - level: integer, recursion depth, default=-1. + * Level -1 means searching for all directories and files under the directory; + * Level 0 means searching for only the files DIRECTLY under the directory; + * level N means searching for those directories that are within N levels. + * @return array files found under the directory. The file list is sorted. + */ + public static function findFiles($dir, array $options = array()) + { + $level = array_key_exists('level', $options) ? $options['level'] : -1; + $filterOptions = $options; + $list = static::findFilesRecursive($dir, '', $filterOptions, $level); + sort($list); + return $list; + } + + /** + * Returns the files found under the specified directory and subdirectories. + * This method is mainly used by [[findFiles]]. + * @param string $dir the source directory. + * @param string $base the path relative to the original source directory. + * @param array $filterOptions list of filter options. + * - filter: a PHP callback, which results indicates if file will be returned. + * - fileTypes: list of file name suffix (without dot). Only files with these suffixes will be returned. + * - exclude: list of directory and file exclusions. Each exclusion can be either a name or a path. + * @param integer $level recursion depth. It defaults to -1. + * Level -1 means searching for all directories and files under the directory; + * Level 0 means searching for only the files DIRECTLY under the directory; + * level N means searching for those directories that are within N levels. + * @return array files found under the directory. + */ + protected static function findFilesRecursive($dir, $base, array $filterOptions, $level) + { + $list = array(); + $handle = opendir($dir); + while (($file = readdir($handle)) !== false) { + if ($file === '.' || $file === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $file; + $isFile = is_file($path); + if (static::validatePath($base, $file, $isFile, $filterOptions)) { + if ($isFile) { + $list[] = $path; + } elseif ($level) { + $list = array_merge($list, static::findFilesRecursive($path, $base . DIRECTORY_SEPARATOR . $file, $filterOptions, $level-1)); + } + } + } + closedir($handle); + return $list; + } + + /** + * Validates a file or directory, checking if it match given conditions. + * @param string $base the path relative to the original source directory + * @param string $name the file or directory name + * @param boolean $isFile whether this is a file + * @param array $filterOptions list of filter options. + * - filter: a PHP callback, which results indicates if file will be returned. + * - fileTypes: list of file name suffix (without dot). Only files with these suffixes will be returned. + * - exclude: list of directory and file exclusions. Each exclusion can be either a name or a path. + * If a file or directory name or path matches the exclusion, false will be returned. For example, an exclusion of + * '.svn' will return false for all files and directories whose name is '.svn'. And an exclusion of '/a/b' will return false for + * file or directory '$src/a/b'. Note, that '/' should be used as separator regardless of the value of the DIRECTORY_SEPARATOR constant. + * @return boolean whether the file or directory is valid + */ + protected static function validatePath($base, $name, $isFile, array $filterOptions) + { + if (isset($filterOptions['filter']) && !call_user_func($filterOptions['filter'], $base, $name, $isFile)) { + return false; + } + if (!empty($filterOptions['exclude'])) { + foreach ($filterOptions['exclude'] as $e) { + if ($name === $e || strpos($base . DIRECTORY_SEPARATOR . $name, $e) === 0) { + return false; + } + } + } + if (!empty($filterOptions['fileTypes'])) { + if (!$isFile) { + return true; + } + if (($type = pathinfo($name, PATHINFO_EXTENSION)) !== '') { + return in_array($type, $filterOptions['fileTypes']); + } else { + return false; + } + } + return true; + } + + /** + * Shared environment safe version of mkdir. Supports recursive creation. + * For avoidance of umask side-effects chmod is used. + * + * @param string $path path to be created. + * @param integer $mode the permission to be set for created directory. If not set 0777 will be used. + * @param boolean $recursive whether to create directory structure recursive if parent dirs do not exist. + * @return boolean result of mkdir. + * @see mkdir + */ + public static function mkdir($path, $mode = null, $recursive = false) + { + $prevDir = dirname($path); + if ($recursive && !is_dir($path) && !is_dir($prevDir)) { + static::mkdir(dirname($path), $mode, true); + } + $mode = isset($mode) ? $mode : 0777; + $result = mkdir($path, $mode); + chmod($path, $mode); + return $result; + } } diff --git a/tests/unit/framework/helpers/FileHelperTest.php b/tests/unit/framework/helpers/FileHelperTest.php new file mode 100644 index 0000000..781812d --- /dev/null +++ b/tests/unit/framework/helpers/FileHelperTest.php @@ -0,0 +1,323 @@ +testFilePath = Yii::getAlias('@yiiunit/runtime') . DIRECTORY_SEPARATOR . get_class($this); + $this->createDir($this->testFilePath); + if (!file_exists($this->testFilePath)) { + $this->markTestIncomplete('Unit tests runtime directory should have writable permissions!'); + } + } + + public function tearDown() + { + $this->removeDir($this->testFilePath); + } + + /** + * Creates directory. + * @param string $dirName directory full name. + */ + protected function createDir($dirName) + { + if (!file_exists($dirName)) { + mkdir($dirName, 0777, true); + } + } + + /** + * Removes directory. + * @param string $dirName directory full name. + */ + protected function removeDir($dirName) + { + if (!empty($dirName) && file_exists($dirName)) { + if ($handle = opendir($dirName)) { + while (false !== ($entry = readdir($handle))) { + if ($entry != '.' && $entry != '..') { + if (is_dir($dirName . DIRECTORY_SEPARATOR . $entry) === true) { + $this->removeDir($dirName . DIRECTORY_SEPARATOR . $entry); + } else { + unlink($dirName . DIRECTORY_SEPARATOR . $entry); + } + } + } + closedir($handle); + rmdir($dirName); + } + } + } + + /** + * Get file permission mode. + * @param string $file file name. + * @return string permission mode. + */ + protected function getMode($file) + { + return substr(sprintf('%o', fileperms($file)), -4); + } + + /** + * Creates test files structure, + * @param array $items file system objects to be created in format: objectName => objectContent + * Arrays specifies directories, other values - files. + * @param string $basePath structure base file path. + */ + protected function createFileStructure(array $items, $basePath = '') + { + if (empty($basePath)) { + $basePath = $this->testFilePath; + } + foreach ($items as $name => $content) { + $itemName = $basePath . DIRECTORY_SEPARATOR . $name; + if (is_array($content)) { + mkdir($itemName, 0777, true); + $this->createFileStructure($content, $itemName); + } else { + file_put_contents($itemName, $content); + } + } + } + + /** + * Asserts that file has specific permission mode. + * @param integer $expectedMode expected file permission mode. + * @param string $fileName file name. + * @param string $message error message + */ + protected function assertFileMode($expectedMode, $fileName, $message='') + { + $expectedMode = sprintf('%o', $expectedMode); + $this->assertEquals($expectedMode, $this->getMode($fileName), $message); + } + + // Tests : + + public function testCopyDirectory() + { + $srcDirName = 'test_src_dir'; + $files = array( + 'file1.txt' => 'file 1 content', + 'file2.txt' => 'file 2 content', + ); + $this->createFileStructure(array( + $srcDirName => $files + )); + + $basePath = $this->testFilePath; + $srcDirName = $basePath . DIRECTORY_SEPARATOR . $srcDirName; + $dstDirName = $basePath . DIRECTORY_SEPARATOR . 'test_dst_dir'; + + FileHelper::copyDirectory($srcDirName, $dstDirName); + + $this->assertTrue(file_exists($dstDirName), 'Destination directory does not exist!'); + foreach ($files as $name => $content) { + $fileName = $dstDirName . DIRECTORY_SEPARATOR . $name; + $this->assertTrue(file_exists($fileName), 'Directory file is missing!'); + $this->assertEquals($content, file_get_contents($fileName), 'Incorrect file content!'); + } + } + + /** + * @depends testCopyDirectory + */ + public function testCopyDirectoryPermissions() + { + if (substr(PHP_OS, 0, 3) == 'WIN') { + $this->markTestSkipped("Can't reliably test it on Windows because fileperms() always return 0777."); + } + + $srcDirName = 'test_src_dir'; + $subDirName = 'test_sub_dir'; + $fileName = 'test_file.txt'; + $this->createFileStructure(array( + $srcDirName => array( + $subDirName => array(), + $fileName => 'test file content', + ), + )); + + $basePath = $this->testFilePath; + $srcDirName = $basePath . DIRECTORY_SEPARATOR . $srcDirName; + $dstDirName = $basePath . DIRECTORY_SEPARATOR . 'test_dst_dir'; + + $dirMode = 0755; + $fileMode = 0755; + $options = array( + 'dirMode' => $dirMode, + 'fileMode' => $fileMode, + ); + FileHelper::copyDirectory($srcDirName, $dstDirName, $options); + + $this->assertFileMode($dirMode, $dstDirName, 'Destination directory has wrong mode!'); + $this->assertFileMode($dirMode, $dstDirName . DIRECTORY_SEPARATOR . $subDirName, 'Copied sub directory has wrong mode!'); + $this->assertFileMode($fileMode, $dstDirName . DIRECTORY_SEPARATOR . $fileName, 'Copied file has wrong mode!'); + } + + public function testRemoveDirectory() + { + $dirName = 'test_dir_for_remove'; + $this->createFileStructure(array( + $dirName => array( + 'file1.txt' => 'file 1 content', + 'file2.txt' => 'file 2 content', + 'test_sub_dir' => array( + 'sub_dir_file_1.txt' => 'sub dir file 1 content', + 'sub_dir_file_2.txt' => 'sub dir file 2 content', + ), + ), + )); + + $basePath = $this->testFilePath; + $dirName = $basePath . DIRECTORY_SEPARATOR . $dirName; + + FileHelper::removeDirectory($dirName); + + $this->assertFalse(file_exists($dirName), 'Unable to remove directory!'); + } + + public function testFindFiles() + { + $dirName = 'test_dir'; + $this->createFileStructure(array( + $dirName => array( + 'file_1.txt' => 'file 1 content', + 'file_2.txt' => 'file 2 content', + 'test_sub_dir' => array( + 'file_1_1.txt' => 'sub dir file 1 content', + 'file_1_2.txt' => 'sub dir file 2 content', + ), + ), + )); + $basePath = $this->testFilePath; + $dirName = $basePath . DIRECTORY_SEPARATOR . $dirName; + $expectedFiles = array( + $dirName . DIRECTORY_SEPARATOR . 'file_1.txt', + $dirName . DIRECTORY_SEPARATOR . 'file_2.txt', + $dirName . DIRECTORY_SEPARATOR . 'test_sub_dir' . DIRECTORY_SEPARATOR . 'file_1_1.txt', + $dirName . DIRECTORY_SEPARATOR . 'test_sub_dir' . DIRECTORY_SEPARATOR . 'file_1_2.txt', + ); + + $foundFiles = FileHelper::findFiles($dirName); + sort($expectedFiles); + $this->assertEquals($expectedFiles, $foundFiles); + } + + /** + * @depends testFindFiles + */ + public function testFindFileFilter() + { + $dirName = 'test_dir'; + $passedFileName = 'passed.txt'; + $this->createFileStructure(array( + $dirName => array( + $passedFileName => 'passed file content', + 'declined.txt' => 'declined file content', + ), + )); + $basePath = $this->testFilePath; + $dirName = $basePath . DIRECTORY_SEPARATOR . $dirName; + + $options = array( + 'filter' => function($base, $name, $isFile) use ($passedFileName) { + return ($passedFileName == $name); + } + ); + $foundFiles = FileHelper::findFiles($dirName, $options); + $this->assertEquals(array($dirName . DIRECTORY_SEPARATOR . $passedFileName), $foundFiles); + } + + /** + * @depends testFindFiles + */ + public function testFindFilesExclude() + { + $dirName = 'test_dir'; + $fileName = 'test_file.txt'; + $excludeFileName = 'exclude_file.txt'; + $this->createFileStructure(array( + $dirName => array( + $fileName => 'file content', + $excludeFileName => 'exclude file content', + ), + )); + $basePath = $this->testFilePath; + $dirName = $basePath . DIRECTORY_SEPARATOR . $dirName; + + $options = array( + 'exclude' => array($excludeFileName), + ); + $foundFiles = FileHelper::findFiles($dirName, $options); + $this->assertEquals(array($dirName . DIRECTORY_SEPARATOR . $fileName), $foundFiles); + } + + /** + * @depends testFindFiles + */ + public function testFindFilesFileType() + { + $dirName = 'test_dir'; + $fileType = 'dat'; + $fileName = 'test_file.' . $fileType; + $excludeFileName = 'exclude_file.txt'; + $this->createFileStructure(array( + $dirName => array( + $fileName => 'file content', + $excludeFileName => 'exclude file content', + ), + )); + $basePath = $this->testFilePath; + $dirName = $basePath . DIRECTORY_SEPARATOR . $dirName; + + $options = array( + 'fileTypes' => array($fileType), + ); + $foundFiles = FileHelper::findFiles($dirName, $options); + $this->assertEquals(array($dirName . DIRECTORY_SEPARATOR . $fileName), $foundFiles); + } + + public function testMkdir() { + $basePath = $this->testFilePath; + + $dirName = $basePath . DIRECTORY_SEPARATOR . 'test_dir'; + FileHelper::mkdir($dirName); + $this->assertTrue(file_exists($dirName), 'Unable to create directory!'); + + $dirName = $basePath . DIRECTORY_SEPARATOR . 'test_dir_level_1' . DIRECTORY_SEPARATOR . 'test_dir_level_2'; + FileHelper::mkdir($dirName, null, true); + $this->assertTrue(file_exists($dirName), 'Unable to create directory recursively!'); + } + + public function testGetMimeTypeByExtension() + { + $magicFile = $this->testFilePath . DIRECTORY_SEPARATOR . 'mime_type.php'; + $mimeTypeMap = array( + 'txa' => 'application/json', + 'txb' => 'another/mime', + ); + $magicFileContent = ' $mimeType) { + $fileName = 'test.' . $extension; + $this->assertNull(FileHelper::getMimeTypeByExtension($fileName)); + $this->assertEquals($mimeType, FileHelper::getMimeTypeByExtension($fileName, $magicFile)); + } + } +} \ No newline at end of file